mirror of
https://github.com/warpdotdev/warp.git
synced 2026-05-06 15:22:21 +08:00
remote_server: gate install on glibc preinstall check + tolerate cleanup races (#9681)
## Description Two related fixes to the remote-server setup flow: ### 1. Gate remote-server install on a glibc preinstall check (APP-4281) The prebuilt remote-server binary is linked against glibc 2.31. On older Linux hosts (e.g. RHEL/Rocky/CentOS 8 with glibc 2.28) the binary downloads fine but fails at runtime, leaving users stuck. This PR adds a lightweight, fail-open preinstall probe that runs over the existing SSH `ControlMaster` socket *before* any user-visible install affordance. ### 2. Don't fail the install when staging-dir cleanup races `install_remote_server.sh` ran `trap 'rm -rf "$tmpdir"' EXIT` under `set -e`. When `rm -rf` hit a "Directory not empty" / `EBUSY` race after the binary had already been moved into place, the trap's non-zero exit replaced the script's success exit, surfacing as `install script failed (exit 1)` in the manager. The cleanup is now best-effort (`rm -rf "$tmpdir" 2>/dev/null || true`) so post-success races no longer break the install. Real install failures (curl, tar, missing binary, unsupported arch/OS) still propagate normally because they happen before the trap fires. Fixes APP-4281 ## Testing Tested locally with an RHEL 7 docker container (which uses glibc 2.28) ## Agent Mode - [x] Warp Agent Mode - This PR was created via Warp's AI Agent Mode [Conversation](https://staging.warp.dev/conversation/86cbc3ea-d9de-4b23-832a-fa8b23c0f295) · [Tech spec](https://staging.warp.dev/drive/notebook/oGmQYNHrHzuGx3FegfsEuQ) <!-- CHANGELOG-IMPROVEMENT: Warp now silently falls back to a regular SSH session on remote hosts where the prebuilt remote-server binary is incompatible (e.g. glibc < 2.31), instead of attempting an install that would fail at runtime. CHANGELOG-BUG-FIX: Remote-server installs no longer fail when the staging-directory cleanup hits a "Directory not empty" race after the binary has already been moved into place. --> Co-Authored-By: Oz <oz-agent@warp.dev>
This commit is contained in:
@@ -29,7 +29,16 @@ install_dir="${install_dir/#\~/"$HOME"}"
|
||||
mkdir -p "$install_dir"
|
||||
|
||||
tmpdir=$(mktemp -d "$install_dir/.install.XXXXXX")
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
# Best-effort cleanup of the staging directory. A failure here (e.g.
|
||||
# EBUSY or "Directory not empty" races on some filesystems/mounts)
|
||||
# must not fail the install: by the time this fires the binary has
|
||||
# either already been moved into its final location, or the script
|
||||
# has already failed for an unrelated reason that we want to surface
|
||||
# instead of clobbering with the cleanup's exit code.
|
||||
cleanup() {
|
||||
rm -rf "$tmpdir" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
curl -fSL "{download_base_url}?package=tar&os=$os_name&arch=$arch_name&channel={channel}{version_query}" \
|
||||
-o "$tmpdir/oz.tar.gz"
|
||||
|
||||
@@ -9,8 +9,12 @@ use crate::auth::RemoteServerAuthContext;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::client::ClientEvent;
|
||||
use crate::client::RemoteServerClient;
|
||||
use crate::setup::PreinstallCheckResult;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::setup::RemoteOs;
|
||||
use crate::setup::RemotePlatform;
|
||||
use crate::setup::RemoteServerSetupState;
|
||||
use crate::setup::UnsupportedReason;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::transport::Connection;
|
||||
use crate::transport::RemoteTransport;
|
||||
@@ -314,6 +318,12 @@ pub enum RemoteServerManagerEvent {
|
||||
/// The detected remote platform (OS + arch) from `uname -sm`.
|
||||
/// `None` if detection failed or was not attempted.
|
||||
remote_platform: Option<RemotePlatform>,
|
||||
/// Outcome of the preinstall check script. Populated when the
|
||||
/// script ran successfully against a Linux host. `None` when the
|
||||
/// host is not Linux (the script is skipped) or when the SSH-level
|
||||
/// invocation failed (the controller treats that as inconclusive
|
||||
/// and falls open).
|
||||
preinstall_check: Option<PreinstallCheckResult>,
|
||||
/// `true` if the remote already has an existing install of the
|
||||
/// remote-server binary, detected by probing whether the install
|
||||
/// directory exists (see `RemoteTransport::check_has_old_binary`).
|
||||
@@ -499,6 +509,25 @@ impl RemoteServerManager {
|
||||
false
|
||||
}
|
||||
};
|
||||
// Run the preinstall check after platform detection
|
||||
// resolves, only on Linux. macOS hosts pay zero extra
|
||||
// round-trips. SSH-level failures are logged and
|
||||
// surfaced as `None`, which the controller treats as
|
||||
// inconclusive (fail open).
|
||||
let preinstall = match &platform {
|
||||
Some(p) if matches!(p.os, RemoteOs::Linux) => {
|
||||
match transport.run_preinstall_check().await {
|
||||
Ok(r) => Some(r),
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Preinstall check failed for session {session_id:?}: {e}"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let _ = spawner
|
||||
.spawn(move |me, ctx| {
|
||||
if let Some(p) = &platform {
|
||||
@@ -516,6 +545,7 @@ impl RemoteServerManager {
|
||||
session_id,
|
||||
result: check_result,
|
||||
remote_platform: platform,
|
||||
preinstall_check: preinstall,
|
||||
has_old_binary,
|
||||
});
|
||||
})
|
||||
@@ -525,6 +555,39 @@ impl RemoteServerManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks a session as unsupported by the prebuilt remote-server
|
||||
/// binary, based on a positive classification from the preinstall
|
||||
/// check. The setup state transitions to `Unsupported`, which the
|
||||
/// downstream UI treats as a clean fall-back to the legacy SSH flow.
|
||||
///
|
||||
/// No-op on WASM (remote server connections use a different transport).
|
||||
#[cfg(target_family = "wasm")]
|
||||
pub fn mark_setup_unsupported(
|
||||
&mut self,
|
||||
_session_id: SessionId,
|
||||
_reason: UnsupportedReason,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
log::warn!("mark_setup_unsupported is a no-op on WASM");
|
||||
}
|
||||
|
||||
/// Marks a session as unsupported by the prebuilt remote-server
|
||||
/// binary, based on a positive classification from the preinstall
|
||||
/// check. The setup state transitions to `Unsupported`, which the
|
||||
/// downstream UI treats as a clean fall-back to the legacy SSH flow.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn mark_setup_unsupported(
|
||||
&mut self,
|
||||
session_id: SessionId,
|
||||
reason: UnsupportedReason,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
ctx.emit(RemoteServerManagerEvent::SetupStateChanged {
|
||||
session_id,
|
||||
state: RemoteServerSetupState::Unsupported { reason },
|
||||
});
|
||||
}
|
||||
|
||||
/// Installs the remote server binary.
|
||||
/// Emits `BinaryInstallComplete { result }`.
|
||||
///
|
||||
|
||||
64
crates/remote_server/src/preinstall_check.sh
Normal file
64
crates/remote_server/src/preinstall_check.sh
Normal file
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# Preinstall check for the Warp remote-server binary.
|
||||
#
|
||||
# Emits a structured key=value summary on stdout. Exits 0 on success.
|
||||
# A non-zero exit indicates a probe-level failure; the client treats
|
||||
# those as `status=unknown` (fail open).
|
||||
|
||||
set -u
|
||||
|
||||
# The minimum glibc the prebuilt Linux CLI requires. The Linux CLI is
|
||||
# built on Ubuntu 20.04 (see `.github/workflows/create_release.yml`),
|
||||
# which ships glibc 2.31. Bump this when the runner image is bumped.
|
||||
required_glibc="2.31"
|
||||
echo "required_glibc=${required_glibc}"
|
||||
|
||||
# 1. Detect libc family and (when glibc) its version.
|
||||
libc_family="unknown"
|
||||
libc_version=""
|
||||
|
||||
if version=$(getconf GNU_LIBC_VERSION 2>/dev/null); then
|
||||
# Output: "glibc 2.31"
|
||||
libc_family="glibc"
|
||||
libc_version="${version##* }"
|
||||
elif ldd_out=$(ldd --version 2>&1 | head -n1); then
|
||||
case "$ldd_out" in
|
||||
*musl*) libc_family="musl" ;;
|
||||
*uClibc*) libc_family="uclibc" ;;
|
||||
*)
|
||||
v=$(printf '%s\n' "$ldd_out" | grep -oE '[0-9]+\.[0-9]+' | head -n1)
|
||||
if [ -n "$v" ]; then
|
||||
libc_family="glibc"
|
||||
libc_version="$v"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
echo "libc_family=${libc_family}"
|
||||
[ -n "$libc_version" ] && echo "libc_version=${libc_version}"
|
||||
|
||||
# 2. Decide status from the gathered facts.
|
||||
status="unknown"
|
||||
reason=""
|
||||
|
||||
if [ "$libc_family" = "glibc" ] && [ -n "$libc_version" ]; then
|
||||
have_major="${libc_version%%.*}"
|
||||
have_minor="${libc_version#*.}"
|
||||
have_minor="${have_minor%%.*}"
|
||||
req_major="${required_glibc%%.*}"
|
||||
req_minor="${required_glibc#*.}"
|
||||
if [ "$have_major" -gt "$req_major" ] \
|
||||
|| { [ "$have_major" -eq "$req_major" ] && [ "$have_minor" -ge "$req_minor" ]; }; then
|
||||
status="supported"
|
||||
else
|
||||
status="unsupported"
|
||||
reason="glibc_too_old"
|
||||
fi
|
||||
elif [ "$libc_family" = "musl" ] || [ "$libc_family" = "bionic" ] || [ "$libc_family" = "uclibc" ]; then
|
||||
status="unsupported"
|
||||
reason="non_glibc"
|
||||
fi
|
||||
|
||||
echo "status=${status}"
|
||||
[ -n "$reason" ] && echo "reason=${reason}"
|
||||
@@ -1,3 +1,7 @@
|
||||
mod glibc;
|
||||
|
||||
pub use glibc::{GlibcVersion, RemoteLibc};
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
@@ -20,6 +24,11 @@ pub enum RemoteServerSetupState {
|
||||
Ready,
|
||||
/// Something failed. Fall back to ControlMaster.
|
||||
Failed { error: String },
|
||||
/// Preinstall check classified the host as incompatible with the
|
||||
/// prebuilt remote-server binary. The controller treats this as a
|
||||
/// clean fall-back to the legacy ControlMaster-backed SSH flow,
|
||||
/// distinct from `Failed` (which is rendered as a real error).
|
||||
Unsupported { reason: UnsupportedReason },
|
||||
}
|
||||
|
||||
impl RemoteServerSetupState {
|
||||
@@ -31,8 +40,12 @@ impl RemoteServerSetupState {
|
||||
matches!(self, Self::Failed { .. })
|
||||
}
|
||||
|
||||
pub fn is_unsupported(&self) -> bool {
|
||||
matches!(self, Self::Unsupported { .. })
|
||||
}
|
||||
|
||||
pub fn is_terminal(&self) -> bool {
|
||||
self.is_ready() || self.is_failed()
|
||||
self.is_ready() || self.is_failed() || self.is_unsupported()
|
||||
}
|
||||
|
||||
pub fn is_in_progress(&self) -> bool {
|
||||
@@ -50,6 +63,142 @@ impl RemoteServerSetupState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of [`crate::transport::RemoteTransport::run_preinstall_check`].
|
||||
///
|
||||
/// The script runs over the existing SSH socket before any install UI
|
||||
/// surfaces and reports whether the host can run the prebuilt
|
||||
/// remote-server binary. The Rust side is intentionally a thin parser
|
||||
/// over the script's structured stdout (see `preinstall_check.sh`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PreinstallCheckResult {
|
||||
pub status: PreinstallStatus,
|
||||
pub libc: RemoteLibc,
|
||||
/// Verbatim, trimmed script stdout. Forwarded to telemetry for
|
||||
/// diagnosing `Unknown` outcomes on exotic distros.
|
||||
pub raw: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum PreinstallStatus {
|
||||
Supported,
|
||||
Unsupported {
|
||||
reason: UnsupportedReason,
|
||||
},
|
||||
/// Probe ran but couldn't classify the host. Treated as supported
|
||||
/// (fail open) by [`PreinstallCheckResult::is_supported`] so we keep
|
||||
/// today's install-and-try behavior on hosts where the probe is
|
||||
/// unreliable.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum UnsupportedReason {
|
||||
GlibcTooOld {
|
||||
detected: GlibcVersion,
|
||||
required: GlibcVersion,
|
||||
},
|
||||
NonGlibc {
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl PreinstallCheckResult {
|
||||
/// Whether the host is supported. Both `Supported` and `Unknown`
|
||||
/// return true — only positive detection of an incompatible libc
|
||||
/// triggers the silent fall-back.
|
||||
pub fn is_supported(&self) -> bool {
|
||||
match self.status {
|
||||
PreinstallStatus::Supported | PreinstallStatus::Unknown => true,
|
||||
PreinstallStatus::Unsupported { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the structured `key=value` stdout emitted by
|
||||
/// `preinstall_check.sh`. Tolerates unknown keys and lines without
|
||||
/// `=` (forward-compatibility): future versions of the script can
|
||||
/// add new keys without coordinating a client release.
|
||||
pub fn parse(stdout: &str) -> Self {
|
||||
let mut status_str: Option<&str> = None;
|
||||
let mut reason_str: Option<&str> = None;
|
||||
let mut libc_family: Option<&str> = None;
|
||||
let mut libc_version: Option<&str> = None;
|
||||
let mut required_glibc: Option<&str> = None;
|
||||
|
||||
for line in stdout.lines() {
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
match key.trim() {
|
||||
"status" => status_str = Some(value.trim()),
|
||||
"reason" => reason_str = Some(value.trim()),
|
||||
"libc_family" => libc_family = Some(value.trim()),
|
||||
"libc_version" => libc_version = Some(value.trim()),
|
||||
"required_glibc" => required_glibc = Some(value.trim()),
|
||||
_ => {} // ignore unknown keys
|
||||
}
|
||||
}
|
||||
|
||||
let libc = glibc::parse_libc(libc_family, libc_version);
|
||||
let status = parse_status(status_str, reason_str, &libc, required_glibc);
|
||||
|
||||
Self {
|
||||
status,
|
||||
libc,
|
||||
raw: stdout.trim().to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_status(
|
||||
status: Option<&str>,
|
||||
reason: Option<&str>,
|
||||
libc: &RemoteLibc,
|
||||
required_glibc: Option<&str>,
|
||||
) -> PreinstallStatus {
|
||||
match status {
|
||||
Some("supported") => PreinstallStatus::Supported,
|
||||
Some("unsupported") => match reason {
|
||||
Some("glibc_too_old") => {
|
||||
let detected = match libc {
|
||||
RemoteLibc::Glibc(v) => Some(*v),
|
||||
_ => None,
|
||||
};
|
||||
let required = required_glibc.and_then(GlibcVersion::parse);
|
||||
match (detected, required) {
|
||||
(Some(detected), Some(required)) => PreinstallStatus::Unsupported {
|
||||
reason: UnsupportedReason::GlibcTooOld { detected, required },
|
||||
},
|
||||
// The script said `unsupported` + `glibc_too_old` but we
|
||||
// can't recover the numbers — fail open rather than
|
||||
// surface a malformed reason.
|
||||
_ => PreinstallStatus::Unknown,
|
||||
}
|
||||
}
|
||||
Some("non_glibc") => {
|
||||
let name = match libc {
|
||||
RemoteLibc::NonGlibc { name } => name.clone(),
|
||||
_ => "unknown".to_string(),
|
||||
};
|
||||
PreinstallStatus::Unsupported {
|
||||
reason: UnsupportedReason::NonGlibc { name },
|
||||
}
|
||||
}
|
||||
_ => PreinstallStatus::Unknown,
|
||||
},
|
||||
// status=unknown, missing, or anything else → fail open.
|
||||
_ => PreinstallStatus::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// The bundled preinstall check script. Loaded as a string so the SSH
|
||||
/// transport can pipe it through the existing ControlMaster socket via
|
||||
/// [`crate::ssh::run_ssh_script`].
|
||||
///
|
||||
/// The script is intentionally self-contained — the supported-glibc
|
||||
/// floor is hardcoded inside the script (see `preinstall_check.sh`)
|
||||
/// rather than templated from Rust.
|
||||
pub const PREINSTALL_CHECK_SCRIPT: &str = include_str!("preinstall_check.sh");
|
||||
|
||||
/// Detected remote platform from `uname -sm` output.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RemotePlatform {
|
||||
|
||||
143
crates/remote_server/src/setup/glibc.rs
Normal file
143
crates/remote_server/src/setup/glibc.rs
Normal file
@@ -0,0 +1,143 @@
|
||||
//! Parsing helpers for the libc family + version reported by the
|
||||
//! `preinstall_check.sh` script.
|
||||
//!
|
||||
//! This is split into its own submodule so the libc-specific logic
|
||||
//! (version parsing, family classification) can evolve independently
|
||||
//! from the rest of [`crate::setup`].
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// A glibc `(major, minor)` version pair, e.g. `2.31`.
|
||||
///
|
||||
/// Wraps a labelled struct rather than a raw `(u32, u32)` so the meaning
|
||||
/// of each field is obvious at call sites and in event payloads.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct GlibcVersion {
|
||||
pub major: u32,
|
||||
pub minor: u32,
|
||||
}
|
||||
|
||||
impl GlibcVersion {
|
||||
pub const fn new(major: u32, minor: u32) -> Self {
|
||||
Self { major, minor }
|
||||
}
|
||||
|
||||
/// Parses a `<major>.<minor>` (or `<major>.<minor>.<patch>`) string.
|
||||
/// Only the first two segments are consumed; trailing components
|
||||
/// (e.g. patch versions, distro suffixes) are ignored.
|
||||
///
|
||||
/// Returns `None` if either segment is missing or non-numeric.
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
let value = value.trim();
|
||||
let (major, rest) = value.split_once('.')?;
|
||||
// Allow `2.31`, `2.31.0`, `2.31-foo`, etc.
|
||||
let minor = rest.split(|c: char| !c.is_ascii_digit()).next()?;
|
||||
Some(Self {
|
||||
major: major.parse().ok()?,
|
||||
minor: minor.parse().ok()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for GlibcVersion {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}.{}", self.major, self.minor)
|
||||
}
|
||||
}
|
||||
|
||||
/// Detected libc on the remote host, derived from the `libc_family` /
|
||||
/// `libc_version` keys emitted by `preinstall_check.sh`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RemoteLibc {
|
||||
Glibc(GlibcVersion),
|
||||
NonGlibc { name: String },
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Builds a [`RemoteLibc`] from the raw `libc_family` / `libc_version`
|
||||
/// values pulled out of the script's `key=value` stdout.
|
||||
pub(super) fn parse_libc(family: Option<&str>, version: Option<&str>) -> RemoteLibc {
|
||||
match family {
|
||||
Some("glibc") => match version.and_then(GlibcVersion::parse) {
|
||||
Some(v) => RemoteLibc::Glibc(v),
|
||||
None => RemoteLibc::Unknown,
|
||||
},
|
||||
Some(name) if !name.is_empty() && name != "unknown" => RemoteLibc::NonGlibc {
|
||||
name: name.to_string(),
|
||||
},
|
||||
_ => RemoteLibc::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_major_minor() {
|
||||
assert_eq!(GlibcVersion::parse("2.31"), Some(GlibcVersion::new(2, 31)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_major_minor_patch() {
|
||||
assert_eq!(
|
||||
GlibcVersion::parse("2.35.0"),
|
||||
Some(GlibcVersion::new(2, 35))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_distro_suffix() {
|
||||
// e.g. "2.35-0ubuntu3.4"
|
||||
assert_eq!(
|
||||
GlibcVersion::parse("2.35-0ubuntu3.4"),
|
||||
Some(GlibcVersion::new(2, 35))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_on_garbage() {
|
||||
assert_eq!(GlibcVersion::parse("garbage"), None);
|
||||
assert_eq!(GlibcVersion::parse(""), None);
|
||||
assert_eq!(GlibcVersion::parse("2.x"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_libc_glibc() {
|
||||
assert_eq!(
|
||||
parse_libc(Some("glibc"), Some("2.31")),
|
||||
RemoteLibc::Glibc(GlibcVersion::new(2, 31))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_libc_glibc_unparseable_version() {
|
||||
assert_eq!(
|
||||
parse_libc(Some("glibc"), Some("garbage")),
|
||||
RemoteLibc::Unknown
|
||||
);
|
||||
assert_eq!(parse_libc(Some("glibc"), None), RemoteLibc::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_libc_non_glibc() {
|
||||
assert_eq!(
|
||||
parse_libc(Some("musl"), None),
|
||||
RemoteLibc::NonGlibc {
|
||||
name: "musl".to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_libc_unknown_family_treated_as_unknown() {
|
||||
assert_eq!(parse_libc(Some("unknown"), None), RemoteLibc::Unknown);
|
||||
assert_eq!(parse_libc(None, None), RemoteLibc::Unknown);
|
||||
assert_eq!(parse_libc(Some(""), None), RemoteLibc::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glibc_version_displays_as_dotted() {
|
||||
assert_eq!(format!("{}", GlibcVersion::new(2, 31)), "2.31");
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,12 @@ fn state_is_terminal() {
|
||||
error: "test".into()
|
||||
}
|
||||
.is_terminal());
|
||||
assert!(RemoteServerSetupState::Unsupported {
|
||||
reason: UnsupportedReason::NonGlibc {
|
||||
name: "musl".into()
|
||||
}
|
||||
}
|
||||
.is_terminal());
|
||||
assert!(!RemoteServerSetupState::Checking.is_terminal());
|
||||
assert!(!RemoteServerSetupState::Installing {
|
||||
progress_percent: None,
|
||||
@@ -107,3 +113,71 @@ fn state_is_terminal() {
|
||||
assert!(!RemoteServerSetupState::Updating.is_terminal());
|
||||
assert!(!RemoteServerSetupState::Initializing.is_terminal());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_preinstall_supported_glibc() {
|
||||
let stdout = "required_glibc=2.31\n\
|
||||
libc_family=glibc\n\
|
||||
libc_version=2.35\n\
|
||||
status=supported\n";
|
||||
let result = PreinstallCheckResult::parse(stdout);
|
||||
assert_eq!(result.status, PreinstallStatus::Supported);
|
||||
assert_eq!(result.libc, RemoteLibc::Glibc(GlibcVersion::new(2, 35)));
|
||||
assert!(result.is_supported());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_preinstall_unsupported_glibc_too_old() {
|
||||
let stdout = "required_glibc=2.31\n\
|
||||
libc_family=glibc\n\
|
||||
libc_version=2.17\n\
|
||||
status=unsupported\n\
|
||||
reason=glibc_too_old\n";
|
||||
let result = PreinstallCheckResult::parse(stdout);
|
||||
assert_eq!(
|
||||
result.status,
|
||||
PreinstallStatus::Unsupported {
|
||||
reason: UnsupportedReason::GlibcTooOld {
|
||||
detected: GlibcVersion::new(2, 17),
|
||||
required: GlibcVersion::new(2, 31),
|
||||
}
|
||||
}
|
||||
);
|
||||
assert!(!result.is_supported());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_preinstall_unsupported_non_glibc() {
|
||||
let stdout = "required_glibc=2.31\n\
|
||||
libc_family=musl\n\
|
||||
status=unsupported\n\
|
||||
reason=non_glibc\n";
|
||||
let result = PreinstallCheckResult::parse(stdout);
|
||||
assert_eq!(
|
||||
result.status,
|
||||
PreinstallStatus::Unsupported {
|
||||
reason: UnsupportedReason::NonGlibc {
|
||||
name: "musl".to_string()
|
||||
}
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
result.libc,
|
||||
RemoteLibc::NonGlibc {
|
||||
name: "musl".to_string()
|
||||
}
|
||||
);
|
||||
assert!(!result.is_supported());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_preinstall_missing_status_falls_open() {
|
||||
// Garbled / partial script output — missing status field. Confirms
|
||||
// the fail-open invariant: anything we can't positively classify as
|
||||
// unsupported degrades to Unknown and is treated as supported, so a
|
||||
// flaky probe doesn't block the install.
|
||||
let stdout = "libc_family=glibc\nlibc_version=2.35\n";
|
||||
let result = PreinstallCheckResult::parse(stdout);
|
||||
assert_eq!(result.status, PreinstallStatus::Unknown);
|
||||
assert!(result.is_supported());
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ use async_channel::Receiver;
|
||||
use warpui::r#async::executor;
|
||||
|
||||
use crate::client::{ClientEvent, RemoteServerClient};
|
||||
use crate::setup::RemotePlatform;
|
||||
use crate::setup::{PreinstallCheckResult, RemotePlatform};
|
||||
|
||||
/// A successful return from [`RemoteTransport::connect`].
|
||||
///
|
||||
@@ -69,6 +69,24 @@ pub trait RemoteTransport: Send + Sync + std::fmt::Debug {
|
||||
&self,
|
||||
) -> Pin<Box<dyn std::future::Future<Output = Result<RemotePlatform, String>> + Send>>;
|
||||
|
||||
/// Runs the preinstall check script ([`crate::setup::PREINSTALL_CHECK_SCRIPT`])
|
||||
/// over the existing connection and parses its structured stdout into
|
||||
/// a [`PreinstallCheckResult`].
|
||||
///
|
||||
/// This runs **before** any user-visible install affordance (the
|
||||
/// install choice block, auto-install, auto-update, or connect) and
|
||||
/// is the gate that decides whether to proceed with the install
|
||||
/// pipeline or fall back to the legacy SSH flow.
|
||||
///
|
||||
/// Returns `Ok(_)` on success (including when the script reported
|
||||
/// `Unknown` — that's a parser-level outcome, not a transport-level
|
||||
/// failure). Returns `Err(_)` only on SSH-level failure (timeout,
|
||||
/// broken pipe, non-zero exit with no parseable summary), which the
|
||||
/// caller treats as inconclusive (fail open).
|
||||
fn run_preinstall_check(
|
||||
&self,
|
||||
) -> Pin<Box<dyn std::future::Future<Output = Result<PreinstallCheckResult, String>> + Send>>;
|
||||
|
||||
/// Checks whether the remote server binary is present on the remote host.
|
||||
///
|
||||
/// Pure I/O — does not emit any events. The caller
|
||||
|
||||
Reference in New Issue
Block a user