Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion app/src/remote_server/ssh_transport/installation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()),
}
}
35 changes: 10 additions & 25 deletions app/src/remote_server/ssh_transport/installation/scp_fallback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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));
Expand All @@ -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))
}
}

Expand Down
9 changes: 9 additions & 0 deletions app/src/server/telemetry/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2821,6 +2821,9 @@ pub enum TelemetryEvent {
install_source: Option<remote_server::transport::InstallSource>,
remote_os: Option<String>,
remote_arch: Option<String>,
is_environment_failure: bool,
counts_as_product_error: bool,
setup_failure_class: Option<String>,
},
/// Emitted when the remote server connection + initialization completes.
/// `error` is `None` on success, `Some(reason)` on failure.
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 9 additions & 1 deletion app/src/terminal/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
Expand Down
60 changes: 59 additions & 1 deletion crates/remote_server/src/install_remote_server.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
Loading