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#[async_trait::async_trait]
669pub trait ContainerLauncher: Send + Sync {
670 async fn ensure_image(&self, image: &str) -> Result<bool>;
672
673 async fn run(&self, spec: &ContainerRunSpec) -> Result<()>;
676
677 async fn stop_and_remove(&self, name: &str, grace: Duration) -> Result<()>;
679}
680
681#[async_trait::async_trait]
682impl ContainerLauncher for LocalRuntime {
683 async fn ensure_image(&self, image: &str) -> Result<bool> {
684 LocalRuntime::ensure_image(self, image).await
685 }
686
687 async fn run(&self, spec: &ContainerRunSpec) -> Result<()> {
688 LocalRuntime::run(self, spec).await
689 }
690
691 async fn stop_and_remove(&self, name: &str, grace: Duration) -> Result<()> {
692 LocalRuntime::stop_and_remove(self, name, grace).await
693 }
694}
695
696#[async_trait::async_trait]
700impl<T: ContainerLauncher + ?Sized> ContainerLauncher for std::sync::Arc<T> {
701 async fn ensure_image(&self, image: &str) -> Result<bool> {
702 (**self).ensure_image(image).await
703 }
704
705 async fn run(&self, spec: &ContainerRunSpec) -> Result<()> {
706 (**self).run(spec).await
707 }
708
709 async fn stop_and_remove(&self, name: &str, grace: Duration) -> Result<()> {
710 (**self).stop_and_remove(name, grace).await
711 }
712}
713
714#[derive(Debug, Clone)]
716pub struct ContainerRunSpec {
717 pub name: String,
719 pub image: String,
721 pub label: String,
723 pub ports: Vec<(u16, u16)>,
725 pub env: BTreeMap<String, String>,
727 pub volumes: Vec<(PathBuf, String)>,
729 pub cmd: Vec<String>,
731 pub cap_add: Vec<String>,
735 pub cgroupns: Option<String>,
741 pub network: Option<String>,
745 pub network_aliases: Vec<String>,
749 pub extra_hosts: Vec<String>,
756}
757
758impl ContainerRunSpec {
759 pub fn new(service: &str, env: &str, slot: &str, image: impl Into<String>) -> Self {
762 Self {
763 name: canonical_name(service, env, slot),
764 image: image.into(),
765 label: canonical_label(service, env, slot),
766 ports: vec![],
767 env: BTreeMap::new(),
768 volumes: vec![],
769 cmd: vec![],
770 cap_add: vec![],
771 cgroupns: None,
772 network: None,
773 network_aliases: vec![],
774 extra_hosts: vec![],
775 }
776 }
777
778 pub fn docker_run_args(&self) -> Vec<String> {
782 let mut args: Vec<String> = vec![
783 "run".into(),
784 "-d".into(),
785 "--name".into(),
786 self.name.clone(),
787 "--label".into(),
788 format!("{LABEL_KEY}={}", self.label),
789 "--restart".into(),
790 "unless-stopped".into(),
791 ];
792 if let Some(mode) = &self.cgroupns {
793 args.push(format!("--cgroupns={mode}"));
794 }
795 for cap in &self.cap_add {
796 args.push("--cap-add".into());
797 args.push(cap.clone());
798 }
799 if let Some(net) = &self.network {
800 args.push("--network".into());
801 args.push(net.clone());
802 for alias in &self.network_aliases {
803 args.push("--network-alias".into());
804 args.push(alias.clone());
805 }
806 }
807 for entry in &self.extra_hosts {
808 args.push("--add-host".into());
809 args.push(entry.clone());
810 }
811 for (host, container) in &self.ports {
812 args.push("-p".into());
813 args.push(format!("{host}:{container}"));
814 }
815 for (k, v) in &self.env {
816 args.push("-e".into());
817 args.push(format!("{k}={v}"));
818 }
819 for (host_path, container_path) in &self.volumes {
820 args.push("-v".into());
821 args.push(format!("{}:{}", host_path.display(), container_path));
822 }
823 args.push(self.image.clone());
824 args.extend(self.cmd.iter().cloned());
825 args
826 }
827}
828
829#[derive(Debug, Clone, PartialEq, Eq)]
831pub struct OwnedContainer {
832 pub name: String,
833 pub label: String,
834 pub state: ContainerState,
835}
836
837#[derive(Debug, Clone, PartialEq, Eq)]
840pub enum ContainerState {
841 Created,
842 Running,
843 Restarting,
844 Exited,
845 Paused,
846 Removing,
847 Dead,
848 Unknown(String),
849}
850
851impl ContainerState {
852 pub fn parse(s: &str) -> Self {
853 match s.trim().to_lowercase().as_str() {
854 "created" => Self::Created,
855 "running" => Self::Running,
856 "restarting" => Self::Restarting,
857 "exited" => Self::Exited,
858 "paused" => Self::Paused,
859 "removing" => Self::Removing,
860 "dead" => Self::Dead,
861 other => Self::Unknown(other.to_string()),
862 }
863 }
864
865 pub fn is_running(&self) -> bool {
866 matches!(self, Self::Running)
867 }
868}
869
870fn is_missing_container_error(stderr: &str) -> bool {
874 let lower = stderr.to_lowercase();
875 lower.contains("no such container")
876 || lower.contains("no such object")
877 || lower.contains("not found")
878}
879
880fn expand_tilde(p: &Path) -> PathBuf {
882 let s = p.to_string_lossy();
883 if let Some(rest) = s.strip_prefix("~/") {
884 if let Ok(home) = std::env::var("HOME") {
885 return PathBuf::from(home).join(rest);
886 }
887 }
888 if s == "~" {
889 if let Ok(home) = std::env::var("HOME") {
890 return PathBuf::from(home);
891 }
892 }
893 p.to_path_buf()
894}
895
896pub struct LocalDockerRuntime {
915 inner: LocalRuntime,
916}
917
918impl LocalDockerRuntime {
919 pub fn new(inner: LocalRuntime) -> Self {
920 Self { inner }
921 }
922
923 pub fn runtime(&self) -> &LocalRuntime {
926 &self.inner
927 }
928}
929
930fn workload_spec_to_crs(spec: &WorkloadSpec) -> ContainerRunSpec {
935 let image = spec.image.docker_ref();
936
937 let mut env = BTreeMap::new();
938 for e in &spec.env {
939 match &e.value {
940 EnvValue::Literal { value } => {
941 env.insert(e.name.clone(), value.clone());
942 }
943 EnvValue::FromSecret { .. } => {
944 warn!(name = %e.name, "LocalDockerRuntime: skipping FromSecret env var (no secrets layer at sim tier)");
945 }
946 EnvValue::FromMesh { .. } => {
947 warn!(name = %e.name, "LocalDockerRuntime: skipping FromMesh env var (no mesh discovery at sim tier)");
948 }
949 }
950 }
951
952 let ports: Vec<(u16, u16)> = spec.expose.mesh.ports.iter().map(|&p| (p, p)).collect();
953
954 let volumes: Vec<(PathBuf, String)> = spec
955 .volumes
956 .iter()
957 .filter_map(|v| match &v.source {
958 VolumeSource::Bind { host_path } => {
959 Some((host_path.clone(), v.target.to_string_lossy().into_owned()))
960 }
961 VolumeSource::Named { name } => {
962 warn!(volume = %name, "LocalDockerRuntime: skipping Named volume (not supported at sim tier)");
963 None
964 }
965 VolumeSource::Tmpfs { .. } => {
966 warn!("LocalDockerRuntime: skipping Tmpfs volume (use -v /dev/null for ephemeral mounts at sim tier)");
967 None
968 }
969 })
970 .collect();
971
972 ContainerRunSpec {
973 name: spec.name.clone(),
974 image,
975 label: spec.name.clone(),
976 ports,
977 env,
978 volumes,
979 cmd: spec.command.clone().unwrap_or_default(),
980 cap_add: vec![],
981 cgroupns: None,
982 network: None,
983 network_aliases: vec![],
984 extra_hosts: vec![],
985 }
986}
987
988#[async_trait::async_trait]
989impl WorkloadRuntime for LocalDockerRuntime {
990 async fn deploy_workload(&self, spec: &WorkloadSpec) -> anyhow::Result<String> {
991 let crs = workload_spec_to_crs(spec);
992 self.inner.ensure_image(&crs.image).await?;
993 self.inner.run(&crs).await?;
994 Ok(spec.name.clone())
995 }
996
997 async fn teardown_workload(&self, name: &str) -> anyhow::Result<()> {
998 self.inner.stop_and_remove(name, Duration::from_secs(10)).await
999 }
1000
1001 async fn is_running(&self, name: &str) -> anyhow::Result<bool> {
1002 Ok(self
1003 .inner
1004 .container_state(name)
1005 .await?
1006 .map(|s| s.is_running())
1007 .unwrap_or(false))
1008 }
1009
1010 async fn runtime_health(&self) -> anyhow::Result<bool> {
1011 let result = self
1013 .inner
1014 .run_capture(&["info", "--format", "{{.ServerVersion}}"])
1015 .await;
1016 Ok(result.is_ok())
1017 }
1018}
1019
1020#[cfg(test)]
1021mod tests {
1022 use super::*;
1023 use std::collections::BTreeMap;
1024
1025 #[test]
1026 fn canonical_name_format() {
1027 assert_eq!(canonical_name("dev-yah", "pond", "static"), "yah-pond-dev-yah-pond-static");
1028 }
1029
1030 #[test]
1031 fn canonical_label_format() {
1032 assert_eq!(canonical_label("dev-yah", "pond", "object_store"), "dev-yah:pond:object_store");
1033 }
1034
1035 #[test]
1036 fn runtime_pref_parse() {
1037 assert_eq!(RuntimePref::parse("auto").unwrap(), RuntimePref::Auto);
1038 assert_eq!(RuntimePref::parse("orbstack").unwrap(), RuntimePref::Orbstack);
1039 assert_eq!(RuntimePref::parse("docker-desktop").unwrap(), RuntimePref::DockerDesktop);
1040 assert_eq!(RuntimePref::parse("docker_desktop").unwrap(), RuntimePref::DockerDesktop);
1041 assert_eq!(RuntimePref::parse("colima").unwrap(), RuntimePref::Colima);
1042 assert_eq!(RuntimePref::parse("podman").unwrap(), RuntimePref::Podman);
1043 assert_eq!(RuntimePref::parse("docker").unwrap(), RuntimePref::Docker);
1044 assert_eq!(RuntimePref::parse("custom").unwrap(), RuntimePref::Custom);
1045 let err = RuntimePref::parse("nonsense").unwrap_err().to_string();
1046 assert!(err.contains("nonsense"), "error should name the bad value, got: {err}");
1047 }
1048
1049 #[test]
1050 fn detected_runtime_as_str_round_trips() {
1051 assert_eq!(DetectedRuntime::Orbstack.as_str(), "orbstack");
1052 assert_eq!(DetectedRuntime::DockerDesktop.as_str(), "docker-desktop");
1053 assert_eq!(DetectedRuntime::Colima.as_str(), "colima");
1054 assert_eq!(DetectedRuntime::Podman.as_str(), "podman");
1055 assert_eq!(DetectedRuntime::Docker.as_str(), "docker");
1056 assert_eq!(DetectedRuntime::Custom.as_str(), "custom");
1057 }
1058
1059 #[tokio::test]
1060 async fn detect_returns_error_when_no_socket_exists() {
1061 let mut discovery = BTreeMap::new();
1063 discovery.insert("orbstack".into(), PathBuf::from("/nonexistent/orbstack.sock"));
1064 discovery.insert("colima".into(), PathBuf::from("/nonexistent/colima.sock"));
1065 discovery.insert("docker".into(), PathBuf::from("/nonexistent/docker.sock"));
1066 let spec = LocalContainerSpec { runtime: RuntimePref::Auto, discovery, custom_docker_host: None };
1067 let err = LocalRuntime::detect(&spec).await.unwrap_err().to_string();
1068 assert!(err.contains("no local container runtime reachable"));
1069 assert!(err.contains("orbstack"));
1070 assert!(err.contains("colima"));
1071 assert!(err.contains("docker"));
1072 }
1073
1074 #[tokio::test]
1075 async fn detect_reports_when_pinned_runtime_has_no_discovery_entry() {
1076 let spec = LocalContainerSpec {
1077 runtime: RuntimePref::Colima,
1078 discovery: BTreeMap::new(),
1079 custom_docker_host: None,
1080 };
1081 let err = LocalRuntime::detect(&spec).await.unwrap_err().to_string();
1082 assert!(err.contains("colima"));
1083 assert!(err.contains("no discovery entry"));
1084 }
1085
1086 #[tokio::test]
1087 async fn detect_picks_existing_socket() {
1088 let tmp = tempfile::TempDir::new().unwrap();
1091 let fake = tmp.path().join("docker.sock");
1092 std::fs::write(&fake, b"").unwrap();
1093 let mut discovery = BTreeMap::new();
1094 discovery.insert("orbstack".into(), PathBuf::from("/nonexistent/no.sock"));
1095 discovery.insert("colima".into(), PathBuf::from("/nonexistent/no.sock"));
1096 discovery.insert("docker".into(), fake.clone());
1097 let spec = LocalContainerSpec { runtime: RuntimePref::Auto, discovery, custom_docker_host: None };
1098 let runtime = LocalRuntime::detect(&spec).await.unwrap();
1099 assert_eq!(runtime.detected, DetectedRuntime::Docker);
1100 assert_eq!(runtime.docker_host, format!("unix://{}", fake.display()));
1101 }
1102
1103 #[tokio::test]
1104 async fn detect_honors_runtime_pin_and_skips_others() {
1105 let tmp = tempfile::TempDir::new().unwrap();
1108 let fake = tmp.path().join("docker.sock");
1109 std::fs::write(&fake, b"").unwrap();
1110 let mut discovery = BTreeMap::new();
1111 discovery.insert("orbstack".into(), PathBuf::from("/nonexistent/no.sock"));
1112 discovery.insert("docker".into(), fake);
1113 let spec = LocalContainerSpec { runtime: RuntimePref::Orbstack, discovery, custom_docker_host: None };
1114 let err = LocalRuntime::detect(&spec).await.unwrap_err().to_string();
1115 assert!(err.contains("orbstack"));
1116 assert!(!err.contains("docker ("), "pinned to orbstack — docker should not be in tried list: {err}");
1120 }
1121
1122 #[tokio::test]
1123 async fn detect_custom_pref_uses_host_directly() {
1124 let spec = LocalContainerSpec {
1125 runtime: RuntimePref::Custom,
1126 discovery: BTreeMap::new(),
1127 custom_docker_host: Some("tcp://localhost:2375".into()),
1128 };
1129 let runtime = LocalRuntime::detect(&spec).await.unwrap();
1130 assert_eq!(runtime.detected, DetectedRuntime::Custom);
1131 assert_eq!(runtime.docker_host, "tcp://localhost:2375");
1132 }
1133
1134 #[tokio::test]
1135 async fn detect_custom_pref_without_host_errors() {
1136 let spec = LocalContainerSpec {
1137 runtime: RuntimePref::Custom,
1138 discovery: BTreeMap::new(),
1139 custom_docker_host: None,
1140 };
1141 let err = LocalRuntime::detect(&spec).await.unwrap_err().to_string();
1142 assert!(err.contains("custom_docker_host"), "error should mention the missing field: {err}");
1143 }
1144
1145 #[tokio::test]
1146 async fn detect_auto_falls_back_to_custom_host() {
1147 let spec = LocalContainerSpec {
1149 runtime: RuntimePref::Auto,
1150 discovery: BTreeMap::new(),
1151 custom_docker_host: Some("tcp://localhost:2375".into()),
1152 };
1153 let runtime = LocalRuntime::detect(&spec).await.unwrap();
1154 assert_eq!(runtime.detected, DetectedRuntime::Custom);
1155 assert_eq!(runtime.docker_host, "tcp://localhost:2375");
1156 }
1157
1158 #[test]
1159 fn build_cascade_returns_providers_with_discovery_entries() {
1160 let mut discovery = BTreeMap::new();
1161 discovery.insert("orbstack".into(), PathBuf::from("/fake/orbstack.sock"));
1162 discovery.insert("docker".into(), PathBuf::from("/fake/docker.sock"));
1163 let spec = LocalContainerSpec { runtime: RuntimePref::Auto, discovery, custom_docker_host: None };
1164 let cascade = spec.build_cascade();
1165 assert_eq!(cascade.len(), 2);
1167 assert!(cascade.iter().any(|(k, _)| *k == DetectedRuntime::Orbstack));
1168 assert!(cascade.iter().any(|(k, _)| *k == DetectedRuntime::Docker));
1169 }
1170
1171 #[test]
1172 fn build_cascade_custom_pref_returns_single_entry() {
1173 let spec = LocalContainerSpec {
1174 runtime: RuntimePref::Custom,
1175 discovery: BTreeMap::new(),
1176 custom_docker_host: Some("tcp://localhost:2375".into()),
1177 };
1178 let cascade = spec.build_cascade();
1179 assert_eq!(cascade.len(), 1);
1180 let (kind, provider) = &cascade[0];
1181 assert_eq!(*kind, DetectedRuntime::Custom);
1182 assert_eq!(provider.docker_host(), "tcp://localhost:2375");
1183 assert!(provider.available());
1184 }
1185
1186 #[test]
1187 fn expand_tilde_replaces_home_prefix() {
1188 std::env::set_var("HOME", "/tmp/fake-home");
1189 let p = expand_tilde(Path::new("~/foo/bar"));
1190 assert_eq!(p, PathBuf::from("/tmp/fake-home/foo/bar"));
1191 }
1192
1193 #[test]
1194 fn expand_tilde_leaves_absolute_paths_alone() {
1195 let p = expand_tilde(Path::new("/var/run/docker.sock"));
1196 assert_eq!(p, PathBuf::from("/var/run/docker.sock"));
1197 }
1198
1199 #[test]
1200 fn container_state_parse_known_values() {
1201 assert_eq!(ContainerState::parse("running"), ContainerState::Running);
1202 assert_eq!(ContainerState::parse("exited"), ContainerState::Exited);
1203 assert_eq!(ContainerState::parse("PAUSED"), ContainerState::Paused);
1204 }
1205
1206 #[test]
1207 fn container_state_parse_unknown_preserves_string() {
1208 match ContainerState::parse("zombie") {
1209 ContainerState::Unknown(s) => assert_eq!(s, "zombie"),
1210 other => panic!("expected Unknown, got {other:?}"),
1211 }
1212 }
1213
1214 #[test]
1215 fn is_missing_container_error_matches_upstream_and_orbstack() {
1216 assert!(is_missing_container_error("Error: No such container: foo"));
1217 assert!(is_missing_container_error("error: no such object: foo"));
1218 assert!(is_missing_container_error("not found: foo"));
1219 assert!(!is_missing_container_error("Error response from daemon: Conflict."));
1220 assert!(!is_missing_container_error(""));
1221 }
1222
1223 #[test]
1224 fn container_run_spec_new_uses_canonical_name() {
1225 let spec = ContainerRunSpec::new("dev-yah", "pond", "static", "caddy:2-alpine");
1226 assert_eq!(spec.name, "yah-pond-dev-yah-pond-static");
1227 assert_eq!(spec.label, "dev-yah:pond:static");
1228 assert!(spec.ports.is_empty());
1229 assert!(spec.network.is_none());
1230 assert!(spec.network_aliases.is_empty());
1231 }
1232
1233 #[test]
1234 fn pond_network_name_is_yah_pond_svc_env() {
1235 assert_eq!(pond_network_name("yah-marketing", "pond"), "yah-pond-yah-marketing-pond");
1236 assert_eq!(pond_network_name("yah-dashboard", "pond"), "yah-pond-yah-dashboard-pond");
1237 }
1238
1239 #[test]
1240 fn docker_run_args_emit_network_and_aliases_when_set() {
1241 let mut spec = ContainerRunSpec::new("dev", "pond", "object_store", "minio:latest");
1242 spec.network = Some("yah-pond-dev-pond".into());
1243 spec.network_aliases = vec!["minio".into()];
1244 let args = spec.docker_run_args();
1245 let joined = args.join(" ");
1246 assert!(
1247 joined.contains("--network yah-pond-dev-pond"),
1248 "expected --network flag in: {joined}",
1249 );
1250 assert!(
1251 joined.contains("--network-alias minio"),
1252 "expected --network-alias minio in: {joined}",
1253 );
1254 }
1255
1256 #[test]
1257 fn docker_run_args_omit_network_flags_when_unset() {
1258 let spec = ContainerRunSpec::new("dev", "pond", "object_store", "minio:latest");
1259 let args = spec.docker_run_args();
1260 let joined = args.join(" ");
1261 assert!(!joined.contains("--network"), "unexpected --network flag: {joined}");
1262 assert!(!joined.contains("--network-alias"), "unexpected --network-alias flag: {joined}");
1263 }
1264
1265 #[test]
1266 fn docker_run_args_skip_aliases_when_no_network() {
1267 let mut spec = ContainerRunSpec::new("dev", "pond", "object_store", "minio:latest");
1270 spec.network_aliases = vec!["minio".into()];
1271 let args = spec.docker_run_args();
1272 let joined = args.join(" ");
1273 assert!(!joined.contains("--network-alias"), "should not emit alias without network: {joined}");
1274 }
1275
1276 fn minimal_workload_spec(name: &str) -> workload_spec::WorkloadSpec {
1279 use workload_spec::*;
1280 WorkloadSpec {
1281 schema_version: SchemaVersion::V1,
1282 name: name.to_string(),
1283 image: ImageRef {
1284 registry: "ghcr.io".into(),
1285 repository: "test/app".into(),
1286 tag: "v1.0".into(),
1287 digest: workload_spec::testing::test_digest(),
1288 },
1289 tier: TierTag("infra".into()),
1290 tenant: workload_spec::TenantId::singleton(),
1291 namespace: workload_spec::NamespaceId::singleton(),
1292 replicas: 1,
1293 command: None,
1294 entrypoint: None,
1295 workdir: None,
1296 user: None,
1297 env: vec![],
1298 secrets: vec![],
1299 volumes: vec![],
1300 resources: ResourceLimits { memory_mb: 256, cpu_millis: 512, ephemeral_storage_mb: 256 },
1301 depends_on: vec![],
1302 healthcheck: None,
1303 restart_policy: RestartPolicy::Always,
1304 archetype: None,
1305 stop_policy: StopPolicy { signal: 15, grace_period: Millis::from_secs(10) },
1306 expose: ExposeSpec {
1307 mesh: MeshExpose { identity: MeshIdent(name.into()), ports: vec![], allow_from: vec![] },
1308 public: None,
1309 operator: None,
1310 },
1311 labels: Default::default(),
1312 annotations: Default::default(),
1313 }
1314 }
1315
1316 #[test]
1317 fn workload_spec_to_crs_sets_name_and_image() {
1318 let spec = minimal_workload_spec("test-app");
1319 let crs = workload_spec_to_crs(&spec);
1320 assert_eq!(crs.name, "test-app");
1321 assert_eq!(
1323 crs.image,
1324 format!("ghcr.io/test/app:v1.0@{}", workload_spec::testing::test_digest())
1325 );
1326 assert!(crs.ports.is_empty());
1327 assert!(crs.env.is_empty());
1328 }
1329
1330 #[test]
1331 fn workload_spec_to_crs_forwards_literal_env_only() {
1332 use workload_spec::{EnvValue, EnvVar, MeshIdent, MeshLookup};
1333 let mut spec = minimal_workload_spec("env-test");
1334 spec.env = vec![
1335 EnvVar { name: "GOOD".into(), value: EnvValue::Literal { value: "yes".into() } },
1336 EnvVar { name: "BAD_SECRET".into(), value: EnvValue::FromSecret { secret: "s".into(), key: "k".into() } },
1337 EnvVar { name: "BAD_MESH".into(), value: EnvValue::FromMesh { ident: MeshIdent("x".into()), kind: MeshLookup::Url } },
1338 ];
1339 let crs = workload_spec_to_crs(&spec);
1340 assert_eq!(crs.env.get("GOOD").map(String::as_str), Some("yes"));
1341 assert!(!crs.env.contains_key("BAD_SECRET"), "FromSecret must be filtered out");
1342 assert!(!crs.env.contains_key("BAD_MESH"), "FromMesh must be filtered out");
1343 }
1344
1345 #[test]
1346 fn workload_spec_to_crs_maps_mesh_ports() {
1347 let mut spec = minimal_workload_spec("port-test");
1348 spec.expose.mesh.ports = vec![8080, 9000];
1349 let crs = workload_spec_to_crs(&spec);
1350 assert_eq!(crs.ports, vec![(8080, 8080), (9000, 9000)]);
1351 }
1352
1353 #[test]
1354 fn workload_spec_to_crs_applies_command_override() {
1355 let mut spec = minimal_workload_spec("cmd-test");
1356 spec.command = Some(vec!["server".into(), "--port=8080".into()]);
1357 let crs = workload_spec_to_crs(&spec);
1358 assert_eq!(crs.cmd, vec!["server", "--port=8080"]);
1359 }
1360
1361 #[test]
1362 fn image_ref_docker_ref_emits_tag_and_digest() {
1363 use workload_spec::ImageRef;
1364 let r = ImageRef {
1365 registry: "ghcr.io".into(),
1366 repository: "org/app".into(),
1367 tag: "v1.0".into(),
1368 digest: "sha256:abc123".into(),
1369 };
1370 assert_eq!(r.docker_ref(), "ghcr.io/org/app:v1.0@sha256:abc123");
1371 }
1372
1373 }