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 {
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 let oci = crate::plan::ImageSource::parse(&spec.image).is_ok_and(|i| i.is_oci());
388 if !spec.env.secrets.is_empty() && !oci && spec.command.is_none() {
389 return Err(format!(
392 "service {service:?}: environment secrets on a system image need a command to give them to"
393 ));
394 }
395 if let Some(h) = &spec.healthcheck {
396 h.probe().map_err(|e| format!("service {service:?}: {e}"))?;
397 }
398 if let Some(d) = &spec.deploy {
399 if d.mode.as_deref().is_some_and(|m| m != "replicated") {
400 return Err(format!(
401 "service {service:?}: deploy.mode {:?} is not supported (only replicated)",
402 d.mode.as_deref().unwrap_or_default()
403 ));
404 }
405 if let Some(l) = d.resources.as_ref().and_then(|r| r.limits.clone()) {
406 if let Some(c) = l.cpus {
407 if spec.cpus.is_some() || spec.cpuset.is_some() {
408 return Err(format!(
409 "service {service:?}: set cpus or deploy.resources.limits.cpus, not both"
410 ));
411 }
412 spec.cpus = Some(c.trim().trim_end_matches(".0").to_string());
413 }
414 if let Some(m) = l.memory {
415 if spec.memory.is_some() {
416 return Err(format!(
417 "service {service:?}: set mem_limit or deploy.resources.limits.memory, not both"
418 ));
419 }
420 spec.memory = Some(m);
421 }
422 }
423 }
424 }
425 dependency_order(file).map(|_| ())
426}
427
428pub fn dependency_order(
431 file: &crate::spec::ComposeFile,
432) -> std::result::Result<Vec<String>, String> {
433 let mut order: Vec<String> = Vec::new();
434 let mut remaining: Vec<&String> = file.services.keys().collect();
435 while !remaining.is_empty() {
436 let ready: Vec<&String> = remaining
437 .iter()
438 .copied()
439 .filter(|s| {
440 file.services[*s]
441 .depends_on
442 .keys()
443 .all(|d| order.contains(d) || !file.services.contains_key(d))
444 })
445 .collect();
446 if ready.is_empty() {
447 return Err(format!(
448 "depends_on has a cycle among: {}",
449 remaining
450 .iter()
451 .map(|s| s.as_str())
452 .collect::<Vec<_>>()
453 .join(", ")
454 ));
455 }
456 for s in ready {
457 order.push(s.clone());
458 remaining.retain(|r| *r != s);
459 }
460 }
461 Ok(order)
462}
463
464pub fn client_for(client: &crate::Client, project: &Project) -> crate::Client {
467 match (&project.file.incus_project, client.project_name()) {
468 (Some(p), "default") => client.clone().project(p),
469 _ => client.clone(),
470 }
471}
472
473pub fn up(
476 client: &crate::Client,
477 project: &Project,
478 services: &[String],
479 opts: crate::EnsureOptions,
480 report: &mut dyn FnMut(&str),
481) -> Result<Vec<(String, crate::ApplyReport)>> {
482 Ok(up_handles(client, project, services, opts, report)?
483 .into_iter()
484 .map(|(s, r, _)| (s, r))
485 .collect())
486}
487
488pub fn up_handles(
496 client: &crate::Client,
497 project: &Project,
498 services: &[String],
499 opts: crate::EnsureOptions,
500 report: &mut dyn FnMut(&str),
501) -> Result<Vec<(String, crate::ApplyReport, crate::Sandbox)>> {
502 let c = client_for(client, project);
503 let selected = project.select(services)?;
504 let healthy_needed: std::collections::BTreeSet<&String> = selected
505 .iter()
506 .flat_map(|s| project.file.services[s].depends_on.iter())
507 .filter(|(_, d)| d.condition == crate::spec::DependCondition::ServiceHealthy)
508 .map(|(k, _)| k)
509 .collect();
510 for dep in &healthy_needed {
511 let spec = &project.file.services[*dep];
512 if spec.health_probe().map_err(Error::invalid)?.is_none() {
513 return Err(Error::invalid(format!(
514 "a service depends on {dep:?} being healthy, but {dep:?} has no healthcheck"
515 )));
516 }
517 let oci = crate::plan::ImageSource::parse(&spec.image)?.is_oci();
518 if spec.command.is_some() && !spec.long_running() && !oci {
519 return Err(Error::invalid(format!(
520 "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"
521 )));
522 }
523 }
524 let secret_values = if selected
525 .iter()
526 .any(|s| !project.file.services[s].secret_keys().is_empty())
527 {
528 project.secret_values()?
529 } else {
530 BTreeMap::new()
531 };
532 for s in &selected {
533 if project.file.services[s].replicas() > 1 {
534 return Err(Error::invalid(format!(
535 "service {s:?} asks for {} replicas; isb up runs one, `isb stack deploy` runs replicas behind a load balancer",
536 project.file.services[s].replicas()
537 )));
538 }
539 }
540 let mut out = Vec::new();
541 for s in selected {
542 let spec = project.service(&s)?;
543 let oci = crate::plan::ImageSource::parse(&spec.image)?.is_oci();
544 let secret_env = crate::supervise::secret_env(spec, &secret_values)?;
545 let mut with_env = spec.clone();
549 if oci {
550 with_env.env.vars.extend(secret_env.clone());
551 } else {
552 with_env.exec.env.extend(secret_env.clone());
553 }
554 let d = crate::sandbox::resolve(&c, &with_env, &project.file.volumes, &project.base_dir)?;
555 let r = crate::sandbox::ensure(&c, &d, opts, report)?;
556 if r.applied.iter().all(|a| !a.is_change()) {
557 report(&format!("{}: up to date", d.name));
558 }
559 let sb = crate::Sandbox::from_desired(&c, &d);
560 if crate::supervise::push_secrets(&sb, spec, &secret_values)? && d.image.is_oci() {
562 sb.restart()?;
563 }
564 if spec.long_running()
565 && spec.command.is_some()
566 && !d.image.is_oci()
567 && crate::supervise::install(&sb, &s, spec, !spec.secrets.is_empty(), &secret_env)?
568 {
569 report(&format!(
570 "{}: supervising command as {}",
571 d.name,
572 crate::supervise::unit_name(&s)
573 ));
574 }
575 if healthy_needed.contains(&s) {
576 wait_healthy(&sb, &s, spec, report)?;
577 }
578 out.push((s, r, sb));
579 }
580 Ok(out)
581}
582
583pub fn wait_healthy(
586 sb: &crate::Sandbox,
587 service: &str,
588 spec: &crate::SandboxSpec,
589 report: &mut dyn FnMut(&str),
590) -> Result<()> {
591 let Some(check) = spec.health_probe().map_err(Error::invalid)? else {
592 return Ok(());
593 };
594 let deadline = (check.start_period + check.interval * check.retries)
595 .max(std::time::Duration::from_secs(60));
596 let started = std::time::Instant::now();
597 report(&format!(
598 "{}: waiting for {service} to be healthy",
599 sb.name()
600 ));
601 loop {
602 let p = crate::supervise::probe(sb, &check);
603 if p.ok {
604 report(&format!("{}: healthy", sb.name()));
605 return Ok(());
606 }
607 if started.elapsed() >= deadline {
608 return Err(Error::NotReady {
609 sandbox: sb.name().to_string(),
610 check: "healthcheck".into(),
611 detail: p.output,
612 waited: started.elapsed(),
613 });
614 }
615 std::thread::sleep(check.start_interval.min(check.interval));
616 }
617}
618
619pub fn plan(
621 client: &crate::Client,
622 project: &Project,
623 services: &[String],
624 diff: crate::DiffOptions,
625) -> Result<Vec<crate::SandboxPlan>> {
626 let c = client_for(client, project);
627 let mut plans = Vec::new();
628 for s in project.select(services)? {
629 let d = crate::sandbox::resolve(
630 &c,
631 project.service(&s)?,
632 &project.file.volumes,
633 &project.base_dir,
634 )?;
635 plans.push(crate::sandbox::plan_desired(&c, &d, diff)?);
636 }
637 Ok(plans)
638}
639
640pub fn down(
644 client: &crate::Client,
645 project: &Project,
646 services: &[String],
647 volumes: bool,
648 report: &mut dyn FnMut(&str),
649) -> Result<()> {
650 let c = client_for(client, project);
651 for s in project.select_exact(services)?.into_iter().rev() {
653 let name = project.service(&s)?.name.clone().unwrap_or_default();
654 match crate::Sandbox::remove(&c, &name, true) {
655 Ok(()) => report(&format!("{name}: deleted")),
656 Err(e) if e.is_not_found() => report(&format!("{name}: not present")),
657 Err(e) => return Err(e),
658 }
659 }
660 if !volumes {
661 return Ok(());
662 }
663 if !services.is_empty() {
664 report("volumes kept: they are shared by the file; remove them with a full down");
665 return Ok(());
666 }
667 let facts = crate::sandbox::host_facts(&c)?;
668 let mut seen = std::collections::BTreeSet::new();
669 let vol_name = |key: &str| {
670 project
671 .file
672 .volumes
673 .get(key)
674 .and_then(|d| d.name.clone())
675 .unwrap_or_else(|| key.to_string())
676 };
677 for spec in project.file.services.values() {
678 let pool = facts.pick_pool(spec.storage.as_deref())?;
679 for v in &spec.volumes {
680 if v.mount_type != MountType::Volume {
681 continue;
682 }
683 let def = project.file.volumes.get(&v.source);
684 if v.external || def.is_some_and(|d| d.external) {
685 continue;
686 }
687 let vpool = match v.pool.as_deref().or(def.and_then(|d| d.pool.as_deref())) {
688 Some(x) if x != "auto" => x.to_string(),
689 _ => pool.clone(),
690 };
691 seen.insert((vpool, vol_name(&v.source)));
692 }
693 }
694 for (key, def) in &project.file.volumes {
695 let vname = vol_name(key);
696 if !def.external && !seen.iter().any(|(_, n)| *n == vname) {
697 seen.insert((facts.pick_pool(def.pool.as_deref())?, vname));
698 }
699 }
700 for (pool, vname) in seen {
701 match crate::volume::remove(&c, &pool, &vname) {
702 Ok(()) => report(&format!("volume {vname}: deleted")),
703 Err(e) if e.is_not_found() => {}
704 Err(e) => report(&format!("volume {vname}: kept ({e})")),
705 }
706 }
707 Ok(())
708}
709
710pub fn sanitize_name(s: &str) -> String {
712 let mut out = String::new();
713 for c in s.to_ascii_lowercase().chars() {
714 if c.is_ascii_alphanumeric() {
715 out.push(c);
716 } else if !out.ends_with('-') {
717 out.push('-');
718 }
719 }
720 let out = out.trim_matches('-').to_string();
721 if out.starts_with(|c: char| c.is_ascii_alphabetic()) {
722 out
723 } else {
724 format!("isb-{out}").trim_end_matches('-').to_string()
725 }
726}
727
728fn strip_extensions(v: &mut Value) {
731 let Value::Mapping(top) = v else { return };
732 top.retain(|k, _| {
733 !k.as_str()
734 .is_some_and(|s| s.starts_with("x-") || s == "version")
735 });
736 if let Some(Value::Mapping(sbs)) = top.get_mut("services") {
737 for (_, sb) in sbs.iter_mut() {
738 if let Value::Mapping(m) = sb {
739 m.retain(|k, _| !k.as_str().is_some_and(|s| s.starts_with("x-")));
740 }
741 }
742 }
743}
744
745const DOCKER_ONLY_TOP: &[(&str, &str)] = &[
748 (
749 "networks",
750 "networking comes from incus profiles (incus_profiles)",
751 ),
752 ("configs", "bind-mount the file instead"),
753 ("include", "pass several files with -f"),
754 ("sandboxes", "services are under services:"),
755 ("project", "the incus project is incus_project:"),
756];
757
758const DOCKER_ONLY_SERVICE: &[(&str, &str)] = &[
759 (
760 "build",
761 "isb runs incus images: build one and name it in image",
762 ),
763 ("env_file", "list the variables under environment"),
764 (
765 "profiles",
766 "docker's service profiles are not supported; incus profiles are incus_profiles",
767 ),
768 (
769 "networks",
770 "networking comes from incus profiles (incus_profiles) or raw_devices",
771 ),
772 (
773 "network_mode",
774 "networking comes from incus profiles (incus_profiles) or raw_devices",
775 ),
776 ("hostname", "the guest's hostname is its container_name"),
777 ("expose", "use ports"),
778 ("devices", "use raw_devices"),
779 ("gpus", "use raw_devices, e.g. {gpu: {type: gpu}}"),
780 ("sysctls", "use raw_config with linux.sysctl.* keys"),
781 ("working_directory", "the key is working_dir"),
782 ("env", "the key is environment"),
783 ("memory", "the key is mem_limit"),
784 ("name", "the instance name is container_name"),
785];
786
787fn reject_docker_only_keys(v: &Value) -> std::result::Result<(), String> {
789 let Value::Mapping(top) = v else {
790 return Ok(());
791 };
792 let unsupported = |key: &str, table: &[(&str, &str)], at: &str| {
793 table
794 .iter()
795 .find(|(k, _)| *k == key)
796 .map(|(k, hint)| format!("{at}`{k}` is not an isb key: {hint}"))
797 };
798 for k in top.keys().filter_map(Value::as_str) {
799 if let Some(e) = unsupported(k, DOCKER_ONLY_TOP, "") {
800 return Err(e);
801 }
802 }
803 if let Some(Value::Mapping(services)) = top.get("services") {
804 for (name, svc) in services {
805 let Value::Mapping(svc) = svc else { continue };
806 let at = format!("service {:?}: ", name.as_str().unwrap_or_default());
807 for k in svc.keys().filter_map(Value::as_str) {
808 if let Some(e) = unsupported(k, DOCKER_ONLY_SERVICE, &at) {
809 return Err(e);
810 }
811 }
812 if let Some(Value::Mapping(exec)) = svc.get("exec") {
813 for (k, to) in [("user", "user"), ("cwd", "working_dir")] {
814 if exec.contains_key(k) {
815 return Err(format!(
816 "{at}`exec.{k}` is not an isb key: use {to} on the service"
817 ));
818 }
819 }
820 }
821 }
822 }
823 Ok(())
824}
825
826fn normalize_lists(v: &mut Value, lookup: &dyn Fn(&str) -> Option<String>) {
831 fn to_map(v: &mut Value, bare: &dyn Fn(&str) -> Option<String>) {
832 let Value::Sequence(items) = v else { return };
833 let mut m = serde_yaml_ng::Mapping::new();
834 for item in items.iter() {
835 let Some(s) = item.as_str() else {
836 return;
838 };
839 match s.split_once('=') {
840 Some((k, val)) => {
841 m.insert(k.into(), val.into());
842 }
843 None => {
844 if let Some(val) = bare(s) {
845 m.insert(s.into(), val.into());
846 }
847 }
848 }
849 }
850 *v = Value::Mapping(m);
851 }
852 let Some(Value::Mapping(services)) = v.get_mut("services") else {
853 return;
854 };
855 for (_, svc) in services.iter_mut() {
856 let Value::Mapping(svc) = svc else { continue };
857 if let Some(e) = svc.get_mut("environment") {
858 to_map(e, lookup);
859 }
860 if let Some(Value::Mapping(exec)) = svc.get_mut("exec") {
861 if let Some(e) = exec.get_mut("env") {
862 to_map(e, lookup);
863 }
864 }
865 if let Some(l) = svc.get_mut("labels") {
866 to_map(l, &|_| Some(String::new()));
867 }
868 }
869}
870
871fn merge_file(merged: &mut Value, v: Value) {
876 let (Value::Mapping(am), Value::Mapping(bm)) = (&mut *merged, v) else {
877 return;
879 };
880 for (k, bv) in bm {
881 let is_services = k.as_str() == Some("services");
882 match am.get_mut(&k) {
883 Some(Value::Mapping(asvcs)) if is_services => {
884 let Value::Mapping(bsvcs) = bv else {
885 am.insert(k, bv);
886 continue;
887 };
888 for (name, bsvc) in bsvcs {
889 match asvcs.get_mut(&name) {
890 Some(asvc) => merge_service(asvc, bsvc),
891 None => {
892 asvcs.insert(name, bsvc);
893 }
894 }
895 }
896 }
897 Some(av) => deep_merge(av, bv),
898 None => {
899 am.insert(k, bv);
900 }
901 }
902 }
903}
904
905fn merge_service(a: &mut Value, b: Value) {
906 let (Value::Mapping(am), Value::Mapping(bm)) = (&mut *a, &b) else {
907 deep_merge(a, b);
908 return;
909 };
910 let bm = bm.clone();
911 for (k, bv) in bm {
912 match (k.as_str(), am.get_mut(&k), bv) {
913 (Some("ports"), Some(Value::Sequence(ap)), Value::Sequence(bp)) => {
914 for p in bp {
915 if !ap.contains(&p) {
916 ap.push(p);
917 }
918 }
919 }
920 (Some("volumes"), Some(Value::Sequence(av)), Value::Sequence(bv)) => {
921 for m in bv {
922 let t = mount_target(&m);
923 match av.iter_mut().find(|x| t.is_some() && mount_target(x) == t) {
924 Some(slot) => *slot = m,
925 None => av.push(m),
926 }
927 }
928 }
929 (_, Some(av), bv) => deep_merge(av, bv),
930 (_, None, bv) => {
931 am.insert(k, bv);
932 }
933 }
934 }
935}
936
937fn mount_target(m: &Value) -> Option<String> {
939 let t = match m {
940 Value::String(s) => s.split(':').nth(1)?.to_string(),
941 Value::Mapping(map) => map.get("target")?.as_str()?.to_string(),
942 _ => return None,
943 };
944 Some(t.trim_end_matches('/').to_string())
945}
946
947fn deep_merge(a: &mut Value, b: Value) {
949 match (a, b) {
950 (Value::Mapping(am), Value::Mapping(bm)) => {
951 for (k, bv) in bm {
952 match am.get_mut(&k) {
953 Some(av) => deep_merge(av, bv),
954 None => {
955 am.insert(k, bv);
956 }
957 }
958 }
959 }
960 (a, b) => *a = b,
961 }
962}
963
964#[cfg(test)]
965mod tests {
966 use super::*;
967 use std::collections::HashMap;
968
969 fn load_with(docs: &[&str], env: &[(&str, &str)]) -> Result<Project> {
970 let env: HashMap<String, String> = env
971 .iter()
972 .map(|(k, v)| (k.to_string(), v.to_string()))
973 .collect();
974 let docs: Vec<(PathBuf, String)> = docs
975 .iter()
976 .enumerate()
977 .map(|(i, d)| (PathBuf::from(format!("f{i}.yaml")), d.to_string()))
978 .collect();
979 load_docs(&docs, Path::new("/tmp/My Project"), None, &|k| {
980 env.get(k).cloned()
981 })
982 }
983
984 #[test]
985 fn secret_sources_are_validated() {
986 let svc = "services:\n web: {image: x, secrets: [k]}\n";
987 for ok in [
988 "{file: ./k}",
989 "{environment: K}",
990 "{external: true}",
991 "{external: true, name: db.password}",
992 "{age: \"YWdl\"}",
993 "{driver: onepassword, name: \"op://vault/item/field\"}",
994 ] {
995 let doc = format!("secrets:\n k: {ok}\n{svc}");
996 assert!(load_with(&[&doc], &[]).is_ok(), "{ok}");
997 }
998 for (bad, why) in [
999 ("{}", "exactly one"),
1000 ("{file: ./k, environment: K}", "exactly one"),
1001 ("{external: true, age: x}", "exactly one"),
1002 ("{driver: onepassword}", "driver needs name"),
1003 ("{environment: K, name: x}", "name goes with"),
1004 ("{external: true, name: \"a/b\"}", "secret name"),
1005 ("{age: \" \"}", "age is empty"),
1006 ("{vault: x}", "unknown field"),
1007 ] {
1008 let doc = format!("secrets:\n k: {bad}\n{svc}");
1009 let e = load_with(&[&doc], &[]).unwrap_err().to_string();
1010 assert!(e.contains(why), "{bad}: {e}");
1011 }
1012 let bad =
1014 "secrets:\n k/x: {external: true}\nservices:\n web: {image: x, secrets: [k/x]}\n";
1015 assert!(load_with(&[bad], &[]).is_err());
1016 let p = load_with(&[&format!("secrets:\n k: {{external: true}}\n{svc}")], &[]).unwrap();
1017 assert_eq!(p.file.secrets["k"].store_name("k"), Some("k"));
1018 let e = p.secret_values().unwrap_err().to_string();
1020 assert!(e.contains("external"), "{e}");
1021 assert_eq!(p.store_backed_secrets().len(), 1);
1022 let mut p2 = p.clone();
1023 p2.store_secrets.0.insert("k".into(), b"v".to_vec());
1024 assert_eq!(p2.secret_values().unwrap()["k"], b"v");
1025 assert!(
1026 !format!("{p2:?}").contains("118"),
1027 "values are not in Debug"
1028 );
1029 }
1030
1031 #[test]
1032 fn environment_secrets() {
1033 let mut ok = load_with(
1034 &["secrets: {k: {environment: K}}\nservices:\n web: {image: docker:busybox, environment: {TOKEN: {secret: k}, A: 1}}\n"],
1035 &[],
1036 )
1037 .unwrap();
1038 ok.vars.insert("K".into(), "v".into());
1039 let web = &ok.file.services["web"];
1040 assert_eq!(web.env.secrets["TOKEN"], "k");
1041 assert_eq!(web.env["A"], "1");
1042 assert_eq!(ok.secret_values().unwrap()["k"], b"v");
1043 let e = load_with(
1045 &["services:\n web: {image: docker:busybox, environment: {T: {secret: nope}}}\n"],
1046 &[],
1047 )
1048 .unwrap_err()
1049 .to_string();
1050 assert!(e.contains("not declared"), "{e}");
1051 let e = load_with(
1053 &["secrets: {k: {environment: K}}\nservices:\n web: {image: dev-base, environment: {T: {secret: k}}}\n"],
1054 &[("K", "v")],
1055 )
1056 .unwrap_err()
1057 .to_string();
1058 assert!(e.contains("need a command"), "{e}");
1059 assert!(
1060 load_with(
1061 &["secrets: {k: {environment: K}}\nservices:\n web: {image: dev-base, command: [app], environment: {T: {secret: k}}}\n"],
1062 &[("K", "v")],
1063 )
1064 .is_ok()
1065 );
1066 let svc = "services:\n web: {image: x, secrets: [k]}\n";
1068 for (bad, why) in [
1069 ("{external: true, refresh: 1h}", "refresh goes with driver"),
1070 ("{driver: d, name: r, refresh: 1s}", "at least 10s"),
1071 ("{driver: d, name: r, refresh: soon}", "refresh"),
1072 ] {
1073 let doc = format!("secrets:\n k: {bad}\n{svc}");
1074 let e = load_with(&[&doc], &[]).unwrap_err().to_string();
1075 assert!(e.contains(why), "{bad}: {e}");
1076 }
1077 let p = load_with(
1078 &[&format!(
1079 "secrets:\n k: {{driver: d, name: r, refresh: 30m}}\n{svc}"
1080 )],
1081 &[],
1082 )
1083 .unwrap();
1084 assert_eq!(
1085 p.file.secrets["k"].refresh_interval(),
1086 std::time::Duration::from_secs(1800)
1087 );
1088 }
1089
1090 #[test]
1091 fn defaults_names_from_project() {
1092 let p = load_with(&["services:\n web: {image: dev-base}\n"], &[]).unwrap();
1093 assert_eq!(p.name, "my-project");
1094 assert_eq!(
1095 p.file.services["web"].name.as_deref(),
1096 Some("my-project-web")
1097 );
1098 let p = load_with(&["name: lasso\nservices:\n Web_1: {image: x}\n"], &[]).unwrap();
1099 assert_eq!(
1100 p.file.services["Web_1"].name.as_deref(),
1101 Some("lasso-web-1")
1102 );
1103 }
1104
1105 #[test]
1106 fn named_volumes_are_project_prefixed() {
1107 let p = load_with(
1108 &["name: app\nvolumes:\n cache: {}\n shared: {external: true}\n pinned: {name: exactly-this}\nservices:\n web:\n image: x\n volumes: [cache:/c, shared:/s, pinned:/p]\n"],
1109 &[],
1110 )
1111 .unwrap();
1112 let v = &p.file.volumes;
1113 assert_eq!(v["cache"].name.as_deref(), Some("app_cache"));
1114 assert_eq!(v["shared"].name.as_deref(), Some("shared"));
1115 assert_eq!(v["pinned"].name.as_deref(), Some("exactly-this"));
1116 assert_eq!(p.file.services["web"].volumes[0].source, "cache");
1118 }
1119
1120 #[test]
1121 fn undeclared_named_volume_is_an_error() {
1122 let e = load_with(
1123 &["services:\n web: {image: x, volumes: [cache:/c]}\n"],
1124 &[],
1125 )
1126 .unwrap_err()
1127 .to_string();
1128 assert!(e.contains("\"cache\"") && e.contains("not declared"), "{e}");
1129 }
1130
1131 #[test]
1132 fn interpolates_and_types() {
1133 let p = load_with(
1134 &["services:\n web:\n container_name: \"${NAME}\"\n image: dev-base\n cpus: ${CPUS:-8}\n labels: {wt: \"${WT}\"}\n"],
1135 &[("NAME", "dev-x"), ("WT", "/w")],
1136 )
1137 .unwrap();
1138 let w = &p.file.services["web"];
1139 assert_eq!(w.name.as_deref(), Some("dev-x"));
1140 assert_eq!(w.cpus.as_deref(), Some("8"));
1141 assert_eq!(w.labels["wt"], "/w");
1142 }
1143
1144 #[test]
1145 fn bare_environment_keys_come_from_the_environment() {
1146 let p = load_with(
1147 &["services:\n web:\n image: x\n environment: [SET, UNSET, A=1]\n exec: {env: [SET]}\n"],
1148 &[("SET", "yes")],
1149 )
1150 .unwrap();
1151 let w = &p.file.services["web"];
1152 assert_eq!(w.env.len(), 2);
1153 assert_eq!(w.env["SET"], "yes");
1154 assert_eq!(w.exec.env["SET"], "yes");
1155 }
1156
1157 #[test]
1158 fn unset_variable_is_an_error_naming_the_file() {
1159 let e = load_with(&["services:\n web: {image: \"${IMG}\"}\n"], &[])
1160 .unwrap_err()
1161 .to_string();
1162 assert!(e.contains("f0.yaml") && e.contains("IMG"), "{e}");
1163 }
1164
1165 #[test]
1166 fn later_files_merge_over_earlier() {
1167 let p = load_with(
1168 &[
1169 "services:\n web:\n image: dev-base\n cpus: 8\n labels: [a=1]\n environment: [X=1]\n ports: [8080:80]\n volumes: [./a:/a, ./b:/b]\n command: [one]\n",
1170 "services:\n web:\n cpus: 4\n labels: {b: '2'}\n environment: [Y=2]\n ports: [8080:80, 9090:90]\n volumes: ['./c:/a/:ro']\n command: two three\n",
1171 ],
1172 &[],
1173 )
1174 .unwrap();
1175 let w = &p.file.services["web"];
1176 assert_eq!(w.image, "dev-base");
1177 assert_eq!(w.cpus.as_deref(), Some("4"));
1178 assert_eq!(w.labels.len(), 2);
1179 assert_eq!(w.env.len(), 2);
1180 assert_eq!(w.ports.len(), 2);
1182 assert_eq!(w.volumes.len(), 2);
1183 assert_eq!(w.volumes[0].source, "./c");
1184 assert!(w.volumes[0].read_only);
1185 assert_eq!(w.volumes[1].source, "./b");
1186 assert_eq!(w.command.as_deref().unwrap(), ["two", "three"]);
1187 }
1188
1189 #[test]
1190 fn extension_keys_anchors_and_version() {
1191 let p = load_with(
1192 &["version: '3.8'\nx-common: &common\n image: dev-base\n cpus: 2\nservices:\n a:\n <<: *common\n x-note: hi\n b:\n <<: *common\n cpus: 3\n"],
1193 &[],
1194 )
1195 .unwrap();
1196 assert_eq!(p.file.services["a"].cpus.as_deref(), Some("2"));
1197 assert_eq!(p.file.services["b"].cpus.as_deref(), Some("3"));
1198 assert_eq!(p.file.services["b"].image, "dev-base");
1199 }
1200
1201 #[test]
1202 fn unknown_fields_rejected() {
1203 let e = load_with(&["services:\n web: {image: x, mem: 1}\n"], &[])
1204 .unwrap_err()
1205 .to_string();
1206 assert!(e.contains("mem"), "{e}");
1207 let e = load_with(&["service:\n web: {image: x}\n"], &[])
1208 .unwrap_err()
1209 .to_string();
1210 assert!(e.contains("service"), "{e}");
1211 }
1212
1213 #[test]
1214 fn docker_only_keys_get_a_hint() {
1215 let hint = |doc: &str| load_with(&[doc], &[]).unwrap_err().to_string();
1216 let e = hint("services:\n web: {image: x, build: .}\n");
1217 assert!(e.contains("`build`") && e.contains("image"), "{e}");
1218 let e = hint("services:\n web: {image: x, env_file: a.env}\n");
1219 assert!(e.contains("environment"), "{e}");
1220 let e = hint("services:\n web: {image: x, profiles: [dev]}\n");
1221 assert!(e.contains("incus_profiles"), "{e}");
1222 let e = hint("networks: {}\nservices: {}\n");
1223 assert!(e.contains("`networks`"), "{e}");
1224 let e = hint("sandboxes:\n web: {image: x}\n");
1225 assert!(e.contains("services"), "{e}");
1226 let e = hint("services:\n web: {image: x, exec: {user: dev}}\n");
1227 assert!(e.contains("user on the service"), "{e}");
1228 }
1229
1230 #[test]
1231 fn select_services() {
1232 let p = load_with(&["services:\n a: {image: x}\n b: {image: x}\n"], &[]).unwrap();
1233 assert_eq!(p.select(&[]).unwrap(), vec!["a", "b"]);
1234 assert_eq!(p.select(&["b".into()]).unwrap(), vec!["b"]);
1235 assert!(p.select(&["c".into()]).is_err());
1236 }
1237
1238 #[test]
1239 fn sanitizes_names() {
1240 assert_eq!(sanitize_name("My Project!"), "my-project");
1241 assert_eq!(sanitize_name("123"), "isb-123");
1242 assert_eq!(sanitize_name("--a--b--"), "a-b");
1243 }
1244}