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    /// Idempotently create a docker bridge network. Returns `true` if a
449    /// network was created, `false` if one already existed under `name`.
450    /// Used by the pond per-cell bridge bring-up (R455-F1).
451    pub async fn ensure_network(&self, name: &str) -> Result<bool> {
452        let out = self
453            .cmd()
454            .args(["network", "inspect", name])
455            .stdout(Stdio::null())
456            .stderr(Stdio::null())
457            .status()
458            .await
459            .with_context(|| format!("spawning docker network inspect {name}"))?;
460        if out.success() {
461            return Ok(false);
462        }
463        let create = self
464            .cmd()
465            .args(["network", "create", name])
466            .stdout(Stdio::null())
467            .stderr(Stdio::piped())
468            .output()
469            .await
470            .with_context(|| format!("spawning docker network create {name}"))?;
471        if create.status.success() {
472            return Ok(true);
473        }
474        let stderr = String::from_utf8_lossy(&create.stderr);
475        // Race: another caller created the network between inspect and create.
476        let lower = stderr.to_lowercase();
477        if lower.contains("already exists") {
478            return Ok(false);
479        }
480        bail!("docker network create {name} failed: {}", stderr.trim());
481    }
482
483    /// Best-effort `docker rm -f <name>` — silent when the container is
484    /// already gone. Used before `run` to clear orphans from a prior crash.
485    pub async fn remove_container(&self, name: &str) -> Result<()> {
486        let out = self
487            .cmd()
488            .args(["rm", "-f", name])
489            .stdout(Stdio::null())
490            .stderr(Stdio::piped())
491            .output()
492            .await
493            .with_context(|| format!("spawning docker rm -f {name}"))?;
494        if out.status.success() {
495            return Ok(());
496        }
497        let stderr = String::from_utf8_lossy(&out.stderr);
498        if is_missing_container_error(&stderr) {
499            return Ok(());
500        }
501        bail!("docker rm -f {name} failed: {}", stderr.trim());
502    }
503
504    /// Start a detached container per `spec`. Pre-clears any prior container
505    /// with the same name so re-runs after a crash are idempotent.
506    pub async fn run(&self, spec: &ContainerRunSpec) -> Result<()> {
507        // Idempotent: clear any leftover with the same name before launching.
508        self.remove_container(&spec.name).await?;
509
510        let args = spec.docker_run_args();
511        let argv: Vec<&str> = args.iter().map(String::as_str).collect();
512        self.run_capture(&argv).await?;
513        Ok(())
514    }
515
516    /// Read the host-side port that the container mapped `container_port/tcp`
517    /// to. Runs `docker port <name> <container_port>` and parses the output
518    /// (`0.0.0.0:XXXXX` or `:::XXXXX`). Returns an error when the container is
519    /// not running or the port is not published.
520    pub async fn container_host_port(&self, name: &str, container_port: u16) -> Result<u16> {
521        let port_str = container_port.to_string();
522        let out = self
523            .run_capture(&["port", name, &port_str])
524            .await
525            .with_context(|| format!("docker port {name} {container_port}"))?;
526        // Output is `0.0.0.0:<host_port>` or `:::<host_port>` — split on `:`,
527        // take the last token.
528        let host_port_str = out
529            .trim()
530            .rsplit(':')
531            .next()
532            .filter(|s| !s.is_empty())
533            .with_context(|| format!("unexpected docker port output: {:?}", out.trim()))?;
534        host_port_str.parse::<u16>().with_context(|| {
535            format!(
536                "parsing host port {:?} for {name}:{container_port}",
537                host_port_str,
538            )
539        })
540    }
541
542    /// Fetch the container's State.Status field (running / exited / …).
543    /// Returns `Ok(None)` when the container doesn't exist.
544    pub async fn container_state(&self, name: &str) -> Result<Option<ContainerState>> {
545        let out = self
546            .cmd()
547            .args([
548                "inspect",
549                "--format",
550                "{{.State.Status}}",
551                name,
552            ])
553            .stdout(Stdio::piped())
554            .stderr(Stdio::piped())
555            .output()
556            .await
557            .with_context(|| format!("spawning docker inspect {name}"))?;
558        if !out.status.success() {
559            let stderr = String::from_utf8_lossy(&out.stderr);
560            if is_missing_container_error(&stderr) {
561                return Ok(None);
562            }
563            bail!("docker inspect {name} failed: {}", stderr.trim());
564        }
565        let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
566        Ok(Some(ContainerState::parse(&raw)))
567    }
568
569    /// Graceful `docker stop -t <grace_seconds>` followed by `docker rm`.
570    /// No-op if the container doesn't exist.
571    pub async fn stop_and_remove(&self, name: &str, grace: Duration) -> Result<()> {
572        let grace_str = grace.as_secs().to_string();
573        let stop = self
574            .cmd()
575            .args(["stop", "-t", &grace_str, name])
576            .stdout(Stdio::null())
577            .stderr(Stdio::piped())
578            .output()
579            .await
580            .with_context(|| format!("spawning docker stop {name}"))?;
581        if !stop.status.success() {
582            let stderr = String::from_utf8_lossy(&stop.stderr);
583            if !is_missing_container_error(&stderr) {
584                bail!("docker stop {name} failed: {}", stderr.trim());
585            }
586        }
587        self.remove_container(name).await
588    }
589
590    /// List every container owned by this module on this runtime.
591    /// Queries both `yah.pond` (current) and `yah.local-sim` (legacy, pre-R362)
592    /// label keys so old-generation containers are visible during the transition.
593    pub async fn list_owned(&self) -> Result<Vec<OwnedContainer>> {
594        // `docker ps -a --filter label=<key> --format '<name>\t<label-value>\t<state>'`
595        // Two passes: current label key + legacy key; merge, dedup by name.
596        let mut owned: Vec<OwnedContainer> = Vec::new();
597        let mut seen_names: std::collections::HashSet<String> = std::collections::HashSet::new();
598
599        for (label_key, format_key) in [
600            (LABEL_KEY, LABEL_KEY),
601            (LEGACY_LABEL_KEY, LEGACY_LABEL_KEY),
602        ] {
603            let fmt = format!("{{{{.Names}}}}\t{{{{.Label \"{format_key}\"}}}}\t{{{{.State}}}}");
604            let out = self
605                .run_capture(&[
606                    "ps",
607                    "-a",
608                    "--filter",
609                    &format!("label={label_key}"),
610                    "--format",
611                    &fmt,
612                ])
613                .await?;
614            for line in out.lines() {
615                let mut parts = line.splitn(3, '\t');
616                let name = parts.next().unwrap_or_default().trim();
617                let label = parts.next().unwrap_or_default().trim();
618                let state = parts.next().unwrap_or_default().trim();
619                if name.is_empty() || seen_names.contains(name) {
620                    continue;
621                }
622                seen_names.insert(name.to_string());
623                owned.push(OwnedContainer {
624                    name: name.to_string(),
625                    label: label.to_string(),
626                    state: ContainerState::parse(state),
627                });
628            }
629        }
630        Ok(owned)
631    }
632}
633
634/// Run-time spec for a single container managed by [`LocalRuntime::run`].
635#[derive(Debug, Clone)]
636pub struct ContainerRunSpec {
637    /// Canonical name — see [`canonical_name`].
638    pub name: String,
639    /// Image ref. Tag-pinned for cache determinism (`caddy:2.10-alpine`).
640    pub image: String,
641    /// Value for the `yah.local-sim` label (see [`canonical_label`]).
642    pub label: String,
643    /// Host→container port bindings.
644    pub ports: Vec<(u16, u16)>,
645    /// Container env vars.
646    pub env: BTreeMap<String, String>,
647    /// Bind-mount pairs (host_path, container_path).
648    pub volumes: Vec<(PathBuf, String)>,
649    /// Optional CMD override; empty leaves the image default.
650    pub cmd: Vec<String>,
651    /// Linux capabilities to add via `--cap-add` (e.g. `["SYS_ADMIN"]`).
652    /// Empty by default; the pond yubaba-container path (R408-T2) sets this
653    /// so Kamaji can perform cgroup ops inside the container.
654    pub cap_add: Vec<String>,
655    /// Cgroup namespace mode forwarded to `docker run --cgroupns=...`. Valid
656    /// values are `"private"` and `"host"`; `None` leaves the daemon default.
657    /// The pond yubaba-container path (R408-T2) sets `"private"` so the
658    /// container sees a fresh `/sys/fs/cgroup` it can write child cgroups
659    /// under.
660    pub cgroupns: Option<String>,
661    /// Docker network to attach the container to via `--network <name>`.
662    /// `None` leaves the daemon default (the `bridge` network). The pond
663    /// per-cell bridge (R455-F1) sets this to [`pond_network_name`].
664    pub network: Option<String>,
665    /// Network aliases registered with `--network-alias <alias>` so siblings
666    /// on the same bridge can reach this container by name regardless of its
667    /// container name. Ignored when [`network`] is `None`.
668    pub network_aliases: Vec<String>,
669}
670
671impl ContainerRunSpec {
672    /// Convenience: build a spec with the canonical name + label derived from
673    /// the (service, env, slot) triple.
674    pub fn new(service: &str, env: &str, slot: &str, image: impl Into<String>) -> Self {
675        Self {
676            name: canonical_name(service, env, slot),
677            image: image.into(),
678            label: canonical_label(service, env, slot),
679            ports: vec![],
680            env: BTreeMap::new(),
681            volumes: vec![],
682            cmd: vec![],
683            cap_add: vec![],
684            cgroupns: None,
685            network: None,
686            network_aliases: vec![],
687        }
688    }
689
690    /// Build the full `docker run …` argv emitted by [`LocalRuntime::run`].
691    /// Pure helper so callers (and tests) can inspect the wiring without a
692    /// live docker socket.
693    pub fn docker_run_args(&self) -> Vec<String> {
694        let mut args: Vec<String> = vec![
695            "run".into(),
696            "-d".into(),
697            "--name".into(),
698            self.name.clone(),
699            "--label".into(),
700            format!("{LABEL_KEY}={}", self.label),
701            "--restart".into(),
702            "unless-stopped".into(),
703        ];
704        if let Some(mode) = &self.cgroupns {
705            args.push(format!("--cgroupns={mode}"));
706        }
707        for cap in &self.cap_add {
708            args.push("--cap-add".into());
709            args.push(cap.clone());
710        }
711        if let Some(net) = &self.network {
712            args.push("--network".into());
713            args.push(net.clone());
714            for alias in &self.network_aliases {
715                args.push("--network-alias".into());
716                args.push(alias.clone());
717            }
718        }
719        for (host, container) in &self.ports {
720            args.push("-p".into());
721            args.push(format!("{host}:{container}"));
722        }
723        for (k, v) in &self.env {
724            args.push("-e".into());
725            args.push(format!("{k}={v}"));
726        }
727        for (host_path, container_path) in &self.volumes {
728            args.push("-v".into());
729            args.push(format!("{}:{}", host_path.display(), container_path));
730        }
731        args.push(self.image.clone());
732        args.extend(self.cmd.iter().cloned());
733        args
734    }
735}
736
737/// A container managed by this module, as observed via [`LocalRuntime::list_owned`].
738#[derive(Debug, Clone, PartialEq, Eq)]
739pub struct OwnedContainer {
740    pub name: String,
741    pub label: String,
742    pub state: ContainerState,
743}
744
745/// Container State.Status from `docker inspect`. Anything the docs don't
746/// enumerate lands in [`Unknown`].
747#[derive(Debug, Clone, PartialEq, Eq)]
748pub enum ContainerState {
749    Created,
750    Running,
751    Restarting,
752    Exited,
753    Paused,
754    Removing,
755    Dead,
756    Unknown(String),
757}
758
759impl ContainerState {
760    pub fn parse(s: &str) -> Self {
761        match s.trim().to_lowercase().as_str() {
762            "created" => Self::Created,
763            "running" => Self::Running,
764            "restarting" => Self::Restarting,
765            "exited" => Self::Exited,
766            "paused" => Self::Paused,
767            "removing" => Self::Removing,
768            "dead" => Self::Dead,
769            other => Self::Unknown(other.to_string()),
770        }
771    }
772
773    pub fn is_running(&self) -> bool {
774        matches!(self, Self::Running)
775    }
776}
777
778/// True if stderr indicates the named container doesn't exist. Both docker
779/// CLI and orbstack's docker shim use this shape — but with varying case
780/// (`No such container` from upstream docker, `no such object` from orbstack).
781fn is_missing_container_error(stderr: &str) -> bool {
782    let lower = stderr.to_lowercase();
783    lower.contains("no such container")
784        || lower.contains("no such object")
785        || lower.contains("not found")
786}
787
788/// Expand a leading `~` to `$HOME`. Anything else is returned as-is.
789fn expand_tilde(p: &Path) -> PathBuf {
790    let s = p.to_string_lossy();
791    if let Some(rest) = s.strip_prefix("~/") {
792        if let Ok(home) = std::env::var("HOME") {
793            return PathBuf::from(home).join(rest);
794        }
795    }
796    if s == "~" {
797        if let Ok(home) = std::env::var("HOME") {
798            return PathBuf::from(home);
799        }
800    }
801    p.to_path_buf()
802}
803
804// ── LocalDockerRuntime ────────────────────────────────────────────────────────
805
806/// `WorkloadRuntime` implementation backed by the docker CLI.
807///
808/// Wraps a detected [`LocalRuntime`] and translates each [`WorkloadSpec`] into
809/// a [`ContainerRunSpec`] before delegating to the underlying docker calls.
810/// This is the sim-tier half of the F10 keystone: camp embeds it pointing at
811/// OrbStack; yubaba supplies the containerd half for cloud/HA.
812///
813/// Translation notes:
814/// - Only `EnvValue::Literal` env vars are forwarded; `FromSecret` and
815///   `FromMesh` values are skipped with a warning (no secrets infrastructure
816///   at the local tier).
817/// - Only `VolumeSource::Bind` mounts are forwarded; named volumes and tmpfs
818///   are skipped with a warning.
819/// - `spec.command` overrides the image CMD when set.
820/// - Mesh / WireGuard / raft fields are ignored — sim containers communicate
821///   over OrbStack's bridge network.
822pub struct LocalDockerRuntime {
823    inner: LocalRuntime,
824}
825
826impl LocalDockerRuntime {
827    pub fn new(inner: LocalRuntime) -> Self {
828        Self { inner }
829    }
830
831    /// Access the underlying [`LocalRuntime`] (e.g. to call `ensure_image`
832    /// or `list_owned` directly).
833    pub fn runtime(&self) -> &LocalRuntime {
834        &self.inner
835    }
836}
837
838/// Translate a `WorkloadSpec` into a `ContainerRunSpec` for the local docker
839/// tier. Only the subset of `WorkloadSpec` fields that map directly to docker
840/// run args are carried across; yubaba-specific fields (mesh, resources, raft
841/// ident) are silently dropped.
842fn workload_spec_to_crs(spec: &WorkloadSpec) -> ContainerRunSpec {
843    let image = spec.image.docker_ref();
844
845    let mut env = BTreeMap::new();
846    for e in &spec.env {
847        match &e.value {
848            EnvValue::Literal { value } => {
849                env.insert(e.name.clone(), value.clone());
850            }
851            EnvValue::FromSecret { .. } => {
852                warn!(name = %e.name, "LocalDockerRuntime: skipping FromSecret env var (no secrets layer at sim tier)");
853            }
854            EnvValue::FromMesh { .. } => {
855                warn!(name = %e.name, "LocalDockerRuntime: skipping FromMesh env var (no mesh discovery at sim tier)");
856            }
857        }
858    }
859
860    let ports: Vec<(u16, u16)> = spec.expose.mesh.ports.iter().map(|&p| (p, p)).collect();
861
862    let volumes: Vec<(PathBuf, String)> = spec
863        .volumes
864        .iter()
865        .filter_map(|v| match &v.source {
866            VolumeSource::Bind { host_path } => {
867                Some((host_path.clone(), v.target.to_string_lossy().into_owned()))
868            }
869            VolumeSource::Named { name } => {
870                warn!(volume = %name, "LocalDockerRuntime: skipping Named volume (not supported at sim tier)");
871                None
872            }
873            VolumeSource::Tmpfs { .. } => {
874                warn!("LocalDockerRuntime: skipping Tmpfs volume (use -v /dev/null for ephemeral mounts at sim tier)");
875                None
876            }
877        })
878        .collect();
879
880    ContainerRunSpec {
881        name: spec.name.clone(),
882        image,
883        label: spec.name.clone(),
884        ports,
885        env,
886        volumes,
887        cmd: spec.command.clone().unwrap_or_default(),
888        cap_add: vec![],
889        cgroupns: None,
890        network: None,
891        network_aliases: vec![],
892    }
893}
894
895#[async_trait::async_trait]
896impl WorkloadRuntime for LocalDockerRuntime {
897    async fn deploy_workload(&self, spec: &WorkloadSpec) -> anyhow::Result<String> {
898        let crs = workload_spec_to_crs(spec);
899        self.inner.ensure_image(&crs.image).await?;
900        self.inner.run(&crs).await?;
901        Ok(spec.name.clone())
902    }
903
904    async fn teardown_workload(&self, name: &str) -> anyhow::Result<()> {
905        self.inner.stop_and_remove(name, Duration::from_secs(10)).await
906    }
907
908    async fn is_running(&self, name: &str) -> anyhow::Result<bool> {
909        Ok(self
910            .inner
911            .container_state(name)
912            .await?
913            .map(|s| s.is_running())
914            .unwrap_or(false))
915    }
916
917    async fn runtime_health(&self) -> anyhow::Result<bool> {
918        // Docker CLI health check: `docker info` exits 0 when the daemon is up.
919        let result = self
920            .inner
921            .run_capture(&["info", "--format", "{{.ServerVersion}}"])
922            .await;
923        Ok(result.is_ok())
924    }
925}
926
927#[cfg(test)]
928mod tests {
929    use super::*;
930    use std::collections::BTreeMap;
931
932    #[test]
933    fn canonical_name_format() {
934        assert_eq!(canonical_name("dev-yah", "pond", "static"), "yah-pond-dev-yah-pond-static");
935    }
936
937    #[test]
938    fn canonical_label_format() {
939        assert_eq!(canonical_label("dev-yah", "pond", "object_store"), "dev-yah:pond:object_store");
940    }
941
942    #[test]
943    fn runtime_pref_parse() {
944        assert_eq!(RuntimePref::parse("auto").unwrap(), RuntimePref::Auto);
945        assert_eq!(RuntimePref::parse("orbstack").unwrap(), RuntimePref::Orbstack);
946        assert_eq!(RuntimePref::parse("docker-desktop").unwrap(), RuntimePref::DockerDesktop);
947        assert_eq!(RuntimePref::parse("docker_desktop").unwrap(), RuntimePref::DockerDesktop);
948        assert_eq!(RuntimePref::parse("colima").unwrap(), RuntimePref::Colima);
949        assert_eq!(RuntimePref::parse("podman").unwrap(), RuntimePref::Podman);
950        assert_eq!(RuntimePref::parse("docker").unwrap(), RuntimePref::Docker);
951        assert_eq!(RuntimePref::parse("custom").unwrap(), RuntimePref::Custom);
952        let err = RuntimePref::parse("nonsense").unwrap_err().to_string();
953        assert!(err.contains("nonsense"), "error should name the bad value, got: {err}");
954    }
955
956    #[test]
957    fn detected_runtime_as_str_round_trips() {
958        assert_eq!(DetectedRuntime::Orbstack.as_str(), "orbstack");
959        assert_eq!(DetectedRuntime::DockerDesktop.as_str(), "docker-desktop");
960        assert_eq!(DetectedRuntime::Colima.as_str(), "colima");
961        assert_eq!(DetectedRuntime::Podman.as_str(), "podman");
962        assert_eq!(DetectedRuntime::Docker.as_str(), "docker");
963        assert_eq!(DetectedRuntime::Custom.as_str(), "custom");
964    }
965
966    #[tokio::test]
967    async fn detect_returns_error_when_no_socket_exists() {
968        // Build a spec pointing at paths that definitely don't exist.
969        let mut discovery = BTreeMap::new();
970        discovery.insert("orbstack".into(), PathBuf::from("/nonexistent/orbstack.sock"));
971        discovery.insert("colima".into(), PathBuf::from("/nonexistent/colima.sock"));
972        discovery.insert("docker".into(), PathBuf::from("/nonexistent/docker.sock"));
973        let spec = LocalContainerSpec { runtime: RuntimePref::Auto, discovery, custom_docker_host: None };
974        let err = LocalRuntime::detect(&spec).await.unwrap_err().to_string();
975        assert!(err.contains("no local container runtime reachable"));
976        assert!(err.contains("orbstack"));
977        assert!(err.contains("colima"));
978        assert!(err.contains("docker"));
979    }
980
981    #[tokio::test]
982    async fn detect_reports_when_pinned_runtime_has_no_discovery_entry() {
983        let spec = LocalContainerSpec {
984            runtime: RuntimePref::Colima,
985            discovery: BTreeMap::new(),
986            custom_docker_host: None,
987        };
988        let err = LocalRuntime::detect(&spec).await.unwrap_err().to_string();
989        assert!(err.contains("colima"));
990        assert!(err.contains("no discovery entry"));
991    }
992
993    #[tokio::test]
994    async fn detect_picks_existing_socket() {
995        // Use a tempdir + touch file to stand in for a real socket. detect()
996        // only checks existence, not socket-ness.
997        let tmp = tempfile::TempDir::new().unwrap();
998        let fake = tmp.path().join("docker.sock");
999        std::fs::write(&fake, b"").unwrap();
1000        let mut discovery = BTreeMap::new();
1001        discovery.insert("orbstack".into(), PathBuf::from("/nonexistent/no.sock"));
1002        discovery.insert("colima".into(), PathBuf::from("/nonexistent/no.sock"));
1003        discovery.insert("docker".into(), fake.clone());
1004        let spec = LocalContainerSpec { runtime: RuntimePref::Auto, discovery, custom_docker_host: None };
1005        let runtime = LocalRuntime::detect(&spec).await.unwrap();
1006        assert_eq!(runtime.detected, DetectedRuntime::Docker);
1007        assert_eq!(runtime.docker_host, format!("unix://{}", fake.display()));
1008    }
1009
1010    #[tokio::test]
1011    async fn detect_honors_runtime_pin_and_skips_others() {
1012        // Even if a later runtime's socket exists, a pinned earlier runtime
1013        // without a socket should fail rather than fall through.
1014        let tmp = tempfile::TempDir::new().unwrap();
1015        let fake = tmp.path().join("docker.sock");
1016        std::fs::write(&fake, b"").unwrap();
1017        let mut discovery = BTreeMap::new();
1018        discovery.insert("orbstack".into(), PathBuf::from("/nonexistent/no.sock"));
1019        discovery.insert("docker".into(), fake);
1020        let spec = LocalContainerSpec { runtime: RuntimePref::Orbstack, discovery, custom_docker_host: None };
1021        let err = LocalRuntime::detect(&spec).await.unwrap_err().to_string();
1022        assert!(err.contains("orbstack"));
1023        // The error body should not list docker as an *attempted* probe entry.
1024        // (The hint text "install or start ... docker-desktop ..." may mention docker
1025        // substrings, but the tried-list should only show orbstack.)
1026        assert!(!err.contains("docker ("), "pinned to orbstack — docker should not be in tried list: {err}");
1027    }
1028
1029    #[tokio::test]
1030    async fn detect_custom_pref_uses_host_directly() {
1031        let spec = LocalContainerSpec {
1032            runtime: RuntimePref::Custom,
1033            discovery: BTreeMap::new(),
1034            custom_docker_host: Some("tcp://localhost:2375".into()),
1035        };
1036        let runtime = LocalRuntime::detect(&spec).await.unwrap();
1037        assert_eq!(runtime.detected, DetectedRuntime::Custom);
1038        assert_eq!(runtime.docker_host, "tcp://localhost:2375");
1039    }
1040
1041    #[tokio::test]
1042    async fn detect_custom_pref_without_host_errors() {
1043        let spec = LocalContainerSpec {
1044            runtime: RuntimePref::Custom,
1045            discovery: BTreeMap::new(),
1046            custom_docker_host: None,
1047        };
1048        let err = LocalRuntime::detect(&spec).await.unwrap_err().to_string();
1049        assert!(err.contains("custom_docker_host"), "error should mention the missing field: {err}");
1050    }
1051
1052    #[tokio::test]
1053    async fn detect_auto_falls_back_to_custom_host() {
1054        // All socket candidates absent, but a custom_docker_host is configured.
1055        let spec = LocalContainerSpec {
1056            runtime: RuntimePref::Auto,
1057            discovery: BTreeMap::new(),
1058            custom_docker_host: Some("tcp://localhost:2375".into()),
1059        };
1060        let runtime = LocalRuntime::detect(&spec).await.unwrap();
1061        assert_eq!(runtime.detected, DetectedRuntime::Custom);
1062        assert_eq!(runtime.docker_host, "tcp://localhost:2375");
1063    }
1064
1065    #[test]
1066    fn build_cascade_returns_providers_with_discovery_entries() {
1067        let mut discovery = BTreeMap::new();
1068        discovery.insert("orbstack".into(), PathBuf::from("/fake/orbstack.sock"));
1069        discovery.insert("docker".into(), PathBuf::from("/fake/docker.sock"));
1070        let spec = LocalContainerSpec { runtime: RuntimePref::Auto, discovery, custom_docker_host: None };
1071        let cascade = spec.build_cascade();
1072        // Only orbstack and docker have entries; docker-desktop/colima/podman are absent.
1073        assert_eq!(cascade.len(), 2);
1074        assert!(cascade.iter().any(|(k, _)| *k == DetectedRuntime::Orbstack));
1075        assert!(cascade.iter().any(|(k, _)| *k == DetectedRuntime::Docker));
1076    }
1077
1078    #[test]
1079    fn build_cascade_custom_pref_returns_single_entry() {
1080        let spec = LocalContainerSpec {
1081            runtime: RuntimePref::Custom,
1082            discovery: BTreeMap::new(),
1083            custom_docker_host: Some("tcp://localhost:2375".into()),
1084        };
1085        let cascade = spec.build_cascade();
1086        assert_eq!(cascade.len(), 1);
1087        let (kind, provider) = &cascade[0];
1088        assert_eq!(*kind, DetectedRuntime::Custom);
1089        assert_eq!(provider.docker_host(), "tcp://localhost:2375");
1090        assert!(provider.available());
1091    }
1092
1093    #[test]
1094    fn expand_tilde_replaces_home_prefix() {
1095        std::env::set_var("HOME", "/tmp/fake-home");
1096        let p = expand_tilde(Path::new("~/foo/bar"));
1097        assert_eq!(p, PathBuf::from("/tmp/fake-home/foo/bar"));
1098    }
1099
1100    #[test]
1101    fn expand_tilde_leaves_absolute_paths_alone() {
1102        let p = expand_tilde(Path::new("/var/run/docker.sock"));
1103        assert_eq!(p, PathBuf::from("/var/run/docker.sock"));
1104    }
1105
1106    #[test]
1107    fn container_state_parse_known_values() {
1108        assert_eq!(ContainerState::parse("running"), ContainerState::Running);
1109        assert_eq!(ContainerState::parse("exited"), ContainerState::Exited);
1110        assert_eq!(ContainerState::parse("PAUSED"), ContainerState::Paused);
1111    }
1112
1113    #[test]
1114    fn container_state_parse_unknown_preserves_string() {
1115        match ContainerState::parse("zombie") {
1116            ContainerState::Unknown(s) => assert_eq!(s, "zombie"),
1117            other => panic!("expected Unknown, got {other:?}"),
1118        }
1119    }
1120
1121    #[test]
1122    fn is_missing_container_error_matches_upstream_and_orbstack() {
1123        assert!(is_missing_container_error("Error: No such container: foo"));
1124        assert!(is_missing_container_error("error: no such object: foo"));
1125        assert!(is_missing_container_error("not found: foo"));
1126        assert!(!is_missing_container_error("Error response from daemon: Conflict."));
1127        assert!(!is_missing_container_error(""));
1128    }
1129
1130    #[test]
1131    fn container_run_spec_new_uses_canonical_name() {
1132        let spec = ContainerRunSpec::new("dev-yah", "pond", "static", "caddy:2-alpine");
1133        assert_eq!(spec.name, "yah-pond-dev-yah-pond-static");
1134        assert_eq!(spec.label, "dev-yah:pond:static");
1135        assert!(spec.ports.is_empty());
1136        assert!(spec.network.is_none());
1137        assert!(spec.network_aliases.is_empty());
1138    }
1139
1140    #[test]
1141    fn pond_network_name_is_yah_pond_svc_env() {
1142        assert_eq!(pond_network_name("yah-marketing", "pond"), "yah-pond-yah-marketing-pond");
1143        assert_eq!(pond_network_name("yah-dashboard", "pond"), "yah-pond-yah-dashboard-pond");
1144    }
1145
1146    #[test]
1147    fn docker_run_args_emit_network_and_aliases_when_set() {
1148        let mut spec = ContainerRunSpec::new("dev", "pond", "object_store", "minio:latest");
1149        spec.network = Some("yah-pond-dev-pond".into());
1150        spec.network_aliases = vec!["minio".into()];
1151        let args = spec.docker_run_args();
1152        let joined = args.join(" ");
1153        assert!(
1154            joined.contains("--network yah-pond-dev-pond"),
1155            "expected --network flag in: {joined}",
1156        );
1157        assert!(
1158            joined.contains("--network-alias minio"),
1159            "expected --network-alias minio in: {joined}",
1160        );
1161    }
1162
1163    #[test]
1164    fn docker_run_args_omit_network_flags_when_unset() {
1165        let spec = ContainerRunSpec::new("dev", "pond", "object_store", "minio:latest");
1166        let args = spec.docker_run_args();
1167        let joined = args.join(" ");
1168        assert!(!joined.contains("--network"), "unexpected --network flag: {joined}");
1169        assert!(!joined.contains("--network-alias"), "unexpected --network-alias flag: {joined}");
1170    }
1171
1172    #[test]
1173    fn docker_run_args_skip_aliases_when_no_network() {
1174        // Aliases without a network are meaningless — docker would reject `--network-alias`
1175        // without `--network`. Defensive: emit neither.
1176        let mut spec = ContainerRunSpec::new("dev", "pond", "object_store", "minio:latest");
1177        spec.network_aliases = vec!["minio".into()];
1178        let args = spec.docker_run_args();
1179        let joined = args.join(" ");
1180        assert!(!joined.contains("--network-alias"), "should not emit alias without network: {joined}");
1181    }
1182
1183    // ── LocalDockerRuntime / workload_spec_to_crs unit tests ─────────────────
1184
1185    fn minimal_workload_spec(name: &str) -> workload_spec::WorkloadSpec {
1186        use workload_spec::*;
1187        WorkloadSpec {
1188            schema_version: SchemaVersion::V1,
1189            name: name.to_string(),
1190            image: ImageRef {
1191                registry: "ghcr.io".into(),
1192                repository: "test/app".into(),
1193                tag: "v1.0".into(),
1194                digest: workload_spec::testing::test_digest(),
1195            },
1196            tier: TierTag("infra".into()),
1197            replicas: 1,
1198            command: None,
1199            entrypoint: None,
1200            workdir: None,
1201            user: None,
1202            env: vec![],
1203            secrets: vec![],
1204            volumes: vec![],
1205            resources: ResourceLimits { memory_mb: 256, cpu_shares: 512, ephemeral_storage_mb: 256 },
1206            depends_on: vec![],
1207            healthcheck: None,
1208            restart_policy: RestartPolicy::Always,
1209            stop_policy: StopPolicy { signal: 15, grace_period: Millis::from_secs(10) },
1210            expose: ExposeSpec {
1211                mesh: MeshExpose { identity: MeshIdent(name.into()), ports: vec![], allow_from: vec![] },
1212                public: None,
1213                operator: None,
1214            },
1215            labels: Default::default(),
1216            annotations: Default::default(),
1217        }
1218    }
1219
1220    #[test]
1221    fn workload_spec_to_crs_sets_name_and_image() {
1222        let spec = minimal_workload_spec("test-app");
1223        let crs = workload_spec_to_crs(&spec);
1224        assert_eq!(crs.name, "test-app");
1225        // workload_spec_to_crs uses ImageRef::docker_ref(), which emits tag@digest.
1226        assert_eq!(
1227            crs.image,
1228            format!("ghcr.io/test/app:v1.0@{}", workload_spec::testing::test_digest())
1229        );
1230        assert!(crs.ports.is_empty());
1231        assert!(crs.env.is_empty());
1232    }
1233
1234    #[test]
1235    fn workload_spec_to_crs_forwards_literal_env_only() {
1236        use workload_spec::{EnvValue, EnvVar, MeshIdent, MeshLookup};
1237        let mut spec = minimal_workload_spec("env-test");
1238        spec.env = vec![
1239            EnvVar { name: "GOOD".into(), value: EnvValue::Literal { value: "yes".into() } },
1240            EnvVar { name: "BAD_SECRET".into(), value: EnvValue::FromSecret { secret: "s".into(), key: "k".into() } },
1241            EnvVar { name: "BAD_MESH".into(), value: EnvValue::FromMesh { ident: MeshIdent("x".into()), kind: MeshLookup::Url } },
1242        ];
1243        let crs = workload_spec_to_crs(&spec);
1244        assert_eq!(crs.env.get("GOOD").map(String::as_str), Some("yes"));
1245        assert!(!crs.env.contains_key("BAD_SECRET"), "FromSecret must be filtered out");
1246        assert!(!crs.env.contains_key("BAD_MESH"), "FromMesh must be filtered out");
1247    }
1248
1249    #[test]
1250    fn workload_spec_to_crs_maps_mesh_ports() {
1251        let mut spec = minimal_workload_spec("port-test");
1252        spec.expose.mesh.ports = vec![8080, 9000];
1253        let crs = workload_spec_to_crs(&spec);
1254        assert_eq!(crs.ports, vec![(8080, 8080), (9000, 9000)]);
1255    }
1256
1257    #[test]
1258    fn workload_spec_to_crs_applies_command_override() {
1259        let mut spec = minimal_workload_spec("cmd-test");
1260        spec.command = Some(vec!["server".into(), "--port=8080".into()]);
1261        let crs = workload_spec_to_crs(&spec);
1262        assert_eq!(crs.cmd, vec!["server", "--port=8080"]);
1263    }
1264
1265    #[test]
1266    fn image_ref_docker_ref_emits_tag_and_digest() {
1267        use workload_spec::ImageRef;
1268        let r = ImageRef {
1269            registry: "ghcr.io".into(),
1270            repository: "org/app".into(),
1271            tag: "v1.0".into(),
1272            digest: "sha256:abc123".into(),
1273        };
1274        assert_eq!(r.docker_ref(), "ghcr.io/org/app:v1.0@sha256:abc123");
1275    }
1276
1277    // Live docker-socket integration tests + the `from_provider_config`
1278    // adapter tests live in `cloud::local_driver_glue::tests` so they can
1279    // depend on `cloud::config::ProviderConfig` without local-driver pulling
1280    // a reverse dep on cloud.
1281}