Skip to main content

local_driver/
local_runtime.rs

1//! Local-container runtime: detect an orbstack/docker-desktop/colima/podman/docker
2//! socket per `.yah/infra/providers/orbstack.toml`, then drive appliance containers
3//! (miniflare, MinIO) for the pond mirror shape.
4//!
5//! Shells out to the `docker` CLI rather than linking a Docker REST client.
6//! OrbStack, Docker Desktop, Colima, and Podman all expose a Docker-compatible
7//! socket; the call sites here are few enough that per-invocation process
8//! overhead is invisible inside the container spin-up budget (few seconds).
9//!
10//! ## Provider cascade
11//!
12//! [`RuntimeProvider`] is the probe contract each back-end implements.
13//! [`LocalContainerSpec::build_cascade`] returns the ordered provider list;
14//! [`LocalRuntime::detect`] walks it and picks the first available one.
15//! Default Auto order: OrbStack → Docker Desktop → Colima → Podman → Docker →
16//! custom `DOCKER_HOST`.
17//!
18//! ## Module shape
19//!
20//! - [`LocalContainerSpec`] carries a runtime preference + socket discovery
21//!   map. The cloud crate provides the `kind = "local-container"`
22//!   ProviderConfig adapter via `cloud::local_container_spec_from_provider`.
23//! - [`LocalRuntime::detect`] probes sockets in preference order, expands `~`
24//!   in paths, and returns a handle that downstream callers feed every docker
25//!   invocation through.
26//! - [`LocalRuntime`] exposes the lifecycle primitives R256-T3 will compose
27//!   into a reconciler: `ensure_image`, `run`, `stop_and_remove`,
28//!   `container_state`, `list_owned`.
29//!
30//! ## Container naming + orphan cleanup
31//!
32//! Every container this module starts gets:
33//! - A canonical name: `yah-pond-<service>-<env>-<slot>` via [`canonical_name`].
34//!   The `yah-pond-` prefix is grep-friendly in `docker ps` output.
35//! - A docker label `yah.pond = <service>:<env>:<slot>` for filtered
36//!   queries via `docker ps --filter label=yah.pond`.
37//!
38//! After a crash that misses graceful shutdown, [`LocalRuntime::list_owned`]
39//! enumerates leftovers and the caller can reap them before starting a fresh
40//! up cycle.
41//!
42//! @yah:relay(R275, "Tier 2 — Container substrate: provider trait + non-orbstack fallbacks")
43//! @yah:at(2026-05-21T21:56:51Z)
44//! @yah:status(review)
45//! @yah:parent(Q273)
46//! @yah:next("F1: promote local_runtime probe into RuntimeProvider trait with explicit OrbStack / Docker Desktop / Colima / Podman / custom DOCKER_HOST providers (per visiting doc)")
47//! @yah:next("F2: settings-panel UX surfacing Detected/Current/Mode in rig-prefs")
48//! @yah:next("F3: spike built-in macOS VM via Virtualization.framework — go/no-go + scoping estimate; defer build until a real 'Nothing found' case")
49//! @yah:next("F4: route reconciler/local_sim.rs Caddy+MinIO path through the new trait (currently calls orbstack-via-docker-CLI directly)")
50//! @yah:gotcha("Today only orbstack-via-docker-CLI is exercised in practice; the cascade exists implicitly. Don't assume other backends work until F1 lands.")
51//! @arch:see(visiting/container-runtime-strategy.md)
52//! @arch:see(.yah/docs/working/W080-dev-yah-static-demo.md)
53//! @arch:see(.yah/docs/architecture/A031-yah-cloud-config-shape.md)
54//! @yah:handoff("F1 + F4 landed: RuntimeProvider trait (name/available/docker_host), SocketRuntimeProvider, CustomDockerHostProvider — all in local_runtime.rs. RuntimePref/DetectedRuntime expanded with DockerDesktop, Podman, Custom variants. LocalContainerSpec gains custom_docker_host: Option<String> and build_cascade() (returns ordered Vec<(DetectedRuntime, Box<dyn RuntimeProvider>)> for settings-panel use). LocalRuntime.socket: PathBuf replaced by docker_host: String; cmd() uses it directly. local_sim.rs F4 log line updated. lib.rs re-exports updated. 164 tests pass.")
55//! @yah:next("F2: settings-panel UX — build_cascade() returns the provider list; surface Detected/Current/Mode in rig-prefs UI (packages/yah/ui)")
56//! @yah:next("F3: spike built-in macOS VM via Virtualization.framework — go/no-go + scoping estimate; defer build until a real 'Nothing found' case arises in dogfood")
57//! @yah:handoff("F1 + F4 already landed (prior session). F2 now landed: local_runtime_probe Tauri command in app/yah/desktop/src/local_runtime_cmd.rs probes all kind=local-container providers (build_cascade + detect); wire types WireLocalRuntimeCandidate/WireLocalRuntimeStatus in env/types.ts; LocalRuntimeRpc interface added to Rpc in env/index.ts; tauri.ts + browser.ts stub wired; LocalRuntimePanel component at packages/yah/ui/src/components/shell/LocalRuntimePanel.tsx; Settings → Container Runtime section added to SettingsView (SettingsSection type, SECTIONS array, panel render). RuntimePref::as_str() added to local_runtime.rs. 164 cloud lib tests + bun typecheck both pass clean.")
58//! @yah:next("F3 (built-in macOS VM via Virtualization.framework): deferred — arch doc (visiting/container-runtime-strategy.md) covers the go/no-go and scoping; defer build until a real Nothing-found case surfaces in dogfood. File as a child spike if/when dogfood hits that case.")
59//! @yah:handoff("All deliverable features shipped: F1 (RuntimeProvider trait + SocketRuntimeProvider/CustomDockerHostProvider + full cascade), F2 (LocalRuntimePanel settings UI + Tauri command + wire types), F4 (local_sim.rs routed through trait). F3 (macOS Virtualization.framework spike) remains deferred until dogfood surfaces a Nothing-found case. This session fixed the only remaining breakage: mesofact_static_e2e.rs was missing the adopt_only field added to LocalStaticOptions — fixed with ..LocalStaticOptions::default(). cargo test -p cloud: all pass. bun typecheck: R275 files clean (pre-existing PartyView/test errors from other in-flight work).")
60
61use std::collections::BTreeMap;
62use std::path::{Path, PathBuf};
63use std::process::Stdio;
64use std::time::Duration;
65
66use anyhow::{bail, Context, Result};
67use tokio::process::Command;
68use tracing::{debug, warn};
69use workload_spec::{EnvValue, VolumeSource, WorkloadRuntime, WorkloadSpec};
70
71// ── RuntimeProvider trait + implementations ───────────────────────────────────
72
73/// Probe contract for a single Docker-compatible runtime back-end.
74/// Each implementation knows its socket path (or raw `DOCKER_HOST`) and can
75/// report whether it is currently reachable.
76pub trait RuntimeProvider: Send + Sync {
77    /// Short, stable identifier used in config keys and log output
78    /// (e.g. `"orbstack"`, `"docker-desktop"`, `"colima"`, `"podman"`,
79    /// `"docker"`, `"custom"`).
80    fn name(&self) -> &str;
81    /// True when the runtime can be used right now (socket exists, etc.).
82    fn available(&self) -> bool;
83    /// The value for the `DOCKER_HOST` env var
84    /// (e.g. `"unix:///…"` or `"tcp://localhost:2375"`).
85    fn docker_host(&self) -> String;
86}
87
88/// [`RuntimeProvider`] backed by a Unix socket path.
89/// Available iff the tilde-expanded path exists on disk.
90pub struct SocketRuntimeProvider {
91    pub label: String,
92    pub socket: PathBuf,
93}
94
95impl RuntimeProvider for SocketRuntimeProvider {
96    fn name(&self) -> &str {
97        &self.label
98    }
99    fn available(&self) -> bool {
100        expand_tilde(&self.socket).exists()
101    }
102    fn docker_host(&self) -> String {
103        format!("unix://{}", expand_tilde(&self.socket).display())
104    }
105}
106
107/// [`RuntimeProvider`] for a raw `DOCKER_HOST` string supplied by the operator
108/// (e.g. `"tcp://localhost:2375"` or `"unix:///path/to/custom.sock"`).
109/// Always reported as available — the operator opted in explicitly; failures
110/// surface as docker CLI errors rather than probe misses.
111pub struct CustomDockerHostProvider {
112    pub host: String,
113}
114
115impl RuntimeProvider for CustomDockerHostProvider {
116    fn name(&self) -> &str {
117        "custom"
118    }
119    fn available(&self) -> bool {
120        true
121    }
122    fn docker_host(&self) -> String {
123        self.host.clone()
124    }
125}
126
127/// Canonical name prefix for every container managed by this module.
128pub const NAME_PREFIX: &str = "yah-pond-";
129
130/// Legacy name prefix from before the sim→pond rename (R362-F5).
131/// Used by orphan reconciliation to detect and reap old-generation containers.
132pub const LEGACY_NAME_PREFIX: &str = "yah-sim-";
133
134/// Docker label key applied to every container this module owns. The value
135/// is `<service>:<env>:<slot>` so `docker ps --filter label=yah.pond=<v>`
136/// scopes orphan cleanup to a specific mirror.
137pub const LABEL_KEY: &str = "yah.pond";
138
139/// Legacy label key from before the sim→pond rename. Used by `list_owned`
140/// to detect old-generation containers during the transition period.
141pub const LEGACY_LABEL_KEY: &str = "yah.local-sim";
142
143/// Build the canonical container name for a (service, env, slot) triple.
144pub fn canonical_name(service: &str, env: &str, slot: &str) -> String {
145    format!("{NAME_PREFIX}{service}-{env}-{slot}")
146}
147
148/// Build the canonical label value for the same triple.
149pub fn canonical_label(service: &str, env: &str, slot: &str) -> String {
150    format!("{service}:{env}:{slot}")
151}
152
153/// Canonical per-cell bridge network name. Every container in a pond cell
154/// (MinIO, miniflare, mesofact-dev, …) joins this network so they reach each
155/// other by their `--network-alias` (R455-F1). One network per (service, env)
156/// pair lets two services' ponds coexist without per-port collision juggling.
157pub fn pond_network_name(service: &str, env: &str) -> String {
158    format!("{NAME_PREFIX}{service}-{env}")
159}
160
161/// Operator-declared runtime preference from the provider TOML's top-level
162/// `runtime` field. `auto` walks the full cascade; any pinned value probes
163/// only that runtime.
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub enum RuntimePref {
166    Auto,
167    Orbstack,
168    DockerDesktop,
169    Colima,
170    Podman,
171    Docker,
172    /// Use the raw `custom_docker_host` string directly.
173    Custom,
174}
175
176impl RuntimePref {
177    pub fn as_str(&self) -> &'static str {
178        match self {
179            Self::Auto => "auto",
180            Self::Orbstack => "orbstack",
181            Self::DockerDesktop => "docker-desktop",
182            Self::Colima => "colima",
183            Self::Podman => "podman",
184            Self::Docker => "docker",
185            Self::Custom => "custom",
186        }
187    }
188
189    pub fn parse(s: &str) -> Result<Self> {
190        match s {
191            "auto" => Ok(Self::Auto),
192            "orbstack" => Ok(Self::Orbstack),
193            "docker-desktop" | "docker_desktop" => Ok(Self::DockerDesktop),
194            "colima" => Ok(Self::Colima),
195            "podman" => Ok(Self::Podman),
196            "docker" => Ok(Self::Docker),
197            "custom" => Ok(Self::Custom),
198            other => bail!(
199                "unknown runtime preference {other:?} \
200                 (expected auto/orbstack/docker-desktop/colima/podman/docker/custom)"
201            ),
202        }
203    }
204}
205
206/// Probe spec lifted from a `kind = "local-container"` provider TOML.
207#[derive(Debug, Clone)]
208pub struct LocalContainerSpec {
209    pub runtime: RuntimePref,
210    /// Socket paths keyed by runtime name. Recognised keys: `orbstack`,
211    /// `docker-desktop`, `colima`, `podman`, `docker`. Values are raw paths
212    /// (no `unix://` scheme); tilde expansion happens at probe time.
213    pub discovery: BTreeMap<String, PathBuf>,
214    /// Raw `DOCKER_HOST` value for `runtime = "custom"` (e.g.
215    /// `"tcp://localhost:2375"` or `"unix:///path/to/custom.sock"`).
216    /// When `runtime = "auto"` this is tried as a last-resort fallback after
217    /// all socket candidates fail. Ignored for other pinned runtimes.
218    pub custom_docker_host: Option<String>,
219}
220
221impl LocalContainerSpec {
222    /// Build the ordered [`RuntimeProvider`] cascade for this spec.
223    ///
224    /// Returns every candidate that has a discovery entry (or a configured
225    /// custom host), in probe order. Unlike [`LocalRuntime::detect`], this does
226    /// not stop at the first available provider — callers can iterate the list
227    /// themselves to render a "Detected / Not found" settings panel.
228    pub fn build_cascade(&self) -> Vec<(DetectedRuntime, Box<dyn RuntimeProvider>)> {
229        if matches!(self.runtime, RuntimePref::Custom) {
230            return if let Some(host) = &self.custom_docker_host {
231                vec![(
232                    DetectedRuntime::Custom,
233                    Box::new(CustomDockerHostProvider { host: host.clone() }),
234                )]
235            } else {
236                vec![]
237            };
238        }
239
240        let order: &[DetectedRuntime] = match self.runtime {
241            RuntimePref::Auto => &[
242                DetectedRuntime::Orbstack,
243                DetectedRuntime::DockerDesktop,
244                DetectedRuntime::Colima,
245                DetectedRuntime::Podman,
246                DetectedRuntime::Docker,
247            ],
248            RuntimePref::Orbstack => &[DetectedRuntime::Orbstack],
249            RuntimePref::DockerDesktop => &[DetectedRuntime::DockerDesktop],
250            RuntimePref::Colima => &[DetectedRuntime::Colima],
251            RuntimePref::Podman => &[DetectedRuntime::Podman],
252            RuntimePref::Docker => &[DetectedRuntime::Docker],
253            RuntimePref::Custom => unreachable!("handled above"),
254        };
255
256        let mut result: Vec<(DetectedRuntime, Box<dyn RuntimeProvider>)> = order
257            .iter()
258            .filter_map(|&kind| {
259                self.discovery.get(kind.as_str()).map(|p| -> (DetectedRuntime, Box<dyn RuntimeProvider>) {
260                    (kind, Box::new(SocketRuntimeProvider {
261                        label: kind.as_str().to_string(),
262                        socket: p.clone(),
263                    }))
264                })
265            })
266            .collect();
267
268        // Auto: custom host surfaces as last-resort fallback.
269        if matches!(self.runtime, RuntimePref::Auto) {
270            if let Some(host) = &self.custom_docker_host {
271                result.push((
272                    DetectedRuntime::Custom,
273                    Box::new(CustomDockerHostProvider { host: host.clone() }),
274                ));
275            }
276        }
277        result
278    }
279}
280
281/// Which runtime answered the probe cascade.
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub enum DetectedRuntime {
284    Orbstack,
285    DockerDesktop,
286    Colima,
287    Podman,
288    Docker,
289    /// Operator-supplied raw `DOCKER_HOST` string (tcp:// or unix://).
290    Custom,
291}
292
293impl DetectedRuntime {
294    pub fn as_str(&self) -> &'static str {
295        match self {
296            Self::Orbstack => "orbstack",
297            Self::DockerDesktop => "docker-desktop",
298            Self::Colima => "colima",
299            Self::Podman => "podman",
300            Self::Docker => "docker",
301            Self::Custom => "custom",
302        }
303    }
304}
305
306/// Reachable local container daemon — the winning provider from the cascade.
307#[derive(Debug, Clone)]
308pub struct LocalRuntime {
309    pub detected: DetectedRuntime,
310    /// `DOCKER_HOST` value used for every `docker` CLI invocation against this
311    /// runtime (e.g. `"unix:///…"` or `"tcp://localhost:2375"`).
312    pub docker_host: String,
313}
314
315impl LocalRuntime {
316    /// Probe providers in `spec.runtime` order. `Auto` walks the full cascade
317    /// orbstack → docker-desktop → colima → podman → docker → custom; a
318    /// pinned preference probes only that runtime. Each socket path is
319    /// tilde-expanded and existence-checked.
320    pub async fn detect(spec: &LocalContainerSpec) -> Result<Self> {
321        // Custom DOCKER_HOST: skip the cascade entirely.
322        if matches!(spec.runtime, RuntimePref::Custom) {
323            let host = spec.custom_docker_host.as_deref().ok_or_else(|| {
324                anyhow::anyhow!(
325                    "runtime = custom but no custom_docker_host declared in the provider"
326                )
327            })?;
328            return Ok(Self { detected: DetectedRuntime::Custom, docker_host: host.to_string() });
329        }
330
331        let order: &[DetectedRuntime] = match spec.runtime {
332            RuntimePref::Auto => &[
333                DetectedRuntime::Orbstack,
334                DetectedRuntime::DockerDesktop,
335                DetectedRuntime::Colima,
336                DetectedRuntime::Podman,
337                DetectedRuntime::Docker,
338            ],
339            RuntimePref::Orbstack => &[DetectedRuntime::Orbstack],
340            RuntimePref::DockerDesktop => &[DetectedRuntime::DockerDesktop],
341            RuntimePref::Colima => &[DetectedRuntime::Colima],
342            RuntimePref::Podman => &[DetectedRuntime::Podman],
343            RuntimePref::Docker => &[DetectedRuntime::Docker],
344            RuntimePref::Custom => unreachable!("handled above"),
345        };
346
347        let mut attempted = Vec::new();
348        for &kind in order {
349            let raw = match spec.discovery.get(kind.as_str()) {
350                Some(p) => p.clone(),
351                None => {
352                    attempted.push(format!("{} (no discovery entry)", kind.as_str()));
353                    continue;
354                }
355            };
356            let provider = SocketRuntimeProvider {
357                label: kind.as_str().to_string(),
358                socket: raw,
359            };
360            if provider.available() {
361                return Ok(Self { detected: kind, docker_host: provider.docker_host() });
362            }
363            attempted.push(format!(
364                "{} ({})",
365                kind.as_str(),
366                expand_tilde(&provider.socket).display()
367            ));
368        }
369
370        // Auto: custom host as last-resort fallback.
371        if matches!(spec.runtime, RuntimePref::Auto) {
372            if let Some(host) = &spec.custom_docker_host {
373                return Ok(Self {
374                    detected: DetectedRuntime::Custom,
375                    docker_host: host.clone(),
376                });
377            }
378        }
379
380        bail!(
381            "no local container runtime reachable (tried: {}); \
382             install or start orbstack, docker-desktop, colima, or podman",
383            attempted.join(", "),
384        )
385    }
386
387    /// Prepare a `docker` Command pre-configured to talk to this runtime via
388    /// `DOCKER_HOST`. The CLI must be on PATH (OrbStack and Colima both
389    /// register a `docker` shim; Docker Desktop and system Docker install into
390    /// `/usr/local/bin`).
391    fn cmd(&self) -> Command {
392        let mut cmd = Command::new("docker");
393        cmd.env("DOCKER_HOST", &self.docker_host);
394        cmd.kill_on_drop(true);
395        cmd
396    }
397
398    /// Run a docker subcommand, returning stdout on success. Captures stderr
399    /// for the error message; PATH-not-found surfaces a clean hint.
400    async fn run_capture(&self, args: &[&str]) -> Result<String> {
401        debug!(runtime = ?self.detected, ?args, "docker");
402        let out = self
403            .cmd()
404            .args(args)
405            .stdout(Stdio::piped())
406            .stderr(Stdio::piped())
407            .output()
408            .await
409            .with_context(|| format!("spawning docker (is the CLI installed?): docker {}", args.join(" ")))?;
410        if !out.status.success() {
411            let stderr = String::from_utf8_lossy(&out.stderr);
412            bail!(
413                "docker {} failed (exit {:?}): {}",
414                args.join(" "),
415                out.status.code(),
416                stderr.trim(),
417            );
418        }
419        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
420    }
421
422    /// True if `image` is already present in the local image store.
423    /// `docker image inspect` returns non-zero (without 'unable to find' on
424    /// stderr) when the image is absent.
425    pub async fn has_image(&self, image: &str) -> Result<bool> {
426        let out = self
427            .cmd()
428            .args(["image", "inspect", image])
429            .stdout(Stdio::null())
430            .stderr(Stdio::null())
431            .status()
432            .await
433            .with_context(|| format!("spawning docker image inspect {image}"))?;
434        Ok(out.success())
435    }
436
437    /// Pull `image` only when it isn't already cached. Returns `true` if a
438    /// pull happened, `false` if the image was already present. Image refs
439    /// should be tag-pinned (`caddy:2.10-alpine`) so this stays deterministic.
440    pub async fn ensure_image(&self, image: &str) -> Result<bool> {
441        if self.has_image(image).await? {
442            return Ok(false);
443        }
444        self.run_capture(&["pull", image]).await?;
445        Ok(true)
446    }
447
448    /// Build a local image from a Dockerfile via `docker build -t <tag>
449    /// -f <dockerfile> <context>`. Unlike [`ensure_image`](Self::ensure_image)
450    /// (which pulls a published ref) this compiles the component's own image
451    /// from source — the `kind = "container"` reconciler path (R602-T1).
452    ///
453    /// `dockerfile` is the path to the Dockerfile; `context` is the build
454    /// context directory. Both are passed to `docker` verbatim, so the caller
455    /// resolves them to real paths first. Docker's layer cache makes repeat
456    /// builds of an unchanged tree cheap, so callers may build unconditionally
457    /// on every reconcile without a separate freshness check.
458    pub async fn build_image(&self, tag: &str, dockerfile: &Path, context: &Path) -> Result<()> {
459        let dockerfile = dockerfile.to_string_lossy();
460        let context = context.to_string_lossy();
461        self.run_capture(&["build", "-t", tag, "-f", &dockerfile, &context])
462            .await
463            .with_context(|| format!("docker build -t {tag}"))?;
464        Ok(())
465    }
466
467    /// Idempotently create a docker bridge network. Returns `true` if a
468    /// network was created, `false` if one already existed under `name`.
469    /// Used by the pond per-cell bridge bring-up (R455-F1).
470    pub async fn ensure_network(&self, name: &str) -> Result<bool> {
471        let out = self
472            .cmd()
473            .args(["network", "inspect", name])
474            .stdout(Stdio::null())
475            .stderr(Stdio::null())
476            .status()
477            .await
478            .with_context(|| format!("spawning docker network inspect {name}"))?;
479        if out.success() {
480            return Ok(false);
481        }
482        let create = self
483            .cmd()
484            .args(["network", "create", name])
485            .stdout(Stdio::null())
486            .stderr(Stdio::piped())
487            .output()
488            .await
489            .with_context(|| format!("spawning docker network create {name}"))?;
490        if create.status.success() {
491            return Ok(true);
492        }
493        let stderr = String::from_utf8_lossy(&create.stderr);
494        // Race: another caller created the network between inspect and create.
495        let lower = stderr.to_lowercase();
496        if lower.contains("already exists") {
497            return Ok(false);
498        }
499        bail!("docker network create {name} failed: {}", stderr.trim());
500    }
501
502    /// Best-effort `docker rm -f <name>` — silent when the container is
503    /// already gone. Used before `run` to clear orphans from a prior crash.
504    pub async fn remove_container(&self, name: &str) -> Result<()> {
505        let out = self
506            .cmd()
507            .args(["rm", "-f", name])
508            .stdout(Stdio::null())
509            .stderr(Stdio::piped())
510            .output()
511            .await
512            .with_context(|| format!("spawning docker rm -f {name}"))?;
513        if out.status.success() {
514            return Ok(());
515        }
516        let stderr = String::from_utf8_lossy(&out.stderr);
517        if is_missing_container_error(&stderr) {
518            return Ok(());
519        }
520        bail!("docker rm -f {name} failed: {}", stderr.trim());
521    }
522
523    /// Start a detached container per `spec`. Pre-clears any prior container
524    /// with the same name so re-runs after a crash are idempotent.
525    pub async fn run(&self, spec: &ContainerRunSpec) -> Result<()> {
526        // Idempotent: clear any leftover with the same name before launching.
527        self.remove_container(&spec.name).await?;
528
529        let args = spec.docker_run_args();
530        let argv: Vec<&str> = args.iter().map(String::as_str).collect();
531        self.run_capture(&argv).await?;
532        Ok(())
533    }
534
535    /// Read the host-side port that the container mapped `container_port/tcp`
536    /// to. Runs `docker port <name> <container_port>` and parses the output
537    /// (`0.0.0.0:XXXXX` or `:::XXXXX`). Returns an error when the container is
538    /// not running or the port is not published.
539    pub async fn container_host_port(&self, name: &str, container_port: u16) -> Result<u16> {
540        let port_str = container_port.to_string();
541        let out = self
542            .run_capture(&["port", name, &port_str])
543            .await
544            .with_context(|| format!("docker port {name} {container_port}"))?;
545        // Output is `0.0.0.0:<host_port>` or `:::<host_port>` — split on `:`,
546        // take the last token.
547        let host_port_str = out
548            .trim()
549            .rsplit(':')
550            .next()
551            .filter(|s| !s.is_empty())
552            .with_context(|| format!("unexpected docker port output: {:?}", out.trim()))?;
553        host_port_str.parse::<u16>().with_context(|| {
554            format!(
555                "parsing host port {:?} for {name}:{container_port}",
556                host_port_str,
557            )
558        })
559    }
560
561    /// Fetch the container's State.Status field (running / exited / …).
562    /// Returns `Ok(None)` when the container doesn't exist.
563    pub async fn container_state(&self, name: &str) -> Result<Option<ContainerState>> {
564        let out = self
565            .cmd()
566            .args([
567                "inspect",
568                "--format",
569                "{{.State.Status}}",
570                name,
571            ])
572            .stdout(Stdio::piped())
573            .stderr(Stdio::piped())
574            .output()
575            .await
576            .with_context(|| format!("spawning docker inspect {name}"))?;
577        if !out.status.success() {
578            let stderr = String::from_utf8_lossy(&out.stderr);
579            if is_missing_container_error(&stderr) {
580                return Ok(None);
581            }
582            bail!("docker inspect {name} failed: {}", stderr.trim());
583        }
584        let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
585        Ok(Some(ContainerState::parse(&raw)))
586    }
587
588    /// Graceful `docker stop -t <grace_seconds>` followed by `docker rm`.
589    /// No-op if the container doesn't exist.
590    pub async fn stop_and_remove(&self, name: &str, grace: Duration) -> Result<()> {
591        let grace_str = grace.as_secs().to_string();
592        let stop = self
593            .cmd()
594            .args(["stop", "-t", &grace_str, name])
595            .stdout(Stdio::null())
596            .stderr(Stdio::piped())
597            .output()
598            .await
599            .with_context(|| format!("spawning docker stop {name}"))?;
600        if !stop.status.success() {
601            let stderr = String::from_utf8_lossy(&stop.stderr);
602            if !is_missing_container_error(&stderr) {
603                bail!("docker stop {name} failed: {}", stderr.trim());
604            }
605        }
606        self.remove_container(name).await
607    }
608
609    /// List every container owned by this module on this runtime.
610    /// Queries both `yah.pond` (current) and `yah.local-sim` (legacy, pre-R362)
611    /// label keys so old-generation containers are visible during the transition.
612    pub async fn list_owned(&self) -> Result<Vec<OwnedContainer>> {
613        // `docker ps -a --filter label=<key> --format '<name>\t<label-value>\t<state>'`
614        // Two passes: current label key + legacy key; merge, dedup by name.
615        let mut owned: Vec<OwnedContainer> = Vec::new();
616        let mut seen_names: std::collections::HashSet<String> = std::collections::HashSet::new();
617
618        for (label_key, format_key) in [
619            (LABEL_KEY, LABEL_KEY),
620            (LEGACY_LABEL_KEY, LEGACY_LABEL_KEY),
621        ] {
622            let fmt = format!("{{{{.Names}}}}\t{{{{.Label \"{format_key}\"}}}}\t{{{{.State}}}}");
623            let out = self
624                .run_capture(&[
625                    "ps",
626                    "-a",
627                    "--filter",
628                    &format!("label={label_key}"),
629                    "--format",
630                    &fmt,
631                ])
632                .await?;
633            for line in out.lines() {
634                let mut parts = line.splitn(3, '\t');
635                let name = parts.next().unwrap_or_default().trim();
636                let label = parts.next().unwrap_or_default().trim();
637                let state = parts.next().unwrap_or_default().trim();
638                if name.is_empty() || seen_names.contains(name) {
639                    continue;
640                }
641                seen_names.insert(name.to_string());
642                owned.push(OwnedContainer {
643                    name: name.to_string(),
644                    label: label.to_string(),
645                    state: ContainerState::parse(state),
646                });
647            }
648        }
649        Ok(owned)
650    }
651}
652
653// ── ContainerLauncher seam (R626-F2) ──────────────────────────────────────────
654
655/// The three container operations the pond bring-up sequences need, factored
656/// out of [`LocalRuntime`] so a caller can supply a different launcher.
657///
658/// The pond `ensure_*_running` functions are *choreography* — write the state
659/// dir, pull, run, wait for the port, wait for HTTP ready, apply post-start
660/// config (MinIO's bucket policy). None of that is docker-CLI-specific; only
661/// the three verbs below are. Splitting them lets yubaba route the same
662/// choreography through kamaji (so dockerd owns restart, R626) while the cloud
663/// tier keeps driving `LocalRuntime` directly and unchanged.
664///
665/// A launcher is expected to be **idempotent**: `run` against an existing
666/// container of the same name replaces it, and `stop_and_remove` on a missing
667/// container succeeds.
668#[async_trait::async_trait]
669pub trait ContainerLauncher: Send + Sync {
670    /// Pull `image` unless it is already present. `true` when a pull happened.
671    async fn ensure_image(&self, image: &str) -> Result<bool>;
672
673    /// Start a detached container per `spec`, replacing any prior container of
674    /// the same name.
675    async fn run(&self, spec: &ContainerRunSpec) -> Result<()>;
676
677    /// Stop with `grace`, then remove. No-op when the container is absent.
678    async fn stop_and_remove(&self, name: &str, grace: Duration) -> Result<()>;
679}
680
681#[async_trait::async_trait]
682impl ContainerLauncher for LocalRuntime {
683    async fn ensure_image(&self, image: &str) -> Result<bool> {
684        LocalRuntime::ensure_image(self, image).await
685    }
686
687    async fn run(&self, spec: &ContainerRunSpec) -> Result<()> {
688        LocalRuntime::run(self, spec).await
689    }
690
691    async fn stop_and_remove(&self, name: &str, grace: Duration) -> Result<()> {
692        LocalRuntime::stop_and_remove(self, name, grace).await
693    }
694}
695
696/// Pond holds its runtimes as `Arc<…>` and the `ensure_*_running` functions are
697/// generic, so `&Arc<T>` needs its own impl — generic parameters don't
698/// auto-deref the way `&LocalRuntime` did.
699#[async_trait::async_trait]
700impl<T: ContainerLauncher + ?Sized> ContainerLauncher for std::sync::Arc<T> {
701    async fn ensure_image(&self, image: &str) -> Result<bool> {
702        (**self).ensure_image(image).await
703    }
704
705    async fn run(&self, spec: &ContainerRunSpec) -> Result<()> {
706        (**self).run(spec).await
707    }
708
709    async fn stop_and_remove(&self, name: &str, grace: Duration) -> Result<()> {
710        (**self).stop_and_remove(name, grace).await
711    }
712}
713
714/// Run-time spec for a single container managed by [`LocalRuntime::run`].
715#[derive(Debug, Clone)]
716pub struct ContainerRunSpec {
717    /// Canonical name — see [`canonical_name`].
718    pub name: String,
719    /// Image ref. Tag-pinned for cache determinism (`caddy:2.10-alpine`).
720    pub image: String,
721    /// Value for the `yah.local-sim` label (see [`canonical_label`]).
722    pub label: String,
723    /// Host→container port bindings.
724    pub ports: Vec<(u16, u16)>,
725    /// Container env vars.
726    pub env: BTreeMap<String, String>,
727    /// Bind-mount pairs (host_path, container_path).
728    pub volumes: Vec<(PathBuf, String)>,
729    /// Optional CMD override; empty leaves the image default.
730    pub cmd: Vec<String>,
731    /// Linux capabilities to add via `--cap-add` (e.g. `["SYS_ADMIN"]`).
732    /// Empty by default; the pond yubaba-container path (R408-T2) sets this
733    /// so Kamaji can perform cgroup ops inside the container.
734    pub cap_add: Vec<String>,
735    /// Cgroup namespace mode forwarded to `docker run --cgroupns=...`. Valid
736    /// values are `"private"` and `"host"`; `None` leaves the daemon default.
737    /// The pond yubaba-container path (R408-T2) sets `"private"` so the
738    /// container sees a fresh `/sys/fs/cgroup` it can write child cgroups
739    /// under.
740    pub cgroupns: Option<String>,
741    /// Docker network to attach the container to via `--network <name>`.
742    /// `None` leaves the daemon default (the `bridge` network). The pond
743    /// per-cell bridge (R455-F1) sets this to [`pond_network_name`].
744    pub network: Option<String>,
745    /// Network aliases registered with `--network-alias <alias>` so siblings
746    /// on the same bridge can reach this container by name regardless of its
747    /// container name. Ignored when [`network`] is `None`.
748    pub network_aliases: Vec<String>,
749    /// Extra `/etc/hosts` entries forwarded as `--add-host <entry>` (e.g.
750    /// `host.docker.internal:host-gateway`). The pond yubaba-container
751    /// (R408-T2) uses this so probes against host-published ports resolve on
752    /// Linux docker, where `host.docker.internal` is not provided by default
753    /// (OrbStack and Docker Desktop define it natively; the redundant
754    /// mapping is harmless there).
755    pub extra_hosts: Vec<String>,
756}
757
758impl ContainerRunSpec {
759    /// Convenience: build a spec with the canonical name + label derived from
760    /// the (service, env, slot) triple.
761    pub fn new(service: &str, env: &str, slot: &str, image: impl Into<String>) -> Self {
762        Self {
763            name: canonical_name(service, env, slot),
764            image: image.into(),
765            label: canonical_label(service, env, slot),
766            ports: vec![],
767            env: BTreeMap::new(),
768            volumes: vec![],
769            cmd: vec![],
770            cap_add: vec![],
771            cgroupns: None,
772            network: None,
773            network_aliases: vec![],
774            extra_hosts: vec![],
775        }
776    }
777
778    /// Build the full `docker run …` argv emitted by [`LocalRuntime::run`].
779    /// Pure helper so callers (and tests) can inspect the wiring without a
780    /// live docker socket.
781    pub fn docker_run_args(&self) -> Vec<String> {
782        let mut args: Vec<String> = vec![
783            "run".into(),
784            "-d".into(),
785            "--name".into(),
786            self.name.clone(),
787            "--label".into(),
788            format!("{LABEL_KEY}={}", self.label),
789            "--restart".into(),
790            "unless-stopped".into(),
791        ];
792        if let Some(mode) = &self.cgroupns {
793            args.push(format!("--cgroupns={mode}"));
794        }
795        for cap in &self.cap_add {
796            args.push("--cap-add".into());
797            args.push(cap.clone());
798        }
799        if let Some(net) = &self.network {
800            args.push("--network".into());
801            args.push(net.clone());
802            for alias in &self.network_aliases {
803                args.push("--network-alias".into());
804                args.push(alias.clone());
805            }
806        }
807        for entry in &self.extra_hosts {
808            args.push("--add-host".into());
809            args.push(entry.clone());
810        }
811        for (host, container) in &self.ports {
812            args.push("-p".into());
813            args.push(format!("{host}:{container}"));
814        }
815        for (k, v) in &self.env {
816            args.push("-e".into());
817            args.push(format!("{k}={v}"));
818        }
819        for (host_path, container_path) in &self.volumes {
820            args.push("-v".into());
821            args.push(format!("{}:{}", host_path.display(), container_path));
822        }
823        args.push(self.image.clone());
824        args.extend(self.cmd.iter().cloned());
825        args
826    }
827}
828
829/// A container managed by this module, as observed via [`LocalRuntime::list_owned`].
830#[derive(Debug, Clone, PartialEq, Eq)]
831pub struct OwnedContainer {
832    pub name: String,
833    pub label: String,
834    pub state: ContainerState,
835}
836
837/// Container State.Status from `docker inspect`. Anything the docs don't
838/// enumerate lands in [`Unknown`].
839#[derive(Debug, Clone, PartialEq, Eq)]
840pub enum ContainerState {
841    Created,
842    Running,
843    Restarting,
844    Exited,
845    Paused,
846    Removing,
847    Dead,
848    Unknown(String),
849}
850
851impl ContainerState {
852    pub fn parse(s: &str) -> Self {
853        match s.trim().to_lowercase().as_str() {
854            "created" => Self::Created,
855            "running" => Self::Running,
856            "restarting" => Self::Restarting,
857            "exited" => Self::Exited,
858            "paused" => Self::Paused,
859            "removing" => Self::Removing,
860            "dead" => Self::Dead,
861            other => Self::Unknown(other.to_string()),
862        }
863    }
864
865    pub fn is_running(&self) -> bool {
866        matches!(self, Self::Running)
867    }
868}
869
870/// True if stderr indicates the named container doesn't exist. Both docker
871/// CLI and orbstack's docker shim use this shape — but with varying case
872/// (`No such container` from upstream docker, `no such object` from orbstack).
873fn is_missing_container_error(stderr: &str) -> bool {
874    let lower = stderr.to_lowercase();
875    lower.contains("no such container")
876        || lower.contains("no such object")
877        || lower.contains("not found")
878}
879
880/// Expand a leading `~` to `$HOME`. Anything else is returned as-is.
881fn expand_tilde(p: &Path) -> PathBuf {
882    let s = p.to_string_lossy();
883    if let Some(rest) = s.strip_prefix("~/") {
884        if let Ok(home) = std::env::var("HOME") {
885            return PathBuf::from(home).join(rest);
886        }
887    }
888    if s == "~" {
889        if let Ok(home) = std::env::var("HOME") {
890            return PathBuf::from(home);
891        }
892    }
893    p.to_path_buf()
894}
895
896// ── LocalDockerRuntime ────────────────────────────────────────────────────────
897
898/// `WorkloadRuntime` implementation backed by the docker CLI.
899///
900/// Wraps a detected [`LocalRuntime`] and translates each [`WorkloadSpec`] into
901/// a [`ContainerRunSpec`] before delegating to the underlying docker calls.
902/// This is the sim-tier half of the F10 keystone: camp embeds it pointing at
903/// OrbStack; yubaba supplies the containerd half for cloud/HA.
904///
905/// Translation notes:
906/// - Only `EnvValue::Literal` env vars are forwarded; `FromSecret` and
907///   `FromMesh` values are skipped with a warning (no secrets infrastructure
908///   at the local tier).
909/// - Only `VolumeSource::Bind` mounts are forwarded; named volumes and tmpfs
910///   are skipped with a warning.
911/// - `spec.command` overrides the image CMD when set.
912/// - Mesh / WireGuard / raft fields are ignored — sim containers communicate
913///   over OrbStack's bridge network.
914pub struct LocalDockerRuntime {
915    inner: LocalRuntime,
916}
917
918impl LocalDockerRuntime {
919    pub fn new(inner: LocalRuntime) -> Self {
920        Self { inner }
921    }
922
923    /// Access the underlying [`LocalRuntime`] (e.g. to call `ensure_image`
924    /// or `list_owned` directly).
925    pub fn runtime(&self) -> &LocalRuntime {
926        &self.inner
927    }
928}
929
930/// Translate a `WorkloadSpec` into a `ContainerRunSpec` for the local docker
931/// tier. Only the subset of `WorkloadSpec` fields that map directly to docker
932/// run args are carried across; yubaba-specific fields (mesh, resources, raft
933/// ident) are silently dropped.
934fn workload_spec_to_crs(spec: &WorkloadSpec) -> ContainerRunSpec {
935    let image = spec.image.docker_ref();
936
937    let mut env = BTreeMap::new();
938    for e in &spec.env {
939        match &e.value {
940            EnvValue::Literal { value } => {
941                env.insert(e.name.clone(), value.clone());
942            }
943            EnvValue::FromSecret { .. } => {
944                warn!(name = %e.name, "LocalDockerRuntime: skipping FromSecret env var (no secrets layer at sim tier)");
945            }
946            EnvValue::FromMesh { .. } => {
947                warn!(name = %e.name, "LocalDockerRuntime: skipping FromMesh env var (no mesh discovery at sim tier)");
948            }
949        }
950    }
951
952    let ports: Vec<(u16, u16)> = spec.expose.mesh.ports.iter().map(|&p| (p, p)).collect();
953
954    let volumes: Vec<(PathBuf, String)> = spec
955        .volumes
956        .iter()
957        .filter_map(|v| match &v.source {
958            VolumeSource::Bind { host_path } => {
959                Some((host_path.clone(), v.target.to_string_lossy().into_owned()))
960            }
961            VolumeSource::Named { name } => {
962                warn!(volume = %name, "LocalDockerRuntime: skipping Named volume (not supported at sim tier)");
963                None
964            }
965            VolumeSource::Tmpfs { .. } => {
966                warn!("LocalDockerRuntime: skipping Tmpfs volume (use -v /dev/null for ephemeral mounts at sim tier)");
967                None
968            }
969        })
970        .collect();
971
972    ContainerRunSpec {
973        name: spec.name.clone(),
974        image,
975        label: spec.name.clone(),
976        ports,
977        env,
978        volumes,
979        cmd: spec.command.clone().unwrap_or_default(),
980        cap_add: vec![],
981        cgroupns: None,
982        network: None,
983        network_aliases: vec![],
984        extra_hosts: vec![],
985    }
986}
987
988#[async_trait::async_trait]
989impl WorkloadRuntime for LocalDockerRuntime {
990    async fn deploy_workload(&self, spec: &WorkloadSpec) -> anyhow::Result<String> {
991        let crs = workload_spec_to_crs(spec);
992        self.inner.ensure_image(&crs.image).await?;
993        self.inner.run(&crs).await?;
994        Ok(spec.name.clone())
995    }
996
997    async fn teardown_workload(&self, name: &str) -> anyhow::Result<()> {
998        self.inner.stop_and_remove(name, Duration::from_secs(10)).await
999    }
1000
1001    async fn is_running(&self, name: &str) -> anyhow::Result<bool> {
1002        Ok(self
1003            .inner
1004            .container_state(name)
1005            .await?
1006            .map(|s| s.is_running())
1007            .unwrap_or(false))
1008    }
1009
1010    async fn runtime_health(&self) -> anyhow::Result<bool> {
1011        // Docker CLI health check: `docker info` exits 0 when the daemon is up.
1012        let result = self
1013            .inner
1014            .run_capture(&["info", "--format", "{{.ServerVersion}}"])
1015            .await;
1016        Ok(result.is_ok())
1017    }
1018}
1019
1020#[cfg(test)]
1021mod tests {
1022    use super::*;
1023    use std::collections::BTreeMap;
1024
1025    #[test]
1026    fn canonical_name_format() {
1027        assert_eq!(canonical_name("dev-yah", "pond", "static"), "yah-pond-dev-yah-pond-static");
1028    }
1029
1030    #[test]
1031    fn canonical_label_format() {
1032        assert_eq!(canonical_label("dev-yah", "pond", "object_store"), "dev-yah:pond:object_store");
1033    }
1034
1035    #[test]
1036    fn runtime_pref_parse() {
1037        assert_eq!(RuntimePref::parse("auto").unwrap(), RuntimePref::Auto);
1038        assert_eq!(RuntimePref::parse("orbstack").unwrap(), RuntimePref::Orbstack);
1039        assert_eq!(RuntimePref::parse("docker-desktop").unwrap(), RuntimePref::DockerDesktop);
1040        assert_eq!(RuntimePref::parse("docker_desktop").unwrap(), RuntimePref::DockerDesktop);
1041        assert_eq!(RuntimePref::parse("colima").unwrap(), RuntimePref::Colima);
1042        assert_eq!(RuntimePref::parse("podman").unwrap(), RuntimePref::Podman);
1043        assert_eq!(RuntimePref::parse("docker").unwrap(), RuntimePref::Docker);
1044        assert_eq!(RuntimePref::parse("custom").unwrap(), RuntimePref::Custom);
1045        let err = RuntimePref::parse("nonsense").unwrap_err().to_string();
1046        assert!(err.contains("nonsense"), "error should name the bad value, got: {err}");
1047    }
1048
1049    #[test]
1050    fn detected_runtime_as_str_round_trips() {
1051        assert_eq!(DetectedRuntime::Orbstack.as_str(), "orbstack");
1052        assert_eq!(DetectedRuntime::DockerDesktop.as_str(), "docker-desktop");
1053        assert_eq!(DetectedRuntime::Colima.as_str(), "colima");
1054        assert_eq!(DetectedRuntime::Podman.as_str(), "podman");
1055        assert_eq!(DetectedRuntime::Docker.as_str(), "docker");
1056        assert_eq!(DetectedRuntime::Custom.as_str(), "custom");
1057    }
1058
1059    #[tokio::test]
1060    async fn detect_returns_error_when_no_socket_exists() {
1061        // Build a spec pointing at paths that definitely don't exist.
1062        let mut discovery = BTreeMap::new();
1063        discovery.insert("orbstack".into(), PathBuf::from("/nonexistent/orbstack.sock"));
1064        discovery.insert("colima".into(), PathBuf::from("/nonexistent/colima.sock"));
1065        discovery.insert("docker".into(), PathBuf::from("/nonexistent/docker.sock"));
1066        let spec = LocalContainerSpec { runtime: RuntimePref::Auto, discovery, custom_docker_host: None };
1067        let err = LocalRuntime::detect(&spec).await.unwrap_err().to_string();
1068        assert!(err.contains("no local container runtime reachable"));
1069        assert!(err.contains("orbstack"));
1070        assert!(err.contains("colima"));
1071        assert!(err.contains("docker"));
1072    }
1073
1074    #[tokio::test]
1075    async fn detect_reports_when_pinned_runtime_has_no_discovery_entry() {
1076        let spec = LocalContainerSpec {
1077            runtime: RuntimePref::Colima,
1078            discovery: BTreeMap::new(),
1079            custom_docker_host: None,
1080        };
1081        let err = LocalRuntime::detect(&spec).await.unwrap_err().to_string();
1082        assert!(err.contains("colima"));
1083        assert!(err.contains("no discovery entry"));
1084    }
1085
1086    #[tokio::test]
1087    async fn detect_picks_existing_socket() {
1088        // Use a tempdir + touch file to stand in for a real socket. detect()
1089        // only checks existence, not socket-ness.
1090        let tmp = tempfile::TempDir::new().unwrap();
1091        let fake = tmp.path().join("docker.sock");
1092        std::fs::write(&fake, b"").unwrap();
1093        let mut discovery = BTreeMap::new();
1094        discovery.insert("orbstack".into(), PathBuf::from("/nonexistent/no.sock"));
1095        discovery.insert("colima".into(), PathBuf::from("/nonexistent/no.sock"));
1096        discovery.insert("docker".into(), fake.clone());
1097        let spec = LocalContainerSpec { runtime: RuntimePref::Auto, discovery, custom_docker_host: None };
1098        let runtime = LocalRuntime::detect(&spec).await.unwrap();
1099        assert_eq!(runtime.detected, DetectedRuntime::Docker);
1100        assert_eq!(runtime.docker_host, format!("unix://{}", fake.display()));
1101    }
1102
1103    #[tokio::test]
1104    async fn detect_honors_runtime_pin_and_skips_others() {
1105        // Even if a later runtime's socket exists, a pinned earlier runtime
1106        // without a socket should fail rather than fall through.
1107        let tmp = tempfile::TempDir::new().unwrap();
1108        let fake = tmp.path().join("docker.sock");
1109        std::fs::write(&fake, b"").unwrap();
1110        let mut discovery = BTreeMap::new();
1111        discovery.insert("orbstack".into(), PathBuf::from("/nonexistent/no.sock"));
1112        discovery.insert("docker".into(), fake);
1113        let spec = LocalContainerSpec { runtime: RuntimePref::Orbstack, discovery, custom_docker_host: None };
1114        let err = LocalRuntime::detect(&spec).await.unwrap_err().to_string();
1115        assert!(err.contains("orbstack"));
1116        // The error body should not list docker as an *attempted* probe entry.
1117        // (The hint text "install or start ... docker-desktop ..." may mention docker
1118        // substrings, but the tried-list should only show orbstack.)
1119        assert!(!err.contains("docker ("), "pinned to orbstack — docker should not be in tried list: {err}");
1120    }
1121
1122    #[tokio::test]
1123    async fn detect_custom_pref_uses_host_directly() {
1124        let spec = LocalContainerSpec {
1125            runtime: RuntimePref::Custom,
1126            discovery: BTreeMap::new(),
1127            custom_docker_host: Some("tcp://localhost:2375".into()),
1128        };
1129        let runtime = LocalRuntime::detect(&spec).await.unwrap();
1130        assert_eq!(runtime.detected, DetectedRuntime::Custom);
1131        assert_eq!(runtime.docker_host, "tcp://localhost:2375");
1132    }
1133
1134    #[tokio::test]
1135    async fn detect_custom_pref_without_host_errors() {
1136        let spec = LocalContainerSpec {
1137            runtime: RuntimePref::Custom,
1138            discovery: BTreeMap::new(),
1139            custom_docker_host: None,
1140        };
1141        let err = LocalRuntime::detect(&spec).await.unwrap_err().to_string();
1142        assert!(err.contains("custom_docker_host"), "error should mention the missing field: {err}");
1143    }
1144
1145    #[tokio::test]
1146    async fn detect_auto_falls_back_to_custom_host() {
1147        // All socket candidates absent, but a custom_docker_host is configured.
1148        let spec = LocalContainerSpec {
1149            runtime: RuntimePref::Auto,
1150            discovery: BTreeMap::new(),
1151            custom_docker_host: Some("tcp://localhost:2375".into()),
1152        };
1153        let runtime = LocalRuntime::detect(&spec).await.unwrap();
1154        assert_eq!(runtime.detected, DetectedRuntime::Custom);
1155        assert_eq!(runtime.docker_host, "tcp://localhost:2375");
1156    }
1157
1158    #[test]
1159    fn build_cascade_returns_providers_with_discovery_entries() {
1160        let mut discovery = BTreeMap::new();
1161        discovery.insert("orbstack".into(), PathBuf::from("/fake/orbstack.sock"));
1162        discovery.insert("docker".into(), PathBuf::from("/fake/docker.sock"));
1163        let spec = LocalContainerSpec { runtime: RuntimePref::Auto, discovery, custom_docker_host: None };
1164        let cascade = spec.build_cascade();
1165        // Only orbstack and docker have entries; docker-desktop/colima/podman are absent.
1166        assert_eq!(cascade.len(), 2);
1167        assert!(cascade.iter().any(|(k, _)| *k == DetectedRuntime::Orbstack));
1168        assert!(cascade.iter().any(|(k, _)| *k == DetectedRuntime::Docker));
1169    }
1170
1171    #[test]
1172    fn build_cascade_custom_pref_returns_single_entry() {
1173        let spec = LocalContainerSpec {
1174            runtime: RuntimePref::Custom,
1175            discovery: BTreeMap::new(),
1176            custom_docker_host: Some("tcp://localhost:2375".into()),
1177        };
1178        let cascade = spec.build_cascade();
1179        assert_eq!(cascade.len(), 1);
1180        let (kind, provider) = &cascade[0];
1181        assert_eq!(*kind, DetectedRuntime::Custom);
1182        assert_eq!(provider.docker_host(), "tcp://localhost:2375");
1183        assert!(provider.available());
1184    }
1185
1186    #[test]
1187    fn expand_tilde_replaces_home_prefix() {
1188        std::env::set_var("HOME", "/tmp/fake-home");
1189        let p = expand_tilde(Path::new("~/foo/bar"));
1190        assert_eq!(p, PathBuf::from("/tmp/fake-home/foo/bar"));
1191    }
1192
1193    #[test]
1194    fn expand_tilde_leaves_absolute_paths_alone() {
1195        let p = expand_tilde(Path::new("/var/run/docker.sock"));
1196        assert_eq!(p, PathBuf::from("/var/run/docker.sock"));
1197    }
1198
1199    #[test]
1200    fn container_state_parse_known_values() {
1201        assert_eq!(ContainerState::parse("running"), ContainerState::Running);
1202        assert_eq!(ContainerState::parse("exited"), ContainerState::Exited);
1203        assert_eq!(ContainerState::parse("PAUSED"), ContainerState::Paused);
1204    }
1205
1206    #[test]
1207    fn container_state_parse_unknown_preserves_string() {
1208        match ContainerState::parse("zombie") {
1209            ContainerState::Unknown(s) => assert_eq!(s, "zombie"),
1210            other => panic!("expected Unknown, got {other:?}"),
1211        }
1212    }
1213
1214    #[test]
1215    fn is_missing_container_error_matches_upstream_and_orbstack() {
1216        assert!(is_missing_container_error("Error: No such container: foo"));
1217        assert!(is_missing_container_error("error: no such object: foo"));
1218        assert!(is_missing_container_error("not found: foo"));
1219        assert!(!is_missing_container_error("Error response from daemon: Conflict."));
1220        assert!(!is_missing_container_error(""));
1221    }
1222
1223    #[test]
1224    fn container_run_spec_new_uses_canonical_name() {
1225        let spec = ContainerRunSpec::new("dev-yah", "pond", "static", "caddy:2-alpine");
1226        assert_eq!(spec.name, "yah-pond-dev-yah-pond-static");
1227        assert_eq!(spec.label, "dev-yah:pond:static");
1228        assert!(spec.ports.is_empty());
1229        assert!(spec.network.is_none());
1230        assert!(spec.network_aliases.is_empty());
1231    }
1232
1233    #[test]
1234    fn pond_network_name_is_yah_pond_svc_env() {
1235        assert_eq!(pond_network_name("yah-marketing", "pond"), "yah-pond-yah-marketing-pond");
1236        assert_eq!(pond_network_name("yah-dashboard", "pond"), "yah-pond-yah-dashboard-pond");
1237    }
1238
1239    #[test]
1240    fn docker_run_args_emit_network_and_aliases_when_set() {
1241        let mut spec = ContainerRunSpec::new("dev", "pond", "object_store", "minio:latest");
1242        spec.network = Some("yah-pond-dev-pond".into());
1243        spec.network_aliases = vec!["minio".into()];
1244        let args = spec.docker_run_args();
1245        let joined = args.join(" ");
1246        assert!(
1247            joined.contains("--network yah-pond-dev-pond"),
1248            "expected --network flag in: {joined}",
1249        );
1250        assert!(
1251            joined.contains("--network-alias minio"),
1252            "expected --network-alias minio in: {joined}",
1253        );
1254    }
1255
1256    #[test]
1257    fn docker_run_args_omit_network_flags_when_unset() {
1258        let spec = ContainerRunSpec::new("dev", "pond", "object_store", "minio:latest");
1259        let args = spec.docker_run_args();
1260        let joined = args.join(" ");
1261        assert!(!joined.contains("--network"), "unexpected --network flag: {joined}");
1262        assert!(!joined.contains("--network-alias"), "unexpected --network-alias flag: {joined}");
1263    }
1264
1265    #[test]
1266    fn docker_run_args_skip_aliases_when_no_network() {
1267        // Aliases without a network are meaningless — docker would reject `--network-alias`
1268        // without `--network`. Defensive: emit neither.
1269        let mut spec = ContainerRunSpec::new("dev", "pond", "object_store", "minio:latest");
1270        spec.network_aliases = vec!["minio".into()];
1271        let args = spec.docker_run_args();
1272        let joined = args.join(" ");
1273        assert!(!joined.contains("--network-alias"), "should not emit alias without network: {joined}");
1274    }
1275
1276    // ── LocalDockerRuntime / workload_spec_to_crs unit tests ─────────────────
1277
1278    fn minimal_workload_spec(name: &str) -> workload_spec::WorkloadSpec {
1279        use workload_spec::*;
1280        WorkloadSpec {
1281            schema_version: SchemaVersion::V1,
1282            name: name.to_string(),
1283            image: ImageRef {
1284                registry: "ghcr.io".into(),
1285                repository: "test/app".into(),
1286                tag: "v1.0".into(),
1287                digest: workload_spec::testing::test_digest(),
1288            },
1289            tier: TierTag("infra".into()),
1290            tenant: workload_spec::TenantId::singleton(),
1291            namespace: workload_spec::NamespaceId::singleton(),
1292            replicas: 1,
1293            command: None,
1294            entrypoint: None,
1295            workdir: None,
1296            user: None,
1297            env: vec![],
1298            secrets: vec![],
1299            volumes: vec![],
1300            resources: ResourceLimits { memory_mb: 256, cpu_millis: 512, ephemeral_storage_mb: 256 },
1301            depends_on: vec![],
1302            healthcheck: None,
1303            restart_policy: RestartPolicy::Always,
1304            archetype: None,
1305            stop_policy: StopPolicy { signal: 15, grace_period: Millis::from_secs(10) },
1306            expose: ExposeSpec {
1307                mesh: MeshExpose { identity: MeshIdent(name.into()), ports: vec![], allow_from: vec![] },
1308                public: None,
1309                operator: None,
1310            },
1311            labels: Default::default(),
1312            annotations: Default::default(),
1313        }
1314    }
1315
1316    #[test]
1317    fn workload_spec_to_crs_sets_name_and_image() {
1318        let spec = minimal_workload_spec("test-app");
1319        let crs = workload_spec_to_crs(&spec);
1320        assert_eq!(crs.name, "test-app");
1321        // workload_spec_to_crs uses ImageRef::docker_ref(), which emits tag@digest.
1322        assert_eq!(
1323            crs.image,
1324            format!("ghcr.io/test/app:v1.0@{}", workload_spec::testing::test_digest())
1325        );
1326        assert!(crs.ports.is_empty());
1327        assert!(crs.env.is_empty());
1328    }
1329
1330    #[test]
1331    fn workload_spec_to_crs_forwards_literal_env_only() {
1332        use workload_spec::{EnvValue, EnvVar, MeshIdent, MeshLookup};
1333        let mut spec = minimal_workload_spec("env-test");
1334        spec.env = vec![
1335            EnvVar { name: "GOOD".into(), value: EnvValue::Literal { value: "yes".into() } },
1336            EnvVar { name: "BAD_SECRET".into(), value: EnvValue::FromSecret { secret: "s".into(), key: "k".into() } },
1337            EnvVar { name: "BAD_MESH".into(), value: EnvValue::FromMesh { ident: MeshIdent("x".into()), kind: MeshLookup::Url } },
1338        ];
1339        let crs = workload_spec_to_crs(&spec);
1340        assert_eq!(crs.env.get("GOOD").map(String::as_str), Some("yes"));
1341        assert!(!crs.env.contains_key("BAD_SECRET"), "FromSecret must be filtered out");
1342        assert!(!crs.env.contains_key("BAD_MESH"), "FromMesh must be filtered out");
1343    }
1344
1345    #[test]
1346    fn workload_spec_to_crs_maps_mesh_ports() {
1347        let mut spec = minimal_workload_spec("port-test");
1348        spec.expose.mesh.ports = vec![8080, 9000];
1349        let crs = workload_spec_to_crs(&spec);
1350        assert_eq!(crs.ports, vec![(8080, 8080), (9000, 9000)]);
1351    }
1352
1353    #[test]
1354    fn workload_spec_to_crs_applies_command_override() {
1355        let mut spec = minimal_workload_spec("cmd-test");
1356        spec.command = Some(vec!["server".into(), "--port=8080".into()]);
1357        let crs = workload_spec_to_crs(&spec);
1358        assert_eq!(crs.cmd, vec!["server", "--port=8080"]);
1359    }
1360
1361    #[test]
1362    fn image_ref_docker_ref_emits_tag_and_digest() {
1363        use workload_spec::ImageRef;
1364        let r = ImageRef {
1365            registry: "ghcr.io".into(),
1366            repository: "org/app".into(),
1367            tag: "v1.0".into(),
1368            digest: "sha256:abc123".into(),
1369        };
1370        assert_eq!(r.docker_ref(), "ghcr.io/org/app:v1.0@sha256:abc123");
1371    }
1372
1373    // Live docker-socket integration tests + the `from_provider_config`
1374    // adapter tests live in `cloud::local_driver_glue::tests` so they can
1375    // depend on `cloud::config::ProviderConfig` without local-driver pulling
1376    // a reverse dep on cloud.
1377}