Skip to main content

kranz_engine/
workspace_container.rs

1//! Local-container WorkspaceProvider (ticket
2//! `.kranz/tickets/local-container-workspace.md`, design D-B implementation
3//! #2 in `docs/scoping/workspace-contract.md`) — gives a mission an isolated
4//! RUNNABLE environment: a per-mission compose project, dynamic host ports,
5//! and contract health/readiness executed INSIDE the container network —
6//! solving the host port clashes and Docker daemon contention that bare
7//! worktrees cannot.
8//!
9//! Relationship to [`crate::sandbox_container`] (M7 tier 3): this provider
10//! REUSES its runtime detection ([`ContainerRuntime`]/[`crate::sandbox_container::detect`]) but the
11//! concepts stay separate ("the APIs stay separate"): the sandbox is the
12//! process blast radius for agent CLIs — its tier-3 `--network none`
13//! semantics live in THAT layer — while the workspace is the runnable
14//! environment (bootstrap/services/readiness/previews). The network model
15//! here is the fs-tier bridge (the runtime's default NAT): registry egress
16//! works for bootstrap, and this provider never passes `--network none`.
17//!
18//! Isolation unit: one compose project per mission,
19//! `kranz-ws-<sanitized-mission-id>` — parallel missions get distinct
20//! projects, hence distinct networks and no shared port namespace. Projects
21//! are mission-owned (the name carries the mission id) and never shared.
22//! `docker compose` (or the runtime's compose subcommand) is required; a
23//! runtime without one fails closed with the reason named, and a host with
24//! no runtime at all fails closed at provision (run start, before spend).
25//!
26//! Ports (the contract's `services[].port.policy`):
27//! - `dynamic` → published as host port 0 (OS-assigned); the assigned port
28//!   is read back from the runtime once the service is up and recorded on
29//!   the handle. Preview urlTemplates get `{port}` substitution ONLY with an
30//!   actually-assigned dynamic port (never fabricated), and only when the
31//!   contract declares exactly one dynamic service — a template never binds
32//!   an arbitrary service's port.
33//! - `fixed: N` → published as `N:N`; provision REFUSES before any container
34//!   starts when N is already bound on the host, naming the service and the
35//!   port — never a silent rebind.
36//!
37//! Provision: render the compose file (from the contract's `services[]` +
38//! `bootstrap`/`readiness` + `mounts[]`) into the mission-owned runtime dir
39//! (`<mission-dir>/workspace/compose.json`, gitignored — JSON is valid YAML
40//! 1.2, so `compose -f` parses it and the provider never hand-rolls YAML
41//! escaping), `compose up -d`, then wait for each declared healthCheck by
42//! polling it inside its service container. The PRIMARY CHECKOUT IS NEVER
43//! MOUNTED OR WRITTEN: the mount set is exactly the mission execution root
44//! (the integration worktree in worktree mode) plus the contract's
45//! `mounts[]`, each at its identical path.
46//!
47//! Readiness: the contract's `bootstrap[]` then `readiness[]` run INSIDE the
48//! container network via `compose exec -T workspace sh -c …` — the
49//! `workspace` service holds the worktree mount — reporting through the same
50//! gate phase shapes as the local-worktree provider
51//! ([`crate::workspace_provider::report_gate_outcomes`]), so block reasons
52//! and decision lines are byte-identical to the host path. The golden-data
53//! hooks (design D-D: clone/migrate/skewCheck at readiness, reset between
54//! validation rounds) exec through the same path — a container workspace
55//! never runs data hooks on the host.
56//!
57//! Teardown: [`TeardownMode::Keep`] leaves the project running (documented:
58//! previews stay live); `Hibernate` is `compose stop` (containers paused,
59//! project kept); `Destroy` is `compose down -v` (project + volumes
60//! removed), then the contract's `disk.prune` hint runs on the host when
61//! declared. The run loop drives `Keep` at non-terminal ends (a
62//! blocked/paused mission keeps its project for resume) and the configured
63//! `workspace.teardownMode` when a run reaches a terminal state (ticket
64//! `workspace-idle-hibernate`).
65//!
66//! v1 honesty notes: every container runs the shared default image
67//! ([`crate::sandbox_container::DEFAULT_IMAGE`]) with its declared
68//! start/healthCheck command — per-service images are a later additive
69//! contract field (the schema's `deny_unknown_fields` fails closed on an
70//! `image` key today). A contract-less provision starts no containers and
71//! needs no runtime (D-H: never imply a runnable environment that does not
72//! exist).
73
74use crate::error::{EngineError, Result};
75use crate::sandbox_container::{self, ContainerRuntime};
76use crate::workspace_contract::{PortPolicy, PreviewSpec, WorkspaceContract};
77use crate::workspace_gate::{
78    CommandOutcome, GatePhase, BOOTSTRAP_SUMMARY_PREFIX, READINESS_SUMMARY_PREFIX,
79};
80use crate::workspace_provider::{
81    report_gate_outcomes, PreviewPlaceholder, ProgressSink, ProvisionSpec, ReadinessOutcome,
82    TeardownMode, WorkspaceHandle, WorkspaceProvider, WorkspaceProviderKind,
83};
84use serde_json::json;
85use std::collections::HashMap;
86use std::path::{Path, PathBuf};
87use std::sync::Arc;
88use std::time::Duration;
89
90/// Compose project name prefix — the sanitized mission id follows, so a
91/// project is mission-owned and never shared between missions.
92const PROJECT_PREFIX: &str = "kranz-ws-";
93
94/// The service every contract bootstrap/readiness command execs into; it
95/// owns the worktree mount and just stays alive (`sleep infinity`).
96const WORKSPACE_SERVICE: &str = "workspace";
97
98/// Container-side port dynamic services publish from. Inside the
99/// per-project bridge network this never collides (each service is its own
100/// container); the HOST side is what gets OS-assigned (`0`) and read back.
101const DYNAMIC_CONTAINER_PORT: u16 = 8080;
102
103/// The rendered compose file name inside `<mission-dir>/workspace/`.
104const COMPOSE_FILE_NAME: &str = "compose.json";
105
106/// The compose file's subdirectory inside the mission runtime dir.
107const WORKSPACE_DIR: &str = "workspace";
108
109/// Bound on the `compose version` availability probe and the `compose port`
110/// readback.
111const COMPOSE_PROBE_TIMEOUT: Duration = Duration::from_secs(30);
112/// `up -d` may pull the image on first use — give it the contract-command
113/// budget.
114const COMPOSE_UP_TIMEOUT: Duration = Duration::from_secs(600);
115/// One exec'd bootstrap/readiness command (mirrors the host gate's cap).
116const EXEC_TIMEOUT: Duration = Duration::from_secs(600);
117/// A health check must pass within this overall window…
118const HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(120);
119/// …polled at this interval, each attempt bounded so a hung check cannot
120/// outlive the window.
121const HEALTH_POLL_INTERVAL: Duration = Duration::from_secs(1);
122const HEALTH_EXEC_TIMEOUT: Duration = Duration::from_secs(30);
123/// `compose stop` / `compose down -v`.
124const TEARDOWN_TIMEOUT: Duration = Duration::from_secs(300);
125/// Combined stdout+stderr tail kept in error messages.
126const OUTPUT_TAIL: usize = 1500;
127
128/// The container provider's handle state — everything `readiness` and
129/// `teardown` need across the seam's three calls (the provider itself stays
130/// stateless).
131#[derive(Debug, Clone)]
132pub struct ContainerWorkspace {
133    pub runtime: ContainerRuntime,
134    /// The mission-owned compose project (`kranz-ws-<sanitized-mission-id>`).
135    pub project: String,
136    /// The rendered compose file in the mission runtime dir.
137    pub compose_file: PathBuf,
138    /// `(service, OS-assigned host port)` for each dynamic service, read
139    /// back from the runtime after the service came up — previews substitute
140    /// only these ports (never fabricated).
141    pub assigned_ports: Vec<(String, u16)>,
142}
143
144/// What runtime detection sees — the production hook is the host's real
145/// [`sandbox_container::detect`].
146type DetectHook = Arc<dyn Fn() -> Option<ContainerRuntime> + Send + Sync>;
147
148/// How one argv runs, bounded: `(exit code, combined-output tail)` — the
149/// production hook is [`spawn_bounded`].
150type RunHook = Arc<dyn Fn(&[String], Duration) -> (Option<i32>, String) + Send + Sync>;
151
152/// The runtime boundary, injectable for tests: what detection sees, and how
153/// argv runs. Production uses the host's real detection
154/// ([`sandbox_container::detect`]) and a bounded process spawn; tests drive
155/// the full provision/readiness/teardown logic against a scripted fake with
156/// no container runtime (the real spawn path is covered by the
157/// runtime-gated smoke test).
158#[derive(Clone)]
159pub(crate) struct RuntimeHooks {
160    detect: DetectHook,
161    run: RunHook,
162}
163
164impl RuntimeHooks {
165    fn host() -> Self {
166        Self {
167            detect: Arc::new(sandbox_container::detect),
168            run: Arc::new(spawn_bounded),
169        }
170    }
171}
172
173/// Bounded argv spawn with a combined-output tail — the production `run`
174/// hook. Reuses the shared `command_exec` runner (timeout kill discipline)
175/// rather than open-coding a spawn.
176fn spawn_bounded(argv: &[String], timeout: Duration) -> (Option<i32>, String) {
177    let Some((program, args)) = argv.split_first() else {
178        return (None, "empty argv".to_string());
179    };
180    match crate::command_exec::run_with_timeout(Path::new(program), args, timeout) {
181        Some(output) => {
182            let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
183            let stderr = String::from_utf8_lossy(&output.stderr);
184            if !stderr.is_empty() {
185                if !text.is_empty() {
186                    text.push('\n');
187                }
188                text.push_str(&stderr);
189            }
190            (
191                output.status.code(),
192                crate::command_exec::last_chars_local(&text, OUTPUT_TAIL),
193            )
194        }
195        None => (
196            None,
197            format!(
198                "no exit code (spawn failure or timeout after {}s)",
199                timeout.as_secs()
200            ),
201        ),
202    }
203}
204
205/// The local-container provider: per-mission compose project, dynamic
206/// ports, in-network contract readiness. See the module docs.
207pub struct LocalContainerProvider {
208    hooks: RuntimeHooks,
209}
210
211impl LocalContainerProvider {
212    /// The production provider: host runtime detection + bounded spawns.
213    pub fn new() -> Self {
214        Self {
215            hooks: RuntimeHooks::host(),
216        }
217    }
218
219    /// A provider driving a scripted runtime boundary: the full
220    /// provision/readiness/teardown logic runs without a container runtime.
221    #[cfg(test)]
222    pub(crate) fn with_hooks(hooks: RuntimeHooks) -> Self {
223        Self { hooks }
224    }
225
226    /// Run one argv through the runtime hook (blocking spawn, so off the
227    /// async executor's way).
228    async fn run_argv(&self, argv: &[String], timeout: Duration) -> (Option<i32>, String) {
229        let run = self.hooks.run.clone();
230        let argv = argv.to_vec();
231        tokio::task::spawn_blocking(move || run(&argv, timeout))
232            .await
233            .unwrap_or_else(|e| (None, format!("runtime invocation failed to complete: {e}")))
234    }
235
236    /// Best-effort `compose down -v` after a failed provision — a
237    /// half-started project must not leak containers the engine never got a
238    /// handle to.
239    async fn cleanup_project(&self, runtime: ContainerRuntime, project: &str, compose_file: &Path) {
240        let argv = compose_argv(runtime, project, compose_file, &["down", "-v"]);
241        let _ = self.run_argv(&argv, TEARDOWN_TIMEOUT).await;
242    }
243}
244
245impl Default for LocalContainerProvider {
246    fn default() -> Self {
247        Self::new()
248    }
249}
250
251/// `kranz-ws-<sanitized-mission-id>` — compose project names must match
252/// `[a-z0-9][a-z0-9_-]*`; the prefix guarantees a valid leading character
253/// even when the mission id sanitizes to nothing.
254pub(crate) fn compose_project_name(mission_id: &str) -> String {
255    let sanitized: String = mission_id
256        .chars()
257        .map(|c| {
258            if c.is_ascii_alphanumeric() {
259                c.to_ascii_lowercase()
260            } else if c == '-' || c == '_' {
261                c
262            } else {
263                '-'
264            }
265        })
266        .collect();
267    if sanitized.is_empty() {
268        format!("{PROJECT_PREFIX}mission")
269    } else {
270        format!("{PROJECT_PREFIX}{sanitized}")
271    }
272}
273
274/// `[bin, "compose", "-p", project, "-f", compose_file, ...args]`.
275fn compose_argv(
276    runtime: ContainerRuntime,
277    project: &str,
278    compose_file: &Path,
279    args: &[&str],
280) -> Vec<String> {
281    let mut argv = vec![
282        runtime.binary().to_string(),
283        "compose".to_string(),
284        "-p".to_string(),
285        project.to_string(),
286        "-f".to_string(),
287        compose_file.display().to_string(),
288    ];
289    argv.extend(args.iter().map(|arg| (*arg).to_string()));
290    argv
291}
292
293/// `<compose argv> exec -T <service> sh -c <command>` — the command goes in
294/// as ONE argv entry (no host shell re-parsing); the container's `sh` gets
295/// it verbatim, exactly like the host gate hands commands to `sh -c`.
296fn exec_argv(
297    runtime: ContainerRuntime,
298    project: &str,
299    compose_file: &Path,
300    service: &str,
301    command: &str,
302) -> Vec<String> {
303    compose_argv(
304        runtime,
305        project,
306        compose_file,
307        &["exec", "-T", service, "sh", "-c", command],
308    )
309}
310
311/// How a runtime invocation ended, for error messages (`Some(n)` a real
312/// exit code, `None` spawn/timeout/signal).
313fn code_phrase(code: Option<i32>) -> String {
314    match code {
315        Some(code) => format!("exit code {code}"),
316        None => "no exit code (spawn failure or timeout)".to_string(),
317    }
318}
319
320/// Refuse any contract fixed port already bound on the host — naming the
321/// service and the port — BEFORE a container starts (never a silent
322/// rebind). Runs before runtime detection: the refusal is a host-local fact
323/// and must not depend on a runtime being installed. A successful probe
324/// bind means the port is free; the listener drops immediately.
325///
326/// The probe models docker's default publish: the wildcard address. That
327/// catches exactly what `docker-proxy` would fail to bind (an existing
328/// wildcard bind fails on every platform, SO_REUSEADDR semantics
329/// notwithstanding); a loopback-only squat is deliberately NOT a collision
330/// — docker's own reuse-semantics bind would succeed there too.
331fn check_fixed_port_collisions(contract: &WorkspaceContract) -> Result<()> {
332    for service in &contract.services {
333        if let PortPolicy::Fixed(port) = service.port.policy {
334            // Compose publishes on 0.0.0.0 by default, so probe the wildcard
335            // address: it collides with any existing bind on that port.
336            if std::net::TcpListener::bind((std::net::Ipv4Addr::UNSPECIFIED, port)).is_err() {
337                return Err(EngineError::InvalidState(format!(
338                    "workspace.provider \"container\": fixed port {port} for service {:?} is \
339                     already bound on the host; refusing rather than silently rebinding \
340                     (owner: repo-setup — free the port or pick another fixed port)",
341                    service.name
342                )));
343            }
344        }
345    }
346    Ok(())
347}
348
349/// Long-syntax bind mount (`source` → `target`) — unlike the `a:b` short
350/// form this is unambiguous for every host path shape, Windows drive
351/// letters included.
352fn bind_mount(source: &str, target: &str) -> serde_json::Value {
353    json!({ "type": "bind", "source": source, "target": target })
354}
355
356/// Render the compose document (as JSON — valid YAML 1.2, so `compose -f`
357/// parses it) from the contract: the `workspace` service (worktree +
358/// declared mounts, env, stay-alive command) plus one service per contract
359/// `services[]` entry. `bootstrap`/`readiness` are recorded under the
360/// top-level `x-kranz` extension so the artifact is self-describing; the
361/// provider still executes them via `compose exec`.
362pub(crate) fn render_compose_file(
363    project: &str,
364    contract: &WorkspaceContract,
365    worktree: &Path,
366    env: &HashMap<String, String>,
367) -> serde_json::Value {
368    let worktree_abs = crate::sandbox::absolutize(worktree).display().to_string();
369    let mut volumes = vec![bind_mount(&worktree_abs, &worktree_abs)];
370    for mount in &contract.mounts {
371        volumes.push(bind_mount(mount, mount));
372    }
373    let mut workspace = json!({
374        "image": sandbox_container::DEFAULT_IMAGE,
375        "command": ["sleep", "infinity"],
376        "working_dir": worktree_abs,
377        "volumes": volumes,
378    });
379    if !env.is_empty() {
380        workspace["environment"] = json!(env);
381    }
382
383    let mut services = serde_json::Map::new();
384    services.insert(WORKSPACE_SERVICE.to_string(), workspace);
385    for service in &contract.services {
386        let mut def = json!({
387            "image": sandbox_container::DEFAULT_IMAGE,
388            "command": ["sh", "-c", service.start],
389        });
390        let ports = match &service.port.policy {
391            // Host port 0 = OS-assigned; read back after the service is up.
392            PortPolicy::Dynamic => vec![format!("0:{DYNAMIC_CONTAINER_PORT}")],
393            // Must bind exactly N on both sides (collision refused earlier).
394            PortPolicy::Fixed(port) => vec![format!("{port}:{port}")],
395        };
396        def["ports"] = json!(ports);
397        if let Some(health_check) = &service.health_check {
398            def["healthcheck"] = json!({
399                "test": ["CMD-SHELL", health_check],
400                "interval": "2s",
401                "timeout": "10s",
402                "retries": 30,
403                "start_period": "5s",
404            });
405        }
406        services.insert(service.name.clone(), def);
407    }
408
409    json!({
410        "name": project,
411        "services": services,
412        "x-kranz": {
413            "workspaceService": WORKSPACE_SERVICE,
414            "bootstrap": contract.bootstrap,
415            "readiness": contract.readiness,
416        },
417    })
418}
419
420/// Parse the OS-assigned host port out of `compose port <svc> <port>`
421/// output (`0.0.0.0:32768`, possibly several lines with an `[::]` row).
422fn parse_compose_port(output: &str) -> Option<u16> {
423    output
424        .lines()
425        .filter_map(|line| line.trim().rsplit(':').next())
426        .find_map(|segment| segment.parse::<u16>().ok())
427}
428
429/// Fill preview templates: substitute `{port}` ONLY with an
430/// actually-assigned dynamic host port (read back from the runtime — never
431/// fabricated), and only when exactly one dynamic port was assigned; with
432/// zero or several, the mapping is ambiguous and the template stays
433/// unfilled (D-E).
434fn fill_previews(
435    previews: &[PreviewSpec],
436    assigned_ports: &[(String, u16)],
437) -> Vec<PreviewPlaceholder> {
438    previews
439        .iter()
440        .map(|preview| {
441            let url_template = match assigned_ports {
442                [(_, port)] => preview.url_template.replace("{port}", &port.to_string()),
443                _ => preview.url_template.clone(),
444            };
445            PreviewPlaceholder {
446                name: preview.name.clone(),
447                url_template,
448            }
449        })
450        .collect()
451}
452
453#[async_trait::async_trait]
454impl WorkspaceProvider for LocalContainerProvider {
455    fn kind(&self) -> WorkspaceProviderKind {
456        WorkspaceProviderKind::Container
457    }
458
459    async fn provision(&self, spec: &ProvisionSpec) -> Result<WorkspaceHandle> {
460        if !spec.repo_root.is_dir() {
461            return Err(EngineError::InvalidState(format!(
462                "container provision: execution cwd {} does not exist",
463                spec.repo_root.display()
464            )));
465        }
466        let env = crate::runner::contract_env(spec.base_sha.as_deref());
467        let Some(contract) = &spec.contract else {
468            // No contract ⇒ nothing runnable to isolate (D-H): no
469            // containers, no runtime required — the handle is the plain
470            // execution cwd, mirroring the local provider's contract-less
471            // behavior.
472            return Ok(WorkspaceHandle {
473                cwd: spec.repo_root.clone(),
474                env,
475                previews: Vec::new(),
476                contract: None,
477                detail: None,
478                container: None,
479                remote: None,
480                gate_env: spec.gate_env.clone(),
481            });
482        };
483
484        // Host-local precondition first (its refusal must not depend on a
485        // runtime being installed), then the runtime boundary.
486        check_fixed_port_collisions(contract)?;
487        let runtime = (self.hooks.detect)().ok_or_else(|| {
488            EngineError::Config(
489                "workspace.provider \"container\" needs a container runtime on PATH (docker, \
490                 podman, or nerdctl — none found; owner: operator — install a runtime or choose \
491                 another workspace.provider); refusing rather than sharing the host port namespace"
492                    .to_string(),
493            )
494        })?;
495        if runtime == ContainerRuntime::AppleContainer {
496            return Err(EngineError::Config(
497                "workspace.provider \"container\": the `container` runtime (Apple Container) has \
498                 no compose subcommand; install docker, podman, or nerdctl (owner: operator) or \
499                 choose another workspace.provider"
500                    .to_string(),
501            ));
502        }
503        let probe = self
504            .run_argv(
505                &[
506                    runtime.binary().to_string(),
507                    "compose".to_string(),
508                    "version".to_string(),
509                ],
510                COMPOSE_PROBE_TIMEOUT,
511            )
512            .await;
513        if probe.0 != Some(0) {
514            return Err(EngineError::Config(format!(
515                "workspace.provider \"container\": `{} compose` is unavailable ({}); the \
516                 local-container provider needs a compose subcommand — install the compose \
517                 plugin (owner: operator) or choose another workspace.provider",
518                runtime.binary(),
519                crate::scrub::scrub(&probe.1)
520            )));
521        }
522
523        // The compose project is mission-owned: rendered into the mission
524        // runtime dir (gitignored), named for the mission, never shared.
525        let project = compose_project_name(&spec.mission_id);
526        let workspace_dir = spec.runtime_dir.join(WORKSPACE_DIR);
527        std::fs::create_dir_all(&workspace_dir)?;
528        let compose_file = workspace_dir.join(COMPOSE_FILE_NAME);
529        let doc = render_compose_file(&project, contract, &spec.repo_root, &env);
530        std::fs::write(&compose_file, serde_json::to_vec_pretty(&doc)?)?;
531
532        let up = self
533            .run_argv(
534                &compose_argv(runtime, &project, &compose_file, &["up", "-d"]),
535                COMPOSE_UP_TIMEOUT,
536            )
537            .await;
538        if up.0 != Some(0) {
539            self.cleanup_project(runtime, &project, &compose_file).await;
540            return Err(EngineError::InvalidState(format!(
541                "container provision: `{} compose up -d` failed ({}): {}",
542                runtime.binary(),
543                code_phrase(up.0),
544                crate::scrub::scrub(&up.1)
545            )));
546        }
547
548        // Wait for the declared health checks by polling each one INSIDE its
549        // service container (the runtime's own health status is rendered
550        // into the compose file too, but this poll is the portable gate).
551        for service in contract
552            .services
553            .iter()
554            .filter(|service| service.health_check.is_some())
555        {
556            let health_check = service.health_check.as_deref().expect("filtered on Some");
557            let deadline = std::time::Instant::now() + HEALTH_CHECK_TIMEOUT;
558            loop {
559                let argv = exec_argv(
560                    runtime,
561                    &project,
562                    &compose_file,
563                    &service.name,
564                    health_check,
565                );
566                let (code, _tail) = self.run_argv(&argv, HEALTH_EXEC_TIMEOUT).await;
567                if code == Some(0) {
568                    break;
569                }
570                if std::time::Instant::now() >= deadline {
571                    self.cleanup_project(runtime, &project, &compose_file).await;
572                    return Err(EngineError::InvalidState(format!(
573                        "container provision: health check for service {:?} did not pass within \
574                         {}s: `{health_check}`",
575                        service.name,
576                        HEALTH_CHECK_TIMEOUT.as_secs()
577                    )));
578                }
579                tokio::time::sleep(HEALTH_POLL_INTERVAL).await;
580            }
581        }
582
583        // Read back the OS-assigned host port of every dynamic service —
584        // the ONLY ports previews may substitute (never fabricated).
585        let mut assigned_ports = Vec::new();
586        for service in contract
587            .services
588            .iter()
589            .filter(|service| matches!(service.port.policy, PortPolicy::Dynamic))
590        {
591            let argv = compose_argv(
592                runtime,
593                &project,
594                &compose_file,
595                &["port", &service.name, &DYNAMIC_CONTAINER_PORT.to_string()],
596            );
597            let (code, output) = self.run_argv(&argv, COMPOSE_PROBE_TIMEOUT).await;
598            let port = if code == Some(0) {
599                parse_compose_port(&output)
600            } else {
601                None
602            };
603            let Some(port) = port else {
604                self.cleanup_project(runtime, &project, &compose_file).await;
605                return Err(EngineError::InvalidState(format!(
606                    "container provision: could not read back the OS-assigned host port for \
607                     dynamic service {:?} (`compose port` {}): {}",
608                    service.name,
609                    code_phrase(code),
610                    crate::scrub::scrub(&output)
611                )));
612            };
613            assigned_ports.push((service.name.clone(), port));
614        }
615
616        Ok(WorkspaceHandle {
617            cwd: spec.repo_root.clone(),
618            env,
619            previews: fill_previews(&contract.previews, &assigned_ports),
620            contract: Some(contract.clone()),
621            detail: Some(format!("compose project {project}")),
622            container: Some(ContainerWorkspace {
623                runtime,
624                project,
625                compose_file,
626                assigned_ports,
627            }),
628            remote: None,
629            gate_env: spec.gate_env.clone(),
630        })
631    }
632
633    async fn readiness(
634        &self,
635        handle: &WorkspaceHandle,
636        progress: &mut ProgressSink<'_>,
637    ) -> Result<ReadinessOutcome> {
638        let Some(contract) = &handle.contract else {
639            // No contract: the gate is a no-op (same as the host path) —
640            // trivially ready, no progress lines.
641            return Ok(ReadinessOutcome::Ready);
642        };
643        let Some(workspace) = &handle.container else {
644            return Err(EngineError::InvalidState(
645                "container readiness: the handle carries a contract but no compose project — \
646                 provision did not complete"
647                    .to_string(),
648            ));
649        };
650
651        // 0. golden data clone/migrate (design D-D) — after provision,
652        //    before bootstrap, exec'd INSIDE the container network (never
653        //    on the host). Undeclared hooks skip silently.
654        if let Some(data) = &contract.data {
655            for (hook, command) in [
656                (crate::workspace_data::DataHookKind::Clone, &data.clone),
657                (crate::workspace_data::DataHookKind::Migrate, &data.migrate),
658            ] {
659                if let Some(command) = command {
660                    if let Some(failed) =
661                        self.run_data_hook(handle, hook, command, progress).await?
662                    {
663                        return Ok(ReadinessOutcome::Failed {
664                            kind: hook.gate_kind(),
665                            failed,
666                        });
667                    }
668                }
669            }
670        }
671
672        // 1. bootstrap — ordered, stop at first failure. Same gate semantics
673        //    as the host path, but exec'd INSIDE the container network (the
674        //    `workspace` service holds the worktree mount), so checks reach
675        //    the project's services by in-network names.
676        let phase = GatePhase {
677            kind: "bootstrap command",
678            unit: "command",
679            plural: "commands",
680            prefix: BOOTSTRAP_SUMMARY_PREFIX,
681            commands: &contract.bootstrap,
682            stop_at_first_failure: true,
683        };
684        if let Some(failed) = self.run_exec_phase(workspace, &phase, progress).await? {
685            return Ok(ReadinessOutcome::Failed {
686                kind: "bootstrap command",
687                failed,
688            });
689        }
690
691        // 2. readiness — every check runs; all must pass.
692        let phase = GatePhase {
693            kind: "readiness check",
694            unit: "check",
695            plural: "checks",
696            prefix: READINESS_SUMMARY_PREFIX,
697            commands: &contract.readiness,
698            stop_at_first_failure: false,
699        };
700        if let Some(failed) = self.run_exec_phase(workspace, &phase, progress).await? {
701            return Ok(ReadinessOutcome::Failed {
702                kind: "readiness check",
703                failed,
704            });
705        }
706
707        // 3. golden data skewCheck (design D-D) — the last readiness step;
708        //    its failure is the distinct SKEW outcome, never a readiness
709        //    flake.
710        if let Some(command) = contract
711            .data
712            .as_ref()
713            .and_then(|data| data.skew_check.as_ref())
714        {
715            if let Some(failed) = self
716                .run_data_hook(
717                    handle,
718                    crate::workspace_data::DataHookKind::SkewCheck,
719                    command,
720                    progress,
721                )
722                .await?
723            {
724                return Ok(ReadinessOutcome::DataSkew { failed });
725            }
726        }
727
728        Ok(ReadinessOutcome::Ready)
729    }
730
731    /// Golden-data hooks exec INSIDE the workspace container (design D-D) —
732    /// the same `compose exec -T workspace sh -c …` path the gate phases
733    /// use, so a container workspace never runs data hooks on the host.
734    async fn run_data_hook(
735        &self,
736        handle: &WorkspaceHandle,
737        hook: crate::workspace_data::DataHookKind,
738        command: &str,
739        progress: &mut ProgressSink<'_>,
740    ) -> Result<Option<CommandOutcome>> {
741        let Some(workspace) = &handle.container else {
742            return Err(EngineError::InvalidState(
743                "container data hook: the handle carries no compose project — provision did \
744                 not complete"
745                    .to_string(),
746            ));
747        };
748        let argv = exec_argv(
749            workspace.runtime,
750            &workspace.project,
751            &workspace.compose_file,
752            WORKSPACE_SERVICE,
753            command,
754        );
755        let (code, output_tail) = self.run_argv(&argv, EXEC_TIMEOUT).await;
756        crate::workspace_data::hook_outcome(hook, command, code, output_tail, progress)
757    }
758
759    async fn teardown(&self, handle: WorkspaceHandle, mode: TeardownMode) -> Result<()> {
760        let Some(workspace) = &handle.container else {
761            return Ok(()); // contract-less provision: nothing is running
762        };
763        match mode {
764            // Leave the project running (documented: previews stay live).
765            TeardownMode::Keep => Ok(()),
766            // Containers paused, project kept.
767            TeardownMode::Hibernate => {
768                let (code, tail) = self
769                    .run_argv(
770                        &compose_argv(
771                            workspace.runtime,
772                            &workspace.project,
773                            &workspace.compose_file,
774                            &["stop"],
775                        ),
776                        TEARDOWN_TIMEOUT,
777                    )
778                    .await;
779                if code == Some(0) {
780                    Ok(())
781                } else {
782                    Err(EngineError::InvalidState(format!(
783                        "container teardown (hibernate): `compose stop` failed ({}): {}",
784                        code_phrase(code),
785                        crate::scrub::scrub(&tail)
786                    )))
787                }
788            }
789            // Project + volumes removed; then honor the contract's disk
790            // prune hint when declared (a HOST command — it prunes the
791            // daemon, not anything inside the container network).
792            TeardownMode::Destroy => {
793                let (code, tail) = self
794                    .run_argv(
795                        &compose_argv(
796                            workspace.runtime,
797                            &workspace.project,
798                            &workspace.compose_file,
799                            &["down", "-v"],
800                        ),
801                        TEARDOWN_TIMEOUT,
802                    )
803                    .await;
804                if code != Some(0) {
805                    return Err(EngineError::InvalidState(format!(
806                        "container teardown (destroy): `compose down -v` failed ({}): {}",
807                        code_phrase(code),
808                        crate::scrub::scrub(&tail)
809                    )));
810                }
811                if let Some(prune) = handle
812                    .contract
813                    .as_ref()
814                    .and_then(|contract| contract.disk.as_ref())
815                    .and_then(|disk| disk.prune.as_deref())
816                {
817                    let cwd = workspace
818                        .compose_file
819                        .parent()
820                        .unwrap_or(handle.cwd.as_path());
821                    // `disk.prune` is the FOURTH contract-declared command
822                    // lane, and it was the one left running on the host with
823                    // the engine's full ambient environment (follow-up
824                    // review, M-3) — the same repo-authored
825                    // `.kranz/workspace.json` the other three lanes were
826                    // cleared for. It goes through the same cleared builder:
827                    // one env discipline for every command the contract can
828                    // name.
829                    let env = crate::workspace_gate::gate_command_env(
830                        &handle.gate_env,
831                        &handle.env,
832                        handle.contract.as_ref(),
833                    );
834                    let (code, tail) =
835                        crate::command_exec::run_shell_command_with_code_cleared(cwd, prune, &env)
836                            .await;
837                    if code != Some(0) {
838                        return Err(EngineError::InvalidState(format!(
839                            "container teardown (destroy): disk prune command `{prune}` failed \
840                             ({}): {}",
841                            code_phrase(code),
842                            crate::scrub::scrub(&tail)
843                        )));
844                    }
845                }
846                Ok(())
847            }
848        }
849    }
850}
851
852impl LocalContainerProvider {
853    /// Run one gate phase's commands via `compose exec` inside the
854    /// workspace container, then report the pass/fail decision lines
855    /// through the seam's shared [`report_gate_outcomes`] — byte-identical
856    /// to the host gate's reporting.
857    async fn run_exec_phase(
858        &self,
859        workspace: &ContainerWorkspace,
860        phase: &GatePhase<'_>,
861        progress: &mut ProgressSink<'_>,
862    ) -> Result<Option<CommandOutcome>> {
863        progress(
864            &format!(
865                "{} running {} {}",
866                phase.prefix,
867                phase.commands.len(),
868                phase.plural
869            ),
870            None,
871        )?;
872        let total = phase.commands.len();
873        let mut outcomes = Vec::with_capacity(total);
874        for (i, command) in phase.commands.iter().enumerate() {
875            let argv = exec_argv(
876                workspace.runtime,
877                &workspace.project,
878                &workspace.compose_file,
879                WORKSPACE_SERVICE,
880                command,
881            );
882            let (code, output_tail) = self.run_argv(&argv, EXEC_TIMEOUT).await;
883            let outcome = CommandOutcome {
884                ordinal: i + 1,
885                total,
886                command: command.clone(),
887                code,
888                output_tail,
889            };
890            let failed = !outcome.ok();
891            outcomes.push(outcome);
892            if failed && phase.stop_at_first_failure {
893                break;
894            }
895        }
896        report_gate_outcomes(phase, outcomes, progress)
897    }
898}
899
900// ---------------------------------------------------------------------------
901
902#[cfg(test)]
903mod tests {
904    use super::*;
905    use crate::workspace_contract::parse_workspace_contract;
906    use std::sync::Mutex;
907
908    fn spec(root: &Path, mission_id: &str, contract: Option<WorkspaceContract>) -> ProvisionSpec {
909        let runtime_dir = root.join(".kranz").join("missions").join(mission_id);
910        ProvisionSpec {
911            mission_id: mission_id.to_string(),
912            repo_root: root.to_path_buf(),
913            gate_env: crate::workspace_provider::GateEnvPolicy::for_mission(&runtime_dir, &[]),
914            runtime_dir,
915            base_sha: Some("deadbeefcafe".to_string()),
916            contract,
917        }
918    }
919
920    fn contract(json: &[u8]) -> WorkspaceContract {
921        parse_workspace_contract(json).expect("valid contract")
922    }
923
924    fn minimal_contract() -> WorkspaceContract {
925        contract(br#"{"schemaVersion": 1, "readiness": ["true"]}"#)
926    }
927
928    /// One dynamic service with a health check, bootstrap + readiness, a
929    /// preview, and a disk prune hint (the no-op is shell-portable; Destroy
930    /// runs it with the compose dir as cwd, so the relative marker lands at
931    /// a known path — the portable idiom, no absolute-path interpolation).
932    fn fake_contract() -> WorkspaceContract {
933        contract(
934            br#"{
935                "schemaVersion": 1,
936                "bootstrap": ["echo boot > .boot-marker"],
937                "services": [
938                    {
939                        "name": "api",
940                        "start": "sleep infinity",
941                        "healthCheck": "true",
942                        "port": { "policy": "dynamic" }
943                    }
944                ],
945                "readiness": ["test -f .boot-marker"],
946                "previews": [{ "name": "app", "urlTemplate": "http://localhost:{port}/" }],
947                "disk": { "prune": "echo pruned > prune-marker.txt" }
948            }"#,
949        )
950    }
951
952    /// Collect progress lines the gate would emit (same helper shape as the
953    /// seam's own tests).
954    #[derive(Default)]
955    struct Progress(Vec<(String, Option<String>)>);
956
957    impl Progress {
958        fn sink(&mut self) -> impl FnMut(&str, Option<String>) -> Result<()> + Send + use<'_> {
959            |summary, detail| {
960                self.0.push((summary.to_string(), detail));
961                Ok(())
962            }
963        }
964
965        fn summaries(&self) -> Vec<&str> {
966            self.0.iter().map(|(s, _)| s.as_str()).collect()
967        }
968    }
969
970    /// A scripted runtime boundary: records every argv and answers the
971    /// compose subcommands the provider issues. `fail_exec_containing`
972    /// makes matching exec'd commands fail (the readiness-failure path)
973    /// while health checks and everything else succeed.
974    #[derive(Default)]
975    struct FakeRuntime {
976        calls: Mutex<Vec<Vec<String>>>,
977        fail_exec_containing: Option<String>,
978    }
979
980    impl FakeRuntime {
981        fn hooks(self: &Arc<Self>) -> RuntimeHooks {
982            let this = Arc::clone(self);
983            RuntimeHooks {
984                detect: Arc::new(|| Some(ContainerRuntime::Docker)),
985                run: Arc::new(move |argv, _timeout| this.answer(argv)),
986            }
987        }
988
989        fn answer(&self, argv: &[String]) -> (Option<i32>, String) {
990            self.calls.lock().unwrap().push(argv.to_vec());
991            let args: Vec<&str> = argv.iter().map(String::as_str).collect();
992            // The availability probe: [bin, "compose", "version"].
993            if args == ["docker", "compose", "version"] {
994                return (Some(0), "v2.27.0".to_string());
995            }
996            // Everything else: [bin, "compose", "-p", P, "-f", F, ...rest].
997            let rest = &args[6..];
998            match rest[0] {
999                "up" | "stop" | "down" | "ps" => (Some(0), String::new()),
1000                "port" => (Some(0), "0.0.0.0:32768\n".to_string()),
1001                "exec" => {
1002                    // rest = ["exec", "-T", service, "sh", "-c", command]
1003                    let command = rest[5];
1004                    if let Some(needle) = &self.fail_exec_containing {
1005                        if command.contains(needle.as_str()) {
1006                            return (Some(3), format!("boom running `{command}`"));
1007                        }
1008                    }
1009                    (Some(0), String::new())
1010                }
1011                other => (
1012                    Some(1),
1013                    format!("fake runtime: unexpected subcommand {other:?}"),
1014                ),
1015            }
1016        }
1017
1018        fn calls(&self) -> Vec<Vec<String>> {
1019            self.calls.lock().unwrap().clone()
1020        }
1021
1022        /// Any recorded argv whose tail is exactly `suffix`.
1023        fn called_with(&self, suffix: &[&str]) -> bool {
1024            self.calls().iter().any(|argv| {
1025                let args: Vec<&str> = argv.iter().map(String::as_str).collect();
1026                args.ends_with(suffix)
1027            })
1028        }
1029
1030        /// Any recorded argv exec'ing `command` inside `service`.
1031        fn execed(&self, service: &str, command: &str) -> bool {
1032            self.calls().iter().any(|argv| {
1033                let args: Vec<&str> = argv.iter().map(String::as_str).collect();
1034                args.windows(6)
1035                    .any(|w| w == ["exec", "-T", service, "sh", "-c", command])
1036            })
1037        }
1038    }
1039
1040    #[test]
1041    fn compose_project_name_is_sanitized_and_mission_owned() {
1042        assert_eq!(compose_project_name("m-abc123"), "kranz-ws-m-abc123");
1043        assert_eq!(
1044            compose_project_name("m-Foo_Bar/baz.qux"),
1045            "kranz-ws-m-foo_bar-baz-qux"
1046        );
1047        assert_eq!(compose_project_name(""), "kranz-ws-mission");
1048        assert_ne!(
1049            compose_project_name("m-a"),
1050            compose_project_name("m-b"),
1051            "parallel missions never share a project"
1052        );
1053    }
1054
1055    #[test]
1056    fn render_compose_file_maps_contract_to_services_ports_and_volumes() {
1057        let dir = tempfile::tempdir().expect("tempdir");
1058        let contract = contract(
1059            br#"{
1060                "schemaVersion": 1,
1061                "bootstrap": ["cargo fetch"],
1062                "services": [
1063                    {
1064                        "name": "api",
1065                        "start": "./run-api",
1066                        "healthCheck": "curl -sf localhost:8080/health",
1067                        "port": { "policy": "dynamic" }
1068                    },
1069                    {
1070                        "name": "db",
1071                        "start": "./run-db",
1072                        "port": { "policy": { "fixed": 5432 } }
1073                    }
1074                ],
1075                "readiness": ["curl -sf localhost:8080/health"],
1076                "mounts": ["/var/cache/cargo"],
1077                "previews": [{ "name": "app", "urlTemplate": "http://localhost:{port}/" }]
1078            }"#,
1079        );
1080        let env = crate::runner::contract_env(Some("deadbeefcafe"));
1081        let doc = render_compose_file("kranz-ws-m-render1", &contract, dir.path(), &env);
1082
1083        assert_eq!(doc["name"], "kranz-ws-m-render1");
1084
1085        // The workspace service: default image, stay-alive command, the
1086        // worktree bind-mounted at its identical (platform-derived)
1087        // absolute path, the declared mounts as volumes, env stamped.
1088        let abs = crate::sandbox::absolutize(dir.path()).display().to_string();
1089        let workspace = &doc["services"][WORKSPACE_SERVICE];
1090        assert_eq!(workspace["image"], sandbox_container::DEFAULT_IMAGE);
1091        assert_eq!(workspace["command"], json!(["sleep", "infinity"]));
1092        assert_eq!(workspace["working_dir"], json!(abs));
1093        assert_eq!(workspace["environment"]["KRANZ_BASE_SHA"], "deadbeefcafe");
1094        let volumes = workspace["volumes"].as_array().expect("volumes array");
1095        assert!(
1096            volumes.contains(&bind_mount(&abs, &abs)),
1097            "worktree bind mount: {volumes:?}"
1098        );
1099        assert!(
1100            volumes.contains(&bind_mount("/var/cache/cargo", "/var/cache/cargo")),
1101            "declared mounts[] become volumes: {volumes:?}"
1102        );
1103
1104        // Ports: dynamic → host 0 (OS-assigned); fixed → N:N.
1105        assert_eq!(
1106            doc["services"]["api"]["ports"],
1107            json!([format!("0:{DYNAMIC_CONTAINER_PORT}")])
1108        );
1109        assert_eq!(doc["services"]["db"]["ports"], json!(["5432:5432"]));
1110
1111        // The health check renders as a compose healthcheck; a service
1112        // without one gets none.
1113        assert_eq!(
1114            doc["services"]["api"]["healthcheck"]["test"],
1115            json!(["CMD-SHELL", "curl -sf localhost:8080/health"])
1116        );
1117        assert!(doc["services"]["db"].get("healthcheck").is_none());
1118
1119        // Service start runs via sh -c; bootstrap/readiness are recorded on
1120        // the artifact (the provider executes them via compose exec).
1121        assert_eq!(
1122            doc["services"]["api"]["command"],
1123            json!(["sh", "-c", "./run-api"])
1124        );
1125        assert_eq!(doc["x-kranz"]["bootstrap"], json!(["cargo fetch"]));
1126        assert_eq!(
1127            doc["x-kranz"]["readiness"],
1128            json!(["curl -sf localhost:8080/health"])
1129        );
1130    }
1131
1132    #[tokio::test]
1133    async fn fixed_port_collision_refuses_at_provision_naming_service_and_port() {
1134        // Hold the port on the wildcard address — what docker publishes by
1135        // default and what the provider probes. (A loopback hold would not
1136        // collide with a wildcard probe on BSD/macOS, where both sockets
1137        // carry SO_REUSEADDR; the wildcard hold collides everywhere.)
1138        let probe = std::net::TcpListener::bind((std::net::Ipv4Addr::UNSPECIFIED, 0))
1139            .expect("bind probe socket");
1140        let port = probe.local_addr().expect("local addr").port();
1141        let dir = tempfile::tempdir().expect("tempdir");
1142        let contract_json = format!(
1143            r#"{{"schemaVersion": 1, "services": [
1144                {{"name": "db", "start": "./run-db", "port": {{"policy": {{"fixed": {port}}}}}}}
1145            ]}}"#
1146        );
1147        // No runtime hooks needed: the refusal precedes runtime detection,
1148        // so it is identical on runtime-less and docker hosts.
1149        let err = LocalContainerProvider::new()
1150            .provision(&spec(
1151                dir.path(),
1152                "m-collision",
1153                Some(contract(contract_json.as_bytes())),
1154            ))
1155            .await
1156            .expect_err("a host-bound fixed port refuses provision");
1157        let msg = err.to_string();
1158        assert!(msg.contains("\"db\""), "{msg}");
1159        assert!(msg.contains(&port.to_string()), "{msg}");
1160        assert!(msg.contains("refusing"), "{msg}");
1161        assert!(
1162            !dir.path()
1163                .join(".kranz/missions/m-collision/workspace/compose.json")
1164                .exists(),
1165            "the refusal precedes any runtime work: no compose file written"
1166        );
1167    }
1168
1169    #[tokio::test]
1170    async fn provision_fails_closed_when_no_runtime_is_detected() {
1171        let dir = tempfile::tempdir().expect("tempdir");
1172        let provider = LocalContainerProvider::with_hooks(RuntimeHooks {
1173            detect: Arc::new(|| None),
1174            run: Arc::new(|_argv, _timeout| {
1175                panic!("no runtime invocation may happen without a detected runtime")
1176            }),
1177        });
1178        let err = provider
1179            .provision(&spec(dir.path(), "m-noruntime", Some(minimal_contract())))
1180            .await
1181            .expect_err("no runtime ⇒ fail closed");
1182        let msg = err.to_string();
1183        assert!(msg.contains("workspace.provider"), "{msg}");
1184        assert!(msg.contains("docker"), "{msg}");
1185        assert!(msg.contains("podman"), "{msg}");
1186        assert!(msg.contains("nerdctl"), "{msg}");
1187        assert!(msg.contains("owner: operator"), "{msg}");
1188    }
1189
1190    /// The REAL host boundary on a runtime-less host (this dev host has
1191    /// none): production detection must hit exactly the fail-closed path.
1192    /// Skips on hosts with a runtime, where the deterministic
1193    /// `provision_fails_closed_when_no_runtime_is_detected` covers the
1194    /// refusal.
1195    #[tokio::test]
1196    async fn provision_fails_closed_on_a_runtimeless_host() {
1197        if sandbox_container::detect().is_some() {
1198            eprintln!(
1199                "host has a container runtime; skipping the real-detection refusal \
1200                 (covered deterministically by the injected-detection test)"
1201            );
1202            return;
1203        }
1204        let dir = tempfile::tempdir().expect("tempdir");
1205        let err = LocalContainerProvider::new()
1206            .provision(&spec(
1207                dir.path(),
1208                "m-noruntime-host",
1209                Some(minimal_contract()),
1210            ))
1211            .await
1212            .expect_err("a runtime-less host fails closed at provision");
1213        let msg = err.to_string();
1214        assert!(msg.contains("workspace.provider"), "{msg}");
1215        assert!(msg.contains("docker"), "{msg}");
1216        assert!(msg.contains("owner: operator"), "{msg}");
1217    }
1218
1219    #[tokio::test]
1220    async fn provision_writes_compose_goes_up_and_reads_back_dynamic_ports() {
1221        let dir = tempfile::tempdir().expect("tempdir");
1222        let fake = Arc::new(FakeRuntime::default());
1223        let provider = LocalContainerProvider::with_hooks(fake.hooks());
1224        let handle = provider
1225            .provision(&spec(dir.path(), "m-Fake1", Some(fake_contract())))
1226            .await
1227            .expect("provision");
1228
1229        assert_eq!(provider.kind(), WorkspaceProviderKind::Container);
1230        assert_eq!(handle.cwd, dir.path());
1231        assert_eq!(
1232            handle.env.get("KRANZ_BASE_SHA").map(String::as_str),
1233            Some("deadbeefcafe")
1234        );
1235        assert_eq!(
1236            handle.detail.as_deref(),
1237            Some("compose project kranz-ws-m-fake1"),
1238            "the provisioned event's detail carries the compose project"
1239        );
1240        let workspace = handle.container.as_ref().expect("container state");
1241        assert_eq!(workspace.project, "kranz-ws-m-fake1");
1242        assert_eq!(
1243            workspace.assigned_ports,
1244            vec![("api".to_string(), 32768)],
1245            "the OS-assigned port is read back from the runtime"
1246        );
1247        // Exactly one dynamic service ⇒ the preview template got the
1248        // ASSIGNED port (never fabricated).
1249        assert_eq!(
1250            handle.previews,
1251            vec![PreviewPlaceholder {
1252                name: "app".to_string(),
1253                url_template: "http://localhost:32768/".to_string(),
1254            }]
1255        );
1256
1257        // The compose file landed in the mission-owned runtime dir
1258        // (gitignored), named for the mission.
1259        let compose_file = dir
1260            .path()
1261            .join(".kranz/missions/m-Fake1/workspace/compose.json");
1262        let doc: serde_json::Value =
1263            serde_json::from_slice(&std::fs::read(&compose_file).expect("compose file written"))
1264                .expect("compose file is JSON");
1265        assert_eq!(doc["name"], "kranz-ws-m-fake1");
1266        assert_eq!(workspace.compose_file, compose_file);
1267
1268        // The project went up; the declared health check was waited on
1269        // inside its service container.
1270        assert!(fake.called_with(&["up", "-d"]), "{:?}", fake.calls());
1271        assert!(fake.execed("api", "true"), "{:?}", fake.calls());
1272    }
1273
1274    #[tokio::test]
1275    async fn readiness_execs_bootstrap_then_readiness_inside_the_container_network() {
1276        let dir = tempfile::tempdir().expect("tempdir");
1277        let fake = Arc::new(FakeRuntime::default());
1278        let provider = LocalContainerProvider::with_hooks(fake.hooks());
1279        let handle = provider
1280            .provision(&spec(dir.path(), "m-fake2", Some(fake_contract())))
1281            .await
1282            .expect("provision");
1283        let mut progress = Progress::default();
1284        let outcome = provider
1285            .readiness(&handle, &mut progress.sink())
1286            .await
1287            .expect("readiness");
1288
1289        assert!(matches!(outcome, ReadinessOutcome::Ready), "{outcome:?}");
1290        assert!(
1291            fake.execed(WORKSPACE_SERVICE, "echo boot > .boot-marker"),
1292            "bootstrap exec'd inside the workspace container: {:?}",
1293            fake.calls()
1294        );
1295        assert!(
1296            fake.execed(WORKSPACE_SERVICE, "test -f .boot-marker"),
1297            "readiness exec'd inside the workspace container: {:?}",
1298            fake.calls()
1299        );
1300        assert_eq!(
1301            progress.summaries(),
1302            vec![
1303                "workspace bootstrap: running 1 commands",
1304                "workspace bootstrap: 1/1 commands ok",
1305                "workspace readiness: running 1 checks",
1306                "workspace readiness: 1/1 checks ok",
1307            ],
1308            "the gate's decision lines are byte-identical to the host path"
1309        );
1310    }
1311
1312    #[tokio::test]
1313    async fn readiness_failure_reports_the_gate_outcome() {
1314        let dir = tempfile::tempdir().expect("tempdir");
1315        let fake = Arc::new(FakeRuntime {
1316            fail_exec_containing: Some("test -f".to_string()),
1317            ..FakeRuntime::default()
1318        });
1319        let provider = LocalContainerProvider::with_hooks(fake.hooks());
1320        let handle = provider
1321            .provision(&spec(dir.path(), "m-fake3", Some(fake_contract())))
1322            .await
1323            .expect("provision (health checks do not match the failure needle)");
1324        let mut progress = Progress::default();
1325        let outcome = provider
1326            .readiness(&handle, &mut progress.sink())
1327            .await
1328            .expect("readiness");
1329
1330        let ReadinessOutcome::Failed { kind, failed } = outcome else {
1331            panic!("readiness failure must be Failed, got {outcome:?}");
1332        };
1333        assert_eq!(kind, "readiness check");
1334        assert_eq!(failed.code, Some(3));
1335        assert_eq!(
1336            progress.summaries(),
1337            vec![
1338                "workspace bootstrap: running 1 commands",
1339                "workspace bootstrap: 1/1 commands ok",
1340                "workspace readiness: running 1 checks",
1341                "workspace readiness: FAILED at check 1/1 — blocking mission (owner: repo-setup)",
1342            ]
1343        );
1344    }
1345
1346    // -----------------------------------------------------------------------
1347    // Golden-data hooks (design D-D, ticket golden-data-hooks): exec'd
1348    // INSIDE the container network, never on the host.
1349    // -----------------------------------------------------------------------
1350
1351    /// A data-block contract (bare hook names — the fake runtime records
1352    /// argv verbatim and never really executes).
1353    fn fake_data_contract() -> WorkspaceContract {
1354        contract(
1355            br#"{
1356                "schemaVersion": 1,
1357                "bootstrap": ["echo boot > .boot-marker"],
1358                "readiness": ["test -f .boot-marker"],
1359                "data": {
1360                    "clone": "clone-golden",
1361                    "migrate": "migrate-golden",
1362                    "reset": "reseed-golden",
1363                    "skewCheck": "check-skew",
1364                    "resetBetweenRounds": true
1365                }
1366            }"#,
1367        )
1368    }
1369
1370    /// The index of the first recorded argv exec'ing `command` inside
1371    /// `service` (for lifecycle-order assertions).
1372    fn exec_index(fake: &FakeRuntime, service: &str, command: &str) -> usize {
1373        fake.calls()
1374            .iter()
1375            .position(|argv| {
1376                let args: Vec<&str> = argv.iter().map(String::as_str).collect();
1377                args.windows(6)
1378                    .any(|w| w == ["exec", "-T", service, "sh", "-c", command])
1379            })
1380            .unwrap_or_else(|| panic!("no exec of {command:?} in {service}: {:?}", fake.calls()))
1381    }
1382
1383    #[tokio::test]
1384    async fn data_hooks_exec_inside_the_container_network_in_lifecycle_order() {
1385        let dir = tempfile::tempdir().expect("tempdir");
1386        let fake = Arc::new(FakeRuntime::default());
1387        let provider = LocalContainerProvider::with_hooks(fake.hooks());
1388        let handle = provider
1389            .provision(&spec(dir.path(), "m-fakedata", Some(fake_data_contract())))
1390            .await
1391            .expect("provision");
1392        let mut progress = Progress::default();
1393        let outcome = provider
1394            .readiness(&handle, &mut progress.sink())
1395            .await
1396            .expect("readiness");
1397
1398        assert!(matches!(outcome, ReadinessOutcome::Ready), "{outcome:?}");
1399        // clone → migrate → bootstrap → readiness → skewCheck, all exec'd
1400        // inside the workspace container (the `compose exec` argv shape).
1401        let clone = exec_index(&fake, WORKSPACE_SERVICE, "clone-golden");
1402        let migrate = exec_index(&fake, WORKSPACE_SERVICE, "migrate-golden");
1403        let bootstrap = exec_index(&fake, WORKSPACE_SERVICE, "echo boot > .boot-marker");
1404        let readiness = exec_index(&fake, WORKSPACE_SERVICE, "test -f .boot-marker");
1405        let skew = exec_index(&fake, WORKSPACE_SERVICE, "check-skew");
1406        assert!(
1407            clone < migrate && migrate < bootstrap && bootstrap < readiness && readiness < skew,
1408            "lifecycle order (clone<{clone} migrate<{migrate} bootstrap<{bootstrap} readiness<{readiness} skew<{skew})"
1409        );
1410        assert_eq!(
1411            progress.summaries(),
1412            vec![
1413                "workspace data: clone `clone-golden` → ok (exit code 0)",
1414                "workspace data: migrate `migrate-golden` → ok (exit code 0)",
1415                "workspace bootstrap: running 1 commands",
1416                "workspace bootstrap: 1/1 commands ok",
1417                "workspace readiness: running 1 checks",
1418                "workspace readiness: 1/1 checks ok",
1419                "workspace data: skewCheck `check-skew` → ok (exit code 0)",
1420            ],
1421            "the data decision lines are byte-identical to the host path"
1422        );
1423
1424        // The reset-between-rounds drive routes through the same compose
1425        // exec path (the engine calls run_data_hook from validation_round).
1426        let mut progress = Progress::default();
1427        let failed = provider
1428            .run_data_hook(
1429                &handle,
1430                crate::workspace_data::DataHookKind::Reset,
1431                "reseed-golden",
1432                &mut progress.sink(),
1433            )
1434            .await
1435            .expect("run_data_hook");
1436        assert!(failed.is_none(), "{failed:?}");
1437        assert!(
1438            fake.execed(WORKSPACE_SERVICE, "reseed-golden"),
1439            "reset exec'd inside the workspace container: {:?}",
1440            fake.calls()
1441        );
1442        assert_eq!(
1443            progress.summaries(),
1444            vec!["workspace data: reset `reseed-golden` → ok (exit code 0)"]
1445        );
1446    }
1447
1448    #[tokio::test]
1449    async fn container_skew_failure_is_the_distinct_skew_outcome() {
1450        let dir = tempfile::tempdir().expect("tempdir");
1451        let fake = Arc::new(FakeRuntime {
1452            fail_exec_containing: Some("check-skew".to_string()),
1453            ..FakeRuntime::default()
1454        });
1455        let provider = LocalContainerProvider::with_hooks(fake.hooks());
1456        let handle = provider
1457            .provision(&spec(dir.path(), "m-fakeskew", Some(fake_data_contract())))
1458            .await
1459            .expect("provision");
1460        let mut progress = Progress::default();
1461        let outcome = provider
1462            .readiness(&handle, &mut progress.sink())
1463            .await
1464            .expect("readiness");
1465
1466        let ReadinessOutcome::DataSkew { failed } = outcome else {
1467            panic!("a skewCheck failure must be DataSkew, got {outcome:?}");
1468        };
1469        assert_eq!(failed.code, Some(3));
1470        let summaries = progress.summaries();
1471        let last = summaries.last().expect("a skew decision line");
1472        assert_eq!(
1473            *last,
1474            "workspace data: skewCheck `check-skew` → FAILED (exit code 3) — blocking mission (owner: repo-setup)"
1475        );
1476    }
1477
1478    #[tokio::test]
1479    async fn teardown_keep_hibernate_and_destroy_semantics() {
1480        // Keep: nothing happens — the project stays live (previews keep
1481        // working); the provider makes NO further runtime calls.
1482        let dir = tempfile::tempdir().expect("tempdir");
1483        let fake = Arc::new(FakeRuntime::default());
1484        let provider = LocalContainerProvider::with_hooks(fake.hooks());
1485        let handle = provider
1486            .provision(&spec(dir.path(), "m-fake4", Some(fake_contract())))
1487            .await
1488            .expect("provision");
1489        let calls_before = fake.calls().len();
1490        provider
1491            .teardown(handle, TeardownMode::Keep)
1492            .await
1493            .expect("keep is a no-op");
1494        assert_eq!(
1495            fake.calls().len(),
1496            calls_before,
1497            "Keep leaves the project running: no runtime calls"
1498        );
1499
1500        // Hibernate: compose stop (containers paused, project kept).
1501        let fake = Arc::new(FakeRuntime::default());
1502        let provider = LocalContainerProvider::with_hooks(fake.hooks());
1503        let handle = provider
1504            .provision(&spec(dir.path(), "m-fake5", Some(fake_contract())))
1505            .await
1506            .expect("provision");
1507        provider
1508            .teardown(handle, TeardownMode::Hibernate)
1509            .await
1510            .expect("hibernate");
1511        assert!(fake.called_with(&["stop"]), "{:?}", fake.calls());
1512        assert!(!fake.called_with(&["down", "-v"]), "{:?}", fake.calls());
1513
1514        // Destroy: compose down -v (project + volumes removed), then the
1515        // contract's disk prune hint runs on the host (relative marker in
1516        // the prune cwd = the compose dir — the portable idiom).
1517        let fake = Arc::new(FakeRuntime::default());
1518        let provider = LocalContainerProvider::with_hooks(fake.hooks());
1519        let handle = provider
1520            .provision(&spec(dir.path(), "m-fake6", Some(fake_contract())))
1521            .await
1522            .expect("provision");
1523        provider
1524            .teardown(handle, TeardownMode::Destroy)
1525            .await
1526            .expect("destroy");
1527        assert!(fake.called_with(&["down", "-v"]), "{:?}", fake.calls());
1528        assert!(
1529            dir.path()
1530                .join(".kranz/missions/m-fake6/workspace/prune-marker.txt")
1531                .exists(),
1532            "the disk prune hint ran with the compose dir as cwd"
1533        );
1534    }
1535
1536    /// M-3 (follow-up review): `disk.prune` is the FOURTH contract-declared
1537    /// command lane and was the one still running on the host with the
1538    /// engine's full ambient environment, from the same repo-authored
1539    /// `.kranz/workspace.json` the other three lanes were cleared for. It now
1540    /// goes through the same cleared builder: undeclared ambient credentials
1541    /// do not cross, and `HOME` is the mission's shared gate home rather than
1542    /// the operator's.
1543    #[cfg(unix)]
1544    #[tokio::test]
1545    async fn disk_prune_runs_with_the_same_cleared_gate_env_as_the_other_lanes() {
1546        let _guard = crate::agent_env::EnvTestGuard::engage(&[(
1547            "KRANZ_SECRET_TEST",
1548            "must-not-reach-disk-prune",
1549        )]);
1550
1551        let dir = tempfile::tempdir().expect("tempdir");
1552        let fake = Arc::new(FakeRuntime::default());
1553        let provider = LocalContainerProvider::with_hooks(fake.hooks());
1554        let contract = contract(
1555            br#"{
1556                "schemaVersion": 1,
1557                "readiness": ["true"],
1558                "services": [
1559                    {
1560                        "name": "api",
1561                        "start": "sleep infinity",
1562                        "healthCheck": "true",
1563                        "port": { "policy": "dynamic" }
1564                    }
1565                ],
1566                "disk": {
1567                  "prune": "printf '%s|%s' \"$KRANZ_SECRET_TEST\" \"$HOME\" > prune-env.txt"
1568                }
1569            }"#,
1570        );
1571        let handle = provider
1572            .provision(&spec(dir.path(), "m-prune", Some(contract)))
1573            .await
1574            .expect("provision");
1575        let gate_home = handle.gate_env.home.clone();
1576        provider
1577            .teardown(handle, TeardownMode::Destroy)
1578            .await
1579            .expect("destroy");
1580
1581        let recorded = std::fs::read_to_string(
1582            dir.path()
1583                .join(".kranz/missions/m-prune/workspace/prune-env.txt"),
1584        )
1585        .expect("the prune command ran");
1586        let (secret, home) = recorded.split_once('|').expect("both values recorded");
1587        assert!(
1588            secret.is_empty(),
1589            "an undeclared ambient credential reached disk.prune: {recorded:?}"
1590        );
1591        assert_eq!(
1592            home,
1593            gate_home.to_str().expect("utf-8 gate home"),
1594            "disk.prune must run with the mission's shared gate HOME, not the operator's"
1595        );
1596    }
1597
1598    #[tokio::test]
1599    async fn provision_without_a_contract_starts_no_containers_and_needs_no_runtime() {
1600        // Production hooks on a possibly runtime-less host: a contract-less
1601        // provision must never touch the runtime boundary (D-H).
1602        let dir = tempfile::tempdir().expect("tempdir");
1603        let provider = LocalContainerProvider::new();
1604        let handle = provider
1605            .provision(&spec(dir.path(), "m-nocontract", None))
1606            .await
1607            .expect("provision");
1608        assert!(handle.container.is_none());
1609        assert!(handle.detail.is_none());
1610        assert!(handle.previews.is_empty());
1611
1612        let mut progress = Progress::default();
1613        let outcome = provider
1614            .readiness(&handle, &mut progress.sink())
1615            .await
1616            .expect("readiness");
1617        assert!(matches!(outcome, ReadinessOutcome::Ready));
1618        assert!(progress.0.is_empty(), "no contract ⇒ no gate lines");
1619        provider
1620            .teardown(handle, TeardownMode::Destroy)
1621            .await
1622            .expect("teardown with no containers is a no-op");
1623    }
1624
1625    #[test]
1626    fn parse_compose_port_reads_the_assigned_host_port() {
1627        assert_eq!(parse_compose_port("0.0.0.0:32768\n"), Some(32768));
1628        assert_eq!(
1629            parse_compose_port("[::]:49153\n0.0.0.0:49153\n"),
1630            Some(49153)
1631        );
1632        assert_eq!(parse_compose_port(""), None);
1633        assert_eq!(parse_compose_port("Error: no such service\n"), None);
1634    }
1635
1636    #[test]
1637    fn previews_substitute_only_a_single_actually_assigned_dynamic_port() {
1638        let previews = vec![PreviewSpec {
1639            name: "app".to_string(),
1640            url_template: "http://localhost:{port}/".to_string(),
1641        }];
1642        // Zero assigned ⇒ the template stays unfilled (never fabricated).
1643        assert_eq!(
1644            fill_previews(&previews, &[])[0].url_template,
1645            "http://localhost:{port}/"
1646        );
1647        // Exactly one ⇒ substituted with that assigned port.
1648        assert_eq!(
1649            fill_previews(&previews, &[("api".to_string(), 32768)])[0].url_template,
1650            "http://localhost:32768/"
1651        );
1652        // Several ⇒ ambiguous which service's port; stays unfilled.
1653        assert_eq!(
1654            fill_previews(
1655                &previews,
1656                &[("api".to_string(), 32768), ("web".to_string(), 32769)]
1657            )[0]
1658            .url_template,
1659            "http://localhost:{port}/"
1660        );
1661    }
1662
1663    /// Runtime-gated smoke: two parallel provisions get distinct compose
1664    /// projects (distinct networks — no shared port namespace), readiness
1665    /// passes inside the container network, the bootstrap write lands on the
1666    /// HOST worktree through the mount, and Destroy removes both projects.
1667    /// Skips outside the live-proven Linux host path or without a runtime;
1668    /// CI ubuntu-latest has Docker. CI runners are ephemeral, so a failed
1669    /// assertion mid-test may leave a project behind.
1670    #[tokio::test]
1671    #[allow(clippy::await_holding_lock)]
1672    async fn container_workspace_smoke_provisions_isolates_and_destroys() {
1673        let _env = crate::agent_env::EnvTestGuard::engage(&[]);
1674        if !sandbox_container::host_supports_container_contract() {
1675            crate::test_capability::skip(
1676                crate::test_capability::capability::CONTAINER,
1677                "live container contract is supported only on Linux",
1678            );
1679            return;
1680        }
1681        let Some(runtime) = sandbox_container::detect() else {
1682            eprintln!(
1683                "no container runtime (docker/podman/nerdctl/container) on PATH; \
1684                 skipping container workspace smoke test"
1685            );
1686            return;
1687        };
1688        if runtime == ContainerRuntime::AppleContainer {
1689            eprintln!("the `container` runtime has no compose subcommand; skipping smoke test");
1690            return;
1691        }
1692        let compose_ok = std::process::Command::new(runtime.binary())
1693            .args(["compose", "version"])
1694            .stdin(std::process::Stdio::null())
1695            .output()
1696            .map(|output| output.status.success())
1697            .unwrap_or(false);
1698        if !compose_ok {
1699            eprintln!(
1700                "`{} compose` unavailable; skipping smoke test",
1701                runtime.binary()
1702            );
1703            return;
1704        }
1705
1706        // Desktop VMs share the checkout, not necessarily /var/folders.
1707        let parent = std::env::current_dir().unwrap();
1708        let dir_a = tempfile::tempdir_in(&parent).expect("tempdir a");
1709        let dir_b = tempfile::tempdir_in(&parent).expect("tempdir b");
1710        let provider = LocalContainerProvider::new();
1711        let handle_a = provider
1712            .provision(&spec(dir_a.path(), "m-smoke-a", Some(fake_contract())))
1713            .await
1714            .expect("provision a");
1715        let handle_b = provider
1716            .provision(&spec(dir_b.path(), "m-smoke-b", Some(fake_contract())))
1717            .await
1718            .expect("provision b (parallel)");
1719
1720        let project_a = handle_a.container.as_ref().unwrap().project.clone();
1721        let project_b = handle_b.container.as_ref().unwrap().project.clone();
1722        assert_ne!(
1723            project_a, project_b,
1724            "parallel missions get distinct projects (distinct networks)"
1725        );
1726
1727        for handle in [&handle_a, &handle_b] {
1728            let mut progress = Progress::default();
1729            let outcome = provider
1730                .readiness(handle, &mut progress.sink())
1731                .await
1732                .expect("readiness");
1733            assert!(
1734                matches!(outcome, ReadinessOutcome::Ready),
1735                "readiness passes inside the container network: {outcome:?}"
1736            );
1737        }
1738        assert!(
1739            dir_a.path().join(".boot-marker").exists(),
1740            "bootstrap wrote through the worktree mount to the host"
1741        );
1742
1743        let port_a = handle_a.container.as_ref().unwrap().assigned_ports[0].1;
1744        let port_b = handle_b.container.as_ref().unwrap().assigned_ports[0].1;
1745        assert!(port_a > 0 && port_b > 0, "OS-assigned ports read back");
1746        assert_ne!(port_a, port_b, "no shared port namespace");
1747        assert!(
1748            handle_a.previews[0]
1749                .url_template
1750                .contains(&port_a.to_string()),
1751            "the preview got the actually-assigned port"
1752        );
1753
1754        provider
1755            .teardown(handle_a, TeardownMode::Destroy)
1756            .await
1757            .expect("destroy a");
1758        provider
1759            .teardown(handle_b, TeardownMode::Destroy)
1760            .await
1761            .expect("destroy b");
1762        for project in [&project_a, &project_b] {
1763            let output = std::process::Command::new(runtime.binary())
1764                .args([
1765                    "ps",
1766                    "-aq",
1767                    "--filter",
1768                    &format!("label=com.docker.compose.project={project}"),
1769                ])
1770                .stdin(std::process::Stdio::null())
1771                .output()
1772                .expect("spawn compose ps");
1773            assert!(
1774                output.status.success()
1775                    && String::from_utf8_lossy(&output.stdout).trim().is_empty(),
1776                "Destroy removes the project {project}: {}",
1777                String::from_utf8_lossy(&output.stderr)
1778            );
1779        }
1780    }
1781}