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/// Run-time spec for a single container managed by [`LocalRuntime::run`].
654#[derive(Debug, Clone)]
655pub struct ContainerRunSpec {
656    /// Canonical name — see [`canonical_name`].
657    pub name: String,
658    /// Image ref. Tag-pinned for cache determinism (`caddy:2.10-alpine`).
659    pub image: String,
660    /// Value for the `yah.local-sim` label (see [`canonical_label`]).
661    pub label: String,
662    /// Host→container port bindings.
663    pub ports: Vec<(u16, u16)>,
664    /// Container env vars.
665    pub env: BTreeMap<String, String>,
666    /// Bind-mount pairs (host_path, container_path).
667    pub volumes: Vec<(PathBuf, String)>,
668    /// Optional CMD override; empty leaves the image default.
669    pub cmd: Vec<String>,
670    /// Linux capabilities to add via `--cap-add` (e.g. `["SYS_ADMIN"]`).
671    /// Empty by default; the pond yubaba-container path (R408-T2) sets this
672    /// so Kamaji can perform cgroup ops inside the container.
673    pub cap_add: Vec<String>,
674    /// Cgroup namespace mode forwarded to `docker run --cgroupns=...`. Valid
675    /// values are `"private"` and `"host"`; `None` leaves the daemon default.
676    /// The pond yubaba-container path (R408-T2) sets `"private"` so the
677    /// container sees a fresh `/sys/fs/cgroup` it can write child cgroups
678    /// under.
679    pub cgroupns: Option<String>,
680    /// Docker network to attach the container to via `--network <name>`.
681    /// `None` leaves the daemon default (the `bridge` network). The pond
682    /// per-cell bridge (R455-F1) sets this to [`pond_network_name`].
683    pub network: Option<String>,
684    /// Network aliases registered with `--network-alias <alias>` so siblings
685    /// on the same bridge can reach this container by name regardless of its
686    /// container name. Ignored when [`network`] is `None`.
687    pub network_aliases: Vec<String>,
688    /// Extra `/etc/hosts` entries forwarded as `--add-host <entry>` (e.g.
689    /// `host.docker.internal:host-gateway`). The pond yubaba-container
690    /// (R408-T2) uses this so probes against host-published ports resolve on
691    /// Linux docker, where `host.docker.internal` is not provided by default
692    /// (OrbStack and Docker Desktop define it natively; the redundant
693    /// mapping is harmless there).
694    pub extra_hosts: Vec<String>,
695}
696
697impl ContainerRunSpec {
698    /// Convenience: build a spec with the canonical name + label derived from
699    /// the (service, env, slot) triple.
700    pub fn new(service: &str, env: &str, slot: &str, image: impl Into<String>) -> Self {
701        Self {
702            name: canonical_name(service, env, slot),
703            image: image.into(),
704            label: canonical_label(service, env, slot),
705            ports: vec![],
706            env: BTreeMap::new(),
707            volumes: vec![],
708            cmd: vec![],
709            cap_add: vec![],
710            cgroupns: None,
711            network: None,
712            network_aliases: vec![],
713            extra_hosts: vec![],
714        }
715    }
716
717    /// Build the full `docker run …` argv emitted by [`LocalRuntime::run`].
718    /// Pure helper so callers (and tests) can inspect the wiring without a
719    /// live docker socket.
720    pub fn docker_run_args(&self) -> Vec<String> {
721        let mut args: Vec<String> = vec![
722            "run".into(),
723            "-d".into(),
724            "--name".into(),
725            self.name.clone(),
726            "--label".into(),
727            format!("{LABEL_KEY}={}", self.label),
728            "--restart".into(),
729            "unless-stopped".into(),
730        ];
731        if let Some(mode) = &self.cgroupns {
732            args.push(format!("--cgroupns={mode}"));
733        }
734        for cap in &self.cap_add {
735            args.push("--cap-add".into());
736            args.push(cap.clone());
737        }
738        if let Some(net) = &self.network {
739            args.push("--network".into());
740            args.push(net.clone());
741            for alias in &self.network_aliases {
742                args.push("--network-alias".into());
743                args.push(alias.clone());
744            }
745        }
746        for entry in &self.extra_hosts {
747            args.push("--add-host".into());
748            args.push(entry.clone());
749        }
750        for (host, container) in &self.ports {
751            args.push("-p".into());
752            args.push(format!("{host}:{container}"));
753        }
754        for (k, v) in &self.env {
755            args.push("-e".into());
756            args.push(format!("{k}={v}"));
757        }
758        for (host_path, container_path) in &self.volumes {
759            args.push("-v".into());
760            args.push(format!("{}:{}", host_path.display(), container_path));
761        }
762        args.push(self.image.clone());
763        args.extend(self.cmd.iter().cloned());
764        args
765    }
766}
767
768/// A container managed by this module, as observed via [`LocalRuntime::list_owned`].
769#[derive(Debug, Clone, PartialEq, Eq)]
770pub struct OwnedContainer {
771    pub name: String,
772    pub label: String,
773    pub state: ContainerState,
774}
775
776/// Container State.Status from `docker inspect`. Anything the docs don't
777/// enumerate lands in [`Unknown`].
778#[derive(Debug, Clone, PartialEq, Eq)]
779pub enum ContainerState {
780    Created,
781    Running,
782    Restarting,
783    Exited,
784    Paused,
785    Removing,
786    Dead,
787    Unknown(String),
788}
789
790impl ContainerState {
791    pub fn parse(s: &str) -> Self {
792        match s.trim().to_lowercase().as_str() {
793            "created" => Self::Created,
794            "running" => Self::Running,
795            "restarting" => Self::Restarting,
796            "exited" => Self::Exited,
797            "paused" => Self::Paused,
798            "removing" => Self::Removing,
799            "dead" => Self::Dead,
800            other => Self::Unknown(other.to_string()),
801        }
802    }
803
804    pub fn is_running(&self) -> bool {
805        matches!(self, Self::Running)
806    }
807}
808
809/// True if stderr indicates the named container doesn't exist. Both docker
810/// CLI and orbstack's docker shim use this shape — but with varying case
811/// (`No such container` from upstream docker, `no such object` from orbstack).
812fn is_missing_container_error(stderr: &str) -> bool {
813    let lower = stderr.to_lowercase();
814    lower.contains("no such container")
815        || lower.contains("no such object")
816        || lower.contains("not found")
817}
818
819/// Expand a leading `~` to `$HOME`. Anything else is returned as-is.
820fn expand_tilde(p: &Path) -> PathBuf {
821    let s = p.to_string_lossy();
822    if let Some(rest) = s.strip_prefix("~/") {
823        if let Ok(home) = std::env::var("HOME") {
824            return PathBuf::from(home).join(rest);
825        }
826    }
827    if s == "~" {
828        if let Ok(home) = std::env::var("HOME") {
829            return PathBuf::from(home);
830        }
831    }
832    p.to_path_buf()
833}
834
835// ── LocalDockerRuntime ────────────────────────────────────────────────────────
836
837/// `WorkloadRuntime` implementation backed by the docker CLI.
838///
839/// Wraps a detected [`LocalRuntime`] and translates each [`WorkloadSpec`] into
840/// a [`ContainerRunSpec`] before delegating to the underlying docker calls.
841/// This is the sim-tier half of the F10 keystone: camp embeds it pointing at
842/// OrbStack; yubaba supplies the containerd half for cloud/HA.
843///
844/// Translation notes:
845/// - Only `EnvValue::Literal` env vars are forwarded; `FromSecret` and
846///   `FromMesh` values are skipped with a warning (no secrets infrastructure
847///   at the local tier).
848/// - Only `VolumeSource::Bind` mounts are forwarded; named volumes and tmpfs
849///   are skipped with a warning.
850/// - `spec.command` overrides the image CMD when set.
851/// - Mesh / WireGuard / raft fields are ignored — sim containers communicate
852///   over OrbStack's bridge network.
853pub struct LocalDockerRuntime {
854    inner: LocalRuntime,
855}
856
857impl LocalDockerRuntime {
858    pub fn new(inner: LocalRuntime) -> Self {
859        Self { inner }
860    }
861
862    /// Access the underlying [`LocalRuntime`] (e.g. to call `ensure_image`
863    /// or `list_owned` directly).
864    pub fn runtime(&self) -> &LocalRuntime {
865        &self.inner
866    }
867}
868
869/// Translate a `WorkloadSpec` into a `ContainerRunSpec` for the local docker
870/// tier. Only the subset of `WorkloadSpec` fields that map directly to docker
871/// run args are carried across; yubaba-specific fields (mesh, resources, raft
872/// ident) are silently dropped.
873fn workload_spec_to_crs(spec: &WorkloadSpec) -> ContainerRunSpec {
874    let image = spec.image.docker_ref();
875
876    let mut env = BTreeMap::new();
877    for e in &spec.env {
878        match &e.value {
879            EnvValue::Literal { value } => {
880                env.insert(e.name.clone(), value.clone());
881            }
882            EnvValue::FromSecret { .. } => {
883                warn!(name = %e.name, "LocalDockerRuntime: skipping FromSecret env var (no secrets layer at sim tier)");
884            }
885            EnvValue::FromMesh { .. } => {
886                warn!(name = %e.name, "LocalDockerRuntime: skipping FromMesh env var (no mesh discovery at sim tier)");
887            }
888        }
889    }
890
891    let ports: Vec<(u16, u16)> = spec.expose.mesh.ports.iter().map(|&p| (p, p)).collect();
892
893    let volumes: Vec<(PathBuf, String)> = spec
894        .volumes
895        .iter()
896        .filter_map(|v| match &v.source {
897            VolumeSource::Bind { host_path } => {
898                Some((host_path.clone(), v.target.to_string_lossy().into_owned()))
899            }
900            VolumeSource::Named { name } => {
901                warn!(volume = %name, "LocalDockerRuntime: skipping Named volume (not supported at sim tier)");
902                None
903            }
904            VolumeSource::Tmpfs { .. } => {
905                warn!("LocalDockerRuntime: skipping Tmpfs volume (use -v /dev/null for ephemeral mounts at sim tier)");
906                None
907            }
908        })
909        .collect();
910
911    ContainerRunSpec {
912        name: spec.name.clone(),
913        image,
914        label: spec.name.clone(),
915        ports,
916        env,
917        volumes,
918        cmd: spec.command.clone().unwrap_or_default(),
919        cap_add: vec![],
920        cgroupns: None,
921        network: None,
922        network_aliases: vec![],
923        extra_hosts: vec![],
924    }
925}
926
927#[async_trait::async_trait]
928impl WorkloadRuntime for LocalDockerRuntime {
929    async fn deploy_workload(&self, spec: &WorkloadSpec) -> anyhow::Result<String> {
930        let crs = workload_spec_to_crs(spec);
931        self.inner.ensure_image(&crs.image).await?;
932        self.inner.run(&crs).await?;
933        Ok(spec.name.clone())
934    }
935
936    async fn teardown_workload(&self, name: &str) -> anyhow::Result<()> {
937        self.inner.stop_and_remove(name, Duration::from_secs(10)).await
938    }
939
940    async fn is_running(&self, name: &str) -> anyhow::Result<bool> {
941        Ok(self
942            .inner
943            .container_state(name)
944            .await?
945            .map(|s| s.is_running())
946            .unwrap_or(false))
947    }
948
949    async fn runtime_health(&self) -> anyhow::Result<bool> {
950        // Docker CLI health check: `docker info` exits 0 when the daemon is up.
951        let result = self
952            .inner
953            .run_capture(&["info", "--format", "{{.ServerVersion}}"])
954            .await;
955        Ok(result.is_ok())
956    }
957}
958
959#[cfg(test)]
960mod tests {
961    use super::*;
962    use std::collections::BTreeMap;
963
964    #[test]
965    fn canonical_name_format() {
966        assert_eq!(canonical_name("dev-yah", "pond", "static"), "yah-pond-dev-yah-pond-static");
967    }
968
969    #[test]
970    fn canonical_label_format() {
971        assert_eq!(canonical_label("dev-yah", "pond", "object_store"), "dev-yah:pond:object_store");
972    }
973
974    #[test]
975    fn runtime_pref_parse() {
976        assert_eq!(RuntimePref::parse("auto").unwrap(), RuntimePref::Auto);
977        assert_eq!(RuntimePref::parse("orbstack").unwrap(), RuntimePref::Orbstack);
978        assert_eq!(RuntimePref::parse("docker-desktop").unwrap(), RuntimePref::DockerDesktop);
979        assert_eq!(RuntimePref::parse("docker_desktop").unwrap(), RuntimePref::DockerDesktop);
980        assert_eq!(RuntimePref::parse("colima").unwrap(), RuntimePref::Colima);
981        assert_eq!(RuntimePref::parse("podman").unwrap(), RuntimePref::Podman);
982        assert_eq!(RuntimePref::parse("docker").unwrap(), RuntimePref::Docker);
983        assert_eq!(RuntimePref::parse("custom").unwrap(), RuntimePref::Custom);
984        let err = RuntimePref::parse("nonsense").unwrap_err().to_string();
985        assert!(err.contains("nonsense"), "error should name the bad value, got: {err}");
986    }
987
988    #[test]
989    fn detected_runtime_as_str_round_trips() {
990        assert_eq!(DetectedRuntime::Orbstack.as_str(), "orbstack");
991        assert_eq!(DetectedRuntime::DockerDesktop.as_str(), "docker-desktop");
992        assert_eq!(DetectedRuntime::Colima.as_str(), "colima");
993        assert_eq!(DetectedRuntime::Podman.as_str(), "podman");
994        assert_eq!(DetectedRuntime::Docker.as_str(), "docker");
995        assert_eq!(DetectedRuntime::Custom.as_str(), "custom");
996    }
997
998    #[tokio::test]
999    async fn detect_returns_error_when_no_socket_exists() {
1000        // Build a spec pointing at paths that definitely don't exist.
1001        let mut discovery = BTreeMap::new();
1002        discovery.insert("orbstack".into(), PathBuf::from("/nonexistent/orbstack.sock"));
1003        discovery.insert("colima".into(), PathBuf::from("/nonexistent/colima.sock"));
1004        discovery.insert("docker".into(), PathBuf::from("/nonexistent/docker.sock"));
1005        let spec = LocalContainerSpec { runtime: RuntimePref::Auto, discovery, custom_docker_host: None };
1006        let err = LocalRuntime::detect(&spec).await.unwrap_err().to_string();
1007        assert!(err.contains("no local container runtime reachable"));
1008        assert!(err.contains("orbstack"));
1009        assert!(err.contains("colima"));
1010        assert!(err.contains("docker"));
1011    }
1012
1013    #[tokio::test]
1014    async fn detect_reports_when_pinned_runtime_has_no_discovery_entry() {
1015        let spec = LocalContainerSpec {
1016            runtime: RuntimePref::Colima,
1017            discovery: BTreeMap::new(),
1018            custom_docker_host: None,
1019        };
1020        let err = LocalRuntime::detect(&spec).await.unwrap_err().to_string();
1021        assert!(err.contains("colima"));
1022        assert!(err.contains("no discovery entry"));
1023    }
1024
1025    #[tokio::test]
1026    async fn detect_picks_existing_socket() {
1027        // Use a tempdir + touch file to stand in for a real socket. detect()
1028        // only checks existence, not socket-ness.
1029        let tmp = tempfile::TempDir::new().unwrap();
1030        let fake = tmp.path().join("docker.sock");
1031        std::fs::write(&fake, b"").unwrap();
1032        let mut discovery = BTreeMap::new();
1033        discovery.insert("orbstack".into(), PathBuf::from("/nonexistent/no.sock"));
1034        discovery.insert("colima".into(), PathBuf::from("/nonexistent/no.sock"));
1035        discovery.insert("docker".into(), fake.clone());
1036        let spec = LocalContainerSpec { runtime: RuntimePref::Auto, discovery, custom_docker_host: None };
1037        let runtime = LocalRuntime::detect(&spec).await.unwrap();
1038        assert_eq!(runtime.detected, DetectedRuntime::Docker);
1039        assert_eq!(runtime.docker_host, format!("unix://{}", fake.display()));
1040    }
1041
1042    #[tokio::test]
1043    async fn detect_honors_runtime_pin_and_skips_others() {
1044        // Even if a later runtime's socket exists, a pinned earlier runtime
1045        // without a socket should fail rather than fall through.
1046        let tmp = tempfile::TempDir::new().unwrap();
1047        let fake = tmp.path().join("docker.sock");
1048        std::fs::write(&fake, b"").unwrap();
1049        let mut discovery = BTreeMap::new();
1050        discovery.insert("orbstack".into(), PathBuf::from("/nonexistent/no.sock"));
1051        discovery.insert("docker".into(), fake);
1052        let spec = LocalContainerSpec { runtime: RuntimePref::Orbstack, discovery, custom_docker_host: None };
1053        let err = LocalRuntime::detect(&spec).await.unwrap_err().to_string();
1054        assert!(err.contains("orbstack"));
1055        // The error body should not list docker as an *attempted* probe entry.
1056        // (The hint text "install or start ... docker-desktop ..." may mention docker
1057        // substrings, but the tried-list should only show orbstack.)
1058        assert!(!err.contains("docker ("), "pinned to orbstack — docker should not be in tried list: {err}");
1059    }
1060
1061    #[tokio::test]
1062    async fn detect_custom_pref_uses_host_directly() {
1063        let spec = LocalContainerSpec {
1064            runtime: RuntimePref::Custom,
1065            discovery: BTreeMap::new(),
1066            custom_docker_host: Some("tcp://localhost:2375".into()),
1067        };
1068        let runtime = LocalRuntime::detect(&spec).await.unwrap();
1069        assert_eq!(runtime.detected, DetectedRuntime::Custom);
1070        assert_eq!(runtime.docker_host, "tcp://localhost:2375");
1071    }
1072
1073    #[tokio::test]
1074    async fn detect_custom_pref_without_host_errors() {
1075        let spec = LocalContainerSpec {
1076            runtime: RuntimePref::Custom,
1077            discovery: BTreeMap::new(),
1078            custom_docker_host: None,
1079        };
1080        let err = LocalRuntime::detect(&spec).await.unwrap_err().to_string();
1081        assert!(err.contains("custom_docker_host"), "error should mention the missing field: {err}");
1082    }
1083
1084    #[tokio::test]
1085    async fn detect_auto_falls_back_to_custom_host() {
1086        // All socket candidates absent, but a custom_docker_host is configured.
1087        let spec = LocalContainerSpec {
1088            runtime: RuntimePref::Auto,
1089            discovery: BTreeMap::new(),
1090            custom_docker_host: Some("tcp://localhost:2375".into()),
1091        };
1092        let runtime = LocalRuntime::detect(&spec).await.unwrap();
1093        assert_eq!(runtime.detected, DetectedRuntime::Custom);
1094        assert_eq!(runtime.docker_host, "tcp://localhost:2375");
1095    }
1096
1097    #[test]
1098    fn build_cascade_returns_providers_with_discovery_entries() {
1099        let mut discovery = BTreeMap::new();
1100        discovery.insert("orbstack".into(), PathBuf::from("/fake/orbstack.sock"));
1101        discovery.insert("docker".into(), PathBuf::from("/fake/docker.sock"));
1102        let spec = LocalContainerSpec { runtime: RuntimePref::Auto, discovery, custom_docker_host: None };
1103        let cascade = spec.build_cascade();
1104        // Only orbstack and docker have entries; docker-desktop/colima/podman are absent.
1105        assert_eq!(cascade.len(), 2);
1106        assert!(cascade.iter().any(|(k, _)| *k == DetectedRuntime::Orbstack));
1107        assert!(cascade.iter().any(|(k, _)| *k == DetectedRuntime::Docker));
1108    }
1109
1110    #[test]
1111    fn build_cascade_custom_pref_returns_single_entry() {
1112        let spec = LocalContainerSpec {
1113            runtime: RuntimePref::Custom,
1114            discovery: BTreeMap::new(),
1115            custom_docker_host: Some("tcp://localhost:2375".into()),
1116        };
1117        let cascade = spec.build_cascade();
1118        assert_eq!(cascade.len(), 1);
1119        let (kind, provider) = &cascade[0];
1120        assert_eq!(*kind, DetectedRuntime::Custom);
1121        assert_eq!(provider.docker_host(), "tcp://localhost:2375");
1122        assert!(provider.available());
1123    }
1124
1125    #[test]
1126    fn expand_tilde_replaces_home_prefix() {
1127        std::env::set_var("HOME", "/tmp/fake-home");
1128        let p = expand_tilde(Path::new("~/foo/bar"));
1129        assert_eq!(p, PathBuf::from("/tmp/fake-home/foo/bar"));
1130    }
1131
1132    #[test]
1133    fn expand_tilde_leaves_absolute_paths_alone() {
1134        let p = expand_tilde(Path::new("/var/run/docker.sock"));
1135        assert_eq!(p, PathBuf::from("/var/run/docker.sock"));
1136    }
1137
1138    #[test]
1139    fn container_state_parse_known_values() {
1140        assert_eq!(ContainerState::parse("running"), ContainerState::Running);
1141        assert_eq!(ContainerState::parse("exited"), ContainerState::Exited);
1142        assert_eq!(ContainerState::parse("PAUSED"), ContainerState::Paused);
1143    }
1144
1145    #[test]
1146    fn container_state_parse_unknown_preserves_string() {
1147        match ContainerState::parse("zombie") {
1148            ContainerState::Unknown(s) => assert_eq!(s, "zombie"),
1149            other => panic!("expected Unknown, got {other:?}"),
1150        }
1151    }
1152
1153    #[test]
1154    fn is_missing_container_error_matches_upstream_and_orbstack() {
1155        assert!(is_missing_container_error("Error: No such container: foo"));
1156        assert!(is_missing_container_error("error: no such object: foo"));
1157        assert!(is_missing_container_error("not found: foo"));
1158        assert!(!is_missing_container_error("Error response from daemon: Conflict."));
1159        assert!(!is_missing_container_error(""));
1160    }
1161
1162    #[test]
1163    fn container_run_spec_new_uses_canonical_name() {
1164        let spec = ContainerRunSpec::new("dev-yah", "pond", "static", "caddy:2-alpine");
1165        assert_eq!(spec.name, "yah-pond-dev-yah-pond-static");
1166        assert_eq!(spec.label, "dev-yah:pond:static");
1167        assert!(spec.ports.is_empty());
1168        assert!(spec.network.is_none());
1169        assert!(spec.network_aliases.is_empty());
1170    }
1171
1172    #[test]
1173    fn pond_network_name_is_yah_pond_svc_env() {
1174        assert_eq!(pond_network_name("yah-marketing", "pond"), "yah-pond-yah-marketing-pond");
1175        assert_eq!(pond_network_name("yah-dashboard", "pond"), "yah-pond-yah-dashboard-pond");
1176    }
1177
1178    #[test]
1179    fn docker_run_args_emit_network_and_aliases_when_set() {
1180        let mut spec = ContainerRunSpec::new("dev", "pond", "object_store", "minio:latest");
1181        spec.network = Some("yah-pond-dev-pond".into());
1182        spec.network_aliases = vec!["minio".into()];
1183        let args = spec.docker_run_args();
1184        let joined = args.join(" ");
1185        assert!(
1186            joined.contains("--network yah-pond-dev-pond"),
1187            "expected --network flag in: {joined}",
1188        );
1189        assert!(
1190            joined.contains("--network-alias minio"),
1191            "expected --network-alias minio in: {joined}",
1192        );
1193    }
1194
1195    #[test]
1196    fn docker_run_args_omit_network_flags_when_unset() {
1197        let spec = ContainerRunSpec::new("dev", "pond", "object_store", "minio:latest");
1198        let args = spec.docker_run_args();
1199        let joined = args.join(" ");
1200        assert!(!joined.contains("--network"), "unexpected --network flag: {joined}");
1201        assert!(!joined.contains("--network-alias"), "unexpected --network-alias flag: {joined}");
1202    }
1203
1204    #[test]
1205    fn docker_run_args_skip_aliases_when_no_network() {
1206        // Aliases without a network are meaningless — docker would reject `--network-alias`
1207        // without `--network`. Defensive: emit neither.
1208        let mut spec = ContainerRunSpec::new("dev", "pond", "object_store", "minio:latest");
1209        spec.network_aliases = vec!["minio".into()];
1210        let args = spec.docker_run_args();
1211        let joined = args.join(" ");
1212        assert!(!joined.contains("--network-alias"), "should not emit alias without network: {joined}");
1213    }
1214
1215    // ── LocalDockerRuntime / workload_spec_to_crs unit tests ─────────────────
1216
1217    fn minimal_workload_spec(name: &str) -> workload_spec::WorkloadSpec {
1218        use workload_spec::*;
1219        WorkloadSpec {
1220            schema_version: SchemaVersion::V1,
1221            name: name.to_string(),
1222            image: ImageRef {
1223                registry: "ghcr.io".into(),
1224                repository: "test/app".into(),
1225                tag: "v1.0".into(),
1226                digest: workload_spec::testing::test_digest(),
1227            },
1228            tier: TierTag("infra".into()),
1229            tenant: workload_spec::TenantId::singleton(),
1230            namespace: workload_spec::NamespaceId::singleton(),
1231            replicas: 1,
1232            command: None,
1233            entrypoint: None,
1234            workdir: None,
1235            user: None,
1236            env: vec![],
1237            secrets: vec![],
1238            volumes: vec![],
1239            resources: ResourceLimits { memory_mb: 256, cpu_millis: 512, ephemeral_storage_mb: 256 },
1240            depends_on: vec![],
1241            healthcheck: None,
1242            restart_policy: RestartPolicy::Always,
1243            archetype: None,
1244            stop_policy: StopPolicy { signal: 15, grace_period: Millis::from_secs(10) },
1245            expose: ExposeSpec {
1246                mesh: MeshExpose { identity: MeshIdent(name.into()), ports: vec![], allow_from: vec![] },
1247                public: None,
1248                operator: None,
1249            },
1250            labels: Default::default(),
1251            annotations: Default::default(),
1252        }
1253    }
1254
1255    #[test]
1256    fn workload_spec_to_crs_sets_name_and_image() {
1257        let spec = minimal_workload_spec("test-app");
1258        let crs = workload_spec_to_crs(&spec);
1259        assert_eq!(crs.name, "test-app");
1260        // workload_spec_to_crs uses ImageRef::docker_ref(), which emits tag@digest.
1261        assert_eq!(
1262            crs.image,
1263            format!("ghcr.io/test/app:v1.0@{}", workload_spec::testing::test_digest())
1264        );
1265        assert!(crs.ports.is_empty());
1266        assert!(crs.env.is_empty());
1267    }
1268
1269    #[test]
1270    fn workload_spec_to_crs_forwards_literal_env_only() {
1271        use workload_spec::{EnvValue, EnvVar, MeshIdent, MeshLookup};
1272        let mut spec = minimal_workload_spec("env-test");
1273        spec.env = vec![
1274            EnvVar { name: "GOOD".into(), value: EnvValue::Literal { value: "yes".into() } },
1275            EnvVar { name: "BAD_SECRET".into(), value: EnvValue::FromSecret { secret: "s".into(), key: "k".into() } },
1276            EnvVar { name: "BAD_MESH".into(), value: EnvValue::FromMesh { ident: MeshIdent("x".into()), kind: MeshLookup::Url } },
1277        ];
1278        let crs = workload_spec_to_crs(&spec);
1279        assert_eq!(crs.env.get("GOOD").map(String::as_str), Some("yes"));
1280        assert!(!crs.env.contains_key("BAD_SECRET"), "FromSecret must be filtered out");
1281        assert!(!crs.env.contains_key("BAD_MESH"), "FromMesh must be filtered out");
1282    }
1283
1284    #[test]
1285    fn workload_spec_to_crs_maps_mesh_ports() {
1286        let mut spec = minimal_workload_spec("port-test");
1287        spec.expose.mesh.ports = vec![8080, 9000];
1288        let crs = workload_spec_to_crs(&spec);
1289        assert_eq!(crs.ports, vec![(8080, 8080), (9000, 9000)]);
1290    }
1291
1292    #[test]
1293    fn workload_spec_to_crs_applies_command_override() {
1294        let mut spec = minimal_workload_spec("cmd-test");
1295        spec.command = Some(vec!["server".into(), "--port=8080".into()]);
1296        let crs = workload_spec_to_crs(&spec);
1297        assert_eq!(crs.cmd, vec!["server", "--port=8080"]);
1298    }
1299
1300    #[test]
1301    fn image_ref_docker_ref_emits_tag_and_digest() {
1302        use workload_spec::ImageRef;
1303        let r = ImageRef {
1304            registry: "ghcr.io".into(),
1305            repository: "org/app".into(),
1306            tag: "v1.0".into(),
1307            digest: "sha256:abc123".into(),
1308        };
1309        assert_eq!(r.docker_ref(), "ghcr.io/org/app:v1.0@sha256:abc123");
1310    }
1311
1312    // Live docker-socket integration tests + the `from_provider_config`
1313    // adapter tests live in `cloud::local_driver_glue::tests` so they can
1314    // depend on `cloud::config::ProviderConfig` without local-driver pulling
1315    // a reverse dep on cloud.
1316}