Skip to main content

cloud/
local_driver_glue.rs

1//! Adapter between cloud's TOML-driven `ProviderConfig` shape and
2//! [`local_driver::LocalContainerSpec`].
3//!
4//! Lives in the cloud crate (rather than `local-driver`) so the driver crate
5//! stays decoupled from cloud's config types. Callers in cloud + yubaba that
6//! need a [`LocalContainerSpec`] from a `kind = "local-container"` provider
7//! TOML go through [`local_container_spec_from_provider`].
8
9use std::collections::BTreeMap;
10use std::path::PathBuf;
11
12use anyhow::{bail, Context, Result};
13use local_driver::{LocalContainerSpec, RuntimePref};
14
15use crate::config::{Provider, ProviderConfig};
16
17/// Parse a `kind = "local-container"` [`ProviderConfig`] into a
18/// [`LocalContainerSpec`]. Rejects any other provider kind so the caller hits
19/// a clean error rather than a confusing socket-missing message downstream.
20pub fn local_container_spec_from_provider(p: &ProviderConfig) -> Result<LocalContainerSpec> {
21    if !matches!(p.kind, Provider::LocalContainer) {
22        bail!(
23            "provider {id} has kind={kind:?}; LocalContainerSpec requires kind=\"local-container\"",
24            id = p.id,
25            kind = p.kind,
26        );
27    }
28    let runtime = match p.fields.get("runtime").and_then(|v| v.as_str()) {
29        Some(s) => RuntimePref::parse(s)?,
30        None => RuntimePref::Auto,
31    };
32    let mut discovery = BTreeMap::new();
33    if let Some(table) = p.fields.get("discovery").and_then(|v| v.as_table()) {
34        for (k, v) in table.iter() {
35            let s = v
36                .as_str()
37                .with_context(|| format!("provider {}: discovery.{k} must be a string", p.id))?;
38            discovery.insert(k.clone(), PathBuf::from(s));
39        }
40    }
41    let custom_docker_host = p
42        .fields
43        .get("custom_docker_host")
44        .and_then(|v| v.as_str())
45        .map(|s| s.to_string());
46    Ok(LocalContainerSpec {
47        runtime,
48        discovery,
49        custom_docker_host,
50    })
51}
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56    use crate::config::{Provider, ProviderConfig};
57    use local_driver::{
58        canonical_label, canonical_name, ContainerRunSpec, ContainerState, DetectedRuntime,
59        LocalRuntime,
60    };
61    use std::collections::BTreeMap;
62    use std::path::PathBuf;
63    use std::time::Duration;
64
65    fn provider_orbstack_toml() -> ProviderConfig {
66        let src = r#"
67schema_version = 1
68id = "orbstack"
69kind = "local-container"
70runtime = "auto"
71
72[discovery]
73orbstack = "~/.orbstack/run/docker.sock"
74colima   = "~/.colima/default/docker.sock"
75docker   = "/var/run/docker.sock"
76"#;
77        toml::from_str(src).unwrap()
78    }
79
80    #[test]
81    fn spec_from_provider_config_picks_up_runtime_and_discovery() {
82        let p = provider_orbstack_toml();
83        let spec = local_container_spec_from_provider(&p).unwrap();
84        assert_eq!(spec.runtime, RuntimePref::Auto);
85        assert_eq!(spec.discovery.len(), 3);
86        assert!(spec.discovery.contains_key("orbstack"));
87        assert!(spec.discovery.contains_key("colima"));
88        assert!(spec.discovery.contains_key("docker"));
89        assert!(spec.custom_docker_host.is_none());
90    }
91
92    #[test]
93    fn spec_from_provider_config_defaults_runtime_to_auto() {
94        let src = r#"
95schema_version = 1
96id = "orbstack"
97kind = "local-container"
98"#;
99        let p: ProviderConfig = toml::from_str(src).unwrap();
100        let spec = local_container_spec_from_provider(&p).unwrap();
101        assert_eq!(spec.runtime, RuntimePref::Auto);
102        assert!(spec.discovery.is_empty(), "no discovery table → empty map");
103        assert!(spec.custom_docker_host.is_none());
104    }
105
106    #[test]
107    fn spec_from_provider_config_parses_custom_docker_host() {
108        let src = r#"
109schema_version = 1
110id = "custom"
111kind = "local-container"
112runtime = "custom"
113custom_docker_host = "tcp://localhost:2375"
114"#;
115        let p: ProviderConfig = toml::from_str(src).unwrap();
116        let spec = local_container_spec_from_provider(&p).unwrap();
117        assert_eq!(spec.runtime, RuntimePref::Custom);
118        assert_eq!(
119            spec.custom_docker_host.as_deref(),
120            Some("tcp://localhost:2375")
121        );
122    }
123
124    #[test]
125    fn spec_rejects_non_local_container_provider() {
126        let p = ProviderConfig {
127            schema_version: 1,
128            id: "cloudflare".into(),
129            kind: Provider::Cloudflare,
130            credentials: None,
131            fields: BTreeMap::new(),
132        };
133        let err = local_container_spec_from_provider(&p)
134            .unwrap_err()
135            .to_string();
136        assert!(
137            err.contains("Cloudflare"),
138            "error should name the wrong kind, got: {err}"
139        );
140    }
141
142    #[test]
143    fn spec_rejects_bad_runtime_value() {
144        let src = r#"
145schema_version = 1
146id = "orbstack"
147kind = "local-container"
148runtime = "nonsense-runtime"
149"#;
150        let p: ProviderConfig = toml::from_str(src).unwrap();
151        let err = local_container_spec_from_provider(&p)
152            .unwrap_err()
153            .to_string();
154        assert!(
155            err.contains("nonsense-runtime"),
156            "error should name the bad value, got: {err}"
157        );
158    }
159
160    #[test]
161    fn spec_rejects_non_string_discovery_value() {
162        let src = r#"
163schema_version = 1
164id = "orbstack"
165kind = "local-container"
166
167[discovery]
168orbstack = 42
169"#;
170        let p: ProviderConfig = toml::from_str(src).unwrap();
171        let err = local_container_spec_from_provider(&p)
172            .unwrap_err()
173            .to_string();
174        assert!(
175            err.contains("orbstack"),
176            "error should name the bad key, got: {err}"
177        );
178    }
179
180    // ── live integration tests (require a reachable docker socket) ───────────
181
182    async fn detect_live() -> Option<LocalRuntime> {
183        let p = provider_orbstack_toml();
184        let spec = local_container_spec_from_provider(&p).ok()?;
185        LocalRuntime::detect(&spec).await.ok()
186    }
187
188    #[tokio::test]
189    #[ignore = "requires docker socket (orbstack/colima/docker running)"]
190    async fn image_cache_skip_pull_when_present() {
191        let Some(runtime) = detect_live().await else {
192            eprintln!("SKIP: no docker socket reachable");
193            return;
194        };
195        let pulled_first = runtime.ensure_image("hello-world:latest").await.unwrap();
196        let pulled_second = runtime.ensure_image("hello-world:latest").await.unwrap();
197        assert!(
198            !pulled_second,
199            "second ensure_image should skip the pull (cache hit); pulled_first={pulled_first}",
200        );
201    }
202
203    #[tokio::test]
204    #[ignore = "requires docker socket"]
205    async fn run_stop_lifecycle_is_idempotent() {
206        let Some(runtime) = detect_live().await else {
207            eprintln!("SKIP: no docker socket reachable");
208            return;
209        };
210        runtime.ensure_image("alpine:3.20").await.unwrap();
211        let spec = ContainerRunSpec {
212            name: canonical_name("itest", "test", "sleeper"),
213            image: "alpine:3.20".into(),
214            label: canonical_label("itest", "test", "sleeper"),
215            ports: vec![],
216            env: BTreeMap::new(),
217            volumes: vec![],
218            cmd: vec!["sleep".into(), "30".into()],
219            cap_add: vec![],
220            cgroupns: None,
221            network: None,
222            network_aliases: vec![],
223            extra_hosts: vec![],
224        };
225        runtime.run(&spec).await.unwrap();
226        let state = runtime.container_state(&spec.name).await.unwrap();
227        assert_eq!(state, Some(ContainerState::Running));
228        runtime.run(&spec).await.unwrap();
229        let state = runtime.container_state(&spec.name).await.unwrap();
230        assert_eq!(state, Some(ContainerState::Running));
231        let owned = runtime.list_owned().await.unwrap();
232        assert!(owned.iter().any(|c| c.name == spec.name));
233        runtime
234            .stop_and_remove(&spec.name, Duration::from_secs(2))
235            .await
236            .unwrap();
237        let state = runtime.container_state(&spec.name).await.unwrap();
238        assert_eq!(state, None);
239        // Path-only fixture suppresses "unused" warning on platforms without a socket.
240        let _ = PathBuf::new();
241        let _ = DetectedRuntime::Orbstack;
242    }
243}