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}