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
148 changes: 148 additions & 0 deletions app/src/remote_server/ssh_transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
5 changes: 4 additions & 1 deletion crates/remote_server/src/install_remote_server.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion crates/remote_server/src/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(""))
}

Expand Down Expand Up @@ -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);

Expand Down
46 changes: 46 additions & 0 deletions crates/remote_server/src/setup_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down