1use std::collections::BTreeMap;
62use std::path::{Path, PathBuf};
63use std::process::Stdio;
64use std::time::Duration;
65
66use anyhow::{bail, Context, Result};
67use tokio::process::Command;
68use tracing::{debug, warn};
69use workload_spec::{EnvValue, VolumeSource, WorkloadRuntime, WorkloadSpec};
70
71pub trait RuntimeProvider: Send + Sync {
77 fn name(&self) -> &str;
81 fn available(&self) -> bool;
83 fn docker_host(&self) -> String;
86}
87
88pub struct SocketRuntimeProvider {
91 pub label: String,
92 pub socket: PathBuf,
93}
94
95impl RuntimeProvider for SocketRuntimeProvider {
96 fn name(&self) -> &str {
97 &self.label
98 }
99 fn available(&self) -> bool {
100 expand_tilde(&self.socket).exists()
101 }
102 fn docker_host(&self) -> String {
103 format!("unix://{}", expand_tilde(&self.socket).display())
104 }
105}
106
107pub struct CustomDockerHostProvider {
112 pub host: String,
113}
114
115impl RuntimeProvider for CustomDockerHostProvider {
116 fn name(&self) -> &str {
117 "custom"
118 }
119 fn available(&self) -> bool {
120 true
121 }
122 fn docker_host(&self) -> String {
123 self.host.clone()
124 }
125}
126
127pub const NAME_PREFIX: &str = "yah-pond-";
129
130pub const LEGACY_NAME_PREFIX: &str = "yah-sim-";
133
134pub const LABEL_KEY: &str = "yah.pond";
138
139pub const LEGACY_LABEL_KEY: &str = "yah.local-sim";
142
143pub fn canonical_name(service: &str, env: &str, slot: &str) -> String {
145 format!("{NAME_PREFIX}{service}-{env}-{slot}")
146}
147
148pub fn canonical_label(service: &str, env: &str, slot: &str) -> String {
150 format!("{service}:{env}:{slot}")
151}
152
153pub fn pond_network_name(service: &str, env: &str) -> String {
158 format!("{NAME_PREFIX}{service}-{env}")
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub enum RuntimePref {
166 Auto,
167 Orbstack,
168 DockerDesktop,
169 Colima,
170 Podman,
171 Docker,
172 Custom,
174}
175
176impl RuntimePref {
177 pub fn as_str(&self) -> &'static str {
178 match self {
179 Self::Auto => "auto",
180 Self::Orbstack => "orbstack",
181 Self::DockerDesktop => "docker-desktop",
182 Self::Colima => "colima",
183 Self::Podman => "podman",
184 Self::Docker => "docker",
185 Self::Custom => "custom",
186 }
187 }
188
189 pub fn parse(s: &str) -> Result<Self> {
190 match s {
191 "auto" => Ok(Self::Auto),
192 "orbstack" => Ok(Self::Orbstack),
193 "docker-desktop" | "docker_desktop" => Ok(Self::DockerDesktop),
194 "colima" => Ok(Self::Colima),
195 "podman" => Ok(Self::Podman),
196 "docker" => Ok(Self::Docker),
197 "custom" => Ok(Self::Custom),
198 other => bail!(
199 "unknown runtime preference {other:?} \
200 (expected auto/orbstack/docker-desktop/colima/podman/docker/custom)"
201 ),
202 }
203 }
204}
205
206#[derive(Debug, Clone)]
208pub struct LocalContainerSpec {
209 pub runtime: RuntimePref,
210 pub discovery: BTreeMap<String, PathBuf>,
214 pub custom_docker_host: Option<String>,
219}
220
221impl LocalContainerSpec {
222 pub fn build_cascade(&self) -> Vec<(DetectedRuntime, Box<dyn RuntimeProvider>)> {
229 if matches!(self.runtime, RuntimePref::Custom) {
230 return if let Some(host) = &self.custom_docker_host {
231 vec![(
232 DetectedRuntime::Custom,
233 Box::new(CustomDockerHostProvider { host: host.clone() }),
234 )]
235 } else {
236 vec![]
237 };
238 }
239
240 let order: &[DetectedRuntime] = match self.runtime {
241 RuntimePref::Auto => &[
242 DetectedRuntime::Orbstack,
243 DetectedRuntime::DockerDesktop,
244 DetectedRuntime::Colima,
245 DetectedRuntime::Podman,
246 DetectedRuntime::Docker,
247 ],
248 RuntimePref::Orbstack => &[DetectedRuntime::Orbstack],
249 RuntimePref::DockerDesktop => &[DetectedRuntime::DockerDesktop],
250 RuntimePref::Colima => &[DetectedRuntime::Colima],
251 RuntimePref::Podman => &[DetectedRuntime::Podman],
252 RuntimePref::Docker => &[DetectedRuntime::Docker],
253 RuntimePref::Custom => unreachable!("handled above"),
254 };
255
256 let mut result: Vec<(DetectedRuntime, Box<dyn RuntimeProvider>)> = order
257 .iter()
258 .filter_map(|&kind| {
259 self.discovery.get(kind.as_str()).map(|p| -> (DetectedRuntime, Box<dyn RuntimeProvider>) {
260 (kind, Box::new(SocketRuntimeProvider {
261 label: kind.as_str().to_string(),
262 socket: p.clone(),
263 }))
264 })
265 })
266 .collect();
267
268 if matches!(self.runtime, RuntimePref::Auto) {
270 if let Some(host) = &self.custom_docker_host {
271 result.push((
272 DetectedRuntime::Custom,
273 Box::new(CustomDockerHostProvider { host: host.clone() }),
274 ));
275 }
276 }
277 result
278 }
279}
280
281#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub enum DetectedRuntime {
284 Orbstack,
285 DockerDesktop,
286 Colima,
287 Podman,
288 Docker,
289 Custom,
291}
292
293impl DetectedRuntime {
294 pub fn as_str(&self) -> &'static str {
295 match self {
296 Self::Orbstack => "orbstack",
297 Self::DockerDesktop => "docker-desktop",
298 Self::Colima => "colima",
299 Self::Podman => "podman",
300 Self::Docker => "docker",
301 Self::Custom => "custom",
302 }
303 }
304}
305
306#[derive(Debug, Clone)]
308pub struct LocalRuntime {
309 pub detected: DetectedRuntime,
310 pub docker_host: String,
313}
314
315impl LocalRuntime {
316 pub async fn detect(spec: &LocalContainerSpec) -> Result<Self> {
321 if matches!(spec.runtime, RuntimePref::Custom) {
323 let host = spec.custom_docker_host.as_deref().ok_or_else(|| {
324 anyhow::anyhow!(
325 "runtime = custom but no custom_docker_host declared in the provider"
326 )
327 })?;
328 return Ok(Self { detected: DetectedRuntime::Custom, docker_host: host.to_string() });
329 }
330
331 let order: &[DetectedRuntime] = match spec.runtime {
332 RuntimePref::Auto => &[
333 DetectedRuntime::Orbstack,
334 DetectedRuntime::DockerDesktop,
335 DetectedRuntime::Colima,
336 DetectedRuntime::Podman,
337 DetectedRuntime::Docker,
338 ],
339 RuntimePref::Orbstack => &[DetectedRuntime::Orbstack],
340 RuntimePref::DockerDesktop => &[DetectedRuntime::DockerDesktop],
341 RuntimePref::Colima => &[DetectedRuntime::Colima],
342 RuntimePref::Podman => &[DetectedRuntime::Podman],
343 RuntimePref::Docker => &[DetectedRuntime::Docker],
344 RuntimePref::Custom => unreachable!("handled above"),
345 };
346
347 let mut attempted = Vec::new();
348 for &kind in order {
349 let raw = match spec.discovery.get(kind.as_str()) {
350 Some(p) => p.clone(),
351 None => {
352 attempted.push(format!("{} (no discovery entry)", kind.as_str()));
353 continue;
354 }
355 };
356 let provider = SocketRuntimeProvider {
357 label: kind.as_str().to_string(),
358 socket: raw,
359 };
360 if provider.available() {
361 return Ok(Self { detected: kind, docker_host: provider.docker_host() });
362 }
363 attempted.push(format!(
364 "{} ({})",
365 kind.as_str(),
366 expand_tilde(&provider.socket).display()
367 ));
368 }
369
370 if matches!(spec.runtime, RuntimePref::Auto) {
372 if let Some(host) = &spec.custom_docker_host {
373 return Ok(Self {
374 detected: DetectedRuntime::Custom,
375 docker_host: host.clone(),
376 });
377 }
378 }
379
380 bail!(
381 "no local container runtime reachable (tried: {}); \
382 install or start orbstack, docker-desktop, colima, or podman",
383 attempted.join(", "),
384 )
385 }
386
387 fn cmd(&self) -> Command {
392 let mut cmd = Command::new("docker");
393 cmd.env("DOCKER_HOST", &self.docker_host);
394 cmd.kill_on_drop(true);
395 cmd
396 }
397
398 async fn run_capture(&self, args: &[&str]) -> Result<String> {
401 debug!(runtime = ?self.detected, ?args, "docker");
402 let out = self
403 .cmd()
404 .args(args)
405 .stdout(Stdio::piped())
406 .stderr(Stdio::piped())
407 .output()
408 .await
409 .with_context(|| format!("spawning docker (is the CLI installed?): docker {}", args.join(" ")))?;
410 if !out.status.success() {
411 let stderr = String::from_utf8_lossy(&out.stderr);
412 bail!(
413 "docker {} failed (exit {:?}): {}",
414 args.join(" "),
415 out.status.code(),
416 stderr.trim(),
417 );
418 }
419 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
420 }
421
422 pub async fn has_image(&self, image: &str) -> Result<bool> {
426 let out = self
427 .cmd()
428 .args(["image", "inspect", image])
429 .stdout(Stdio::null())
430 .stderr(Stdio::null())
431 .status()
432 .await
433 .with_context(|| format!("spawning docker image inspect {image}"))?;
434 Ok(out.success())
435 }
436
437 pub async fn ensure_image(&self, image: &str) -> Result<bool> {
441 if self.has_image(image).await? {
442 return Ok(false);
443 }
444 self.run_capture(&["pull", image]).await?;
445 Ok(true)
446 }
447
448 pub async fn build_image(&self, tag: &str, dockerfile: &Path, context: &Path) -> Result<()> {
459 let dockerfile = dockerfile.to_string_lossy();
460 let context = context.to_string_lossy();
461 self.run_capture(&["build", "-t", tag, "-f", &dockerfile, &context])
462 .await
463 .with_context(|| format!("docker build -t {tag}"))?;
464 Ok(())
465 }
466
467 pub async fn ensure_network(&self, name: &str) -> Result<bool> {
471 let out = self
472 .cmd()
473 .args(["network", "inspect", name])
474 .stdout(Stdio::null())
475 .stderr(Stdio::null())
476 .status()
477 .await
478 .with_context(|| format!("spawning docker network inspect {name}"))?;
479 if out.success() {
480 return Ok(false);
481 }
482 let create = self
483 .cmd()
484 .args(["network", "create", name])
485 .stdout(Stdio::null())
486 .stderr(Stdio::piped())
487 .output()
488 .await
489 .with_context(|| format!("spawning docker network create {name}"))?;
490 if create.status.success() {
491 return Ok(true);
492 }
493 let stderr = String::from_utf8_lossy(&create.stderr);
494 let lower = stderr.to_lowercase();
496 if lower.contains("already exists") {
497 return Ok(false);
498 }
499 bail!("docker network create {name} failed: {}", stderr.trim());
500 }
501
502 pub async fn remove_container(&self, name: &str) -> Result<()> {
505 let out = self
506 .cmd()
507 .args(["rm", "-f", name])
508 .stdout(Stdio::null())
509 .stderr(Stdio::piped())
510 .output()
511 .await
512 .with_context(|| format!("spawning docker rm -f {name}"))?;
513 if out.status.success() {
514 return Ok(());
515 }
516 let stderr = String::from_utf8_lossy(&out.stderr);
517 if is_missing_container_error(&stderr) {
518 return Ok(());
519 }
520 bail!("docker rm -f {name} failed: {}", stderr.trim());
521 }
522
523 pub async fn run(&self, spec: &ContainerRunSpec) -> Result<()> {
526 self.remove_container(&spec.name).await?;
528
529 let args = spec.docker_run_args();
530 let argv: Vec<&str> = args.iter().map(String::as_str).collect();
531 self.run_capture(&argv).await?;
532 Ok(())
533 }
534
535 pub async fn container_host_port(&self, name: &str, container_port: u16) -> Result<u16> {
540 let port_str = container_port.to_string();
541 let out = self
542 .run_capture(&["port", name, &port_str])
543 .await
544 .with_context(|| format!("docker port {name} {container_port}"))?;
545 let host_port_str = out
548 .trim()
549 .rsplit(':')
550 .next()
551 .filter(|s| !s.is_empty())
552 .with_context(|| format!("unexpected docker port output: {:?}", out.trim()))?;
553 host_port_str.parse::<u16>().with_context(|| {
554 format!(
555 "parsing host port {:?} for {name}:{container_port}",
556 host_port_str,
557 )
558 })
559 }
560
561 pub async fn container_state(&self, name: &str) -> Result<Option<ContainerState>> {
564 let out = self
565 .cmd()
566 .args([
567 "inspect",
568 "--format",
569 "{{.State.Status}}",
570 name,
571 ])
572 .stdout(Stdio::piped())
573 .stderr(Stdio::piped())
574 .output()
575 .await
576 .with_context(|| format!("spawning docker inspect {name}"))?;
577 if !out.status.success() {
578 let stderr = String::from_utf8_lossy(&out.stderr);
579 if is_missing_container_error(&stderr) {
580 return Ok(None);
581 }
582 bail!("docker inspect {name} failed: {}", stderr.trim());
583 }
584 let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
585 Ok(Some(ContainerState::parse(&raw)))
586 }
587
588 pub async fn stop_and_remove(&self, name: &str, grace: Duration) -> Result<()> {
591 let grace_str = grace.as_secs().to_string();
592 let stop = self
593 .cmd()
594 .args(["stop", "-t", &grace_str, name])
595 .stdout(Stdio::null())
596 .stderr(Stdio::piped())
597 .output()
598 .await
599 .with_context(|| format!("spawning docker stop {name}"))?;
600 if !stop.status.success() {
601 let stderr = String::from_utf8_lossy(&stop.stderr);
602 if !is_missing_container_error(&stderr) {
603 bail!("docker stop {name} failed: {}", stderr.trim());
604 }
605 }
606 self.remove_container(name).await
607 }
608
609 pub async fn list_owned(&self) -> Result<Vec<OwnedContainer>> {
613 let mut owned: Vec<OwnedContainer> = Vec::new();
616 let mut seen_names: std::collections::HashSet<String> = std::collections::HashSet::new();
617
618 for (label_key, format_key) in [
619 (LABEL_KEY, LABEL_KEY),
620 (LEGACY_LABEL_KEY, LEGACY_LABEL_KEY),
621 ] {
622 let fmt = format!("{{{{.Names}}}}\t{{{{.Label \"{format_key}\"}}}}\t{{{{.State}}}}");
623 let out = self
624 .run_capture(&[
625 "ps",
626 "-a",
627 "--filter",
628 &format!("label={label_key}"),
629 "--format",
630 &fmt,
631 ])
632 .await?;
633 for line in out.lines() {
634 let mut parts = line.splitn(3, '\t');
635 let name = parts.next().unwrap_or_default().trim();
636 let label = parts.next().unwrap_or_default().trim();
637 let state = parts.next().unwrap_or_default().trim();
638 if name.is_empty() || seen_names.contains(name) {
639 continue;
640 }
641 seen_names.insert(name.to_string());
642 owned.push(OwnedContainer {
643 name: name.to_string(),
644 label: label.to_string(),
645 state: ContainerState::parse(state),
646 });
647 }
648 }
649 Ok(owned)
650 }
651}
652
653#[derive(Debug, Clone)]
655pub struct ContainerRunSpec {
656 pub name: String,
658 pub image: String,
660 pub label: String,
662 pub ports: Vec<(u16, u16)>,
664 pub env: BTreeMap<String, String>,
666 pub volumes: Vec<(PathBuf, String)>,
668 pub cmd: Vec<String>,
670 pub cap_add: Vec<String>,
674 pub cgroupns: Option<String>,
680 pub network: Option<String>,
684 pub network_aliases: Vec<String>,
688 pub extra_hosts: Vec<String>,
695}
696
697impl ContainerRunSpec {
698 pub fn new(service: &str, env: &str, slot: &str, image: impl Into<String>) -> Self {
701 Self {
702 name: canonical_name(service, env, slot),
703 image: image.into(),
704 label: canonical_label(service, env, slot),
705 ports: vec![],
706 env: BTreeMap::new(),
707 volumes: vec![],
708 cmd: vec![],
709 cap_add: vec![],
710 cgroupns: None,
711 network: None,
712 network_aliases: vec![],
713 extra_hosts: vec![],
714 }
715 }
716
717 pub fn docker_run_args(&self) -> Vec<String> {
721 let mut args: Vec<String> = vec![
722 "run".into(),
723 "-d".into(),
724 "--name".into(),
725 self.name.clone(),
726 "--label".into(),
727 format!("{LABEL_KEY}={}", self.label),
728 "--restart".into(),
729 "unless-stopped".into(),
730 ];
731 if let Some(mode) = &self.cgroupns {
732 args.push(format!("--cgroupns={mode}"));
733 }
734 for cap in &self.cap_add {
735 args.push("--cap-add".into());
736 args.push(cap.clone());
737 }
738 if let Some(net) = &self.network {
739 args.push("--network".into());
740 args.push(net.clone());
741 for alias in &self.network_aliases {
742 args.push("--network-alias".into());
743 args.push(alias.clone());
744 }
745 }
746 for entry in &self.extra_hosts {
747 args.push("--add-host".into());
748 args.push(entry.clone());
749 }
750 for (host, container) in &self.ports {
751 args.push("-p".into());
752 args.push(format!("{host}:{container}"));
753 }
754 for (k, v) in &self.env {
755 args.push("-e".into());
756 args.push(format!("{k}={v}"));
757 }
758 for (host_path, container_path) in &self.volumes {
759 args.push("-v".into());
760 args.push(format!("{}:{}", host_path.display(), container_path));
761 }
762 args.push(self.image.clone());
763 args.extend(self.cmd.iter().cloned());
764 args
765 }
766}
767
768#[derive(Debug, Clone, PartialEq, Eq)]
770pub struct OwnedContainer {
771 pub name: String,
772 pub label: String,
773 pub state: ContainerState,
774}
775
776#[derive(Debug, Clone, PartialEq, Eq)]
779pub enum ContainerState {
780 Created,
781 Running,
782 Restarting,
783 Exited,
784 Paused,
785 Removing,
786 Dead,
787 Unknown(String),
788}
789
790impl ContainerState {
791 pub fn parse(s: &str) -> Self {
792 match s.trim().to_lowercase().as_str() {
793 "created" => Self::Created,
794 "running" => Self::Running,
795 "restarting" => Self::Restarting,
796 "exited" => Self::Exited,
797 "paused" => Self::Paused,
798 "removing" => Self::Removing,
799 "dead" => Self::Dead,
800 other => Self::Unknown(other.to_string()),
801 }
802 }
803
804 pub fn is_running(&self) -> bool {
805 matches!(self, Self::Running)
806 }
807}
808
809fn is_missing_container_error(stderr: &str) -> bool {
813 let lower = stderr.to_lowercase();
814 lower.contains("no such container")
815 || lower.contains("no such object")
816 || lower.contains("not found")
817}
818
819fn expand_tilde(p: &Path) -> PathBuf {
821 let s = p.to_string_lossy();
822 if let Some(rest) = s.strip_prefix("~/") {
823 if let Ok(home) = std::env::var("HOME") {
824 return PathBuf::from(home).join(rest);
825 }
826 }
827 if s == "~" {
828 if let Ok(home) = std::env::var("HOME") {
829 return PathBuf::from(home);
830 }
831 }
832 p.to_path_buf()
833}
834
835pub struct LocalDockerRuntime {
854 inner: LocalRuntime,
855}
856
857impl LocalDockerRuntime {
858 pub fn new(inner: LocalRuntime) -> Self {
859 Self { inner }
860 }
861
862 pub fn runtime(&self) -> &LocalRuntime {
865 &self.inner
866 }
867}
868
869fn workload_spec_to_crs(spec: &WorkloadSpec) -> ContainerRunSpec {
874 let image = spec.image.docker_ref();
875
876 let mut env = BTreeMap::new();
877 for e in &spec.env {
878 match &e.value {
879 EnvValue::Literal { value } => {
880 env.insert(e.name.clone(), value.clone());
881 }
882 EnvValue::FromSecret { .. } => {
883 warn!(name = %e.name, "LocalDockerRuntime: skipping FromSecret env var (no secrets layer at sim tier)");
884 }
885 EnvValue::FromMesh { .. } => {
886 warn!(name = %e.name, "LocalDockerRuntime: skipping FromMesh env var (no mesh discovery at sim tier)");
887 }
888 }
889 }
890
891 let ports: Vec<(u16, u16)> = spec.expose.mesh.ports.iter().map(|&p| (p, p)).collect();
892
893 let volumes: Vec<(PathBuf, String)> = spec
894 .volumes
895 .iter()
896 .filter_map(|v| match &v.source {
897 VolumeSource::Bind { host_path } => {
898 Some((host_path.clone(), v.target.to_string_lossy().into_owned()))
899 }
900 VolumeSource::Named { name } => {
901 warn!(volume = %name, "LocalDockerRuntime: skipping Named volume (not supported at sim tier)");
902 None
903 }
904 VolumeSource::Tmpfs { .. } => {
905 warn!("LocalDockerRuntime: skipping Tmpfs volume (use -v /dev/null for ephemeral mounts at sim tier)");
906 None
907 }
908 })
909 .collect();
910
911 ContainerRunSpec {
912 name: spec.name.clone(),
913 image,
914 label: spec.name.clone(),
915 ports,
916 env,
917 volumes,
918 cmd: spec.command.clone().unwrap_or_default(),
919 cap_add: vec![],
920 cgroupns: None,
921 network: None,
922 network_aliases: vec![],
923 extra_hosts: vec![],
924 }
925}
926
927#[async_trait::async_trait]
928impl WorkloadRuntime for LocalDockerRuntime {
929 async fn deploy_workload(&self, spec: &WorkloadSpec) -> anyhow::Result<String> {
930 let crs = workload_spec_to_crs(spec);
931 self.inner.ensure_image(&crs.image).await?;
932 self.inner.run(&crs).await?;
933 Ok(spec.name.clone())
934 }
935
936 async fn teardown_workload(&self, name: &str) -> anyhow::Result<()> {
937 self.inner.stop_and_remove(name, Duration::from_secs(10)).await
938 }
939
940 async fn is_running(&self, name: &str) -> anyhow::Result<bool> {
941 Ok(self
942 .inner
943 .container_state(name)
944 .await?
945 .map(|s| s.is_running())
946 .unwrap_or(false))
947 }
948
949 async fn runtime_health(&self) -> anyhow::Result<bool> {
950 let result = self
952 .inner
953 .run_capture(&["info", "--format", "{{.ServerVersion}}"])
954 .await;
955 Ok(result.is_ok())
956 }
957}
958
959#[cfg(test)]
960mod tests {
961 use super::*;
962 use std::collections::BTreeMap;
963
964 #[test]
965 fn canonical_name_format() {
966 assert_eq!(canonical_name("dev-yah", "pond", "static"), "yah-pond-dev-yah-pond-static");
967 }
968
969 #[test]
970 fn canonical_label_format() {
971 assert_eq!(canonical_label("dev-yah", "pond", "object_store"), "dev-yah:pond:object_store");
972 }
973
974 #[test]
975 fn runtime_pref_parse() {
976 assert_eq!(RuntimePref::parse("auto").unwrap(), RuntimePref::Auto);
977 assert_eq!(RuntimePref::parse("orbstack").unwrap(), RuntimePref::Orbstack);
978 assert_eq!(RuntimePref::parse("docker-desktop").unwrap(), RuntimePref::DockerDesktop);
979 assert_eq!(RuntimePref::parse("docker_desktop").unwrap(), RuntimePref::DockerDesktop);
980 assert_eq!(RuntimePref::parse("colima").unwrap(), RuntimePref::Colima);
981 assert_eq!(RuntimePref::parse("podman").unwrap(), RuntimePref::Podman);
982 assert_eq!(RuntimePref::parse("docker").unwrap(), RuntimePref::Docker);
983 assert_eq!(RuntimePref::parse("custom").unwrap(), RuntimePref::Custom);
984 let err = RuntimePref::parse("nonsense").unwrap_err().to_string();
985 assert!(err.contains("nonsense"), "error should name the bad value, got: {err}");
986 }
987
988 #[test]
989 fn detected_runtime_as_str_round_trips() {
990 assert_eq!(DetectedRuntime::Orbstack.as_str(), "orbstack");
991 assert_eq!(DetectedRuntime::DockerDesktop.as_str(), "docker-desktop");
992 assert_eq!(DetectedRuntime::Colima.as_str(), "colima");
993 assert_eq!(DetectedRuntime::Podman.as_str(), "podman");
994 assert_eq!(DetectedRuntime::Docker.as_str(), "docker");
995 assert_eq!(DetectedRuntime::Custom.as_str(), "custom");
996 }
997
998 #[tokio::test]
999 async fn detect_returns_error_when_no_socket_exists() {
1000 let mut discovery = BTreeMap::new();
1002 discovery.insert("orbstack".into(), PathBuf::from("/nonexistent/orbstack.sock"));
1003 discovery.insert("colima".into(), PathBuf::from("/nonexistent/colima.sock"));
1004 discovery.insert("docker".into(), PathBuf::from("/nonexistent/docker.sock"));
1005 let spec = LocalContainerSpec { runtime: RuntimePref::Auto, discovery, custom_docker_host: None };
1006 let err = LocalRuntime::detect(&spec).await.unwrap_err().to_string();
1007 assert!(err.contains("no local container runtime reachable"));
1008 assert!(err.contains("orbstack"));
1009 assert!(err.contains("colima"));
1010 assert!(err.contains("docker"));
1011 }
1012
1013 #[tokio::test]
1014 async fn detect_reports_when_pinned_runtime_has_no_discovery_entry() {
1015 let spec = LocalContainerSpec {
1016 runtime: RuntimePref::Colima,
1017 discovery: BTreeMap::new(),
1018 custom_docker_host: None,
1019 };
1020 let err = LocalRuntime::detect(&spec).await.unwrap_err().to_string();
1021 assert!(err.contains("colima"));
1022 assert!(err.contains("no discovery entry"));
1023 }
1024
1025 #[tokio::test]
1026 async fn detect_picks_existing_socket() {
1027 let tmp = tempfile::TempDir::new().unwrap();
1030 let fake = tmp.path().join("docker.sock");
1031 std::fs::write(&fake, b"").unwrap();
1032 let mut discovery = BTreeMap::new();
1033 discovery.insert("orbstack".into(), PathBuf::from("/nonexistent/no.sock"));
1034 discovery.insert("colima".into(), PathBuf::from("/nonexistent/no.sock"));
1035 discovery.insert("docker".into(), fake.clone());
1036 let spec = LocalContainerSpec { runtime: RuntimePref::Auto, discovery, custom_docker_host: None };
1037 let runtime = LocalRuntime::detect(&spec).await.unwrap();
1038 assert_eq!(runtime.detected, DetectedRuntime::Docker);
1039 assert_eq!(runtime.docker_host, format!("unix://{}", fake.display()));
1040 }
1041
1042 #[tokio::test]
1043 async fn detect_honors_runtime_pin_and_skips_others() {
1044 let tmp = tempfile::TempDir::new().unwrap();
1047 let fake = tmp.path().join("docker.sock");
1048 std::fs::write(&fake, b"").unwrap();
1049 let mut discovery = BTreeMap::new();
1050 discovery.insert("orbstack".into(), PathBuf::from("/nonexistent/no.sock"));
1051 discovery.insert("docker".into(), fake);
1052 let spec = LocalContainerSpec { runtime: RuntimePref::Orbstack, discovery, custom_docker_host: None };
1053 let err = LocalRuntime::detect(&spec).await.unwrap_err().to_string();
1054 assert!(err.contains("orbstack"));
1055 assert!(!err.contains("docker ("), "pinned to orbstack — docker should not be in tried list: {err}");
1059 }
1060
1061 #[tokio::test]
1062 async fn detect_custom_pref_uses_host_directly() {
1063 let spec = LocalContainerSpec {
1064 runtime: RuntimePref::Custom,
1065 discovery: BTreeMap::new(),
1066 custom_docker_host: Some("tcp://localhost:2375".into()),
1067 };
1068 let runtime = LocalRuntime::detect(&spec).await.unwrap();
1069 assert_eq!(runtime.detected, DetectedRuntime::Custom);
1070 assert_eq!(runtime.docker_host, "tcp://localhost:2375");
1071 }
1072
1073 #[tokio::test]
1074 async fn detect_custom_pref_without_host_errors() {
1075 let spec = LocalContainerSpec {
1076 runtime: RuntimePref::Custom,
1077 discovery: BTreeMap::new(),
1078 custom_docker_host: None,
1079 };
1080 let err = LocalRuntime::detect(&spec).await.unwrap_err().to_string();
1081 assert!(err.contains("custom_docker_host"), "error should mention the missing field: {err}");
1082 }
1083
1084 #[tokio::test]
1085 async fn detect_auto_falls_back_to_custom_host() {
1086 let spec = LocalContainerSpec {
1088 runtime: RuntimePref::Auto,
1089 discovery: BTreeMap::new(),
1090 custom_docker_host: Some("tcp://localhost:2375".into()),
1091 };
1092 let runtime = LocalRuntime::detect(&spec).await.unwrap();
1093 assert_eq!(runtime.detected, DetectedRuntime::Custom);
1094 assert_eq!(runtime.docker_host, "tcp://localhost:2375");
1095 }
1096
1097 #[test]
1098 fn build_cascade_returns_providers_with_discovery_entries() {
1099 let mut discovery = BTreeMap::new();
1100 discovery.insert("orbstack".into(), PathBuf::from("/fake/orbstack.sock"));
1101 discovery.insert("docker".into(), PathBuf::from("/fake/docker.sock"));
1102 let spec = LocalContainerSpec { runtime: RuntimePref::Auto, discovery, custom_docker_host: None };
1103 let cascade = spec.build_cascade();
1104 assert_eq!(cascade.len(), 2);
1106 assert!(cascade.iter().any(|(k, _)| *k == DetectedRuntime::Orbstack));
1107 assert!(cascade.iter().any(|(k, _)| *k == DetectedRuntime::Docker));
1108 }
1109
1110 #[test]
1111 fn build_cascade_custom_pref_returns_single_entry() {
1112 let spec = LocalContainerSpec {
1113 runtime: RuntimePref::Custom,
1114 discovery: BTreeMap::new(),
1115 custom_docker_host: Some("tcp://localhost:2375".into()),
1116 };
1117 let cascade = spec.build_cascade();
1118 assert_eq!(cascade.len(), 1);
1119 let (kind, provider) = &cascade[0];
1120 assert_eq!(*kind, DetectedRuntime::Custom);
1121 assert_eq!(provider.docker_host(), "tcp://localhost:2375");
1122 assert!(provider.available());
1123 }
1124
1125 #[test]
1126 fn expand_tilde_replaces_home_prefix() {
1127 std::env::set_var("HOME", "/tmp/fake-home");
1128 let p = expand_tilde(Path::new("~/foo/bar"));
1129 assert_eq!(p, PathBuf::from("/tmp/fake-home/foo/bar"));
1130 }
1131
1132 #[test]
1133 fn expand_tilde_leaves_absolute_paths_alone() {
1134 let p = expand_tilde(Path::new("/var/run/docker.sock"));
1135 assert_eq!(p, PathBuf::from("/var/run/docker.sock"));
1136 }
1137
1138 #[test]
1139 fn container_state_parse_known_values() {
1140 assert_eq!(ContainerState::parse("running"), ContainerState::Running);
1141 assert_eq!(ContainerState::parse("exited"), ContainerState::Exited);
1142 assert_eq!(ContainerState::parse("PAUSED"), ContainerState::Paused);
1143 }
1144
1145 #[test]
1146 fn container_state_parse_unknown_preserves_string() {
1147 match ContainerState::parse("zombie") {
1148 ContainerState::Unknown(s) => assert_eq!(s, "zombie"),
1149 other => panic!("expected Unknown, got {other:?}"),
1150 }
1151 }
1152
1153 #[test]
1154 fn is_missing_container_error_matches_upstream_and_orbstack() {
1155 assert!(is_missing_container_error("Error: No such container: foo"));
1156 assert!(is_missing_container_error("error: no such object: foo"));
1157 assert!(is_missing_container_error("not found: foo"));
1158 assert!(!is_missing_container_error("Error response from daemon: Conflict."));
1159 assert!(!is_missing_container_error(""));
1160 }
1161
1162 #[test]
1163 fn container_run_spec_new_uses_canonical_name() {
1164 let spec = ContainerRunSpec::new("dev-yah", "pond", "static", "caddy:2-alpine");
1165 assert_eq!(spec.name, "yah-pond-dev-yah-pond-static");
1166 assert_eq!(spec.label, "dev-yah:pond:static");
1167 assert!(spec.ports.is_empty());
1168 assert!(spec.network.is_none());
1169 assert!(spec.network_aliases.is_empty());
1170 }
1171
1172 #[test]
1173 fn pond_network_name_is_yah_pond_svc_env() {
1174 assert_eq!(pond_network_name("yah-marketing", "pond"), "yah-pond-yah-marketing-pond");
1175 assert_eq!(pond_network_name("yah-dashboard", "pond"), "yah-pond-yah-dashboard-pond");
1176 }
1177
1178 #[test]
1179 fn docker_run_args_emit_network_and_aliases_when_set() {
1180 let mut spec = ContainerRunSpec::new("dev", "pond", "object_store", "minio:latest");
1181 spec.network = Some("yah-pond-dev-pond".into());
1182 spec.network_aliases = vec!["minio".into()];
1183 let args = spec.docker_run_args();
1184 let joined = args.join(" ");
1185 assert!(
1186 joined.contains("--network yah-pond-dev-pond"),
1187 "expected --network flag in: {joined}",
1188 );
1189 assert!(
1190 joined.contains("--network-alias minio"),
1191 "expected --network-alias minio in: {joined}",
1192 );
1193 }
1194
1195 #[test]
1196 fn docker_run_args_omit_network_flags_when_unset() {
1197 let spec = ContainerRunSpec::new("dev", "pond", "object_store", "minio:latest");
1198 let args = spec.docker_run_args();
1199 let joined = args.join(" ");
1200 assert!(!joined.contains("--network"), "unexpected --network flag: {joined}");
1201 assert!(!joined.contains("--network-alias"), "unexpected --network-alias flag: {joined}");
1202 }
1203
1204 #[test]
1205 fn docker_run_args_skip_aliases_when_no_network() {
1206 let mut spec = ContainerRunSpec::new("dev", "pond", "object_store", "minio:latest");
1209 spec.network_aliases = vec!["minio".into()];
1210 let args = spec.docker_run_args();
1211 let joined = args.join(" ");
1212 assert!(!joined.contains("--network-alias"), "should not emit alias without network: {joined}");
1213 }
1214
1215 fn minimal_workload_spec(name: &str) -> workload_spec::WorkloadSpec {
1218 use workload_spec::*;
1219 WorkloadSpec {
1220 schema_version: SchemaVersion::V1,
1221 name: name.to_string(),
1222 image: ImageRef {
1223 registry: "ghcr.io".into(),
1224 repository: "test/app".into(),
1225 tag: "v1.0".into(),
1226 digest: workload_spec::testing::test_digest(),
1227 },
1228 tier: TierTag("infra".into()),
1229 tenant: workload_spec::TenantId::singleton(),
1230 namespace: workload_spec::NamespaceId::singleton(),
1231 replicas: 1,
1232 command: None,
1233 entrypoint: None,
1234 workdir: None,
1235 user: None,
1236 env: vec![],
1237 secrets: vec![],
1238 volumes: vec![],
1239 resources: ResourceLimits { memory_mb: 256, cpu_millis: 512, ephemeral_storage_mb: 256 },
1240 depends_on: vec![],
1241 healthcheck: None,
1242 restart_policy: RestartPolicy::Always,
1243 archetype: None,
1244 stop_policy: StopPolicy { signal: 15, grace_period: Millis::from_secs(10) },
1245 expose: ExposeSpec {
1246 mesh: MeshExpose { identity: MeshIdent(name.into()), ports: vec![], allow_from: vec![] },
1247 public: None,
1248 operator: None,
1249 },
1250 labels: Default::default(),
1251 annotations: Default::default(),
1252 }
1253 }
1254
1255 #[test]
1256 fn workload_spec_to_crs_sets_name_and_image() {
1257 let spec = minimal_workload_spec("test-app");
1258 let crs = workload_spec_to_crs(&spec);
1259 assert_eq!(crs.name, "test-app");
1260 assert_eq!(
1262 crs.image,
1263 format!("ghcr.io/test/app:v1.0@{}", workload_spec::testing::test_digest())
1264 );
1265 assert!(crs.ports.is_empty());
1266 assert!(crs.env.is_empty());
1267 }
1268
1269 #[test]
1270 fn workload_spec_to_crs_forwards_literal_env_only() {
1271 use workload_spec::{EnvValue, EnvVar, MeshIdent, MeshLookup};
1272 let mut spec = minimal_workload_spec("env-test");
1273 spec.env = vec![
1274 EnvVar { name: "GOOD".into(), value: EnvValue::Literal { value: "yes".into() } },
1275 EnvVar { name: "BAD_SECRET".into(), value: EnvValue::FromSecret { secret: "s".into(), key: "k".into() } },
1276 EnvVar { name: "BAD_MESH".into(), value: EnvValue::FromMesh { ident: MeshIdent("x".into()), kind: MeshLookup::Url } },
1277 ];
1278 let crs = workload_spec_to_crs(&spec);
1279 assert_eq!(crs.env.get("GOOD").map(String::as_str), Some("yes"));
1280 assert!(!crs.env.contains_key("BAD_SECRET"), "FromSecret must be filtered out");
1281 assert!(!crs.env.contains_key("BAD_MESH"), "FromMesh must be filtered out");
1282 }
1283
1284 #[test]
1285 fn workload_spec_to_crs_maps_mesh_ports() {
1286 let mut spec = minimal_workload_spec("port-test");
1287 spec.expose.mesh.ports = vec![8080, 9000];
1288 let crs = workload_spec_to_crs(&spec);
1289 assert_eq!(crs.ports, vec![(8080, 8080), (9000, 9000)]);
1290 }
1291
1292 #[test]
1293 fn workload_spec_to_crs_applies_command_override() {
1294 let mut spec = minimal_workload_spec("cmd-test");
1295 spec.command = Some(vec!["server".into(), "--port=8080".into()]);
1296 let crs = workload_spec_to_crs(&spec);
1297 assert_eq!(crs.cmd, vec!["server", "--port=8080"]);
1298 }
1299
1300 #[test]
1301 fn image_ref_docker_ref_emits_tag_and_digest() {
1302 use workload_spec::ImageRef;
1303 let r = ImageRef {
1304 registry: "ghcr.io".into(),
1305 repository: "org/app".into(),
1306 tag: "v1.0".into(),
1307 digest: "sha256:abc123".into(),
1308 };
1309 assert_eq!(r.docker_ref(), "ghcr.io/org/app:v1.0@sha256:abc123");
1310 }
1311
1312 }