Skip to main content

opal/
config.rs

1use crate::{EngineChoice, EngineKind, runtime};
2use anyhow::{Context, Result, anyhow};
3use serde::Deserialize;
4use std::collections::HashSet;
5use std::env;
6use std::fs;
7use std::path::Path;
8
9#[derive(Debug, Clone, Default, Deserialize)]
10#[serde(default)]
11pub struct OpalConfig {
12    pub container: Option<ContainerEngineConfig>,
13    pub jobs: Vec<JobOverrideConfig>,
14    #[serde(alias = "engine")]
15    pub engines: EngineSettings,
16    #[serde(rename = "registry")]
17    pub registries: Vec<RegistryAuth>,
18}
19
20#[derive(Debug, Clone, Default, Deserialize)]
21#[serde(default)]
22pub struct EngineSettings {
23    pub default: Option<EngineChoice>,
24    pub container: Option<ContainerEngineConfig>,
25    pub preserve_runtime_objects: bool,
26}
27
28#[derive(Debug, Clone, Default, Deserialize)]
29#[serde(default)]
30pub struct ContainerEngineConfig {
31    pub arch: Option<String>,
32    pub cpus: Option<String>,
33    pub memory: Option<String>,
34    pub dns: Option<String>,
35}
36
37#[derive(Debug, Clone, Default, Deserialize)]
38#[serde(default)]
39pub struct JobOverrideConfig {
40    pub name: String,
41    pub arch: Option<String>,
42    pub privileged: Option<bool>,
43    pub cap_add: Vec<String>,
44    pub cap_drop: Vec<String>,
45}
46
47#[derive(Debug, Clone, Default)]
48pub struct ResolvedJobOverride {
49    pub arch: Option<String>,
50    pub privileged: bool,
51    pub cap_add: Vec<String>,
52    pub cap_drop: Vec<String>,
53}
54
55#[derive(Debug, Clone, Deserialize)]
56pub struct RegistryAuth {
57    pub server: String,
58    pub username: String,
59    pub password: Option<String>,
60    pub password_env: Option<String>,
61    #[serde(default)]
62    pub engines: Vec<String>,
63    pub scheme: Option<String>,
64}
65
66#[derive(Debug, Clone)]
67pub struct ResolvedRegistryAuth {
68    pub server: String,
69    pub username: String,
70    pub password: String,
71    pub scheme: Option<String>,
72}
73
74impl OpalConfig {
75    pub fn load(workdir: &Path) -> Result<Self> {
76        let mut merged = OpalConfig::default();
77        for path in runtime::config_dirs(workdir) {
78            if path.exists() {
79                let contents = fs::read_to_string(&path)
80                    .with_context(|| format!("failed to read {}", path.display()))?;
81                let parsed: OpalConfig = toml::from_str(&contents)
82                    .with_context(|| format!("failed to parse {}", path.display()))?;
83                merged.merge(parsed);
84            }
85        }
86        Ok(merged)
87    }
88
89    pub fn container_settings(&self) -> Option<&ContainerEngineConfig> {
90        if let Some(cfg) = self.container.as_ref() {
91            return Some(cfg);
92        }
93        self.engines.container.as_ref()
94    }
95
96    pub fn default_engine(&self) -> Option<EngineChoice> {
97        self.engines.default
98    }
99
100    pub fn preserve_runtime_objects(&self) -> bool {
101        self.engines.preserve_runtime_objects
102    }
103
104    pub fn registry_auth_for(&self, engine: EngineKind) -> Result<Vec<ResolvedRegistryAuth>> {
105        let mut seen = HashSet::new();
106        let mut results = Vec::new();
107        for auth in &self.registries {
108            if !auth.applies_to(engine) {
109                continue;
110            }
111            let resolved = auth.resolve()?;
112            if seen.insert((resolved.server.clone(), resolved.username.clone())) {
113                results.push(resolved);
114            }
115        }
116        Ok(results)
117    }
118
119    pub fn job_override_for(&self, job_name: &str) -> Option<ResolvedJobOverride> {
120        let mut resolved = ResolvedJobOverride::default();
121        let mut matched = false;
122        for entry in &self.jobs {
123            if entry.name != job_name {
124                continue;
125            }
126            matched = true;
127            if let Some(value) = &entry.arch {
128                resolved.arch = Some(value.clone());
129            }
130            if let Some(value) = entry.privileged {
131                resolved.privileged = value;
132            }
133            if !entry.cap_add.is_empty() {
134                resolved.cap_add = entry.cap_add.clone();
135            }
136            if !entry.cap_drop.is_empty() {
137                resolved.cap_drop = entry.cap_drop.clone();
138            }
139        }
140        matched.then_some(resolved)
141    }
142
143    fn merge(&mut self, mut other: OpalConfig) {
144        if let Some(new_container) = other.container.take() {
145            match &mut self.container {
146                Some(existing) => existing.merge(new_container),
147                slot @ None => *slot = Some(new_container),
148            }
149        }
150        self.engines.merge(other.engines);
151        self.jobs.extend(other.jobs);
152        self.registries.extend(other.registries);
153    }
154}
155
156impl EngineSettings {
157    fn merge(&mut self, other: EngineSettings) {
158        if let Some(default) = other.default {
159            self.default = Some(default);
160        }
161        self.preserve_runtime_objects =
162            self.preserve_runtime_objects || other.preserve_runtime_objects;
163        if let Some(new_container) = other.container {
164            match &mut self.container {
165                Some(existing) => existing.merge(new_container),
166                slot @ None => *slot = Some(new_container),
167            }
168        }
169    }
170}
171
172impl ContainerEngineConfig {
173    fn merge(&mut self, other: ContainerEngineConfig) {
174        let ContainerEngineConfig {
175            arch,
176            cpus,
177            memory,
178            dns,
179        } = other;
180        if let Some(value) = arch {
181            self.arch = Some(value);
182        }
183        if let Some(value) = cpus {
184            self.cpus = Some(value);
185        }
186        if let Some(value) = memory {
187            self.memory = Some(value);
188        }
189        if let Some(value) = dns {
190            self.dns = Some(value);
191        }
192    }
193}
194
195impl RegistryAuth {
196    fn applies_to(&self, engine: EngineKind) -> bool {
197        if self.engines.is_empty() {
198            return true;
199        }
200        let target = engine_name(engine);
201        self.engines
202            .iter()
203            .any(|value| value.eq_ignore_ascii_case(target))
204    }
205
206    fn resolve(&self) -> Result<ResolvedRegistryAuth> {
207        let password = if let Some(env_name) = &self.password_env {
208            env::var(env_name).with_context(|| {
209                format!(
210                    "registry auth for '{}' missing env var {}",
211                    self.server, env_name
212                )
213            })?
214        } else if let Some(pass) = &self.password {
215            pass.clone()
216        } else {
217            return Err(anyhow!(
218                "registry auth for '{}' must specify password or password_env",
219                self.server
220            ));
221        };
222        Ok(ResolvedRegistryAuth {
223            server: self.server.clone(),
224            username: self.username.clone(),
225            password,
226            scheme: self.scheme.clone(),
227        })
228    }
229}
230
231fn engine_name(engine: EngineKind) -> &'static str {
232    match engine {
233        EngineKind::ContainerCli => "container",
234        EngineKind::Docker => "docker",
235        EngineKind::Podman => "podman",
236        EngineKind::Nerdctl => "nerdctl",
237        EngineKind::Orbstack => "orbstack",
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::{ContainerEngineConfig, JobOverrideConfig, OpalConfig};
244
245    #[test]
246    fn container_config_merge_overrides_arch() {
247        let mut base = OpalConfig {
248            container: Some(ContainerEngineConfig {
249                arch: Some("x86_64".into()),
250                cpus: None,
251                memory: None,
252                dns: None,
253            }),
254            ..OpalConfig::default()
255        };
256
257        base.merge(OpalConfig {
258            container: Some(ContainerEngineConfig {
259                arch: Some("arm64".into()),
260                cpus: None,
261                memory: None,
262                dns: None,
263            }),
264            ..OpalConfig::default()
265        });
266
267        assert_eq!(
268            base.container_settings()
269                .and_then(|cfg| cfg.arch.as_deref()),
270            Some("arm64")
271        );
272    }
273
274    #[test]
275    fn job_override_for_merges_matching_entries() {
276        let config = OpalConfig {
277            jobs: vec![
278                JobOverrideConfig {
279                    name: "deploy".into(),
280                    arch: Some("arm64".into()),
281                    privileged: Some(false),
282                    cap_add: Vec::new(),
283                    cap_drop: Vec::new(),
284                },
285                JobOverrideConfig {
286                    name: "deploy".into(),
287                    arch: None,
288                    privileged: Some(true),
289                    cap_add: vec!["NET_ADMIN".into()],
290                    cap_drop: vec!["MKNOD".into()],
291                },
292            ],
293            ..OpalConfig::default()
294        };
295
296        let resolved = config.job_override_for("deploy").expect("override present");
297        assert_eq!(resolved.arch.as_deref(), Some("arm64"));
298        assert!(resolved.privileged);
299        assert_eq!(resolved.cap_add, vec!["NET_ADMIN"]);
300        assert_eq!(resolved.cap_drop, vec!["MKNOD"]);
301    }
302
303    #[test]
304    fn parses_default_engine_from_engine_table() {
305        let parsed: OpalConfig = toml::from_str(
306            r#"
307[engine]
308default = "docker"
309"#,
310        )
311        .expect("parse config");
312
313        assert_eq!(parsed.default_engine(), Some(crate::EngineChoice::Docker));
314    }
315
316    #[test]
317    fn project_level_engine_default_overrides_global() {
318        let mut base = OpalConfig::default();
319        base.merge(
320            toml::from_str(
321                r#"
322[engine]
323default = "docker"
324"#,
325            )
326            .expect("parse global config"),
327        );
328        base.merge(
329            toml::from_str(
330                r#"
331[engine]
332default = "container"
333"#,
334            )
335            .expect("parse project config"),
336        );
337
338        assert_eq!(base.default_engine(), Some(crate::EngineChoice::Container));
339    }
340
341    #[test]
342    fn parses_preserve_runtime_objects_from_engine_table() {
343        let parsed: OpalConfig = toml::from_str(
344            r#"
345[engine]
346preserve_runtime_objects = true
347"#,
348        )
349        .expect("parse config");
350
351        assert!(parsed.preserve_runtime_objects());
352    }
353}