From 73d974dea8a8d0b056a16dc6b2380d89438ce484 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:08:45 -0700 Subject: [PATCH] fix(sandbox): negotiate the Docker Engine API version (#457) The Engine client wrote /v1.54 into every request path. On Docker 29.1.3 (Engine API 1.52) every Sandbox create failed at its first request, GET /v1.54/volumes/{name}, with HTTP 400 "client version 1.54 is too new". #459 made that message visible; this makes the client speak the Engine's version. The first request of a Docker client now reads the Engine's version once, with an unversioned GET /version, and every request of that client and its clones uses /v1./ at the Engine's ApiVersion, up to 1.54. The call sites pass unversioned paths; Docker::send adds the prefix and transmit is the old send. An Engine below 1.48 is refused before any versioned request: an Unsupported failure with attempted false whose diagnostic names both versions ("Engine API 1.47 is older than the 1.48 to 1.54 this client speaks"). If GET /version cannot be read (not 200, not JSON, no ApiVersion) the client uses 1.54 as before, so nothing that works now regresses and the Engine's own refusal still reaches the caller. The probe is a separate read, never a retry: a request that fails is not sent again at another version. The floor comes from running it. The version history shows nothing the client uses changing after 1.30 (copyUIDGID) except the multiplexed-stream Content-Type on streams (1.42), and nothing in 1.53 or 1.54 touches a field it sends. But the daemon upload fails on Engines 23.0 to 27.5 (API 1.42 to 1.47), in PUT /containers/{id}/archive: "getent unable to find entry "0:0" in passwd database", the container's User under copyUIDGID. Engine 28.0 (API 1.48) and every later one tested pass. Tests: engine_protocol.rs gains four cases on a scripted Engine: a 1.52 Engine gets GET /version, then /v1.52 volume and create requests; a 1.56 Engine gets /v1.54; a 1.47 Engine is refused in the Volume phase with attempted false and both versions in the failure, /version the only request sent; a 404 on /version gets /v1.54. All four fail on the old client, which never asks /version. The peer in support/engine.rs and the Engine in engine_keepalive.rs answer that first request themselves (API 1.54) and leave it out of what finish() returns, so the existing tests keep their assertions; a peer with no responses expects no request at all. sandbox_create_debug_shows_docker_api_version_refusal_without_negotiating is renamed ..._without_retrying: it still proves the refused request is not resent. cargo test -p layerfs-sandbox: 36 pass. Rustfmt and Clippy -D warnings (workspace without layerfs-node) clean. Real SDK walk (LayerFS.init, open, sandbox.create, sandbox.close; debian:bookworm-slim sha256:7ff6ba31) in docker dind: Engine 29.1.3 (1.52) fails on main with the 400 above and passes now; 29.9.2 (1.56) and 28.0.4 (1.48) to 28.3.3 (1.51) pass; 27.5.1 (1.47) is refused with the message above. No tests deleted. Production LOC: 102200 -> 102288 (delta +88) Scope: first-party core, engine and API production source. Core 38241 and api 2646 unchanged; engine: 61313 -> 61401 (layerfs-sandbox 3329 -> 3417). Method: tools/production_loc.py --root on git archive of the first parent and the staged tree (5c6fe6fbca11 -> 563acd1d2306). Reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). Co-Authored-By: Claude Opus 5.5 --- api/README.md | 8 +- api/docs/agent.md | 12 ++- .../71-ordinary-engine-runtime-foundation.md | 2 +- .../src/backend/docker/archive.rs | 2 +- .../src/backend/docker/container.rs | 13 ++- .../src/backend/docker/endpoint.rs | 2 +- .../src/backend/docker/http.rs | 20 ++++- .../src/backend/docker/inspect.rs | 2 +- .../src/backend/docker/listener.rs | 4 +- .../layerfs-sandbox/src/backend/docker/mod.rs | 3 +- .../src/backend/docker/request.rs | 9 +- .../src/backend/docker/streams.rs | 11 ++- .../src/backend/docker/version.rs | 87 +++++++++++++++++++ .../layerfs-sandbox/tests/engine_keepalive.rs | 12 ++- .../layerfs-sandbox/tests/engine_protocol.rs | 64 +++++++++++++- .../layerfs-sandbox/tests/support/engine.rs | 20 ++++- 16 files changed, 234 insertions(+), 37 deletions(-) create mode 100644 engine/crates/layerfs-sandbox/src/backend/docker/version.rs diff --git a/api/README.md b/api/README.md index dbb6e5720c..9b7cd26445 100644 --- a/api/README.md +++ b/api/README.md @@ -77,9 +77,11 @@ place, avoiding stale macOS executable-page signature state. `CARGO_TARGET_DIR` must stay inside this worktree; `LAYERFS_NATIVE_OUTPUT` can select a separate platform artifact. Keep Linux addons separate from the host addon. -At runtime, the Docker Engine server selected by `LAYERFS_DOCKER` must accept -Engine API v1.54 requests. The SDK always uses explicit `/v1.54/...` paths and -does not negotiate from the server's maximum API version; see the [agent settings](docs/agent.md#settings). +At runtime, the Docker Engine server selected by `LAYERFS_DOCKER` must speak +Engine API v1.48 (Docker 28.0) or newer. Before its first request the SDK reads +the server's version once (`GET /version`) and uses `/v1./...` paths at +that version, up to v1.54; an older server is refused up front, naming both +versions. See the [agent settings](docs/agent.md#settings). New Store schema versions are 16–18 (pack layouts) and 22–24 (with acquisition), with history identity format 3. Old Disposable Stores are rejected without diff --git a/api/docs/agent.md b/api/docs/agent.md index 1c3febf513..18bb91f302 100644 --- a/api/docs/agent.md +++ b/api/docs/agent.md @@ -149,9 +149,15 @@ Keep each agent's operations on a Workspace sequential. | `LAYERFS_TRACE_STEP` | Optional step label put on each trace line as `step`; read at every call. | | `LAYERFS_TRACE_START` | `1` adds a start line before each traced call; read at every call. | -The Docker Engine server selected by `LAYERFS_DOCKER` must accept Engine API -v1.54 requests. The SDK always uses explicit `/v1.54/...` paths and does not -negotiate from the server's maximum API version. Check the server's API range +The Docker Engine server selected by `LAYERFS_DOCKER` must speak Engine API +v1.48 (Docker 28.0) or newer. Before its first request the SDK reads the +server's API version once with an unversioned `GET /version` and uses +`/v1./...` paths at that version, up to v1.54 (a newer server is +addressed as v1.54). A server older than v1.48 is refused before any other +request, with an error naming the server's version and the supported range: +earlier Engines fail to upload the daemon. If `GET /version` cannot be read, +the SDK uses v1.54, and the server's own refusal of it, if any, is the error. A +failed request is never resent at another version. Check the server's API range with `docker version`. The SDK and the `layerfs` command read only these `LAYERFS_` names. An diff --git a/core/docs/architecture/71-ordinary-engine-runtime-foundation.md b/core/docs/architecture/71-ordinary-engine-runtime-foundation.md index ea962556eb..c4d60a7467 100644 --- a/core/docs/architecture/71-ordinary-engine-runtime-foundation.md +++ b/core/docs/architecture/71-ordinary-engine-runtime-foundation.md @@ -7,7 +7,7 @@ > and command-client provenance are deferred by owner direction. Native FUSE > remains R2; ordinary execution adds no filesystem registration. -The active replacement Sandbox provides a concrete Docker Engine API1.54 Unix +The active replacement Sandbox provides a concrete Docker Engine API1.48 to 1.54 Unix adapter for ordinary command creation, non-TTY launch, independent stdin/output owners and coherent runtime inspection. It uses exact full acknowledged container and Exec IDs, not names, prefixes, host/VM PID guesses or filesystem registration. diff --git a/engine/crates/layerfs-sandbox/src/backend/docker/archive.rs b/engine/crates/layerfs-sandbox/src/backend/docker/archive.rs index b9265da182..79f44f975b 100644 --- a/engine/crates/layerfs-sandbox/src/backend/docker/archive.rs +++ b/engine/crates/layerfs-sandbox/src/backend/docker/archive.rs @@ -71,7 +71,7 @@ impl Sandbox { } self.upload_attempted = true; let path = format!( - "/v1.54/containers/{}/archive?path=/&noOverwriteDirNonDir=true©UIDGID=true", + "/containers/{}/archive?path=/&noOverwriteDirNonDir=true©UIDGID=true", self.id ); let mut response = self diff --git a/engine/crates/layerfs-sandbox/src/backend/docker/container.rs b/engine/crates/layerfs-sandbox/src/backend/docker/container.rs index 152e4b6ddc..0b1ceaa8a6 100644 --- a/engine/crates/layerfs-sandbox/src/backend/docker/container.rs +++ b/engine/crates/layerfs-sandbox/src/backend/docker/container.rs @@ -43,8 +43,7 @@ impl SandboxRequest { impl Docker { /// One read of an existing exact plain local volume; it has no create effect. fn plain_volume(&self, name: &str) -> Result<(), Box> { - let mut response = - self.exchange("GET", &format!("/v1.54/volumes/{name}"), false, |_| Ok(()))?; + let mut response = self.exchange("GET", &format!("/volumes/{name}"), false, |_| Ok(()))?; if response.status != 200 { return Err(response.fail(RuntimeError::Http(response.status), None)); } @@ -103,8 +102,8 @@ impl Docker { phase = CreatePhase::Create; // `naming` admitted only characters that need no URL escape. let path = match &self.container_name { - Some(name) => format!("/v1.54/containers/create?name={name}"), - None => "/v1.54/containers/create".to_owned(), + Some(name) => format!("/containers/create?name={name}"), + None => "/containers/create".to_owned(), }; let mut response = self.exchange("POST", &path, false, |w| request.encode(w))?; if response.status != 201 { @@ -181,7 +180,7 @@ impl Sandbox { return Err(self.invalid("original container Start admission")); } self.start_attempted = true; - self.empty("POST", &format!("/v1.54/containers/{}/start", self.id), 204)?; + self.empty("POST", &format!("/containers/{}/start", self.id), 204)?; self.started = true; Ok(()) } @@ -193,7 +192,7 @@ impl Sandbox { return Err(self.invalid("original container Stop admission")); } self.stop_attempted = true; - let path = format!("/v1.54/containers/{}/stop?t={seconds}", self.id); + let path = format!("/containers/{}/stop?t={seconds}", self.id); self.empty("POST", &path, 204) .or_else(|failure| match failure.cause { RuntimeError::Http(304) => Ok(()), @@ -210,7 +209,7 @@ impl Sandbox { self.delete_attempted = true; self.empty( "DELETE", - &format!("/v1.54/containers/{}?force=false&v=false", self.id), + &format!("/containers/{}?force=false&v=false", self.id), 204, )?; self.deleted = true; diff --git a/engine/crates/layerfs-sandbox/src/backend/docker/endpoint.rs b/engine/crates/layerfs-sandbox/src/backend/docker/endpoint.rs index fd8d0f0ddc..0d207f1049 100644 --- a/engine/crates/layerfs-sandbox/src/backend/docker/endpoint.rs +++ b/engine/crates/layerfs-sandbox/src/backend/docker/endpoint.rs @@ -65,7 +65,7 @@ impl Sandbox { .docker .exchange( "GET", - &format!("/v1.54/containers/{}/json", self.id), + &format!("/containers/{}/json", self.id), false, |_| Ok(()), ) diff --git a/engine/crates/layerfs-sandbox/src/backend/docker/http.rs b/engine/crates/layerfs-sandbox/src/backend/docker/http.rs index 47dcb8aad8..aecc3b0ee7 100644 --- a/engine/crates/layerfs-sandbox/src/backend/docker/http.rs +++ b/engine/crates/layerfs-sandbox/src/backend/docker/http.rs @@ -10,7 +10,7 @@ use std::{ io::{self, BufReader, Read, Write}, net::Shutdown, path::{Path, PathBuf}, - sync::{Arc, Mutex}, + sync::{Arc, Mutex, OnceLock}, time::{Duration, Instant}, }; /// How long an idle kept Engine connection may wait for its next request. @@ -28,6 +28,9 @@ pub struct Docker { /// Exec create and inspect (#352): each takes it, or connects when it is /// absent, stale or in use, and returns it after reading its whole body. pub(super) kept: Kept, + /// The Engine API minor version every request of this client and its + /// clones uses, set by the first request (see `version.rs`). + pub(super) minor: Arc>, } #[derive(Clone, Default)] pub(super) struct Kept(Arc>>); @@ -75,6 +78,7 @@ impl Docker { deadline: None, container_name: None, kept: Kept::default(), + minor: Arc::default(), }) } /// Names the containers this client creates `name`: an ASCII letter or @@ -142,6 +146,18 @@ impl Docker { media: &str, length: u64, encode: impl FnOnce(&mut dyn Write) -> io::Result<()>, + ) -> Result> { + let path = format!("/v1.{}{path}", self.minor()?); + self.transmit(method, &path, mode, media, length, encode) + } + pub(super) fn transmit( + &self, + method: &str, + path: &str, + mode: Mode, + media: &str, + length: u64, + encode: impl FnOnce(&mut dyn Write) -> io::Result<()>, ) -> Result> { let upgrade = matches!(mode, Mode::Upgrade); let reused = match mode { @@ -235,7 +251,7 @@ impl Docker { } /// The connection header a request sends. #[derive(Clone, Copy)] -enum Mode { +pub(super) enum Mode { Close, Upgrade, Keep, diff --git a/engine/crates/layerfs-sandbox/src/backend/docker/inspect.rs b/engine/crates/layerfs-sandbox/src/backend/docker/inspect.rs index 528fdc9bef..f4d5e88b4a 100644 --- a/engine/crates/layerfs-sandbox/src/backend/docker/inspect.rs +++ b/engine/crates/layerfs-sandbox/src/backend/docker/inspect.rs @@ -12,7 +12,7 @@ impl Docker { exec: ExecId, ) -> Result> { let mut response = self - .exchange_kept("GET", &format!("/v1.54/exec/{exec}/json"), |_| Ok(())) + .exchange_kept("GET", &format!("/exec/{exec}/json"), |_| Ok(())) .map_err(|mut failure| { failure.requested_container = Some(container); failure.requested_exec = Some(exec); diff --git a/engine/crates/layerfs-sandbox/src/backend/docker/listener.rs b/engine/crates/layerfs-sandbox/src/backend/docker/listener.rs index 03014c1055..f7b8709208 100644 --- a/engine/crates/layerfs-sandbox/src/backend/docker/listener.rs +++ b/engine/crates/layerfs-sandbox/src/backend/docker/listener.rs @@ -47,7 +47,7 @@ impl Sandbox { let mut r = pending.exchange( "GET", &format!( - "/v1.54/containers/{}/logs?stdout=true&stderr=true&follow=true&tail=all×tamps=false", + "/containers/{}/logs?stdout=true&stderr=true&follow=true&tail=all×tamps=false", self.id ), false, @@ -145,7 +145,7 @@ impl Sandbox { .exchange( "GET", &format!( - "/v1.54/containers/{}/logs?stdout=true&stderr=true&follow=false&tail={lines}×tamps=false", + "/containers/{}/logs?stdout=true&stderr=true&follow=false&tail={lines}×tamps=false", self.id ), false, diff --git a/engine/crates/layerfs-sandbox/src/backend/docker/mod.rs b/engine/crates/layerfs-sandbox/src/backend/docker/mod.rs index b4aaf1bf13..e0eedb87b1 100644 --- a/engine/crates/layerfs-sandbox/src/backend/docker/mod.rs +++ b/engine/crates/layerfs-sandbox/src/backend/docker/mod.rs @@ -1,4 +1,4 @@ -//! Local Docker Engine API1.54, with exact IDs and no command replay. +//! Local Docker Engine API1.48 to 1.54, negotiated once, with exact IDs and no command replay. mod archive; mod body; mod container; @@ -15,6 +15,7 @@ mod socket; mod streams; mod strings; mod topology; +mod version; pub use archive::{UploadFailure, UploadWork}; pub use container_types::{ CreatePhase, Sandbox, SandboxCreateFailure, SandboxDisposition, SandboxRequest, diff --git a/engine/crates/layerfs-sandbox/src/backend/docker/request.rs b/engine/crates/layerfs-sandbox/src/backend/docker/request.rs index 7937da532a..b7622d7efc 100644 --- a/engine/crates/layerfs-sandbox/src/backend/docker/request.rs +++ b/engine/crates/layerfs-sandbox/src/backend/docker/request.rs @@ -119,11 +119,10 @@ impl Docker { error_body: None, }) })?; - let mut response = self.exchange_kept( - "POST", - &format!("/v1.54/containers/{container}/exec"), - |w| request.encode(w), - )?; + let mut response = + self.exchange_kept("POST", &format!("/containers/{container}/exec"), |w| { + request.encode(w) + })?; if response.status != 201 { return Err(response.fail(RuntimeError::Http(response.status), None)); } diff --git a/engine/crates/layerfs-sandbox/src/backend/docker/streams.rs b/engine/crates/layerfs-sandbox/src/backend/docker/streams.rs index e5038b839e..9a014ab331 100644 --- a/engine/crates/layerfs-sandbox/src/backend/docker/streams.rs +++ b/engine/crates/layerfs-sandbox/src/backend/docker/streams.rs @@ -97,12 +97,11 @@ impl ExecCreated { })); } self.start_attempted = true; - let result = self.docker.exchange( - "POST", - &format!("/v1.54/exec/{}/start", self.exec), - true, - |w| w.write_all(b"{\"Detach\":false,\"Tty\":false}"), - ); + let result = + self.docker + .exchange("POST", &format!("/exec/{}/start", self.exec), true, |w| { + w.write_all(b"{\"Detach\":false,\"Tty\":false}") + }); match result { Ok(response) if response.status == 101 => Ok(Attached { created: self, diff --git a/engine/crates/layerfs-sandbox/src/backend/docker/version.rs b/engine/crates/layerfs-sandbox/src/backend/docker/version.rs new file mode 100644 index 0000000000..ae3504b098 --- /dev/null +++ b/engine/crates/layerfs-sandbox/src/backend/docker/version.rs @@ -0,0 +1,87 @@ +//! The Engine API version a client speaks: the daemon's, up to the newest this client targets. +use super::{ + http::{failure, Mode}, + json::{once, Json}, + Docker, +}; +use crate::{ErrorDiagnostic, RuntimeError, WireFailure}; +/// The oldest Engine API minor version (1.N) this client speaks: Engine 28.0. +/// Before it, uploading the daemon fails (`getent unable to find entry "0:0"`: +/// the archive's `copyUIDGID` cannot resolve the container's `0:0` user). +const OLDEST: u32 = 48; +/// The newest Engine API minor version this client speaks. +const NEWEST: u32 = 54; +impl Docker { + /// The minor version every request of this client and its clones uses. The + /// first request reads the daemon's with one unversioned `GET /version`; a + /// daemon older than [`OLDEST`] is refused before the request is sent. A + /// daemon that does not answer it readably gets [`NEWEST`], as before + /// negotiation, and its own refusal of that version reaches the caller. + pub(super) fn minor(&self) -> Result> { + if let Some(minor) = self.minor.get() { + return Ok(*minor); + } + let minor = match self.daemon_minor() { + Some(daemon) if daemon < OLDEST => return Err(refusal(daemon)), + Some(daemon) => daemon.min(NEWEST), + None => NEWEST, + }; + Ok(*self.minor.get_or_init(|| minor)) + } + /// One read of the daemon's newest API minor version; never resent or retried. + fn daemon_minor(&self) -> Option { + let mut response = self + .transmit( + "GET", + "/version", + Mode::Close, + "application/json", + 0, + |_| Ok(()), + ) + .ok()?; + if response.status != 200 { + return None; + } + let mut bits = 0; + let mut minor = None; + let mut j = Json::new(&mut response.body); + j.object(|j, key| { + if key.equals("ApiVersion") { + once(&mut bits, 1)?; + let version = j.string(true)?; + minor = version + .text() + .strip_prefix("1.") + .and_then(|minor| minor.parse().ok()) + .filter(|_| !version.overflow); + } else { + j.skip(1)?; + } + Ok(()) + }) + .ok()?; + j.finish().ok()?; + minor + } +} +fn refusal(daemon: u32) -> Box { + let message = format!( + "Engine API 1.{daemon} is older than the 1.{OLDEST} to 1.{NEWEST} this client speaks" + ) + .into_bytes(); + let mut refused = failure( + false, + 0, + None, + RuntimeError::Unsupported("Engine API version"), + None, + ); + refused.error_body = Some(ErrorDiagnostic { + observed_bytes: message.len() as u64, + prefix: message, + complete: true, + read_error: None, + }); + refused +} diff --git a/engine/crates/layerfs-sandbox/tests/engine_keepalive.rs b/engine/crates/layerfs-sandbox/tests/engine_keepalive.rs index 13c3a2a8c5..7f8bc5ad60 100644 --- a/engine/crates/layerfs-sandbox/tests/engine_keepalive.rs +++ b/engine/crates/layerfs-sandbox/tests/engine_keepalive.rs @@ -70,7 +70,13 @@ struct Engine { worker: Option>>>, } impl Engine { - fn new(script: Vec) -> (Docker, Self) { + /// The client's first connection asks the version, so the script follows + /// an Engine that answers it; `finish` leaves that connection out. + fn new(mut script: Vec) -> (Docker, Self) { + script.insert( + 0, + (vec![response(200, r#"{"ApiVersion":"1.54"}"#, true)], None), + ); let directory = std::env::temp_dir().join(format!( "layerfs-keepalive-{}-{}", std::process::id(), @@ -118,7 +124,9 @@ impl Engine { } /// The request heads each connection received. fn finish(mut self) -> Vec> { - self.worker.take().unwrap().join().unwrap() + let mut connections = self.worker.take().unwrap().join().unwrap(); + connections.remove(0); + connections } } impl Drop for Engine { diff --git a/engine/crates/layerfs-sandbox/tests/engine_protocol.rs b/engine/crates/layerfs-sandbox/tests/engine_protocol.rs index 4acf25f551..c7c97e3284 100644 --- a/engine/crates/layerfs-sandbox/tests/engine_protocol.rs +++ b/engine/crates/layerfs-sandbox/tests/engine_protocol.rs @@ -230,7 +230,7 @@ fn actual_mismatched_inspection_and_http_error_body_remain_distinct_from_selecto server.finish(); } #[test] -fn sandbox_create_debug_shows_docker_api_version_refusal_without_negotiating() { +fn sandbox_create_debug_shows_docker_api_version_refusal_without_retrying() { let message = "client version 1.54 is too new. Maximum supported API version is 1.52"; let body = format!("{{\"message\":\"{message}\"}}"); let volume = br#"{"Name":"project-store","Driver":"local","Scope":"local","Options":null}"#; @@ -267,6 +267,68 @@ fn sandbox_volume_api_version_refusal_stops_before_create() { assert!(request.starts_with("GET /v1.54/volumes/project-store HTTP/1.1")); assert!(!request.contains("POST /v1.54/containers/create")); } +fn volume() -> Vec { + peer::fixed( + 200, + br#"{"Name":"project-store","Driver":"local","Scope":"local","Options":null}"#, + ) +} +fn create_requests(version: Vec) -> Vec { + let (docker, server) = peer::Peer::scripted(vec![version, volume(), created()], 8192); + docker.create_sandbox(sandbox_request()).unwrap(); + let requests = server.finish(); + requests + .iter() + .map(|request| { + String::from_utf8_lossy(request) + .lines() + .next() + .unwrap() + .to_owned() + }) + .collect() +} +#[test] +fn an_older_engine_gets_its_own_api_version() { + let heads = create_requests(peer::fixed(200, br#"{"ApiVersion":"1.52"}"#)); + assert_eq!( + heads, + [ + "GET /version HTTP/1.1", + "GET /v1.52/volumes/project-store HTTP/1.1", + "POST /v1.52/containers/create HTTP/1.1" + ] + ); +} +#[test] +fn a_newer_engine_gets_api_1_54() { + let heads = create_requests(peer::fixed(200, br#"{"ApiVersion":"1.56"}"#)); + assert_eq!(heads[1], "GET /v1.54/volumes/project-store HTTP/1.1"); + assert_eq!(heads[2], "POST /v1.54/containers/create HTTP/1.1"); +} +#[test] +fn an_engine_below_api_1_48_is_refused_before_any_versioned_request() { + let (docker, server) = + peer::Peer::scripted(vec![peer::fixed(200, br#"{"ApiVersion":"1.47"}"#)], 8192); + let failed = docker.create_sandbox(sandbox_request()).unwrap_err(); + assert_eq!(failed.phase, CreatePhase::Volume); + assert!(!failed.transfer.attempted); + assert!(matches!( + failed.transfer.cause, + RuntimeError::Unsupported(_) + )); + let rendered = format!("{failed:?}"); + assert!(rendered.contains("Engine API 1.47 is older than the 1.48 to 1.54 this client speaks")); + let requests = server.finish(); + assert_eq!(requests.len(), 1); + assert!(requests[0].starts_with(b"GET /version HTTP/1.1")); +} +#[test] +fn an_engine_that_does_not_report_its_version_gets_api_1_54() { + let heads = create_requests(peer::fixed(404, br#"{"message":"page not found"}"#)); + assert_eq!(heads[1], "GET /v1.54/volumes/project-store HTTP/1.1"); + assert_eq!(heads[2], "POST /v1.54/containers/create HTTP/1.1"); +} #[test] fn http_error_debug_shows_only_the_bounded_prefix() { let mut body = vec![b'x'; 1024]; diff --git a/engine/crates/layerfs-sandbox/tests/support/engine.rs b/engine/crates/layerfs-sandbox/tests/support/engine.rs index 712f3fbc3b..8918f6741c 100644 --- a/engine/crates/layerfs-sandbox/tests/support/engine.rs +++ b/engine/crates/layerfs-sandbox/tests/support/engine.rs @@ -13,9 +13,25 @@ static NEXT: AtomicUsize = AtomicUsize::new(0); pub struct Peer { directory: PathBuf, worker: Option>>>, + /// Leading requests `finish` leaves out: the client's version probe. + probes: usize, } impl Peer { + /// A peer whose Engine reports API 1.54 first, as an unprobed client asks; + /// `finish` returns only the requests after that probe. No responses + /// means no request at all, so no probe either. pub fn new(responses: Vec>, split: usize) -> (Docker, Self) { + if responses.is_empty() { + return Self::scripted(responses, split); + } + let mut served = vec![fixed(200, br#"{"ApiVersion":"1.54"}"#)]; + served.extend(responses); + let (docker, mut peer) = Self::scripted(served, split); + peer.probes = 1; + (docker, peer) + } + /// A peer that serves exactly `responses`, probe included. + pub fn scripted(responses: Vec>, split: usize) -> (Docker, Self) { let directory = std::env::temp_dir().join(format!( "layerfs-engine-{}-{}", std::process::id(), @@ -105,12 +121,14 @@ impl Peer { Self { directory, worker: Some(worker), + probes: 0, }, ) } pub fn finish(mut self) -> Vec> { - let result = self.worker.take().unwrap().join().unwrap(); + let mut result = self.worker.take().unwrap().join().unwrap(); fs::remove_dir_all(&self.directory).unwrap(); + result.drain(..self.probes); result } }