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 ensure_network(&self, name: &str) -> Result<bool> {
452 let out = self
453 .cmd()
454 .args(["network", "inspect", name])
455 .stdout(Stdio::null())
456 .stderr(Stdio::null())
457 .status()
458 .await
459 .with_context(|| format!("spawning docker network inspect {name}"))?;
460 if out.success() {
461 return Ok(false);
462 }
463 let create = self
464 .cmd()
465 .args(["network", "create", name])
466 .stdout(Stdio::null())
467 .stderr(Stdio::piped())
468 .output()
469 .await
470 .with_context(|| format!("spawning docker network create {name}"))?;
471 if create.status.success() {
472 return Ok(true);
473 }
474 let stderr = String::from_utf8_lossy(&create.stderr);
475 let lower = stderr.to_lowercase();
477 if lower.contains("already exists") {
478 return Ok(false);
479 }
480 bail!("docker network create {name} failed: {}", stderr.trim());
481 }
482
483 pub async fn remove_container(&self, name: &str) -> Result<()> {
486 let out = self
487 .cmd()
488 .args(["rm", "-f", name])
489 .stdout(Stdio::null())
490 .stderr(Stdio::piped())
491 .output()
492 .await
493 .with_context(|| format!("spawning docker rm -f {name}"))?;
494 if out.status.success() {
495 return Ok(());
496 }
497 let stderr = String::from_utf8_lossy(&out.stderr);
498 if is_missing_container_error(&stderr) {
499 return Ok(());
500 }
501 bail!("docker rm -f {name} failed: {}", stderr.trim());
502 }
503
504 pub async fn run(&self, spec: &ContainerRunSpec) -> Result<()> {
507 self.remove_container(&spec.name).await?;
509
510 let args = spec.docker_run_args();
511 let argv: Vec<&str> = args.iter().map(String::as_str).collect();
512 self.run_capture(&argv).await?;
513 Ok(())
514 }
515
516 pub async fn container_host_port(&self, name: &str, container_port: u16) -> Result<u16> {
521 let port_str = container_port.to_string();
522 let out = self
523 .run_capture(&["port", name, &port_str])
524 .await
525 .with_context(|| format!("docker port {name} {container_port}"))?;
526 let host_port_str = out
529 .trim()
530 .rsplit(':')
531 .next()
532 .filter(|s| !s.is_empty())
533 .with_context(|| format!("unexpected docker port output: {:?}", out.trim()))?;
534 host_port_str.parse::<u16>().with_context(|| {
535 format!(
536 "parsing host port {:?} for {name}:{container_port}",
537 host_port_str,
538 )
539 })
540 }
541
542 pub async fn container_state(&self, name: &str) -> Result<Option<ContainerState>> {
545 let out = self
546 .cmd()
547 .args([
548 "inspect",
549 "--format",
550 "{{.State.Status}}",
551 name,
552 ])
553 .stdout(Stdio::piped())
554 .stderr(Stdio::piped())
555 .output()
556 .await
557 .with_context(|| format!("spawning docker inspect {name}"))?;
558 if !out.status.success() {
559 let stderr = String::from_utf8_lossy(&out.stderr);
560 if is_missing_container_error(&stderr) {
561 return Ok(None);
562 }
563 bail!("docker inspect {name} failed: {}", stderr.trim());
564 }
565 let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
566 Ok(Some(ContainerState::parse(&raw)))
567 }
568
569 pub async fn stop_and_remove(&self, name: &str, grace: Duration) -> Result<()> {
572 let grace_str = grace.as_secs().to_string();
573 let stop = self
574 .cmd()
575 .args(["stop", "-t", &grace_str, name])
576 .stdout(Stdio::null())
577 .stderr(Stdio::piped())
578 .output()
579 .await
580 .with_context(|| format!("spawning docker stop {name}"))?;
581 if !stop.status.success() {
582 let stderr = String::from_utf8_lossy(&stop.stderr);
583 if !is_missing_container_error(&stderr) {
584 bail!("docker stop {name} failed: {}", stderr.trim());
585 }
586 }
587 self.remove_container(name).await
588 }
589
590 pub async fn list_owned(&self) -> Result<Vec<OwnedContainer>> {
594 let mut owned: Vec<OwnedContainer> = Vec::new();
597 let mut seen_names: std::collections::HashSet<String> = std::collections::HashSet::new();
598
599 for (label_key, format_key) in [
600 (LABEL_KEY, LABEL_KEY),
601 (LEGACY_LABEL_KEY, LEGACY_LABEL_KEY),
602 ] {
603 let fmt = format!("{{{{.Names}}}}\t{{{{.Label \"{format_key}\"}}}}\t{{{{.State}}}}");
604 let out = self
605 .run_capture(&[
606 "ps",
607 "-a",
608 "--filter",
609 &format!("label={label_key}"),
610 "--format",
611 &fmt,
612 ])
613 .await?;
614 for line in out.lines() {
615 let mut parts = line.splitn(3, '\t');
616 let name = parts.next().unwrap_or_default().trim();
617 let label = parts.next().unwrap_or_default().trim();
618 let state = parts.next().unwrap_or_default().trim();
619 if name.is_empty() || seen_names.contains(name) {
620 continue;
621 }
622 seen_names.insert(name.to_string());
623 owned.push(OwnedContainer {
624 name: name.to_string(),
625 label: label.to_string(),
626 state: ContainerState::parse(state),
627 });
628 }
629 }
630 Ok(owned)
631 }
632}
633
634#[derive(Debug, Clone)]
636pub struct ContainerRunSpec {
637 pub name: String,
639 pub image: String,
641 pub label: String,
643 pub ports: Vec<(u16, u16)>,
645 pub env: BTreeMap<String, String>,
647 pub volumes: Vec<(PathBuf, String)>,
649 pub cmd: Vec<String>,
651 pub cap_add: Vec<String>,
655 pub cgroupns: Option<String>,
661 pub network: Option<String>,
665 pub network_aliases: Vec<String>,
669}
670
671impl ContainerRunSpec {
672 pub fn new(service: &str, env: &str, slot: &str, image: impl Into<String>) -> Self {
675 Self {
676 name: canonical_name(service, env, slot),
677 image: image.into(),
678 label: canonical_label(service, env, slot),
679 ports: vec![],
680 env: BTreeMap::new(),
681 volumes: vec![],
682 cmd: vec![],
683 cap_add: vec![],
684 cgroupns: None,
685 network: None,
686 network_aliases: vec![],
687 }
688 }
689
690 pub fn docker_run_args(&self) -> Vec<String> {
694 let mut args: Vec<String> = vec![
695 "run".into(),
696 "-d".into(),
697 "--name".into(),
698 self.name.clone(),
699 "--label".into(),
700 format!("{LABEL_KEY}={}", self.label),
701 "--restart".into(),
702 "unless-stopped".into(),
703 ];
704 if let Some(mode) = &self.cgroupns {
705 args.push(format!("--cgroupns={mode}"));
706 }
707 for cap in &self.cap_add {
708 args.push("--cap-add".into());
709 args.push(cap.clone());
710 }
711 if let Some(net) = &self.network {
712 args.push("--network".into());
713 args.push(net.clone());
714 for alias in &self.network_aliases {
715 args.push("--network-alias".into());
716 args.push(alias.clone());
717 }
718 }
719 for (host, container) in &self.ports {
720 args.push("-p".into());
721 args.push(format!("{host}:{container}"));
722 }
723 for (k, v) in &self.env {
724 args.push("-e".into());
725 args.push(format!("{k}={v}"));
726 }
727 for (host_path, container_path) in &self.volumes {
728 args.push("-v".into());
729 args.push(format!("{}:{}", host_path.display(), container_path));
730 }
731 args.push(self.image.clone());
732 args.extend(self.cmd.iter().cloned());
733 args
734 }
735}
736
737#[derive(Debug, Clone, PartialEq, Eq)]
739pub struct OwnedContainer {
740 pub name: String,
741 pub label: String,
742 pub state: ContainerState,
743}
744
745#[derive(Debug, Clone, PartialEq, Eq)]
748pub enum ContainerState {
749 Created,
750 Running,
751 Restarting,
752 Exited,
753 Paused,
754 Removing,
755 Dead,
756 Unknown(String),
757}
758
759impl ContainerState {
760 pub fn parse(s: &str) -> Self {
761 match s.trim().to_lowercase().as_str() {
762 "created" => Self::Created,
763 "running" => Self::Running,
764 "restarting" => Self::Restarting,
765 "exited" => Self::Exited,
766 "paused" => Self::Paused,
767 "removing" => Self::Removing,
768 "dead" => Self::Dead,
769 other => Self::Unknown(other.to_string()),
770 }
771 }
772
773 pub fn is_running(&self) -> bool {
774 matches!(self, Self::Running)
775 }
776}
777
778fn is_missing_container_error(stderr: &str) -> bool {
782 let lower = stderr.to_lowercase();
783 lower.contains("no such container")
784 || lower.contains("no such object")
785 || lower.contains("not found")
786}
787
788fn expand_tilde(p: &Path) -> PathBuf {
790 let s = p.to_string_lossy();
791 if let Some(rest) = s.strip_prefix("~/") {
792 if let Ok(home) = std::env::var("HOME") {
793 return PathBuf::from(home).join(rest);
794 }
795 }
796 if s == "~" {
797 if let Ok(home) = std::env::var("HOME") {
798 return PathBuf::from(home);
799 }
800 }
801 p.to_path_buf()
802}
803
804pub struct LocalDockerRuntime {
823 inner: LocalRuntime,
824}
825
826impl LocalDockerRuntime {
827 pub fn new(inner: LocalRuntime) -> Self {
828 Self { inner }
829 }
830
831 pub fn runtime(&self) -> &LocalRuntime {
834 &self.inner
835 }
836}
837
838fn workload_spec_to_crs(spec: &WorkloadSpec) -> ContainerRunSpec {
843 let image = spec.image.docker_ref();
844
845 let mut env = BTreeMap::new();
846 for e in &spec.env {
847 match &e.value {
848 EnvValue::Literal { value } => {
849 env.insert(e.name.clone(), value.clone());
850 }
851 EnvValue::FromSecret { .. } => {
852 warn!(name = %e.name, "LocalDockerRuntime: skipping FromSecret env var (no secrets layer at sim tier)");
853 }
854 EnvValue::FromMesh { .. } => {
855 warn!(name = %e.name, "LocalDockerRuntime: skipping FromMesh env var (no mesh discovery at sim tier)");
856 }
857 }
858 }
859
860 let ports: Vec<(u16, u16)> = spec.expose.mesh.ports.iter().map(|&p| (p, p)).collect();
861
862 let volumes: Vec<(PathBuf, String)> = spec
863 .volumes
864 .iter()
865 .filter_map(|v| match &v.source {
866 VolumeSource::Bind { host_path } => {
867 Some((host_path.clone(), v.target.to_string_lossy().into_owned()))
868 }
869 VolumeSource::Named { name } => {
870 warn!(volume = %name, "LocalDockerRuntime: skipping Named volume (not supported at sim tier)");
871 None
872 }
873 VolumeSource::Tmpfs { .. } => {
874 warn!("LocalDockerRuntime: skipping Tmpfs volume (use -v /dev/null for ephemeral mounts at sim tier)");
875 None
876 }
877 })
878 .collect();
879
880 ContainerRunSpec {
881 name: spec.name.clone(),
882 image,
883 label: spec.name.clone(),
884 ports,
885 env,
886 volumes,
887 cmd: spec.command.clone().unwrap_or_default(),
888 cap_add: vec![],
889 cgroupns: None,
890 network: None,
891 network_aliases: vec![],
892 }
893}
894
895#[async_trait::async_trait]
896impl WorkloadRuntime for LocalDockerRuntime {
897 async fn deploy_workload(&self, spec: &WorkloadSpec) -> anyhow::Result<String> {
898 let crs = workload_spec_to_crs(spec);
899 self.inner.ensure_image(&crs.image).await?;
900 self.inner.run(&crs).await?;
901 Ok(spec.name.clone())
902 }
903
904 async fn teardown_workload(&self, name: &str) -> anyhow::Result<()> {
905 self.inner.stop_and_remove(name, Duration::from_secs(10)).await
906 }
907
908 async fn is_running(&self, name: &str) -> anyhow::Result<bool> {
909 Ok(self
910 .inner
911 .container_state(name)
912 .await?
913 .map(|s| s.is_running())
914 .unwrap_or(false))
915 }
916
917 async fn runtime_health(&self) -> anyhow::Result<bool> {
918 let result = self
920 .inner
921 .run_capture(&["info", "--format", "{{.ServerVersion}}"])
922 .await;
923 Ok(result.is_ok())
924 }
925}
926
927#[cfg(test)]
928mod tests {
929 use super::*;
930 use std::collections::BTreeMap;
931
932 #[test]
933 fn canonical_name_format() {
934 assert_eq!(canonical_name("dev-yah", "pond", "static"), "yah-pond-dev-yah-pond-static");
935 }
936
937 #[test]
938 fn canonical_label_format() {
939 assert_eq!(canonical_label("dev-yah", "pond", "object_store"), "dev-yah:pond:object_store");
940 }
941
942 #[test]
943 fn runtime_pref_parse() {
944 assert_eq!(RuntimePref::parse("auto").unwrap(), RuntimePref::Auto);
945 assert_eq!(RuntimePref::parse("orbstack").unwrap(), RuntimePref::Orbstack);
946 assert_eq!(RuntimePref::parse("docker-desktop").unwrap(), RuntimePref::DockerDesktop);
947 assert_eq!(RuntimePref::parse("docker_desktop").unwrap(), RuntimePref::DockerDesktop);
948 assert_eq!(RuntimePref::parse("colima").unwrap(), RuntimePref::Colima);
949 assert_eq!(RuntimePref::parse("podman").unwrap(), RuntimePref::Podman);
950 assert_eq!(RuntimePref::parse("docker").unwrap(), RuntimePref::Docker);
951 assert_eq!(RuntimePref::parse("custom").unwrap(), RuntimePref::Custom);
952 let err = RuntimePref::parse("nonsense").unwrap_err().to_string();
953 assert!(err.contains("nonsense"), "error should name the bad value, got: {err}");
954 }
955
956 #[test]
957 fn detected_runtime_as_str_round_trips() {
958 assert_eq!(DetectedRuntime::Orbstack.as_str(), "orbstack");
959 assert_eq!(DetectedRuntime::DockerDesktop.as_str(), "docker-desktop");
960 assert_eq!(DetectedRuntime::Colima.as_str(), "colima");
961 assert_eq!(DetectedRuntime::Podman.as_str(), "podman");
962 assert_eq!(DetectedRuntime::Docker.as_str(), "docker");
963 assert_eq!(DetectedRuntime::Custom.as_str(), "custom");
964 }
965
966 #[tokio::test]
967 async fn detect_returns_error_when_no_socket_exists() {
968 let mut discovery = BTreeMap::new();
970 discovery.insert("orbstack".into(), PathBuf::from("/nonexistent/orbstack.sock"));
971 discovery.insert("colima".into(), PathBuf::from("/nonexistent/colima.sock"));
972 discovery.insert("docker".into(), PathBuf::from("/nonexistent/docker.sock"));
973 let spec = LocalContainerSpec { runtime: RuntimePref::Auto, discovery, custom_docker_host: None };
974 let err = LocalRuntime::detect(&spec).await.unwrap_err().to_string();
975 assert!(err.contains("no local container runtime reachable"));
976 assert!(err.contains("orbstack"));
977 assert!(err.contains("colima"));
978 assert!(err.contains("docker"));
979 }
980
981 #[tokio::test]
982 async fn detect_reports_when_pinned_runtime_has_no_discovery_entry() {
983 let spec = LocalContainerSpec {
984 runtime: RuntimePref::Colima,
985 discovery: BTreeMap::new(),
986 custom_docker_host: None,
987 };
988 let err = LocalRuntime::detect(&spec).await.unwrap_err().to_string();
989 assert!(err.contains("colima"));
990 assert!(err.contains("no discovery entry"));
991 }
992
993 #[tokio::test]
994 async fn detect_picks_existing_socket() {
995 let tmp = tempfile::TempDir::new().unwrap();
998 let fake = tmp.path().join("docker.sock");
999 std::fs::write(&fake, b"").unwrap();
1000 let mut discovery = BTreeMap::new();
1001 discovery.insert("orbstack".into(), PathBuf::from("/nonexistent/no.sock"));
1002 discovery.insert("colima".into(), PathBuf::from("/nonexistent/no.sock"));
1003 discovery.insert("docker".into(), fake.clone());
1004 let spec = LocalContainerSpec { runtime: RuntimePref::Auto, discovery, custom_docker_host: None };
1005 let runtime = LocalRuntime::detect(&spec).await.unwrap();
1006 assert_eq!(runtime.detected, DetectedRuntime::Docker);
1007 assert_eq!(runtime.docker_host, format!("unix://{}", fake.display()));
1008 }
1009
1010 #[tokio::test]
1011 async fn detect_honors_runtime_pin_and_skips_others() {
1012 let tmp = tempfile::TempDir::new().unwrap();
1015 let fake = tmp.path().join("docker.sock");
1016 std::fs::write(&fake, b"").unwrap();
1017 let mut discovery = BTreeMap::new();
1018 discovery.insert("orbstack".into(), PathBuf::from("/nonexistent/no.sock"));
1019 discovery.insert("docker".into(), fake);
1020 let spec = LocalContainerSpec { runtime: RuntimePref::Orbstack, discovery, custom_docker_host: None };
1021 let err = LocalRuntime::detect(&spec).await.unwrap_err().to_string();
1022 assert!(err.contains("orbstack"));
1023 assert!(!err.contains("docker ("), "pinned to orbstack — docker should not be in tried list: {err}");
1027 }
1028
1029 #[tokio::test]
1030 async fn detect_custom_pref_uses_host_directly() {
1031 let spec = LocalContainerSpec {
1032 runtime: RuntimePref::Custom,
1033 discovery: BTreeMap::new(),
1034 custom_docker_host: Some("tcp://localhost:2375".into()),
1035 };
1036 let runtime = LocalRuntime::detect(&spec).await.unwrap();
1037 assert_eq!(runtime.detected, DetectedRuntime::Custom);
1038 assert_eq!(runtime.docker_host, "tcp://localhost:2375");
1039 }
1040
1041 #[tokio::test]
1042 async fn detect_custom_pref_without_host_errors() {
1043 let spec = LocalContainerSpec {
1044 runtime: RuntimePref::Custom,
1045 discovery: BTreeMap::new(),
1046 custom_docker_host: None,
1047 };
1048 let err = LocalRuntime::detect(&spec).await.unwrap_err().to_string();
1049 assert!(err.contains("custom_docker_host"), "error should mention the missing field: {err}");
1050 }
1051
1052 #[tokio::test]
1053 async fn detect_auto_falls_back_to_custom_host() {
1054 let spec = LocalContainerSpec {
1056 runtime: RuntimePref::Auto,
1057 discovery: BTreeMap::new(),
1058 custom_docker_host: Some("tcp://localhost:2375".into()),
1059 };
1060 let runtime = LocalRuntime::detect(&spec).await.unwrap();
1061 assert_eq!(runtime.detected, DetectedRuntime::Custom);
1062 assert_eq!(runtime.docker_host, "tcp://localhost:2375");
1063 }
1064
1065 #[test]
1066 fn build_cascade_returns_providers_with_discovery_entries() {
1067 let mut discovery = BTreeMap::new();
1068 discovery.insert("orbstack".into(), PathBuf::from("/fake/orbstack.sock"));
1069 discovery.insert("docker".into(), PathBuf::from("/fake/docker.sock"));
1070 let spec = LocalContainerSpec { runtime: RuntimePref::Auto, discovery, custom_docker_host: None };
1071 let cascade = spec.build_cascade();
1072 assert_eq!(cascade.len(), 2);
1074 assert!(cascade.iter().any(|(k, _)| *k == DetectedRuntime::Orbstack));
1075 assert!(cascade.iter().any(|(k, _)| *k == DetectedRuntime::Docker));
1076 }
1077
1078 #[test]
1079 fn build_cascade_custom_pref_returns_single_entry() {
1080 let spec = LocalContainerSpec {
1081 runtime: RuntimePref::Custom,
1082 discovery: BTreeMap::new(),
1083 custom_docker_host: Some("tcp://localhost:2375".into()),
1084 };
1085 let cascade = spec.build_cascade();
1086 assert_eq!(cascade.len(), 1);
1087 let (kind, provider) = &cascade[0];
1088 assert_eq!(*kind, DetectedRuntime::Custom);
1089 assert_eq!(provider.docker_host(), "tcp://localhost:2375");
1090 assert!(provider.available());
1091 }
1092
1093 #[test]
1094 fn expand_tilde_replaces_home_prefix() {
1095 std::env::set_var("HOME", "/tmp/fake-home");
1096 let p = expand_tilde(Path::new("~/foo/bar"));
1097 assert_eq!(p, PathBuf::from("/tmp/fake-home/foo/bar"));
1098 }
1099
1100 #[test]
1101 fn expand_tilde_leaves_absolute_paths_alone() {
1102 let p = expand_tilde(Path::new("/var/run/docker.sock"));
1103 assert_eq!(p, PathBuf::from("/var/run/docker.sock"));
1104 }
1105
1106 #[test]
1107 fn container_state_parse_known_values() {
1108 assert_eq!(ContainerState::parse("running"), ContainerState::Running);
1109 assert_eq!(ContainerState::parse("exited"), ContainerState::Exited);
1110 assert_eq!(ContainerState::parse("PAUSED"), ContainerState::Paused);
1111 }
1112
1113 #[test]
1114 fn container_state_parse_unknown_preserves_string() {
1115 match ContainerState::parse("zombie") {
1116 ContainerState::Unknown(s) => assert_eq!(s, "zombie"),
1117 other => panic!("expected Unknown, got {other:?}"),
1118 }
1119 }
1120
1121 #[test]
1122 fn is_missing_container_error_matches_upstream_and_orbstack() {
1123 assert!(is_missing_container_error("Error: No such container: foo"));
1124 assert!(is_missing_container_error("error: no such object: foo"));
1125 assert!(is_missing_container_error("not found: foo"));
1126 assert!(!is_missing_container_error("Error response from daemon: Conflict."));
1127 assert!(!is_missing_container_error(""));
1128 }
1129
1130 #[test]
1131 fn container_run_spec_new_uses_canonical_name() {
1132 let spec = ContainerRunSpec::new("dev-yah", "pond", "static", "caddy:2-alpine");
1133 assert_eq!(spec.name, "yah-pond-dev-yah-pond-static");
1134 assert_eq!(spec.label, "dev-yah:pond:static");
1135 assert!(spec.ports.is_empty());
1136 assert!(spec.network.is_none());
1137 assert!(spec.network_aliases.is_empty());
1138 }
1139
1140 #[test]
1141 fn pond_network_name_is_yah_pond_svc_env() {
1142 assert_eq!(pond_network_name("yah-marketing", "pond"), "yah-pond-yah-marketing-pond");
1143 assert_eq!(pond_network_name("yah-dashboard", "pond"), "yah-pond-yah-dashboard-pond");
1144 }
1145
1146 #[test]
1147 fn docker_run_args_emit_network_and_aliases_when_set() {
1148 let mut spec = ContainerRunSpec::new("dev", "pond", "object_store", "minio:latest");
1149 spec.network = Some("yah-pond-dev-pond".into());
1150 spec.network_aliases = vec!["minio".into()];
1151 let args = spec.docker_run_args();
1152 let joined = args.join(" ");
1153 assert!(
1154 joined.contains("--network yah-pond-dev-pond"),
1155 "expected --network flag in: {joined}",
1156 );
1157 assert!(
1158 joined.contains("--network-alias minio"),
1159 "expected --network-alias minio in: {joined}",
1160 );
1161 }
1162
1163 #[test]
1164 fn docker_run_args_omit_network_flags_when_unset() {
1165 let spec = ContainerRunSpec::new("dev", "pond", "object_store", "minio:latest");
1166 let args = spec.docker_run_args();
1167 let joined = args.join(" ");
1168 assert!(!joined.contains("--network"), "unexpected --network flag: {joined}");
1169 assert!(!joined.contains("--network-alias"), "unexpected --network-alias flag: {joined}");
1170 }
1171
1172 #[test]
1173 fn docker_run_args_skip_aliases_when_no_network() {
1174 let mut spec = ContainerRunSpec::new("dev", "pond", "object_store", "minio:latest");
1177 spec.network_aliases = vec!["minio".into()];
1178 let args = spec.docker_run_args();
1179 let joined = args.join(" ");
1180 assert!(!joined.contains("--network-alias"), "should not emit alias without network: {joined}");
1181 }
1182
1183 fn minimal_workload_spec(name: &str) -> workload_spec::WorkloadSpec {
1186 use workload_spec::*;
1187 WorkloadSpec {
1188 schema_version: SchemaVersion::V1,
1189 name: name.to_string(),
1190 image: ImageRef {
1191 registry: "ghcr.io".into(),
1192 repository: "test/app".into(),
1193 tag: "v1.0".into(),
1194 digest: workload_spec::testing::test_digest(),
1195 },
1196 tier: TierTag("infra".into()),
1197 replicas: 1,
1198 command: None,
1199 entrypoint: None,
1200 workdir: None,
1201 user: None,
1202 env: vec![],
1203 secrets: vec![],
1204 volumes: vec![],
1205 resources: ResourceLimits { memory_mb: 256, cpu_shares: 512, ephemeral_storage_mb: 256 },
1206 depends_on: vec![],
1207 healthcheck: None,
1208 restart_policy: RestartPolicy::Always,
1209 stop_policy: StopPolicy { signal: 15, grace_period: Millis::from_secs(10) },
1210 expose: ExposeSpec {
1211 mesh: MeshExpose { identity: MeshIdent(name.into()), ports: vec![], allow_from: vec![] },
1212 public: None,
1213 operator: None,
1214 },
1215 labels: Default::default(),
1216 annotations: Default::default(),
1217 }
1218 }
1219
1220 #[test]
1221 fn workload_spec_to_crs_sets_name_and_image() {
1222 let spec = minimal_workload_spec("test-app");
1223 let crs = workload_spec_to_crs(&spec);
1224 assert_eq!(crs.name, "test-app");
1225 assert_eq!(
1227 crs.image,
1228 format!("ghcr.io/test/app:v1.0@{}", workload_spec::testing::test_digest())
1229 );
1230 assert!(crs.ports.is_empty());
1231 assert!(crs.env.is_empty());
1232 }
1233
1234 #[test]
1235 fn workload_spec_to_crs_forwards_literal_env_only() {
1236 use workload_spec::{EnvValue, EnvVar, MeshIdent, MeshLookup};
1237 let mut spec = minimal_workload_spec("env-test");
1238 spec.env = vec![
1239 EnvVar { name: "GOOD".into(), value: EnvValue::Literal { value: "yes".into() } },
1240 EnvVar { name: "BAD_SECRET".into(), value: EnvValue::FromSecret { secret: "s".into(), key: "k".into() } },
1241 EnvVar { name: "BAD_MESH".into(), value: EnvValue::FromMesh { ident: MeshIdent("x".into()), kind: MeshLookup::Url } },
1242 ];
1243 let crs = workload_spec_to_crs(&spec);
1244 assert_eq!(crs.env.get("GOOD").map(String::as_str), Some("yes"));
1245 assert!(!crs.env.contains_key("BAD_SECRET"), "FromSecret must be filtered out");
1246 assert!(!crs.env.contains_key("BAD_MESH"), "FromMesh must be filtered out");
1247 }
1248
1249 #[test]
1250 fn workload_spec_to_crs_maps_mesh_ports() {
1251 let mut spec = minimal_workload_spec("port-test");
1252 spec.expose.mesh.ports = vec![8080, 9000];
1253 let crs = workload_spec_to_crs(&spec);
1254 assert_eq!(crs.ports, vec![(8080, 8080), (9000, 9000)]);
1255 }
1256
1257 #[test]
1258 fn workload_spec_to_crs_applies_command_override() {
1259 let mut spec = minimal_workload_spec("cmd-test");
1260 spec.command = Some(vec!["server".into(), "--port=8080".into()]);
1261 let crs = workload_spec_to_crs(&spec);
1262 assert_eq!(crs.cmd, vec!["server", "--port=8080"]);
1263 }
1264
1265 #[test]
1266 fn image_ref_docker_ref_emits_tag_and_digest() {
1267 use workload_spec::ImageRef;
1268 let r = ImageRef {
1269 registry: "ghcr.io".into(),
1270 repository: "org/app".into(),
1271 tag: "v1.0".into(),
1272 digest: "sha256:abc123".into(),
1273 };
1274 assert_eq!(r.docker_ref(), "ghcr.io/org/app:v1.0@sha256:abc123");
1275 }
1276
1277 }