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.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            // A system image's secret variables live in its command's unit
399            // (or exec), never in instance config.
400            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
437/// Service names with every service after the ones it depends on (ties in
438/// name order). A cycle is an error.
439pub 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
473/// A client for the project's incus project (`incus_project:` in the file),
474/// unless `client` was already pointed elsewhere explicitly.
475pub 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
482/// `isb up`: ensure each selected service (all when `services` is empty).
483/// Returns (service, report) pairs in order.
484pub 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
497/// [`up`], also returning a handle on each sandbox (with its exec defaults),
498/// for running its `command`.
499///
500/// Services come up in dependency order. After each is ready its secrets are
501/// written and, when it is long-running (`restart`), its command is installed
502/// as a supervised unit. A dependency with `condition: service_healthy` is
503/// probed until healthy before its dependents are touched.
504pub 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        // Secret variables: an OCI image's go into its config (redacted in
558        // reports, since `env.secrets` stays set); a system image's reach
559        // its command only, through exec defaults and the unit's env file.
560        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            // A new OCI instance gets its files before its first start.
570            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        // An OCI app started before its files arrived (one created by an
581        // older isb, or a stopped one started here): restart it once.
582        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
604/// Probe a service until it passes, within its start period plus `retries`
605/// intervals (at least a minute).
606pub 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
640/// `isb plan`: what `up` would change, per selected service.
641pub 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
661/// `isb down`: delete the selected sandboxes; with `volumes` (and no service
662/// subset), also the file's non-external named volumes, resolved to the pools
663/// `up` used. In-use volumes are kept and reported.
664pub 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    // Dependents first, the reverse of the order `up` brings them up in.
673    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
731/// Lowercase, `[a-z0-9-]`, squeezed, trimmed; starts with a letter.
732pub 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
749/// Drop `x-*` keys (compose extension fields, handy as YAML anchor holders)
750/// at the top level and inside each service, and the obsolete `version`.
751fn 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
766/// Docker compose keys isb has no equivalent for, with what to use instead.
767/// Anything else unknown is still rejected, by serde, as an unknown field.
768const 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
808/// A friendly error for a docker compose key that isb does not support.
809fn 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
847/// Turn docker's list forms of `environment`, `exec.env` and `labels` into
848/// maps, so files merge them key by key. A bare `KEY` in an environment takes
849/// its value from the variables used for interpolation, and is dropped when
850/// unset, as docker does; a bare label is empty.
851fn 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                // Leave it for serde to reject with a proper message.
858                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
892/// Merge one file over the files before it, the way docker compose does:
893/// mappings merge key by key and scalars and lists are replaced, except a
894/// service's `ports`, which are appended, and its `volumes`, which merge by
895/// target.
896fn merge_file(merged: &mut Value, v: Value) {
897    let (Value::Mapping(am), Value::Mapping(bm)) = (&mut *merged, v) else {
898        // Files are mappings once parsed and validated.
899        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
958/// The guest path of a mount in either syntax, trailing `/` ignored.
959fn 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
968/// Merge `b` over `a`: mappings merge key by key, anything else is replaced.
969fn 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;