1use 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
90const PROJECT_PREFIX: &str = "kranz-ws-";
93
94const WORKSPACE_SERVICE: &str = "workspace";
97
98const DYNAMIC_CONTAINER_PORT: u16 = 8080;
102
103const COMPOSE_FILE_NAME: &str = "compose.json";
105
106const WORKSPACE_DIR: &str = "workspace";
108
109const COMPOSE_PROBE_TIMEOUT: Duration = Duration::from_secs(30);
112const COMPOSE_UP_TIMEOUT: Duration = Duration::from_secs(600);
115const EXEC_TIMEOUT: Duration = Duration::from_secs(600);
117const HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(120);
119const HEALTH_POLL_INTERVAL: Duration = Duration::from_secs(1);
122const HEALTH_EXEC_TIMEOUT: Duration = Duration::from_secs(30);
123const TEARDOWN_TIMEOUT: Duration = Duration::from_secs(300);
125const OUTPUT_TAIL: usize = 1500;
127
128#[derive(Debug, Clone)]
132pub struct ContainerWorkspace {
133 pub runtime: ContainerRuntime,
134 pub project: String,
136 pub compose_file: PathBuf,
138 pub assigned_ports: Vec<(String, u16)>,
142}
143
144type DetectHook = Arc<dyn Fn() -> Option<ContainerRuntime> + Send + Sync>;
147
148type RunHook = Arc<dyn Fn(&[String], Duration) -> (Option<i32>, String) + Send + Sync>;
151
152#[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
173fn 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
205pub struct LocalContainerProvider {
208 hooks: RuntimeHooks,
209}
210
211impl LocalContainerProvider {
212 pub fn new() -> Self {
214 Self {
215 hooks: RuntimeHooks::host(),
216 }
217 }
218
219 #[cfg(test)]
222 pub(crate) fn with_hooks(hooks: RuntimeHooks) -> Self {
223 Self { hooks }
224 }
225
226 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 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
251pub(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
274fn 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
293fn 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
311fn 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
320fn check_fixed_port_collisions(contract: &WorkspaceContract) -> Result<()> {
332 for service in &contract.services {
333 if let PortPolicy::Fixed(port) = service.port.policy {
334 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
349fn bind_mount(source: &str, target: &str) -> serde_json::Value {
353 json!({ "type": "bind", "source": source, "target": target })
354}
355
356pub(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 PortPolicy::Dynamic => vec![format!("0:{DYNAMIC_CONTAINER_PORT}")],
393 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
420fn 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
429fn 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 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 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 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 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 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 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 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 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 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 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 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(()); };
763 match mode {
764 TeardownMode::Keep => Ok(()),
766 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 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 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 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#[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 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 #[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 #[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 if args == ["docker", "compose", "version"] {
994 return (Some(0), "v2.27.0".to_string());
995 }
996 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 #[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 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 assert_eq!(
1644 fill_previews(&previews, &[])[0].url_template,
1645 "http://localhost:{port}/"
1646 );
1647 assert_eq!(
1649 fill_previews(&previews, &[("api".to_string(), 32768)])[0].url_template,
1650 "http://localhost:32768/"
1651 );
1652 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 #[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 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}