Skip to main content

isb_core/
compose.rs

1//! Loading compose files: `-f a.yaml -f b.yaml`, interpolation, defaults.
2
3use 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
12/// File names tried, in order, when no `-f` is given.
13pub const DEFAULT_FILES: &[&str] = &["isb.yaml", "isb.yml"];
14
15/// Override files merged over the default file when no `-f` is given, like
16/// docker's `compose.override.yaml`.
17pub const OVERRIDE_FILES: &[&str] = &["isb.override.yaml", "isb.override.yml"];
18
19/// A loaded, interpolated, merged compose project.
20#[derive(Debug, Clone)]
21pub struct Project {
22    /// Project name (default sandbox names are `<name>-<service>`).
23    pub name: String,
24    /// The merged file, with every service's `container_name` and every
25    /// volume's `name` filled in.
26    pub file: ComposeFile,
27    /// Directory of the first file: relative bind paths resolve against it.
28    pub base_dir: PathBuf,
29    pub files: Vec<PathBuf>,
30    /// Explicit variables and the env files' values, kept so that secrets
31    /// with `environment:` resolve the way `${VAR}` did.
32    pub vars: BTreeMap<String, String>,
33    pub dotenv: BTreeMap<String, String>,
34    /// Values of the secrets that come from the org's store (`external`,
35    /// `age`, `driver`), read by the caller before `up`
36    /// ([`Project::store_backed_secrets`] says which).
37    pub store_secrets: SecretValues,
38}
39
40/// Secret values by top-level key. Debug output shows the keys only.
41#[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    /// A sandbox by service name.
52    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    /// Service names in dependency order, or the given subset (validated)
68    /// plus what it depends on, as `docker compose up web` also starts web's
69    /// dependencies.
70    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    /// Exactly the given services (all when empty), validated, in dependency
92    /// order. For commands that should not pull in dependencies (`down`, `ps`).
93    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    /// A variable as interpolation sees it: explicit vars, then the
113    /// environment, then the env files.
114    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    /// The values of the secrets the services use: `file:` and
123    /// `environment:` ones read here, the rest from
124    /// [`Project::store_secrets`].
125    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    /// The secrets the services use whose values come from the org's store
142    /// and the daemon's key (`external`, `age`, `driver`).
143    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    /// The resolved project as YAML (what `isb config` prints).
154    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/// How to load.
160#[derive(Debug, Clone, Default)]
161pub struct LoadOptions {
162    /// Compose files, merged in order. Empty: `isb.yaml` / `isb.yml` in the
163    /// cwd, plus `isb.override.yaml` / `isb.override.yml` if present.
164    pub files: Vec<PathBuf>,
165    /// dotenv files for interpolation. The process environment wins over them.
166    /// Empty: `.env` next to the first compose file, if present.
167    pub env_files: Vec<PathBuf>,
168    /// Overrides the project name.
169    pub project_name: Option<String>,
170    /// Variables for interpolation that win over the environment and env files.
171    pub vars: BTreeMap<String, String>,
172}
173
174/// Find the default compose file in `dir`, if any.
175pub 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
182/// Find the override file in `dir`, if any.
183pub 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
190/// Load from disk using the process environment.
191pub 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        // Like docker: `.env` in the project directory, unless --env-file.
218        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
255/// Load from in-memory documents. `base` anchors relative paths and names the
256/// default project.
257pub 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        // Validate each file on its own too, for an error that names the file.
279        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
349/// Checks across services, and folding `deploy.resources.limits` into `cpus`
350/// and `mem_limit`.
351fn 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            // A system image's secret variables live in its command's unit
390            // (or exec), never in instance config.
391            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
428/// Service names with every service after the ones it depends on (ties in
429/// name order). A cycle is an error.
430pub 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
464/// A client for the project's incus project (`incus_project:` in the file),
465/// unless `client` was already pointed elsewhere explicitly.
466pub 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
473/// `isb up`: ensure each selected service (all when `services` is empty).
474/// Returns (service, report) pairs in order.
475pub 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
488/// [`up`], also returning a handle on each sandbox (with its exec defaults),
489/// for running its `command`.
490///
491/// Services come up in dependency order. After each is ready its secrets are
492/// written and, when it is long-running (`restart`), its command is installed
493/// as a supervised unit. A dependency with `condition: service_healthy` is
494/// probed until healthy before its dependents are touched.
495pub 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        // Secret variables: an OCI image's go into its config (redacted in
546        // reports, since `env.secrets` stays set); a system image's reach
547        // its command only, through exec defaults and the unit's env file.
548        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        // An OCI app started before its files arrived: restart it once.
561        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
583/// Probe a service until it passes, within its start period plus `retries`
584/// intervals (at least a minute).
585pub 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
619/// `isb plan`: what `up` would change, per selected service.
620pub 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
640/// `isb down`: delete the selected sandboxes; with `volumes` (and no service
641/// subset), also the file's non-external named volumes, resolved to the pools
642/// `up` used. In-use volumes are kept and reported.
643pub 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    // Dependents first, the reverse of the order `up` brings them up in.
652    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
710/// Lowercase, `[a-z0-9-]`, squeezed, trimmed; starts with a letter.
711pub 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
728/// Drop `x-*` keys (compose extension fields, handy as YAML anchor holders)
729/// at the top level and inside each service, and the obsolete `version`.
730fn 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
745/// Docker compose keys isb has no equivalent for, with what to use instead.
746/// Anything else unknown is still rejected, by serde, as an unknown field.
747const 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
787/// A friendly error for a docker compose key that isb does not support.
788fn 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
826/// Turn docker's list forms of `environment`, `exec.env` and `labels` into
827/// maps, so files merge them key by key. A bare `KEY` in an environment takes
828/// its value from the variables used for interpolation, and is dropped when
829/// unset, as docker does; a bare label is empty.
830fn 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                // Leave it for serde to reject with a proper message.
837                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
871/// Merge one file over the files before it, the way docker compose does:
872/// mappings merge key by key and scalars and lists are replaced, except a
873/// service's `ports`, which are appended, and its `volumes`, which merge by
874/// target.
875fn merge_file(merged: &mut Value, v: Value) {
876    let (Value::Mapping(am), Value::Mapping(bm)) = (&mut *merged, v) else {
877        // Files are mappings once parsed and validated.
878        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
937/// The guest path of a mount in either syntax, trailing `/` ignored.
938fn 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
947/// Merge `b` over `a`: mappings merge key by key, anything else is replaced.
948fn 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        // An external secret's key is its store name unless `name` says.
1013        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        // Read from the org's store by the caller; missing, it says so.
1019        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        // An undeclared secret.
1044        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        // A system image needs a command to hand the variable to.
1052        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        // refresh goes with a driver, and is at least 10s.
1067        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        // Mounts keep the key; resolution maps it to the volume's name.
1117        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        // Ports append (an identical entry once); volumes merge by target.
1181        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}