Skip to main content

cloud/reconciler/
container.rs

1//! `kind = "container"` reconciler — the local build+run path (R602-T1).
2//!
3//! A `container` component is a service packaged as a Docker image built from
4//! a Dockerfile that lives next to the component (`<path>/Dockerfile`). This
5//! reconciler drives the operator-local tier: detect the workspace's
6//! `local-container` runtime (orbstack/colima/docker, same provider the pond
7//! primitives use), `docker build` the image, then `docker run` it with the
8//! declared ports — adopt-idempotent, so a re-reconcile rebuilds (layer-cached)
9//! and replaces the running container in place.
10//!
11//! Config lives in the component's `workload.toml`, parsed through the shared
12//! [`workload_spec::Workload`] envelope as a [`ContainerBuild`] recipe — the
13//! `[build]` table is what selects the recipe form over the digest-pinned
14//! reference form the prod tier uses (R783-F1 / W324). Its *keys* all default,
15//! but the header itself must be present.
16//!
17//! ```toml
18//! schema_version = 1
19//! name = "yah-cloud-admin"
20//! kind = "container"
21//!
22//! [build]
23//! # Dockerfile path, relative to the component dir. Default "Dockerfile".
24//! dockerfile = "Dockerfile"
25//! # Build context, relative to the workspace root. Default: the component
26//! # dir. Workspace crates set "." so their path-dependency sources resolve.
27//! context = "."
28//! # Image tag to build + run. Default: yah-local/<service>-<component>:dev.
29//! image = "yah-local/yah-cloud-admin:dev"
30//!
31//! [run]
32//! # Container port the process listens on.
33//! port = 4325
34//! # Host port to publish it on. Default: same as `port`.
35//! host_port = 4325
36//! # Environment passed into the container.
37//! [run.env]
38//! YAH_CLOUD_ADMIN_ADDR = "0.0.0.0:4325"
39//!
40//! # Bind mounts. `host` is relative to the workspace root (absolute paths are
41//! # taken as-is); `read_only` defaults to true.
42//! [[run.mounts]]
43//! host = ".yah/infra"
44//! container = "/workspace/.yah/infra"
45//! ```
46//!
47//! Mounts exist because a runtime image is a *binary*, not a checkout: the
48//! multi-stage build that produces it deliberately drops the workspace after
49//! the compile, so a service whose job is to read workspace config
50//! (yah-cloud-admin reads `.yah/infra/machines/*.toml`) came up rendering an
51//! empty fleet — running, healthy, and describing a fleet of zero machines,
52//! which is the worst possible failure for a monitor (R568-T7).
53//!
54//! Scope (R602-T1): the **local** tier only. Non-`local` mirror shapes bail
55//! with a pointer to `yah cloud workload deploy` (the yubaba-mediated cloud
56//! tier), which is a separate surface.
57//!
58//! @yah:ticket(R783-F2, "ContainerReconciler consumes the envelope instead of its own private struct")
59//! @yah:status(review)
60//! @yah:at(2026-08-19T07:12:36Z)
61//! @yah:assignee(agent:bundle-anthropic-ashguard)
62//! @yah:parent(R783)
63//! @arch:see(.yah/docs/working/W324-workload-kind-is-not-a-runtime.md)
64//! @yah:verify("The cloud-admin pond tier still comes up: yah cloud mirror up yah-cloud-admin --env pond, container builds and runs, publishes on 4326.")
65//! @yah:gotcha("crates/yah/cloud-admin/workload.toml also carries a [process] table read by LocalProcessReconciler on the dev mirror - one file, three tiers. Do not let the envelope reject the [process] table when parsing the container form; that file must stay loadable by both reconcilers.")
66//! @yah:handoff("LANDED in the same session as R783-F1. ContainerReconciler's private ContainerComponent / BuildSpec / RunSpec / MountSpec structs are DELETED (oss/yubaba/crates/cloud/src/reconciler/container.rs). load_container_component now calls a new parse_container_recipe() that goes through workload_spec::Workload and requires the recipe form; resolve_mounts takes &[ContainerMount]. One parser, one discriminator - the local and cloud shapes can no longer diverge silently, which was the whole point of the split.")
67//! @yah:verify("cargo test --manifest-path oss/yubaba/Cargo.toml -p yah-cloud --lib reconciler::container - 12 pass, 0 fail (4 new: a_container_declaring_neither_form_is_rejected_by_name, a_digest_pinned_reference_is_refused_as_the_wrong_tier, the_real_cloud_admin_manifest_loads_through_the_envelope, build_keys_default_inside_an_empty_build_table).")
68//! @yah:verify("cargo test --manifest-path oss/yubaba/Cargo.toml -p yah-cloud --lib - 881 pass, 0 fail, 4 ignored.")
69//! @yah:verify("The acceptance case is now a unit test, not a manual step: the_real_cloud_admin_manifest_loads_through_the_envelope reads the REAL crates/yah/cloud-admin/workload.toml off disk (skipping if absent, since the yubaba workspace exports standalone) and asserts name/port/host_port/mounts survive - [process] table and all.")
70//! @yah:gotcha("BEHAVIOUR CHANGE, small but real: the `[build]` HEADER is now load-bearing. Every key inside it still defaults (dockerfile=Dockerfile, context=component dir, image=yah-local/<service>-<component>:dev), but a kind = container manifest with only [run] used to parse with a fully defaulted build section and now fails with an error naming both container forms. Zero on-disk files are affected - crates/yah/cloud-admin/workload.toml is the only container manifest in the camp and it has [build]. The old permissive case was covered by a test named build_defaults_when_section_absent; it is replaced by build_keys_default_inside_an_empty_build_table plus a_container_declaring_neither_form_is_rejected_by_name.")
71//! @yah:gotcha("The [process] table is TOLERATED, not modelled: ContainerBuild deliberately has no serde(deny_unknown_fields), which is what keeps crates/yah/cloud-admin/workload.toml loadable by BOTH ContainerReconciler and LocalProcessReconciler. Adding deny_unknown_fields there later would break the dev tier - the reason is on ContainerBuild's doc comment, keep it.")
72//! @yah:verify("NOT RUN, stated plainly: the ticket's own acceptance line - `yah cloud mirror up yah-cloud-admin --env pond`, container builds and runs, publishes on 4326 - was NOT executed. It needs a live docker/orbstack daemon and a real docker build of the cloud-admin image on this box. The parse path it exercises is covered by the_real_cloud_admin_manifest_loads_through_the_envelope (reads the actual file), and everything after the parse (build_image, ContainerRunSpec, teardown naming) is byte-for-byte the pre-existing code path - only the struct the fields are read off changed. Still worth one live run before archive.")
73
74use std::future::Future;
75use std::pin::Pin;
76use std::time::Duration;
77
78use anyhow::{bail, Context, Result};
79use async_trait::async_trait;
80use local_driver::{canonical_name, ContainerRunSpec, ContainerState, LocalRuntime};
81use workload_spec::{ContainerBuild, ContainerMount, Workload};
82
83use super::{ReconcileCtx, Reconciler, RunningWorkload};
84use crate::config::{CloudConfig, Provider};
85use crate::local_container_spec_from_provider;
86use crate::MirrorShape;
87
88/// The slot role a container component occupies on its mirror.
89const SLOT: &str = "compute";
90
91/// Options controlling the container reconciler's local path.
92#[derive(Debug, Clone, Default)]
93pub struct ContainerOptions {
94    /// When true, skip build+run and only adopt an already-running container
95    /// (parity with `PondOptions::adopt_only`). Errors when none is running.
96    pub adopt_only: bool,
97}
98
99/// Reconciler for `kind = "container"` components.
100#[derive(Debug, Default)]
101pub struct ContainerReconciler {
102    opts: ContainerOptions,
103}
104
105impl ContainerReconciler {
106    pub fn new() -> Self {
107        Self::default()
108    }
109
110    pub fn with_options(mut self, opts: ContainerOptions) -> Self {
111        self.opts = opts;
112        self
113    }
114}
115
116#[async_trait]
117impl Reconciler for ContainerReconciler {
118    fn kind(&self) -> &'static str {
119        "container"
120    }
121
122    async fn up(&self, ctx: ReconcileCtx<'_>) -> Result<RunningWorkload> {
123        // git-sourced components: clone/update the local checkout first
124        // (no-op for in-tree components).
125        ctx.materialize().await?;
126
127        // Scope guard: T1 is the operator-local tier. The prod tier is
128        // yubaba-mediated (`yah cloud workload deploy`), a separate surface.
129        if !matches!(ctx.mirror.shape, MirrorShape::Local) {
130            bail!(
131                "component {}: kind \"container\" has only a local reconciler — mirror \
132                 shape is {:?}, not `local`. Deploy the prod tier via \
133                 `yah cloud workload deploy` against a yubaba machine.",
134                ctx.component.id,
135                ctx.mirror.shape,
136            );
137        }
138
139        let spec = load_container_component(&ctx)?;
140        let container_port = spec.run.port.with_context(|| {
141            format!(
142                "component {}: workload.toml is missing [run].port — the container reconciler \
143                 needs the port the process listens on",
144                ctx.component.id,
145            )
146        })?;
147        let host_port = spec.run.host_port.unwrap_or(container_port);
148
149        let runtime = detect_local_runtime(&ctx)
150            .await
151            .context("detecting local container runtime (orbstack/colima/docker)")?;
152
153        let name = canonical_name(&ctx.service.name, ctx.env, &ctx.component.id);
154
155        // adopt-only: don't build/run, just report an already-running container.
156        if self.opts.adopt_only {
157            return match runtime.container_state(&name).await? {
158                Some(ContainerState::Running) => {
159                    let hp = runtime
160                        .container_host_port(&name, container_port)
161                        .await
162                        .unwrap_or(host_port);
163                    Ok(RunningWorkload::adopted(
164                        "container",
165                        SLOT,
166                        Some(format!("http://127.0.0.1:{hp}")),
167                    )
168                    .with_teardown(teardown_for(&ctx, name.clone())))
169                }
170                other => bail!(
171                    "adopt_only: no running container named {name} for component {} \
172                     (state: {other:?}) — nothing to adopt",
173                    ctx.component.id,
174                ),
175            };
176        }
177
178        // Build the image from the component's Dockerfile.
179        let image = spec
180            .build
181            .image
182            .clone()
183            .unwrap_or_else(|| default_image_tag(&ctx.service.name, &ctx.component.id));
184        let dockerfile = ctx.workload_dir().join(&spec.build.dockerfile);
185        let context = match &spec.build.context {
186            Some(rel) => ctx.workspace_root.join(rel),
187            None => ctx.workload_dir(),
188        };
189        runtime
190            .build_image(&image, &dockerfile, &context)
191            .await
192            .with_context(|| {
193                format!(
194                    "building image {image} for component {} (dockerfile {}, context {})",
195                    ctx.component.id,
196                    dockerfile.display(),
197                    context.display(),
198                )
199            })?;
200
201        // Run it with the declared ports + env. `run` clears any prior
202        // container of the same name first, so re-reconcile is idempotent.
203        let mut run_spec =
204            ContainerRunSpec::new(&ctx.service.name, ctx.env, &ctx.component.id, image);
205        run_spec.ports = vec![(host_port, container_port)];
206        run_spec.env = spec.run.env.clone();
207        run_spec.volumes = resolve_mounts(&spec.run.mounts, ctx.workspace_root)?;
208        runtime
209            .run(&run_spec)
210            .await
211            .with_context(|| format!("running container for component {}", ctx.component.id))?;
212
213        // Read the actual host port (host_port=0 requests an ephemeral one).
214        let actual = runtime
215            .container_host_port(&name, container_port)
216            .await
217            .unwrap_or(host_port);
218
219        Ok(RunningWorkload::adopted(
220            "container",
221            SLOT,
222            Some(format!("http://127.0.0.1:{actual}")),
223        )
224        .with_teardown(teardown_for(&ctx, name.clone())))
225    }
226}
227
228/// Grace period for the stop half of the teardown before the container is
229/// removed. Matches what an operator expects from a ■ button: long enough for
230/// a well-behaved server to close listeners, short enough not to look hung.
231const TEARDOWN_GRACE: Duration = Duration::from_secs(5);
232
233/// Build the explicit teardown for a container-kind workload (R714-B1).
234///
235/// Before this, `up()` handed back a bare `RunningWorkload::adopted()`, whose
236/// `shutdown()` is a documented no-op. Nothing else covered the gap either:
237/// the desktop's pond teardown half gates on a `Provider::MiniflareContainer`
238/// static slot, and a plain `kind = container` mirror declares no static slot,
239/// so its ident list came back empty and the loop body never ran. The ■ button
240/// removed the registry entry, called the no-op, and returned success with the
241/// container still up.
242///
243/// The runtime is re-detected inside the hook rather than captured: the hook
244/// outlives the borrowed [`ReconcileCtx`], and re-running the same lookup
245/// `up()` did means both halves agree on which daemon they mean even if the
246/// operator switched runtimes in between.
247/// The `Sync` in the return bound is required by [`RunningWorkload::with_teardown`]
248/// — see the note on its `TeardownFn` alias for why a non-`Sync` hook breaks
249/// every desktop Tauri command that touches the mirror registry.
250fn teardown_for(
251    ctx: &ReconcileCtx<'_>,
252    name: String,
253) -> impl FnOnce() -> Pin<Box<dyn Future<Output = Result<()>> + Send>> + Send + Sync + 'static {
254    let workspace_root = ctx.workspace_root.to_path_buf();
255    move || {
256        Box::pin(async move {
257            let runtime = detect_local_runtime_at(&workspace_root)
258                .await
259                .context("detecting local container runtime for teardown")?;
260            runtime
261                .stop_and_remove(&name, TEARDOWN_GRACE)
262                .await
263                .with_context(|| format!("stopping container {name}"))
264        })
265    }
266}
267
268/// Read `<workload_dir>/workload.toml` and parse the container `[build]` +
269/// `[run]` sections.
270fn load_container_component(ctx: &ReconcileCtx<'_>) -> Result<ContainerBuild> {
271    let path = ctx.workload_dir().join("workload.toml");
272    let src =
273        std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
274    parse_container_recipe(&src).with_context(|| format!("parsing {}", path.display()))
275}
276
277/// Parse a `workload.toml` through the shared envelope and require the recipe
278/// form (R783-F2).
279///
280/// This reconciler used to deserialize its own private `ContainerComponent`
281/// straight off the file. That is why R658-B2 could exist for months: with two
282/// parsers reading one `kind`, the shapes had no way to disagree *loudly* —
283/// `crates/yah/cloud-admin/workload.toml` parsed fine here while failing
284/// `workload_spec::Workload` entirely, and only a CI walk over every manifest
285/// noticed. One parser, one discriminator: now a manifest that is neither a
286/// digest-pinned reference nor a build recipe fails in both places with the
287/// same message.
288pub(crate) fn parse_container_recipe(src: &str) -> Result<ContainerBuild> {
289    let workload: Workload = toml::from_str(src)?;
290    let Workload::Container(manifest) = &workload else {
291        bail!(
292            "expected kind = \"container\", found kind = {:?}",
293            workload.kind_str(),
294        );
295    };
296    match manifest.clone().into_spec() {
297        Err(recipe) => Ok(recipe),
298        Ok(spec) => bail!(
299            "workload {:?} is a digest-pinned container REFERENCE, not a local build recipe — \
300             that form deploys to a yubaba machine via `yah cloud workload deploy`, not \
301             through this reconciler",
302            spec.name,
303        ),
304    }
305}
306
307/// Default image tag when `workload.toml` doesn't pin one.
308fn default_image_tag(service: &str, component: &str) -> String {
309    format!("yah-local/{service}-{component}:dev")
310}
311
312/// Turn `[[run.mounts]]` into `docker run -v` pairs, resolved against the
313/// workspace root.
314///
315/// A missing host path is a hard error rather than a skipped mount. Docker
316/// would happily create an empty directory in its place, and the container
317/// would then start, pass health checks, and serve whatever "no config found"
318/// means for that service — a deploy that looks green while the thing it was
319/// supposed to read isn't there.
320///
321/// The `:ro` suffix rides on the container-path string because
322/// `ContainerRunSpec::docker_run_args` emits `-v <host>:<container>` verbatim;
323/// that is docker's own mount-option syntax, not a hack around the type.
324fn resolve_mounts(
325    mounts: &[ContainerMount],
326    workspace_root: &std::path::Path,
327) -> Result<Vec<(std::path::PathBuf, String)>> {
328    mounts
329        .iter()
330        .map(|m| {
331            let host = std::path::Path::new(&m.host);
332            let host = if host.is_absolute() {
333                host.to_path_buf()
334            } else {
335                workspace_root.join(host)
336            };
337            if !host.exists() {
338                bail!(
339                    "mount source {} does not exist (declared as `{}` in workload.toml \
340                     [[run.mounts]]) — the container would silently get an empty directory",
341                    host.display(),
342                    m.host,
343                );
344            }
345            let container = m.container.display().to_string();
346            let target = if m.read_only {
347                format!("{container}:ro")
348            } else {
349                container
350            };
351            Ok((host, target))
352        })
353        .collect()
354}
355
356/// Detect the workspace's `local-container` runtime, the same way the pond
357/// primitives do — find the `kind = "local-container"` provider (orbstack.toml
358/// et al.) and probe its sockets. `ReconcileCtx` doesn't carry `CloudConfig`,
359/// so we reload it from the workspace root.
360async fn detect_local_runtime(ctx: &ReconcileCtx<'_>) -> Result<LocalRuntime> {
361    detect_local_runtime_at(ctx.workspace_root).await
362}
363
364/// Same lookup keyed on the workspace root alone, so the R714-B1 teardown hook
365/// — which outlives the borrowed [`ReconcileCtx`] — can re-detect the runtime
366/// without capturing it.
367async fn detect_local_runtime_at(workspace_root: &std::path::Path) -> Result<LocalRuntime> {
368    let cfg = CloudConfig::load(workspace_root)
369        .context("loading CloudConfig for local-container provider lookup")?;
370    let provider = cfg
371        .providers
372        .iter()
373        .find(|p| matches!(p.kind, Provider::LocalContainer))
374        .with_context(|| {
375            format!(
376                "no `kind = \"local-container\"` provider declared in {}/.yah/infra/providers/ — \
377                 the container reconciler needs orbstack.toml or equivalent",
378                workspace_root.display(),
379            )
380        })?;
381    let local_spec = local_container_spec_from_provider(provider)?;
382    LocalRuntime::detect(&local_spec).await
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    #[test]
390    fn parses_build_and_run_sections() {
391        let src = r#"
392schema_version = 1
393name = "yah-cloud-admin"
394kind = "container"
395
396[build]
397dockerfile = "Dockerfile"
398context = "."
399image = "yah-local/yah-cloud-admin:dev"
400
401[run]
402port = 4325
403host_port = 4325
404
405[run.env]
406YAH_CLOUD_ADMIN_ADDR = "0.0.0.0:4325"
407YAH_CLOUD_ADMIN_DEV_ANON = "1"
408"#;
409        let c = parse_container_recipe(src).unwrap();
410        assert_eq!(c.build.dockerfile, std::path::Path::new("Dockerfile"));
411        assert_eq!(c.build.context.as_deref(), Some(std::path::Path::new(".")));
412        assert_eq!(
413            c.build.image.as_deref(),
414            Some("yah-local/yah-cloud-admin:dev")
415        );
416        assert_eq!(c.run.port, Some(4325));
417        assert_eq!(c.run.host_port, Some(4325));
418        assert_eq!(
419            c.run.env.get("YAH_CLOUD_ADMIN_ADDR").map(String::as_str),
420            Some("0.0.0.0:4325")
421        );
422        assert_eq!(
423            c.run
424                .env
425                .get("YAH_CLOUD_ADMIN_DEV_ANON")
426                .map(String::as_str),
427            Some("1")
428        );
429    }
430
431    #[test]
432    fn mounts_resolve_against_the_workspace_root_and_default_read_only() {
433        let tmp = tempfile::tempdir().unwrap();
434        std::fs::create_dir_all(tmp.path().join(".yah/infra")).unwrap();
435        let src = r#"
436schema_version = 1
437name = "svc"
438kind = "container"
439[build]
440[run]
441port = 4325
442[[run.mounts]]
443host = ".yah/infra"
444container = "/workspace/.yah/infra"
445"#;
446        let c = parse_container_recipe(src).unwrap();
447        let out = resolve_mounts(&c.run.mounts, tmp.path()).unwrap();
448        assert_eq!(out.len(), 1);
449        assert_eq!(out[0].0, tmp.path().join(".yah/infra"));
450        assert_eq!(out[0].1, "/workspace/.yah/infra:ro");
451    }
452
453    #[test]
454    fn a_writable_mount_must_be_asked_for() {
455        let tmp = tempfile::tempdir().unwrap();
456        std::fs::create_dir_all(tmp.path().join("state")).unwrap();
457        let src = r#"
458schema_version = 1
459name = "svc"
460kind = "container"
461[build]
462[run]
463port = 1
464[[run.mounts]]
465host = "state"
466container = "/var/lib/state"
467read_only = false
468"#;
469        let c = parse_container_recipe(src).unwrap();
470        let out = resolve_mounts(&c.run.mounts, tmp.path()).unwrap();
471        assert_eq!(out[0].1, "/var/lib/state");
472    }
473
474    /// Docker would invent an empty directory here; a monitor mounting a
475    /// non-existent inventory must fail the deploy, not render zero machines.
476    #[test]
477    fn a_missing_mount_source_fails_the_reconcile() {
478        let tmp = tempfile::tempdir().unwrap();
479        let src = r#"
480schema_version = 1
481name = "svc"
482kind = "container"
483[build]
484[run]
485port = 1
486[[run.mounts]]
487host = "nope"
488container = "/nope"
489"#;
490        let c = parse_container_recipe(src).unwrap();
491        let err = resolve_mounts(&c.run.mounts, tmp.path()).unwrap_err();
492        assert!(err.to_string().contains("does not exist"), "{err}");
493    }
494
495    #[test]
496    fn no_mounts_declared_is_no_volumes() {
497        let c = parse_container_recipe(
498            "schema_version = 1\nname = \"svc\"\nkind = \"container\"\n[build]\n[run]\nport = 1\n",
499        )
500        .unwrap();
501        assert!(c.run.mounts.is_empty());
502        assert!(resolve_mounts(&c.run.mounts, std::path::Path::new("/"))
503            .unwrap()
504            .is_empty());
505    }
506
507    /// An empty `[build]` header is enough: every key inside it defaults, so a
508    /// component that just wants `Dockerfile` in its own directory writes one
509    /// line.
510    #[test]
511    fn build_keys_default_inside_an_empty_build_table() {
512        let src = r#"
513schema_version = 1
514name = "svc"
515kind = "container"
516[build]
517[run]
518port = 8080
519"#;
520        let c = parse_container_recipe(src).unwrap();
521        assert_eq!(c.build.dockerfile, std::path::Path::new("Dockerfile"));
522        assert!(c.build.context.is_none());
523        assert!(c.build.image.is_none());
524        assert_eq!(c.run.port, Some(8080));
525        assert!(c.run.host_port.is_none());
526        assert!(c.run.env.is_empty());
527    }
528
529    /// R783-F2, the point of routing through the envelope: the `[build]`
530    /// header is now load-bearing. Without it — and without a digest-pinned
531    /// `image` — the file declares neither container form, and the error says
532    /// so instead of quietly defaulting a Dockerfile that may not be there.
533    #[test]
534    fn a_container_declaring_neither_form_is_rejected_by_name() {
535        let src = r#"
536schema_version = 1
537name = "svc"
538kind = "container"
539[run]
540port = 8080
541"#;
542        let err = parse_container_recipe(src).unwrap_err().to_string();
543        assert!(err.contains("image"), "{err}");
544        assert!(err.contains("[build]"), "{err}");
545    }
546
547    /// The other half of the same seam: the wire form is a valid container
548    /// manifest, and this reconciler must say it is the wrong *tier* rather
549    /// than fail on a parse error that blames the file.
550    #[test]
551    fn a_digest_pinned_reference_is_refused_as_the_wrong_tier() {
552        // Build the fixture from the type rather than by hand — a
553        // hand-written WorkloadSpec TOML would be testing my ability to
554        // transcribe 20 required fields, not the dispatch.
555        let spec = workload_spec::WorkloadSpec::for_forge(
556            "noisetable-api",
557            workload_spec::ImageRef {
558                registry: "ghcr.io".into(),
559                repository: "noisetable/api".into(),
560                tag: "v1".into(),
561                digest: workload_spec::testing::test_digest(),
562            },
563            workload_spec::TierTag("private".into()),
564            vec![8080],
565        );
566        let src = toml::to_string(&Workload::container(spec)).expect("serialize the wire form");
567        assert!(src.contains("kind = \"container\""), "{src}");
568
569        let err = parse_container_recipe(&src).unwrap_err().to_string();
570        assert!(err.contains("REFERENCE"), "{err}");
571        assert!(err.contains("yah cloud workload deploy"), "{err}");
572    }
573
574    /// The acceptance case for R658-B2 / R783: the real cloud-admin manifest,
575    /// `[process]` table and all, loads through the shared envelope.
576    #[test]
577    fn the_real_cloud_admin_manifest_loads_through_the_envelope() {
578        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
579            .ancestors()
580            .nth(3)
581            .expect("oss/yubaba/crates/cloud has at least three ancestors")
582            .join("crates/yah/cloud-admin/workload.toml");
583        let Ok(src) = std::fs::read_to_string(&path) else {
584            // The yubaba workspace is exported standalone; the camp file is not
585            // there in the mirror. Skip rather than fail in that build.
586            return;
587        };
588        let c = parse_container_recipe(&src)
589            .unwrap_or_else(|e| panic!("{} must parse as a container recipe: {e}", path.display()));
590        assert_eq!(c.name, "yah-cloud-admin");
591        assert_eq!(c.run.port, Some(4325));
592        assert_eq!(c.run.host_port, Some(4326));
593        assert_eq!(c.run.mounts.len(), 1, "the [[run.mounts]] entry survived");
594    }
595
596    /// R714-B1: the teardown must target the container `run()` actually
597    /// created. `LocalRuntime::stop_and_remove` is a documented no-op on a
598    /// container that doesn't exist, so if these two names ever drift apart
599    /// the ■ button goes back to reporting success while the container runs —
600    /// and it does so silently, with no error to surface. `up()` feeds the
601    /// same `(service, env, component.id)` triple to both; this pins that.
602    #[test]
603    fn the_teardown_name_matches_the_name_run_created() {
604        let (service, env, component) = ("yah-cloud-admin", "pond", "cloud-admin");
605        let run_spec = ContainerRunSpec::new(service, env, component, "img:dev");
606        let teardown_target = canonical_name(service, env, component);
607        assert_eq!(
608            run_spec.name, teardown_target,
609            "teardown would docker-stop a name that was never created"
610        );
611    }
612
613    #[test]
614    fn default_image_tag_derives_from_service_and_component() {
615        assert_eq!(
616            default_image_tag("yah-cloud-admin", "cloud-admin"),
617            "yah-local/yah-cloud-admin-cloud-admin:dev"
618        );
619    }
620
621    #[test]
622    fn run_spec_publishes_declared_ports_and_env() {
623        // The docker_run_args wiring a live reconcile would emit, exercised
624        // without a docker socket.
625        let mut run_spec =
626            ContainerRunSpec::new("yah-cloud-admin", "dev", "cloud-admin", "img:dev");
627        run_spec.ports = vec![(4325, 4325)];
628        run_spec.env.insert("K".into(), "V".into());
629        let args = run_spec.docker_run_args();
630        // -p 4325:4325 present.
631        let joined = args.join(" ");
632        assert!(joined.contains("-p 4325:4325"), "args: {joined}");
633        assert!(joined.contains("-e K=V"), "args: {joined}");
634        assert!(
635            joined.ends_with("img:dev"),
636            "image is the final arg: {joined}"
637        );
638    }
639}