diff --git a/app/src/remote_server/ssh_transport.rs b/app/src/remote_server/ssh_transport.rs index fcce50d0a..274354907 100644 --- a/app/src/remote_server/ssh_transport.rs +++ b/app/src/remote_server/ssh_transport.rs @@ -217,6 +217,14 @@ impl RemoteTransport for SshTransport { .await .map_err(Error::Other) } + Ok(output) + if output.status.code() == Some(remote_server::setup::NO_TAR_EXIT_CODE) => + { + log::info!("Remote server has no tar, falling back to gzip SCP upload"); + gzip_scp_install_fallback(&socket_path) + .await + .map_err(Error::Other) + } Ok(output) => { let exit_code = output.status.code().unwrap_or(-1); let stderr = String::from_utf8_lossy(&output.stderr).to_string(); @@ -391,6 +399,146 @@ async fn scp_install_fallback(socket_path: &Path) -> anyhow::Result<()> { } } +/// Gzip SCP install fallback for hosts that have gzip but no tar. +/// +/// Downloads the tarball locally, extracts the binary locally, gzips just +/// the binary, uploads the `.gz` file via SCP, then decompresses and +/// marks it executable on the remote. +async fn gzip_scp_install_fallback(socket_path: &Path) -> anyhow::Result<()> { + use std::process::Stdio; + + let platform = detect_remote_platform(socket_path) + .await + .map_err(|e| anyhow::anyhow!("Gzip SCP fallback: {e:#}"))?; + + let url = remote_server::setup::download_tarball_url(&platform); + let timeout = remote_server::setup::SCP_INSTALL_TIMEOUT; + let binary_name = remote_server::setup::binary_name(); + let remote_binary = remote_server::setup::remote_server_binary(); + let remote_install_dir = remote_server::setup::remote_server_dir(); + + // 1. Download the tarball locally into a temp directory. + let tmp_dir = + tempfile::tempdir().map_err(|e| anyhow::anyhow!("Failed to create local temp dir: {e}"))?; + let temp_tarball = tmp_dir.path().join("oz.tar.gz"); + + log::info!("Gzip SCP fallback: downloading tarball locally from {url}"); + let output = command::r#async::Command::new("curl") + .arg("-fSL") + .arg("--connect-timeout") + .arg("15") + .arg(&url) + .arg("-o") + .arg(&temp_tarball) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .output() + .await + .map_err(|e| anyhow::anyhow!("Failed to spawn local curl: {e}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow::anyhow!( + "Local curl failed (exit {:?}): {stderr}", + output.status.code() + )); + } + + // 2. Extract the binary locally with tar. + log::info!("Gzip SCP fallback: extracting binary locally"); + let extract_dir = tmp_dir.path().join("extracted"); + std::fs::create_dir_all(&extract_dir) + .map_err(|e| anyhow::anyhow!("Failed to create extraction dir: {e}"))?; + let output = command::r#async::Command::new("tar") + .arg("-xzf") + .arg(&temp_tarball) + .arg("-C") + .arg(&extract_dir) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .output() + .await + .map_err(|e| anyhow::anyhow!("Failed to spawn local tar: {e}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow::anyhow!( + "Local tar extraction failed (exit {:?}): {stderr}", + output.status.code() + )); + } + + // Find the extracted binary. + let mut found_binary = None; + for entry in std::fs::read_dir(&extract_dir) + .map_err(|e| anyhow::anyhow!("Failed to read extraction dir: {e}"))? + { + let entry = entry.map_err(|e| anyhow::anyhow!("Failed to read dir entry: {e}"))?; + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with("oz") && !name_str.ends_with(".tar.gz") { + found_binary = Some(entry.path()); + break; + } + } + let local_binary = + found_binary.ok_or_else(|| anyhow::anyhow!("No binary found in extracted tarball"))?; + + // 3. Gzip just the binary. + let gzipped_path = tmp_dir.path().join(format!("{binary_name}.gz")); + log::info!("Gzip SCP fallback: compressing binary to {gzipped_path:?}"); + let gzip_out_file = std::fs::File::create(&gzipped_path) + .map_err(|e| anyhow::anyhow!("Failed to create gzip output file: {e}"))?; + let output = command::r#async::Command::new("gzip") + .arg("-c") + .arg(&local_binary) + .stdout(Stdio::from(gzip_out_file)) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .output() + .await + .map_err(|e| anyhow::anyhow!("Failed to spawn gzip: {e}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow::anyhow!( + "gzip failed (exit {:?}): {stderr}", + output.status.code() + )); + } + + // 4. Ensure the remote install directory exists. + log::info!("Gzip SCP fallback: ensuring remote directory exists"); + let mkdir_cmd = format!("mkdir -p {remote_install_dir}"); + let output = remote_server::ssh::run_ssh_command(socket_path, &mkdir_cmd, timeout).await?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow::anyhow!( + "Failed to create remote install dir (exit {:?}): {stderr}", + output.status.code() + )); + } + + // 5. SCP the .gz file to the remote install dir. + let remote_gz_path = format!("{remote_binary}.gz"); + log::info!("Gzip SCP fallback: uploading {gzipped_path:?} to {remote_gz_path}"); + remote_server::ssh::scp_upload(socket_path, &gzipped_path, &remote_gz_path, timeout).await?; + + // 6. Decompress and chmod on the remote. + log::info!("Gzip SCP fallback: decompressing and setting permissions on remote"); + let decompress_cmd = format!("gzip -d {remote_gz_path} && chmod +x {remote_binary}"); + let output = remote_server::ssh::run_ssh_command(socket_path, &decompress_cmd, timeout).await?; + if !output.status.success() { + let code = output.status.code().unwrap_or(-1); + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow::anyhow!( + "Remote gzip -d + chmod failed (exit {code}): {stderr}" + )); + } + + log::info!("Gzip SCP fallback: install complete at {remote_binary}"); + Ok(()) +} + #[cfg(test)] #[path = "ssh_transport_tests.rs"] mod tests; diff --git a/crates/remote_server/src/install_remote_server.sh b/crates/remote_server/src/install_remote_server.sh index 4e8eae824..f8aace483 100644 --- a/crates/remote_server/src/install_remote_server.sh +++ b/crates/remote_server/src/install_remote_server.sh @@ -9,12 +9,13 @@ # {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 +# {no_tar_exit_code} — exit code when tar is not available # {staging_tarball_path} — path to a pre-uploaded tarball (SCP fallback; empty normally) set -e arch=$(uname -m) case "$arch" in - x86_64) arch_name=x86_64 ;; + x86_64|amd64) arch_name=x86_64 ;; aarch64|arm64) arch_name=aarch64 ;; *) echo "unsupported arch: $arch" >&2; exit 2 ;; esac @@ -55,6 +56,8 @@ cleanup() { } trap cleanup EXIT +command -v tar >/dev/null 2>&1 || { echo "error: tar is not available" >&2; exit {no_tar_exit_code}; } + staging_tarball_path="{staging_tarball_path}" if [ -n "$staging_tarball_path" ]; then # SCP fallback: tarball already uploaded by the client. diff --git a/crates/remote_server/src/setup.rs b/crates/remote_server/src/setup.rs index d5d27bc4b..3549794a9 100644 --- a/crates/remote_server/src/setup.rs +++ b/crates/remote_server/src/setup.rs @@ -270,7 +270,7 @@ pub fn parse_uname_output( }; let arch = match arch_str { - "x86_64" => RemoteArch::X86_64, + "x86_64" | "amd64" => RemoteArch::X86_64, "aarch64" | "arm64" | "armv8l" => RemoteArch::Aarch64, other => { return Err(Error::UnsupportedArch { @@ -433,6 +433,7 @@ pub fn install_script(staging_tarball_path: Option<&str>) -> String { "{no_http_client_exit_code}", &NO_HTTP_CLIENT_EXIT_CODE.to_string(), ) + .replace("{no_tar_exit_code}", &NO_TAR_EXIT_CODE.to_string()) .replace("{staging_tarball_path}", staging_tarball_path.unwrap_or("")) } @@ -493,6 +494,10 @@ pub fn download_tarball_url(platform: &RemotePlatform) -> String { /// trigger the SCP upload fallback. pub const NO_HTTP_CLIENT_EXIT_CODE: i32 = 3; +/// Exit code the install script uses when `tar` is not available. +/// The Rust side matches on this to trigger the gzip SCP fallback. +pub const NO_TAR_EXIT_CODE: i32 = 4; + /// Timeout for the binary existence check. pub const CHECK_TIMEOUT: Duration = Duration::from_secs(10); diff --git a/crates/remote_server/src/setup_tests.rs b/crates/remote_server/src/setup_tests.rs index 373d5c519..973603ffb 100644 --- a/crates/remote_server/src/setup_tests.rs +++ b/crates/remote_server/src/setup_tests.rs @@ -321,6 +321,52 @@ fn install_script_avoids_pattern_substitution_for_tilde_expansion() { ); } +#[test] +fn parse_uname_linux_amd64() { + let platform = parse_uname_output("Linux amd64").unwrap(); + assert_eq!(platform.os, RemoteOs::Linux); + assert_eq!(platform.arch, RemoteArch::X86_64); +} + +#[test] +fn parse_uname_unsupported_armv7l() { + let result = parse_uname_output("Linux armv7l"); + match result { + Err(crate::transport::Error::UnsupportedArch { arch }) => { + assert_eq!(arch, "armv7l"); + } + other => panic!("expected UnsupportedArch, got {other:?}"), + } +} + +#[test] +fn install_script_contains_no_tar_exit_code() { + let script = install_script(None); + // The template placeholder {no_tar_exit_code} should be replaced with + // the numeric value of NO_TAR_EXIT_CODE. + let expected = format!("exit {}", NO_TAR_EXIT_CODE); + assert!( + script.contains(&expected), + "install script should contain 'exit {val}' for NO_TAR_EXIT_CODE, \ + but it was not found. Searched for: {expected}", + val = NO_TAR_EXIT_CODE, + ); + // The raw placeholder must not survive substitution. + assert!( + !script.contains("{no_tar_exit_code}"), + "install script still contains the raw {{no_tar_exit_code}} placeholder", + ); +} + +#[test] +fn install_script_contains_tar_pre_check() { + let script = install_script(None); + assert!( + script.contains("command -v tar"), + "install script should contain a tar availability check", + ); +} + #[test] fn parse_preinstall_missing_status_falls_open() { // Garbled / partial script output — missing status field. Confirms