1use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use serde_yaml_ng::Value;
7
8use crate::error::{Error, Result};
9use crate::interp;
10use crate::spec::{ComposeFile, MountType, SandboxSpec};
11
12pub const DEFAULT_FILES: &[&str] = &["isb.yaml", "isb.yml"];
14
15pub const OVERRIDE_FILES: &[&str] = &["isb.override.yaml", "isb.override.yml"];
18
19#[derive(Debug, Clone)]
21pub struct Project {
22 pub name: String,
24 pub file: ComposeFile,
27 pub base_dir: PathBuf,
29 pub files: Vec<PathBuf>,
30 pub vars: BTreeMap<String, String>,
33 pub dotenv: BTreeMap<String, String>,
34 pub store_secrets: SecretValues,
38}
39
40#[derive(Clone, Default, PartialEq)]
42pub struct SecretValues(pub BTreeMap<String, Vec<u8>>);
43
44impl std::fmt::Debug for SecretValues {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 f.debug_set().entries(self.0.keys()).finish()
47 }
48}
49
50impl Project {
51 pub fn service(&self, service: &str) -> Result<&SandboxSpec> {
53 self.file.services.get(service).ok_or_else(|| {
54 Error::invalid(format!(
55 "no service {service:?} in {} (have: {})",
56 self.files_display(),
57 self.file
58 .services
59 .keys()
60 .cloned()
61 .collect::<Vec<_>>()
62 .join(", ")
63 ))
64 })
65 }
66
67 pub fn select(&self, services: &[String]) -> Result<Vec<String>> {
71 let order = dependency_order(&self.file).map_err(Error::invalid)?;
72 if services.is_empty() {
73 return Ok(order);
74 }
75 let mut want: Vec<String> = Vec::new();
76 let mut stack: Vec<String> = Vec::new();
77 for s in services {
78 self.service(s)?;
79 stack.push(s.clone());
80 }
81 while let Some(s) = stack.pop() {
82 if want.contains(&s) {
83 continue;
84 }
85 stack.extend(self.file.services[&s].depends_on.keys().cloned());
86 want.push(s);
87 }
88 Ok(order.into_iter().filter(|s| want.contains(s)).collect())
89 }
90
91 pub fn select_exact(&self, services: &[String]) -> Result<Vec<String>> {
94 for s in services {
95 self.service(s)?;
96 }
97 let order = dependency_order(&self.file).map_err(Error::invalid)?;
98 Ok(order
99 .into_iter()
100 .filter(|s| services.is_empty() || services.contains(s))
101 .collect())
102 }
103
104 pub fn files_display(&self) -> String {
105 self.files
106 .iter()
107 .map(|p| p.display().to_string())
108 .collect::<Vec<_>>()
109 .join(", ")
110 }
111
112 pub fn lookup(&self, k: &str) -> Option<String> {
115 self.vars
116 .get(k)
117 .cloned()
118 .or_else(|| std::env::var(k).ok())
119 .or_else(|| self.dotenv.get(k).cloned())
120 }
121
122 pub fn secret_values(&self) -> Result<BTreeMap<String, Vec<u8>>> {
126 let mut out = crate::supervise::resolve_secret_values(&self.file, &self.base_dir, &|k| {
127 self.lookup(k)
128 })?;
129 for (key, def) in self.store_backed_secrets() {
130 let v = self.store_secrets.0.get(&key).ok_or_else(|| {
131 Error::invalid(format!(
132 "secret {key:?}: {} secrets come from the org's secret store, which was not read",
133 def.source_kind()
134 ))
135 })?;
136 out.insert(key, v.clone());
137 }
138 Ok(out)
139 }
140
141 pub fn store_backed_secrets(&self) -> BTreeMap<String, crate::spec::SecretDef> {
144 crate::stack::secrets::used_keys(&self.file)
145 .into_iter()
146 .filter_map(|k| {
147 let d = self.file.secrets.get(&k)?;
148 (!d.is_client_side()).then(|| (k, d.clone()))
149 })
150 .collect()
151 }
152
153 pub fn to_yaml(&self) -> Result<String> {
155 serde_yaml_ng::to_string(&self.file).map_err(|e| Error::invalid(e.to_string()))
156 }
157}
158
159#[derive(Debug, Clone, Default)]
161pub struct LoadOptions {
162 pub files: Vec<PathBuf>,
165 pub env_files: Vec<PathBuf>,
168 pub project_name: Option<String>,
170 pub vars: BTreeMap<String, String>,
172}
173
174pub fn find_default(dir: &Path) -> Option<PathBuf> {
176 DEFAULT_FILES
177 .iter()
178 .map(|f| dir.join(f))
179 .find(|p| p.is_file())
180}
181
182pub fn find_override(dir: &Path) -> Option<PathBuf> {
184 OVERRIDE_FILES
185 .iter()
186 .map(|f| dir.join(f))
187 .find(|p| p.is_file())
188}
189
190pub fn load(opts: &LoadOptions) -> Result<Project> {
192 let mut files = opts.files.clone();
193 if files.is_empty() {
194 let cwd = std::env::current_dir()?;
195 files.push(find_default(&cwd).ok_or_else(|| {
196 Error::invalid(format!(
197 "no compose file in {} (isb reads only ./{}, never a parent directory's; -f FILE names another)",
198 cwd.display(),
199 DEFAULT_FILES.join(" or ./")
200 ))
201 })?);
202 files.extend(find_override(&cwd));
203 }
204 let base = files[0]
205 .parent()
206 .map(|p| {
207 if p.as_os_str().is_empty() {
208 PathBuf::from(".")
209 } else {
210 p.to_path_buf()
211 }
212 })
213 .unwrap_or_else(|| PathBuf::from("."));
214 let base = base.canonicalize().unwrap_or(base);
215 let mut env_files = opts.env_files.clone();
216 if env_files.is_empty() {
217 env_files.extend(Some(base.join(".env")).filter(|p| p.is_file()));
219 }
220 let mut dotenv: BTreeMap<String, String> = BTreeMap::new();
221 for f in &env_files {
222 let text = std::fs::read_to_string(f).map_err(|e| Error::Parse {
223 path: f.display().to_string(),
224 message: e.to_string(),
225 })?;
226 for (k, v) in interp::parse_env_file(&text).map_err(|e| Error::Parse {
227 path: f.display().to_string(),
228 message: e.to_string(),
229 })? {
230 dotenv.insert(k, v);
231 }
232 }
233 let vars = opts.vars.clone();
234 let dotenv_kept = dotenv.clone();
235 let lookup = move |k: &str| {
236 vars.get(k)
237 .cloned()
238 .or_else(|| std::env::var(k).ok())
239 .or_else(|| dotenv.get(k).cloned())
240 };
241 let mut docs = Vec::new();
242 for f in &files {
243 let text = std::fs::read_to_string(f).map_err(|e| Error::Parse {
244 path: f.display().to_string(),
245 message: e.to_string(),
246 })?;
247 docs.push((f.clone(), text));
248 }
249 let mut p = load_docs(&docs, &base, opts.project_name.as_deref(), &lookup)?;
250 p.vars = opts.vars.clone();
251 p.dotenv = dotenv_kept;
252 Ok(p)
253}
254
255pub fn load_docs(
258 docs: &[(PathBuf, String)],
259 base: &Path,
260 project_name: Option<&str>,
261 lookup: &dyn Fn(&str) -> Option<String>,
262) -> Result<Project> {
263 let mut merged = Value::Mapping(Default::default());
264 for (path, text) in docs {
265 let perr = |message: String| Error::Parse {
266 path: path.display().to_string(),
267 message,
268 };
269 let mut v: Value = serde_yaml_ng::from_str(text).map_err(|e| perr(e.to_string()))?;
270 if v.is_null() {
271 continue;
272 }
273 v.apply_merge().map_err(|e| perr(e.to_string()))?;
274 strip_extensions(&mut v);
275 reject_docker_only_keys(&v).map_err(perr)?;
276 interp::interpolate_yaml(&mut v, lookup).map_err(|e| perr(e.to_string()))?;
277 normalize_lists(&mut v, lookup);
278 serde_yaml_ng::from_value::<ComposeFile>(v.clone()).map_err(|e| perr(e.to_string()))?;
280 merge_file(&mut merged, v);
281 }
282 let files: Vec<PathBuf> = docs.iter().map(|(p, _)| p.clone()).collect();
283 let mut file: ComposeFile = serde_yaml_ng::from_value(merged).map_err(|e| Error::Parse {
284 path: files
285 .iter()
286 .map(|p| p.display().to_string())
287 .collect::<Vec<_>>()
288 .join(" + "),
289 message: e.to_string(),
290 })?;
291 let name = project_name
292 .map(String::from)
293 .or_else(|| file.name.clone())
294 .unwrap_or_else(|| {
295 base.file_name()
296 .map(|s| s.to_string_lossy().into_owned())
297 .unwrap_or_else(|| "isb".into())
298 });
299 let name = sanitize_name(&name);
300 for (key, vol) in file.volumes.iter_mut() {
301 if vol.name.as_deref().is_none_or(str::is_empty) {
302 vol.name = Some(if vol.external {
303 key.clone()
304 } else {
305 format!("{name}_{key}")
306 });
307 }
308 }
309 for (service, spec) in file.services.iter_mut() {
310 if spec.name.as_deref().is_none_or(str::is_empty) {
311 spec.name = Some(format!("{name}-{}", sanitize_name(service)));
312 }
313 for v in &spec.volumes {
314 if v.mount_type == MountType::Volume && !file.volumes.contains_key(&v.source) {
315 return Err(Error::Parse {
316 path: files
317 .iter()
318 .map(|p| p.display().to_string())
319 .collect::<Vec<_>>()
320 .join(" + "),
321 message: format!(
322 "service {service:?} mounts volume {:?}, which is not declared under top-level volumes",
323 v.source
324 ),
325 });
326 }
327 }
328 }
329 validate_services(&mut file).map_err(|message| Error::Parse {
330 path: files
331 .iter()
332 .map(|p| p.display().to_string())
333 .collect::<Vec<_>>()
334 .join(" + "),
335 message,
336 })?;
337 file.name = Some(name.clone());
338 Ok(Project {
339 name,
340 file,
341 base_dir: base.to_path_buf(),
342 files,
343 vars: BTreeMap::new(),
344 dotenv: BTreeMap::new(),
345 store_secrets: SecretValues::default(),
346 })
347}
348
349fn validate_services(file: &mut crate::spec::ComposeFile) -> std::result::Result<(), String> {
352 for (key, def) in &file.secrets {
353 def.validate().map_err(|e| format!("secret {key:?}: {e}"))?;
354 if def.external && def.name.is_none() {
355 crate::secrets::validate_name(key).map_err(|e| format!("external secret: {e}"))?;
356 }
357 }
358 let names: Vec<String> = file.services.keys().cloned().collect();
359 for (service, spec) in file.services.iter_mut() {
360 for dep in spec.depends_on.keys() {
361 if !names.contains(dep) {
362 return Err(format!(
363 "service {service:?} depends on {dep:?}, which is not a service here"
364 ));
365 }
366 if dep == service {
367 return Err(format!("service {service:?} depends on itself"));
368 }
369 }
370 for s in &spec.secrets {
371 if !file.secrets.contains_key(&s.source) {
372 return Err(format!(
373 "service {service:?} uses secret {:?}, which is not declared under top-level secrets",
374 s.source
375 ));
376 }
377 s.file_mode()
378 .map_err(|e| format!("service {service:?}: {e}"))?;
379 }
380 for (var, key) in spec.env.secrets.iter().chain(&spec.env.files) {
381 if !file.secrets.contains_key(key) {
382 return Err(format!(
383 "service {service:?}: environment {var} uses secret {key:?}, which is not declared under top-level secrets"
384 ));
385 }
386 }
387 for (var, _) in spec.env.file_vars() {
388 if spec.env.vars.contains_key(&var) || spec.env.secrets.contains_key(&var) {
389 return Err(format!(
390 "service {service:?}: environment {var} is set, and also by {} `as: file`; drop one",
391 var.trim_end_matches("_FILE")
392 ));
393 }
394 }
395 let oci = crate::plan::ImageSource::parse(&spec.image).is_ok_and(|i| i.is_oci());
396 crate::plan::check_oci_command(service, spec)?;
397 if !spec.env.secrets.is_empty() && !oci && spec.command.is_none() {
398 return Err(format!(
401 "service {service:?}: environment secrets on a system image need a command to give them to"
402 ));
403 }
404 if let Some(h) = &spec.healthcheck {
405 h.probe().map_err(|e| format!("service {service:?}: {e}"))?;
406 }
407 if let Some(d) = &spec.deploy {
408 if d.mode.as_deref().is_some_and(|m| m != "replicated") {
409 return Err(format!(
410 "service {service:?}: deploy.mode {:?} is not supported (only replicated)",
411 d.mode.as_deref().unwrap_or_default()
412 ));
413 }
414 if let Some(l) = d.resources.as_ref().and_then(|r| r.limits.clone()) {
415 if let Some(c) = l.cpus {
416 if spec.cpus.is_some() || spec.cpuset.is_some() {
417 return Err(format!(
418 "service {service:?}: set cpus or deploy.resources.limits.cpus, not both"
419 ));
420 }
421 spec.cpus = Some(c.trim().trim_end_matches(".0").to_string());
422 }
423 if let Some(m) = l.memory {
424 if spec.memory.is_some() {
425 return Err(format!(
426 "service {service:?}: set mem_limit or deploy.resources.limits.memory, not both"
427 ));
428 }
429 spec.memory = Some(m);
430 }
431 }
432 }
433 }
434 dependency_order(file).map(|_| ())
435}
436
437pub fn dependency_order(
440 file: &crate::spec::ComposeFile,
441) -> std::result::Result<Vec<String>, String> {
442 let mut order: Vec<String> = Vec::new();
443 let mut remaining: Vec<&String> = file.services.keys().collect();
444 while !remaining.is_empty() {
445 let ready: Vec<&String> = remaining
446 .iter()
447 .copied()
448 .filter(|s| {
449 file.services[*s]
450 .depends_on
451 .keys()
452 .all(|d| order.contains(d) || !file.services.contains_key(d))
453 })
454 .collect();
455 if ready.is_empty() {
456 return Err(format!(
457 "depends_on has a cycle among: {}",
458 remaining
459 .iter()
460 .map(|s| s.as_str())
461 .collect::<Vec<_>>()
462 .join(", ")
463 ));
464 }
465 for s in ready {
466 order.push(s.clone());
467 remaining.retain(|r| *r != s);
468 }
469 }
470 Ok(order)
471}
472
473pub fn client_for(client: &crate::Client, project: &Project) -> crate::Client {
476 match (&project.file.incus_project, client.project_name()) {
477 (Some(p), "default") => client.clone().project(p),
478 _ => client.clone(),
479 }
480}
481
482pub fn up(
485 client: &crate::Client,
486 project: &Project,
487 services: &[String],
488 opts: crate::EnsureOptions,
489 report: &mut dyn FnMut(&str),
490) -> Result<Vec<(String, crate::ApplyReport)>> {
491 Ok(up_handles(client, project, services, opts, report)?
492 .into_iter()
493 .map(|(s, r, _)| (s, r))
494 .collect())
495}
496
497pub fn up_handles(
505 client: &crate::Client,
506 project: &Project,
507 services: &[String],
508 opts: crate::EnsureOptions,
509 report: &mut dyn FnMut(&str),
510) -> Result<Vec<(String, crate::ApplyReport, crate::Sandbox)>> {
511 let c = client_for(client, project);
512 let selected = project.select(services)?;
513 let healthy_needed: std::collections::BTreeSet<&String> = selected
514 .iter()
515 .flat_map(|s| project.file.services[s].depends_on.iter())
516 .filter(|(_, d)| d.condition == crate::spec::DependCondition::ServiceHealthy)
517 .map(|(k, _)| k)
518 .collect();
519 for dep in &healthy_needed {
520 let spec = &project.file.services[*dep];
521 if spec.health_probe().map_err(Error::invalid)?.is_none() {
522 return Err(Error::invalid(format!(
523 "a service depends on {dep:?} being healthy, but {dep:?} has no healthcheck"
524 )));
525 }
526 let oci = crate::plan::ImageSource::parse(&spec.image)?.is_oci();
527 if spec.command.is_some() && !spec.long_running() && !oci {
528 return Err(Error::invalid(format!(
529 "a service depends on {dep:?} being healthy, but its command only runs once every service is up; set restart on {dep:?} so isb supervises it"
530 )));
531 }
532 }
533 let secret_values = if selected
534 .iter()
535 .any(|s| !project.file.services[s].secret_keys().is_empty())
536 {
537 project.secret_values()?
538 } else {
539 BTreeMap::new()
540 };
541 for s in &selected {
542 if project.file.services[s].replicas() > 1 {
543 return Err(Error::invalid(format!(
544 "service {s:?} asks for {} replicas; isb up runs one, `isb stack deploy` runs replicas behind a load balancer",
545 project.file.services[s].replicas()
546 )));
547 }
548 }
549 if let Some(w) = crate::stack::secrets::env_exposure_warning(&project.file) {
550 report(&format!("warning: {w}"));
551 }
552 let mut out = Vec::new();
553 for s in selected {
554 let spec = project.service(&s)?;
555 let oci = crate::plan::ImageSource::parse(&spec.image)?.is_oci();
556 let secret_env = crate::supervise::secret_env(spec, &secret_values)?;
557 let mut with_env = spec.clone();
561 if oci {
562 with_env.env.vars.extend(secret_env.clone());
563 } else {
564 with_env.exec.env.extend(secret_env.clone());
565 }
566 let mut d =
567 crate::sandbox::resolve(&c, &with_env, &project.file.volumes, &project.base_dir)?;
568 if oci && spec.has_secret_files() {
569 let (spec, values) = (spec.clone(), secret_values.clone());
571 d.before_start = Some(crate::plan::BeforeStart(std::sync::Arc::new(
572 move |c, n| crate::supervise::push_secret_files(c, n, &spec, &values).map(|_| ()),
573 )));
574 }
575 let r = crate::sandbox::ensure(&c, &d, opts, report)?;
576 if r.applied.iter().all(|a| !a.is_change()) {
577 report(&format!("{}: up to date", d.name));
578 }
579 let sb = crate::Sandbox::from_desired(&c, &d);
580 if crate::supervise::push_secrets(&sb, spec, &secret_values)? && d.image.is_oci() {
583 sb.restart()?;
584 }
585 if spec.long_running()
586 && spec.command.is_some()
587 && !d.image.is_oci()
588 && crate::supervise::install(&sb, &s, spec, spec.has_secret_files(), &secret_env)?
589 {
590 report(&format!(
591 "{}: supervising command as {}",
592 d.name,
593 crate::supervise::unit_name(&s)
594 ));
595 }
596 if healthy_needed.contains(&s) {
597 wait_healthy(&sb, &s, spec, report)?;
598 }
599 out.push((s, r, sb));
600 }
601 Ok(out)
602}
603
604pub fn wait_healthy(
607 sb: &crate::Sandbox,
608 service: &str,
609 spec: &crate::SandboxSpec,
610 report: &mut dyn FnMut(&str),
611) -> Result<()> {
612 let Some(check) = spec.health_probe().map_err(Error::invalid)? else {
613 return Ok(());
614 };
615 let deadline = (check.start_period + check.interval * check.retries)
616 .max(std::time::Duration::from_secs(60));
617 let started = std::time::Instant::now();
618 report(&format!(
619 "{}: waiting for {service} to be healthy",
620 sb.name()
621 ));
622 loop {
623 let p = crate::supervise::probe(sb, &check);
624 if p.ok {
625 report(&format!("{}: healthy", sb.name()));
626 return Ok(());
627 }
628 if started.elapsed() >= deadline {
629 return Err(Error::NotReady {
630 sandbox: sb.name().to_string(),
631 check: "healthcheck".into(),
632 detail: p.output,
633 waited: started.elapsed(),
634 });
635 }
636 std::thread::sleep(check.start_interval.min(check.interval));
637 }
638}
639
640pub fn plan(
642 client: &crate::Client,
643 project: &Project,
644 services: &[String],
645 diff: crate::DiffOptions,
646) -> Result<Vec<crate::SandboxPlan>> {
647 let c = client_for(client, project);
648 let mut plans = Vec::new();
649 for s in project.select(services)? {
650 let d = crate::sandbox::resolve(
651 &c,
652 project.service(&s)?,
653 &project.file.volumes,
654 &project.base_dir,
655 )?;
656 plans.push(crate::sandbox::plan_desired(&c, &d, diff)?);
657 }
658 Ok(plans)
659}
660
661pub fn down(
665 client: &crate::Client,
666 project: &Project,
667 services: &[String],
668 volumes: bool,
669 report: &mut dyn FnMut(&str),
670) -> Result<()> {
671 let c = client_for(client, project);
672 for s in project.select_exact(services)?.into_iter().rev() {
674 let name = project.service(&s)?.name.clone().unwrap_or_default();
675 match crate::Sandbox::remove(&c, &name, true) {
676 Ok(()) => report(&format!("{name}: deleted")),
677 Err(e) if e.is_not_found() => report(&format!("{name}: not present")),
678 Err(e) => return Err(e),
679 }
680 }
681 if !volumes {
682 return Ok(());
683 }
684 if !services.is_empty() {
685 report("volumes kept: they are shared by the file; remove them with a full down");
686 return Ok(());
687 }
688 let facts = crate::sandbox::host_facts(&c)?;
689 let mut seen = std::collections::BTreeSet::new();
690 let vol_name = |key: &str| {
691 project
692 .file
693 .volumes
694 .get(key)
695 .and_then(|d| d.name.clone())
696 .unwrap_or_else(|| key.to_string())
697 };
698 for spec in project.file.services.values() {
699 let pool = facts.pick_pool(spec.storage.as_deref())?;
700 for v in &spec.volumes {
701 if v.mount_type != MountType::Volume {
702 continue;
703 }
704 let def = project.file.volumes.get(&v.source);
705 if v.external || def.is_some_and(|d| d.external) {
706 continue;
707 }
708 let vpool = match v.pool.as_deref().or(def.and_then(|d| d.pool.as_deref())) {
709 Some(x) if x != "auto" => x.to_string(),
710 _ => pool.clone(),
711 };
712 seen.insert((vpool, vol_name(&v.source)));
713 }
714 }
715 for (key, def) in &project.file.volumes {
716 let vname = vol_name(key);
717 if !def.external && !seen.iter().any(|(_, n)| *n == vname) {
718 seen.insert((facts.pick_pool(def.pool.as_deref())?, vname));
719 }
720 }
721 for (pool, vname) in seen {
722 match crate::volume::remove(&c, &pool, &vname) {
723 Ok(()) => report(&format!("volume {vname}: deleted")),
724 Err(e) if e.is_not_found() => {}
725 Err(e) => report(&format!("volume {vname}: kept ({e})")),
726 }
727 }
728 Ok(())
729}
730
731pub fn sanitize_name(s: &str) -> String {
733 let mut out = String::new();
734 for c in s.to_ascii_lowercase().chars() {
735 if c.is_ascii_alphanumeric() {
736 out.push(c);
737 } else if !out.ends_with('-') {
738 out.push('-');
739 }
740 }
741 let out = out.trim_matches('-').to_string();
742 if out.starts_with(|c: char| c.is_ascii_alphabetic()) {
743 out
744 } else {
745 format!("isb-{out}").trim_end_matches('-').to_string()
746 }
747}
748
749fn strip_extensions(v: &mut Value) {
752 let Value::Mapping(top) = v else { return };
753 top.retain(|k, _| {
754 !k.as_str()
755 .is_some_and(|s| s.starts_with("x-") || s == "version")
756 });
757 if let Some(Value::Mapping(sbs)) = top.get_mut("services") {
758 for (_, sb) in sbs.iter_mut() {
759 if let Value::Mapping(m) = sb {
760 m.retain(|k, _| !k.as_str().is_some_and(|s| s.starts_with("x-")));
761 }
762 }
763 }
764}
765
766const DOCKER_ONLY_TOP: &[(&str, &str)] = &[
769 (
770 "networks",
771 "networking comes from incus profiles (incus_profiles)",
772 ),
773 ("configs", "bind-mount the file instead"),
774 ("include", "pass several files with -f"),
775 ("sandboxes", "services are under services:"),
776 ("project", "the incus project is incus_project:"),
777];
778
779const DOCKER_ONLY_SERVICE: &[(&str, &str)] = &[
780 (
781 "build",
782 "isb runs incus images: build one and name it in image",
783 ),
784 ("env_file", "list the variables under environment"),
785 (
786 "profiles",
787 "docker's service profiles are not supported; incus profiles are incus_profiles",
788 ),
789 (
790 "networks",
791 "networking comes from incus profiles (incus_profiles) or raw_devices",
792 ),
793 (
794 "network_mode",
795 "networking comes from incus profiles (incus_profiles) or raw_devices",
796 ),
797 ("hostname", "the guest's hostname is its container_name"),
798 ("expose", "use ports"),
799 ("devices", "use raw_devices"),
800 ("gpus", "use raw_devices, e.g. {gpu: {type: gpu}}"),
801 ("sysctls", "use raw_config with linux.sysctl.* keys"),
802 ("working_directory", "the key is working_dir"),
803 ("env", "the key is environment"),
804 ("memory", "the key is mem_limit"),
805 ("name", "the instance name is container_name"),
806];
807
808fn reject_docker_only_keys(v: &Value) -> std::result::Result<(), String> {
810 let Value::Mapping(top) = v else {
811 return Ok(());
812 };
813 let unsupported = |key: &str, table: &[(&str, &str)], at: &str| {
814 table
815 .iter()
816 .find(|(k, _)| *k == key)
817 .map(|(k, hint)| format!("{at}`{k}` is not an isb key: {hint}"))
818 };
819 for k in top.keys().filter_map(Value::as_str) {
820 if let Some(e) = unsupported(k, DOCKER_ONLY_TOP, "") {
821 return Err(e);
822 }
823 }
824 if let Some(Value::Mapping(services)) = top.get("services") {
825 for (name, svc) in services {
826 let Value::Mapping(svc) = svc else { continue };
827 let at = format!("service {:?}: ", name.as_str().unwrap_or_default());
828 for k in svc.keys().filter_map(Value::as_str) {
829 if let Some(e) = unsupported(k, DOCKER_ONLY_SERVICE, &at) {
830 return Err(e);
831 }
832 }
833 if let Some(Value::Mapping(exec)) = svc.get("exec") {
834 for (k, to) in [("user", "user"), ("cwd", "working_dir")] {
835 if exec.contains_key(k) {
836 return Err(format!(
837 "{at}`exec.{k}` is not an isb key: use {to} on the service"
838 ));
839 }
840 }
841 }
842 }
843 }
844 Ok(())
845}
846
847fn normalize_lists(v: &mut Value, lookup: &dyn Fn(&str) -> Option<String>) {
852 fn to_map(v: &mut Value, bare: &dyn Fn(&str) -> Option<String>) {
853 let Value::Sequence(items) = v else { return };
854 let mut m = serde_yaml_ng::Mapping::new();
855 for item in items.iter() {
856 let Some(s) = item.as_str() else {
857 return;
859 };
860 match s.split_once('=') {
861 Some((k, val)) => {
862 m.insert(k.into(), val.into());
863 }
864 None => {
865 if let Some(val) = bare(s) {
866 m.insert(s.into(), val.into());
867 }
868 }
869 }
870 }
871 *v = Value::Mapping(m);
872 }
873 let Some(Value::Mapping(services)) = v.get_mut("services") else {
874 return;
875 };
876 for (_, svc) in services.iter_mut() {
877 let Value::Mapping(svc) = svc else { continue };
878 if let Some(e) = svc.get_mut("environment") {
879 to_map(e, lookup);
880 }
881 if let Some(Value::Mapping(exec)) = svc.get_mut("exec") {
882 if let Some(e) = exec.get_mut("env") {
883 to_map(e, lookup);
884 }
885 }
886 if let Some(l) = svc.get_mut("labels") {
887 to_map(l, &|_| Some(String::new()));
888 }
889 }
890}
891
892fn merge_file(merged: &mut Value, v: Value) {
897 let (Value::Mapping(am), Value::Mapping(bm)) = (&mut *merged, v) else {
898 return;
900 };
901 for (k, bv) in bm {
902 let is_services = k.as_str() == Some("services");
903 match am.get_mut(&k) {
904 Some(Value::Mapping(asvcs)) if is_services => {
905 let Value::Mapping(bsvcs) = bv else {
906 am.insert(k, bv);
907 continue;
908 };
909 for (name, bsvc) in bsvcs {
910 match asvcs.get_mut(&name) {
911 Some(asvc) => merge_service(asvc, bsvc),
912 None => {
913 asvcs.insert(name, bsvc);
914 }
915 }
916 }
917 }
918 Some(av) => deep_merge(av, bv),
919 None => {
920 am.insert(k, bv);
921 }
922 }
923 }
924}
925
926fn merge_service(a: &mut Value, b: Value) {
927 let (Value::Mapping(am), Value::Mapping(bm)) = (&mut *a, &b) else {
928 deep_merge(a, b);
929 return;
930 };
931 let bm = bm.clone();
932 for (k, bv) in bm {
933 match (k.as_str(), am.get_mut(&k), bv) {
934 (Some("ports"), Some(Value::Sequence(ap)), Value::Sequence(bp)) => {
935 for p in bp {
936 if !ap.contains(&p) {
937 ap.push(p);
938 }
939 }
940 }
941 (Some("volumes"), Some(Value::Sequence(av)), Value::Sequence(bv)) => {
942 for m in bv {
943 let t = mount_target(&m);
944 match av.iter_mut().find(|x| t.is_some() && mount_target(x) == t) {
945 Some(slot) => *slot = m,
946 None => av.push(m),
947 }
948 }
949 }
950 (_, Some(av), bv) => deep_merge(av, bv),
951 (_, None, bv) => {
952 am.insert(k, bv);
953 }
954 }
955 }
956}
957
958fn mount_target(m: &Value) -> Option<String> {
960 let t = match m {
961 Value::String(s) => s.split(':').nth(1)?.to_string(),
962 Value::Mapping(map) => map.get("target")?.as_str()?.to_string(),
963 _ => return None,
964 };
965 Some(t.trim_end_matches('/').to_string())
966}
967
968fn deep_merge(a: &mut Value, b: Value) {
970 match (a, b) {
971 (Value::Mapping(am), Value::Mapping(bm)) => {
972 for (k, bv) in bm {
973 match am.get_mut(&k) {
974 Some(av) => deep_merge(av, bv),
975 None => {
976 am.insert(k, bv);
977 }
978 }
979 }
980 }
981 (a, b) => *a = b,
982 }
983}
984
985#[cfg(test)]
986mod tests;