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}