diff --git a/app/src/remote_server/ssh_transport/installation.rs b/app/src/remote_server/ssh_transport/installation.rs index 5af280d146..a2cd16bb7d 100644 --- a/app/src/remote_server/ssh_transport/installation.rs +++ b/app/src/remote_server/ssh_transport/installation.rs @@ -76,6 +76,7 @@ pub(super) async fn install_binary(socket_path: &Path) -> InstallOutcome { /// Runs the install script on the remote host to download and install the /// binary directly from the CDN. async fn install_on_server(socket_path: &Path) -> Result<(), Error> { + run_filesystem_preflight(socket_path).await?; let script = remote_server::setup::install_script(None); match remote_server::ssh::run_ssh_script( socket_path, @@ -88,9 +89,28 @@ async fn install_on_server(socket_path: &Path) -> Result<(), Error> { Ok(output) => { let exit_code = output.status.code().unwrap_or(-1); let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - Err(Error::ScriptFailed { exit_code, stderr }) + Err(Error::from_script_failure(exit_code, stderr)) } Err(SshCommandError::TimedOut { .. }) => Err(Error::TimedOut), Err(e) => Err(Error::Other(e.into())), } } + +pub(super) async fn run_filesystem_preflight(socket_path: &Path) -> Result<(), Error> { + let script = remote_server::setup::filesystem_preflight_script(); + match remote_server::ssh::run_ssh_script( + socket_path, + &script, + remote_server::setup::CHECK_TIMEOUT, + ) + .await + { + Ok(output) if output.status.success() => Ok(()), + Ok(output) => { + let exit_code = output.status.code().unwrap_or(-1); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + Err(Error::from_script_failure(exit_code, stderr)) + } + Err(e) => Err(e.into()), + } +} diff --git a/app/src/remote_server/ssh_transport/installation/scp_fallback.rs b/app/src/remote_server/ssh_transport/installation/scp_fallback.rs index 94c7d94b1d..83b0768b98 100644 --- a/app/src/remote_server/ssh_transport/installation/scp_fallback.rs +++ b/app/src/remote_server/ssh_transport/installation/scp_fallback.rs @@ -24,7 +24,10 @@ const REMOTE_SERVER_TARBALL_DOWNLOAD_RETRY_DELAY: Duration = Duration::from_mill /// Exit codes where SCP fallback would not help because the failure is on the /// remote host itself, not a network/download issue. pub(super) fn should_try_install(error: &Error) -> bool { - !matches!(error, Error::ScriptFailed { exit_code, .. } if *exit_code == 2) + !matches!( + error, + Error::EnvironmentFailure { .. } | Error::ScriptFailed { exit_code: 2, .. } + ) } /// Installs the remote server via SCP fallback. @@ -34,6 +37,7 @@ pub(super) fn should_try_install(error: &Error) -> bool { /// archive. This avoids requiring the remote host to download the tarball itself. pub(super) async fn install(socket_path: &Path) -> Result<(), Error> { let platform = super::super::detect_remote_platform(socket_path).await?; + super::run_filesystem_preflight(socket_path).await?; let client_tarball_path = cached_remote_server_tarball(&platform) .await @@ -43,25 +47,6 @@ pub(super) async fn install(socket_path: &Path) -> Result<(), Error> { let remote_tarball_name = format!("oz-upload-{}.tar.gz", uuid::Uuid::new_v4()); let remote_tarball_path = format!("{install_dir}/{remote_tarball_name}"); - // The normal install script creates this directory before downloading, but - // SCP fallback can run after a failure that happened before that point. - // Ensure the destination exists before uploading the staged tarball. - let mkdir_output = remote_server::ssh::run_ssh_command( - socket_path, - &format!("mkdir -p {install_dir}"), - remote_server::setup::CHECK_TIMEOUT, - ) - .await - .map_err(Error::from)?; - if !mkdir_output.status.success() { - let code = mkdir_output.status.code().unwrap_or(-1); - let stderr = String::from_utf8_lossy(&mkdir_output.stderr).to_string(); - return Err(Error::ScriptFailed { - exit_code: code, - stderr, - }); - } - log::info!("Uploading tarball to remote at {remote_tarball_path}"); remote_server::ssh::scp_upload( socket_path, @@ -70,7 +55,10 @@ pub(super) async fn install(socket_path: &Path) -> Result<(), Error> { timeout, ) .await - .map_err(Error::Other)?; + .map_err(|e| { + let stderr = e.to_string(); + Error::from_script_failure(-1, stderr) + })?; log::info!("Running extraction via install script with tarball at {remote_tarball_path}"); let script = remote_server::setup::install_script(Some(&remote_tarball_path)); @@ -83,10 +71,7 @@ pub(super) async fn install(socket_path: &Path) -> Result<(), Error> { } else { let code = output.status.code().unwrap_or(-1); let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - Err(Error::ScriptFailed { - exit_code: code, - stderr, - }) + Err(Error::from_script_failure(code, stderr)) } } diff --git a/app/src/server/telemetry/events.rs b/app/src/server/telemetry/events.rs index 65a9bfd437..9619f0dd69 100644 --- a/app/src/server/telemetry/events.rs +++ b/app/src/server/telemetry/events.rs @@ -2821,6 +2821,9 @@ pub enum TelemetryEvent { install_source: Option, remote_os: Option, remote_arch: Option, + is_environment_failure: bool, + counts_as_product_error: bool, + setup_failure_class: Option, }, /// Emitted when the remote server connection + initialization completes. /// `error` is `None` on success, `Some(reason)` on failure. @@ -4200,11 +4203,17 @@ impl TelemetryEvent { install_source, remote_os, remote_arch, + is_environment_failure, + counts_as_product_error, + setup_failure_class, } => Some(json!({ "error": error, "install_source": install_source, "remote_os": remote_os, "remote_arch": remote_arch, + "is_environment_failure": is_environment_failure, + "counts_as_product_error": counts_as_product_error, + "setup_failure_class": setup_failure_class, })), TelemetryEvent::RemoteServerInitialization { phase, diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 174b27995a..16ad326e1b 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -4503,12 +4503,20 @@ impl TerminalView { ) }) .unwrap_or((None, None)); + let error = result.as_ref().err(); send_telemetry_from_ctx!( TelemetryEvent::RemoteServerInstallation { - error: result.as_ref().err().map(|e| e.to_string()), + error: error.map(|e| e.to_string()), install_source: *install_source, remote_os, remote_arch, + is_environment_failure: error + .is_some_and(|e| e.is_environment_failure()), + counts_as_product_error: error + .is_some_and(|e| e.counts_as_product_error()), + setup_failure_class: error + .and_then(|e| e.setup_failure_class()) + .map(str::to_string), }, ctx ); diff --git a/crates/remote_server/src/install_remote_server.sh b/crates/remote_server/src/install_remote_server.sh index 721ceaca7b..e4b71505f6 100644 --- a/crates/remote_server/src/install_remote_server.sh +++ b/crates/remote_server/src/install_remote_server.sh @@ -9,6 +9,10 @@ # {version_query} — e.g. &version=v0.2026... (empty when no release tag) # {version_suffix} — e.g. -v0.2026... (empty when no release tag) # {no_http_client_exit_code} — exit code when neither curl nor wget is available +# {filesystem_preflight_exit_code} — exit code for local remote-filesystem environment failures +# {required_install_space_kib} — minimum available KiB required in install_dir +# {required_install_inodes} — minimum available inodes required in install_dir +# {filesystem_preflight_failure_marker} — stable stderr marker for typed client classification # {staging_tarball_path} — path to a pre-uploaded tarball (SCP fallback; empty normally) set -e @@ -41,7 +45,61 @@ install_dir="{install_dir}" case "$install_dir" in "~"|"~/"*) install_dir="${HOME}${install_dir#\~}" ;; esac -mkdir -p "$install_dir" + +required_kib={required_install_space_kib} +required_inodes={required_install_inodes} +fs_preflight_marker="{filesystem_preflight_failure_marker}" + +emit_fs_preflight_failure() { + kind="$1" + detail="$2" + echo "$fs_preflight_marker kind=$kind path=$install_dir detail=$detail action=fix_remote_install_directory" >&2 + exit {filesystem_preflight_exit_code} +} + +mkdir_error=$(mkdir -p "$install_dir" 2>&1) || { + case "$mkdir_error" in + *"Permission denied"*) kind=permission_denied ;; + *"Read-only file system"*) kind=read_only_filesystem ;; + *"No space left on device"*) kind=no_space ;; + *"Disk quota exceeded"*|*"Quota exceeded"*) kind=quota_exceeded ;; + *) kind=write_probe_failed ;; + esac + emit_fs_preflight_failure "$kind" "mkdir_failed" +} + +probe_path="$install_dir/.warp-write-probe.$$" +probe_error=$({ : > "$probe_path"; } 2>&1) || { + case "$probe_error" in + *"Permission denied"*) kind=permission_denied ;; + *"Read-only file system"*) kind=read_only_filesystem ;; + *"No space left on device"*) kind=no_space ;; + *"Disk quota exceeded"*|*"Quota exceeded"*) kind=quota_exceeded ;; + *) kind=write_probe_failed ;; + esac + emit_fs_preflight_failure "$kind" "write_probe_failed" +} +rm -f "$probe_path" 2>/dev/null || true + +available_kib=$(df -Pk "$install_dir" 2>/dev/null | awk 'NR==2 {print $4}') +case "$available_kib" in + ""|*[!0-9]*) ;; + *) + if [ "$available_kib" -lt "$required_kib" ]; then + emit_fs_preflight_failure "no_space" "available_kib=${available_kib},required_kib=${required_kib}" + fi + ;; +esac + +available_inodes=$(df -Pi "$install_dir" 2>/dev/null | awk 'NR==2 {print $4}') +case "$available_inodes" in + ""|*[!0-9]*) ;; + *) + if [ "$available_inodes" -lt "$required_inodes" ]; then + emit_fs_preflight_failure "inode_exhausted" "available_inodes=${available_inodes},required_inodes=${required_inodes}" + fi + ;; +esac tmpdir=$(mktemp -d "$install_dir/.install.XXXXXX") # Best-effort cleanup of the staging directory. A failure here (e.g. diff --git a/crates/remote_server/src/setup.rs b/crates/remote_server/src/setup.rs index 3270b50bf3..3a953b2bf6 100644 --- a/crates/remote_server/src/setup.rs +++ b/crates/remote_server/src/setup.rs @@ -5,6 +5,7 @@ pub use glibc::{GlibcVersion, RemoteLibc}; use std::time::Duration; use anyhow::anyhow; +use serde::Serialize; use warp_core::channel::{Channel, ChannelState}; pub const REMOTE_SERVER_ARTIFACT_VERSION_UNPINNED: &str = "unversioned"; @@ -131,6 +132,7 @@ impl UnsupportedReason { Some(Self::UnsupportedArch { arch: arch.clone() }) } crate::transport::Error::TimedOut + | crate::transport::Error::EnvironmentFailure { .. } | crate::transport::Error::ScriptFailed { .. } | crate::transport::Error::Other(_) => None, } @@ -146,6 +148,192 @@ impl UnsupportedReason { } } +pub const FILESYSTEM_PREFLIGHT_EXIT_CODE: i32 = 4; +pub const REQUIRED_INSTALL_SPACE_KIB: u64 = 256 * 1024; +const REQUIRED_INSTALL_INODES: u64 = 16; +const FILESYSTEM_PREFLIGHT_FAILURE_MARKER: &str = "warp_remote_server_filesystem_preflight_failed"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InstallEnvironmentFailure { + pub kind: InstallEnvironmentFailureKind, + pub path: Option, + pub detail: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum InstallEnvironmentFailureKind { + PermissionDenied, + ReadOnlyFilesystem, + NoSpace, + QuotaExceeded, + InodeExhausted, + WriteProbeFailed, + Unknown, +} + +impl InstallEnvironmentFailureKind { + fn from_preflight_kind(kind: &str) -> Self { + match kind { + "permission_denied" => Self::PermissionDenied, + "read_only_filesystem" => Self::ReadOnlyFilesystem, + "no_space" => Self::NoSpace, + "quota_exceeded" => Self::QuotaExceeded, + "inode_exhausted" => Self::InodeExhausted, + "write_probe_failed" => Self::WriteProbeFailed, + _ => Self::Unknown, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::PermissionDenied => "permission_denied", + Self::ReadOnlyFilesystem => "read_only_filesystem", + Self::NoSpace => "no_space", + Self::QuotaExceeded => "quota_exceeded", + Self::InodeExhausted => "inode_exhausted", + Self::WriteProbeFailed => "write_probe_failed", + Self::Unknown => "unknown", + } + } + + pub fn user_guidance(self) -> &'static str { + match self { + Self::PermissionDenied => { + "Grant write permission to the SSH extension install directory or configure a writable home directory." + } + Self::ReadOnlyFilesystem => { + "Remount the filesystem as writable or choose a writable install location." + } + Self::NoSpace => { + "Free disk space on the remote host or move the SSH extension install directory to a larger filesystem." + } + Self::QuotaExceeded => { + "Free space within your remote-user quota or ask the host administrator to raise the quota." + } + Self::InodeExhausted => { + "Remove files from the remote filesystem or choose an install location with available inodes." + } + Self::WriteProbeFailed | Self::Unknown => { + "Check that the remote install directory is writable and has enough disk space." + } + } + } +} + +impl std::fmt::Display for InstallEnvironmentFailureKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl InstallEnvironmentFailure { + pub fn setup_failure_class(&self) -> &'static str { + "unsupported_environment" + } + + pub fn counts_as_product_error(&self) -> bool { + false + } + + pub fn user_facing_detail(&self) -> String { + let path = self + .path + .as_ref() + .map(|path| format!(" at {path}")) + .unwrap_or_default(); + format!( + "The remote host cannot write the Warp SSH extension{path}: {} {}", + self.detail, + self.kind.user_guidance(), + ) + } +} + +impl std::fmt::Display for InstallEnvironmentFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.path { + Some(path) => write!( + f, + "remote install environment failure: {} at {} ({})", + self.kind, path, self.detail + ), + None => write!( + f, + "remote install environment failure: {} ({})", + self.kind, self.detail + ), + } + } +} + +pub fn classify_install_environment_failure( + exit_code: i32, + stderr: &str, +) -> Option { + if stderr.contains(FILESYSTEM_PREFLIGHT_FAILURE_MARKER) { + return Some(parse_preflight_failure(stderr)); + } + + let lowercase = stderr.to_ascii_lowercase(); + let kind = if lowercase.contains("no space left on device") + || lowercase.contains("failure writing output to destination") + { + InstallEnvironmentFailureKind::NoSpace + } else if lowercase.contains("disk quota exceeded") || lowercase.contains("quota exceeded") { + InstallEnvironmentFailureKind::QuotaExceeded + } else if lowercase.contains("read-only file system") + || lowercase.contains("readonly file system") + { + InstallEnvironmentFailureKind::ReadOnlyFilesystem + } else if lowercase.contains("permission denied") { + InstallEnvironmentFailureKind::PermissionDenied + } else { + return None; + }; + + Some(InstallEnvironmentFailure { + kind, + path: None, + detail: format!( + "install script failed with exit code {exit_code}: {}", + stderr.trim() + ), + }) +} + +fn parse_preflight_failure(stderr: &str) -> InstallEnvironmentFailure { + let line = stderr + .lines() + .find(|line| line.contains(FILESYSTEM_PREFLIGHT_FAILURE_MARKER)) + .unwrap_or(stderr) + .trim(); + let kind = extract_preflight_field(line, "kind") + .map(|kind| InstallEnvironmentFailureKind::from_preflight_kind(&kind)) + .unwrap_or(InstallEnvironmentFailureKind::Unknown); + let path = extract_preflight_path(line); + let detail = extract_preflight_field(line, "detail").unwrap_or_else(|| line.to_string()); + + InstallEnvironmentFailure { kind, path, detail } +} + +fn extract_preflight_field(line: &str, key: &str) -> Option { + let prefix = format!("{key}="); + let value = line + .split_whitespace() + .find_map(|part| part.strip_prefix(&prefix))?; + Some(value.to_string()) +} + +fn extract_preflight_path(line: &str) -> Option { + let start = line.find(" path=")? + " path=".len(); + let end = line[start..] + .find(" detail=") + .map(|offset| start + offset) + .unwrap_or(line.len()); + Some(line[start..end].to_string()) +} + impl PreinstallCheckResult { pub fn unsupported(reason: UnsupportedReason) -> Self { Self { @@ -539,6 +727,70 @@ pub fn remote_server_artifact_version() -> &'static str { /// [`install_script`]. const INSTALL_SCRIPT_TEMPLATE: &str = include_str!("install_remote_server.sh"); +const FILESYSTEM_PREFLIGHT_SCRIPT_TEMPLATE: &str = r#" +set -e + +install_dir="{install_dir}" +case "$install_dir" in + "~"|"~/"*) install_dir="${HOME}${install_dir#\~}" ;; +esac + +required_kib={required_install_space_kib} +required_inodes={required_install_inodes} +fs_preflight_marker="{filesystem_preflight_failure_marker}" + +emit_fs_preflight_failure() { + kind="$1" + detail="$2" + echo "$fs_preflight_marker kind=$kind path=$install_dir detail=$detail action=fix_remote_install_directory" >&2 + exit {filesystem_preflight_exit_code} +} + +mkdir_error=$(mkdir -p "$install_dir" 2>&1) || { + case "$mkdir_error" in + *"Permission denied"*) kind=permission_denied ;; + *"Read-only file system"*) kind=read_only_filesystem ;; + *"No space left on device"*) kind=no_space ;; + *"Disk quota exceeded"*|*"Quota exceeded"*) kind=quota_exceeded ;; + *) kind=write_probe_failed ;; + esac + emit_fs_preflight_failure "$kind" "mkdir_failed" +} + +probe_path="$install_dir/.warp-write-probe.$$" +probe_error=$({ : > "$probe_path"; } 2>&1) || { + case "$probe_error" in + *"Permission denied"*) kind=permission_denied ;; + *"Read-only file system"*) kind=read_only_filesystem ;; + *"No space left on device"*) kind=no_space ;; + *"Disk quota exceeded"*|*"Quota exceeded"*) kind=quota_exceeded ;; + *) kind=write_probe_failed ;; + esac + emit_fs_preflight_failure "$kind" "write_probe_failed" +} +rm -f "$probe_path" 2>/dev/null || true + +available_kib=$(df -Pk "$install_dir" 2>/dev/null | awk 'NR==2 {print $4}') +case "$available_kib" in + ""|*[!0-9]*) ;; + *) + if [ "$available_kib" -lt "$required_kib" ]; then + emit_fs_preflight_failure "no_space" "available_kib=${available_kib},required_kib=${required_kib}" + fi + ;; +esac + +available_inodes=$(df -Pi "$install_dir" 2>/dev/null | awk 'NR==2 {print $4}') +case "$available_inodes" in + ""|*[!0-9]*) ;; + *) + if [ "$available_inodes" -lt "$required_inodes" ]; then + emit_fs_preflight_failure "inode_exhausted" "available_inodes=${available_inodes},required_inodes=${required_inodes}" + fi + ;; +esac +"#; + /// Returns the install script that downloads and installs the CLI binary /// at the current client version. /// @@ -570,9 +822,46 @@ pub fn install_script(staging_tarball_path: Option<&str>) -> String { "{no_http_client_exit_code}", &NO_HTTP_CLIENT_EXIT_CODE.to_string(), ) + .replace( + "{filesystem_preflight_exit_code}", + &FILESYSTEM_PREFLIGHT_EXIT_CODE.to_string(), + ) + .replace( + "{required_install_space_kib}", + &REQUIRED_INSTALL_SPACE_KIB.to_string(), + ) + .replace( + "{required_install_inodes}", + &REQUIRED_INSTALL_INODES.to_string(), + ) + .replace( + "{filesystem_preflight_failure_marker}", + FILESYSTEM_PREFLIGHT_FAILURE_MARKER, + ) .replace("{staging_tarball_path}", staging_tarball_path.unwrap_or("")) } +pub fn filesystem_preflight_script() -> String { + FILESYSTEM_PREFLIGHT_SCRIPT_TEMPLATE + .replace("{install_dir}", &remote_server_dir()) + .replace( + "{filesystem_preflight_exit_code}", + &FILESYSTEM_PREFLIGHT_EXIT_CODE.to_string(), + ) + .replace( + "{required_install_space_kib}", + &REQUIRED_INSTALL_SPACE_KIB.to_string(), + ) + .replace( + "{required_install_inodes}", + &REQUIRED_INSTALL_INODES.to_string(), + ) + .replace( + "{filesystem_preflight_failure_marker}", + FILESYSTEM_PREFLIGHT_FAILURE_MARKER, + ) +} + /// Construct the download URL from the server root URL. /// /// For example, given `https://app.warp.dev`, returns diff --git a/crates/remote_server/src/setup_tests.rs b/crates/remote_server/src/setup_tests.rs index e9a238de57..0396bfdfc5 100644 --- a/crates/remote_server/src/setup_tests.rs +++ b/crates/remote_server/src/setup_tests.rs @@ -280,8 +280,8 @@ fn install_script_tilde_expansion_resolves_correctly() { }; let script = install_script(None); - let cutoff = script.find("mkdir -p \"$install_dir\"").expect( - "install script no longer contains the `mkdir -p \"$install_dir\"` \ + let cutoff = script.find("mkdir_error=$(").expect( + "install script no longer contains the `mkdir_error=$(` \ checkpoint this test relies on; update the test alongside the \ script change", ); @@ -366,6 +366,92 @@ fn install_script_avoids_pattern_substitution_for_tilde_expansion() { ); } +#[test] +fn classify_preflight_failure_extracts_kind_path_and_detail() { + let stderr = format!( + "ignored noise\n{FILESYSTEM_PREFLIGHT_FAILURE_MARKER} kind=no_space path=/home/test user/.warp/remote-server detail=available_kib=1024,required_kib=262144 action=fix_remote_install_directory\n" + ); + + let failure = classify_install_environment_failure(FILESYSTEM_PREFLIGHT_EXIT_CODE, &stderr) + .expect("preflight marker should classify as environment failure"); + + assert_eq!(failure.kind, InstallEnvironmentFailureKind::NoSpace); + assert_eq!( + failure.path, + Some("/home/test user/.warp/remote-server".to_string()) + ); + assert_eq!(failure.detail, "available_kib=1024,required_kib=262144"); + assert_eq!(failure.setup_failure_class(), "unsupported_environment"); + assert!(!failure.counts_as_product_error()); +} + +#[test] +fn classify_late_no_space_stderr_as_environment_failure() { + let stderr = "tar: write error: No space left on device"; + let failure = classify_install_environment_failure(1, stderr) + .expect("late no-space stderr should classify as environment failure"); + + assert_eq!(failure.kind, InstallEnvironmentFailureKind::NoSpace); + assert_eq!(failure.path, None); + assert!(failure.detail.contains("exit code 1")); + assert!(failure.detail.contains(stderr)); +} + +#[test] +fn classify_late_quota_stderr_as_environment_failure() { + let stderr = "scp: dest open \"/home/user/.warp/file\": Disk quota exceeded"; + let failure = classify_install_environment_failure(1, stderr) + .expect("late quota stderr should classify as environment failure"); + + assert_eq!(failure.kind, InstallEnvironmentFailureKind::QuotaExceeded); +} + +#[test] +fn classify_late_read_only_and_permission_stderr() { + let read_only = classify_install_environment_failure( + 1, + "mkdir: cannot create directory '/opt/warp': Read-only file system", + ) + .expect("read-only stderr should classify"); + assert_eq!( + read_only.kind, + InstallEnvironmentFailureKind::ReadOnlyFilesystem + ); + + let permission = classify_install_environment_failure( + 1, + "mkdir: cannot create directory '/root/.warp': Permission denied", + ) + .expect("permission stderr should classify"); + assert_eq!( + permission.kind, + InstallEnvironmentFailureKind::PermissionDenied + ); +} + +#[test] +fn script_failure_promotes_environment_failures() { + let error = crate::transport::Error::from_script_failure( + 23, + "curl: (23) Failure writing output to destination".to_string(), + ); + + assert!(error.is_environment_failure()); + assert_eq!(error.setup_failure_class(), Some("unsupported_environment")); + assert!(!error.counts_as_product_error()); +} + +#[test] +fn filesystem_preflight_script_contains_required_checks() { + let script = filesystem_preflight_script(); + + assert!(script.contains("mkdir -p \"$install_dir\"")); + assert!(script.contains(".warp-write-probe")); + assert!(script.contains("df -Pk \"$install_dir\"")); + assert!(script.contains("df -Pi \"$install_dir\"")); + assert!(script.contains(FILESYSTEM_PREFLIGHT_FAILURE_MARKER)); + assert!(script.contains(&REQUIRED_INSTALL_SPACE_KIB.to_string())); +} #[test] fn version_hash_is_deterministic() { // version_hash uses the compile-time GIT_RELEASE_TAG which is typically diff --git a/crates/remote_server/src/transport.rs b/crates/remote_server/src/transport.rs index c3e9ce9a12..3fa3dafe69 100644 --- a/crates/remote_server/src/transport.rs +++ b/crates/remote_server/src/transport.rs @@ -20,7 +20,10 @@ use warpui::r#async::executor; use crate::client::{ClientEvent, RemoteServerClient}; use crate::manager::RemoteServerExitStatus; -use crate::setup::{PreinstallCheckResult, RemotePlatform}; +use crate::setup::{ + classify_install_environment_failure, InstallEnvironmentFailure, PreinstallCheckResult, + RemotePlatform, +}; use serde::Serialize; /// How the remote server binary was installed. Used for telemetry to @@ -93,6 +96,9 @@ pub enum Error { /// The remote host reported a CPU architecture not supported by the prebuilt binary. #[error("unsupported architecture: {arch}")] UnsupportedArch { arch: String }, + /// The remote host environment cannot support installing the binary at the chosen path. + #[error("{failure}")] + EnvironmentFailure { failure: InstallEnvironmentFailure }, /// A remote script ran but exited with a non-zero code. #[error("script failed (exit {exit_code}): {stderr}")] ScriptFailed { exit_code: i32, stderr: String }, @@ -107,6 +113,39 @@ pub enum Error { const MAX_STDERR_DISPLAY_CHARS: usize = 512; impl Error { + pub fn from_script_failure(exit_code: i32, stderr: String) -> Self { + if let Some(failure) = classify_install_environment_failure(exit_code, &stderr) { + Self::EnvironmentFailure { failure } + } else { + Self::ScriptFailed { exit_code, stderr } + } + } + + pub fn is_environment_failure(&self) -> bool { + matches!(self, Self::EnvironmentFailure { .. }) + } + + pub fn setup_failure_class(&self) -> Option<&'static str> { + match self { + Self::EnvironmentFailure { failure } => Some(failure.setup_failure_class()), + Self::TimedOut + | Self::UnsupportedOs { .. } + | Self::UnsupportedArch { .. } + | Self::ScriptFailed { .. } + | Self::Other(_) => None, + } + } + + pub fn counts_as_product_error(&self) -> bool { + match self { + Self::EnvironmentFailure { failure } => failure.counts_as_product_error(), + Self::TimedOut + | Self::UnsupportedOs { .. } + | Self::UnsupportedArch { .. } + | Self::ScriptFailed { .. } + | Self::Other(_) => true, + } + } /// Converts this error into a [`UserFacingError`] suitable for the /// SSH remote-server failed banner, using `stage` to provide /// context-appropriate copy. @@ -118,6 +157,7 @@ impl Error { } Self::UnsupportedOs { os } => Some(format!("Unsupported OS: {os}")), Self::UnsupportedArch { arch } => Some(format!("Unsupported architecture: {arch}")), + Self::EnvironmentFailure { failure } => Some(failure.user_facing_detail()), Self::ScriptFailed { exit_code, stderr } => { let truncated = if stderr.chars().count() > MAX_STDERR_DISPLAY_CHARS { let end: usize = stderr