Skip to main content

isb_core/
spec.rs

1//! The one spec model shared by the library API, the CLI and the compose YAML.
2//!
3//! Every struct denies unknown fields, so a typo is an error rather than a
4//! silently ignored setting. The JSON Schema (`isb schema`) is generated from these types.
5
6use std::collections::BTreeMap;
7
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11use crate::flex;
12
13/// A compose file: named volumes plus any number of services, each one
14/// sandbox. Mirrors docker compose wherever incus allows.
15#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
16#[serde(deny_unknown_fields)]
17pub struct ComposeFile {
18    /// Project name. Default sandbox names are `<name>-<service>` and named
19    /// volumes are `<name>_<volume>`. Defaults to the directory holding the
20    /// first compose file.
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub name: Option<String>,
23
24    /// incus project to operate in (default: `default`).
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub incus_project: Option<String>,
27
28    /// Named custom storage volumes, created if missing before any sandbox that
29    /// uses them. Keys are what services refer to.
30    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
31    pub volumes: BTreeMap<String, NamedVolumeSpec>,
32
33    /// Sandboxes, keyed by service name.
34    #[serde(default)]
35    pub services: BTreeMap<String, SandboxSpec>,
36
37    /// Secrets services can mount as files under `/run/secrets`. Values are
38    /// read when the file is deployed and never stored in instance config.
39    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
40    pub secrets: BTreeMap<String, SecretDef>,
41}
42
43/// Where a secret's value comes from. Exactly one source.
44#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
45#[serde(deny_unknown_fields)]
46pub struct SecretDef {
47    /// A host file holding the value (relative to the compose file).
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub file: Option<String>,
50
51    /// An environment variable of whoever deploys the file (`isb up`, or the
52    /// client calling `isb stack deploy`).
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub environment: Option<String>,
55
56    /// The secret already exists in the org's secret store (`isb secret
57    /// create`), under `name` (default: the key).
58    #[serde(
59        default,
60        deserialize_with = "flex::bool",
61        skip_serializing_if = "std::ops::Not::not"
62    )]
63    #[schemars(with = "flex::BoolOrString")]
64    pub external: bool,
65
66    /// With `external`: the store's name for it. With `driver`: the
67    /// driver's reference (a 1Password `op://` path, say).
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub name: Option<String>,
70
71    /// The value, age-encrypted to the daemon's recipients (`isb secret
72    /// encrypt`): ASCII-armored, or base64 of the binary format.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub age: Option<String>,
75
76    /// Read through this secrets driver, from `name`.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub driver: Option<String>,
79
80    /// With `driver`: how often `isb serve` checks the driver for a new
81    /// version (`30m`, `1h`; default 1h). A new version rolls the services
82    /// using it.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub refresh: Option<String>,
85}
86
87impl SecretDef {
88    /// Check that exactly one source is given: `file`, `environment`,
89    /// `external`, `age`, or `driver` with `name`.
90    pub fn validate(&self) -> std::result::Result<(), String> {
91        let sources = [
92            self.file.is_some(),
93            self.environment.is_some(),
94            self.external,
95            self.age.is_some(),
96            self.driver.is_some(),
97        ];
98        if sources.iter().filter(|s| **s).count() != 1 {
99            return Err(
100                "needs exactly one of file, environment, external, age, or driver (with name)"
101                    .into(),
102            );
103        }
104        if self.name.is_some() && !self.external && self.driver.is_none() {
105            return Err("name goes with external or driver".into());
106        }
107        if self.driver.is_some() && self.name.as_deref().is_none_or(str::is_empty) {
108            return Err("driver needs name: the driver's reference to the secret".into());
109        }
110        if self.external {
111            if let Some(n) = &self.name {
112                crate::secrets::validate_name(n).map_err(|e| e.to_string())?;
113            }
114        }
115        if self.age.as_deref().is_some_and(|a| a.trim().is_empty()) {
116            return Err("age is empty".into());
117        }
118        if let Some(r) = &self.refresh {
119            if self.driver.is_none() {
120                return Err("refresh goes with driver".into());
121            }
122            let d = flex::parse_duration(r).map_err(|e| format!("refresh: {e}"))?;
123            if d < std::time::Duration::from_secs(10) {
124                return Err(format!("refresh {r:?}: at least 10s"));
125            }
126        }
127        Ok(())
128    }
129
130    /// The store name of an `external` secret declared under `key`.
131    pub fn store_name<'a>(&'a self, key: &'a str) -> Option<&'a str> {
132        self.external.then(|| self.name.as_deref().unwrap_or(key))
133    }
134
135    /// How often a driver-backed secret is checked for a new version.
136    pub fn refresh_interval(&self) -> std::time::Duration {
137        self.refresh
138            .as_deref()
139            .and_then(|r| flex::parse_duration(r).ok())
140            .unwrap_or(DEFAULT_SECRET_REFRESH)
141    }
142
143    /// Resolved where the deployer stands (`file`, `environment`), rather
144    /// than from the org's store and the daemon's key.
145    pub fn is_client_side(&self) -> bool {
146        self.file.is_some() || self.environment.is_some()
147    }
148
149    /// The source kind, for messages.
150    pub fn source_kind(&self) -> &'static str {
151        if self.file.is_some() {
152            "file"
153        } else if self.environment.is_some() {
154            "environment"
155        } else if self.external {
156            "external"
157        } else if self.age.is_some() {
158            "age"
159        } else if self.driver.is_some() {
160            "driver"
161        } else {
162            "none"
163        }
164    }
165}
166
167/// How often `isb serve` checks a driver-backed secret by default.
168pub const DEFAULT_SECRET_REFRESH: std::time::Duration = std::time::Duration::from_secs(3600);
169
170/// A service's environment: plain values, and variables whose value is a
171/// top-level secret (`KEY: {secret: NAME}`).
172#[derive(Debug, Clone, Default, PartialEq)]
173pub struct Environment {
174    /// `KEY: VALUE`: instance config (`environment.KEY`).
175    pub vars: BTreeMap<String, String>,
176    /// `KEY: {secret: NAME}`: variable to top-level secret key.
177    pub secrets: BTreeMap<String, String>,
178}
179
180impl Environment {
181    pub fn is_empty(&self) -> bool {
182        self.vars.is_empty() && self.secrets.is_empty()
183    }
184}
185
186/// The plain values, so `spec.env` reads as the map it mostly is.
187impl std::ops::Deref for Environment {
188    type Target = BTreeMap<String, String>;
189    fn deref(&self) -> &Self::Target {
190        &self.vars
191    }
192}
193
194impl std::ops::DerefMut for Environment {
195    fn deref_mut(&mut self) -> &mut Self::Target {
196        &mut self.vars
197    }
198}
199
200impl<'a> IntoIterator for &'a Environment {
201    type Item = (&'a String, &'a String);
202    type IntoIter = std::collections::btree_map::Iter<'a, String, String>;
203    fn into_iter(self) -> Self::IntoIter {
204        self.vars.iter()
205    }
206}
207
208impl From<BTreeMap<String, String>> for Environment {
209    fn from(vars: BTreeMap<String, String>) -> Self {
210        Environment {
211            vars,
212            secrets: BTreeMap::new(),
213        }
214    }
215}
216
217impl Serialize for Environment {
218    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
219        use serde::ser::SerializeMap;
220        let mut m = s.serialize_map(None)?;
221        let mut keys: Vec<&String> = self.vars.keys().chain(self.secrets.keys()).collect();
222        keys.sort();
223        keys.dedup();
224        for k in keys {
225            match (self.vars.get(k), self.secrets.get(k)) {
226                (Some(v), _) => m.serialize_entry(k, v)?,
227                (None, Some(sec)) => {
228                    m.serialize_entry(k, &BTreeMap::from([("secret", sec.as_str())]))?
229                }
230                (None, None) => {}
231            }
232        }
233        m.end()
234    }
235}
236
237impl<'de> Deserialize<'de> for Environment {
238    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
239        use serde::de::Error as _;
240        let mut env = Environment::default();
241        match flex::EnvMapOrList::deserialize(d)? {
242            flex::EnvMapOrList::Map(m) => {
243                for (k, v) in m {
244                    match v {
245                        flex::EnvValue::Scalar(v) => {
246                            env.vars.insert(k, v.into_string());
247                        }
248                        flex::EnvValue::Secret { secret } if secret.is_empty() => {
249                            return Err(D::Error::custom(format!(
250                                "environment {k}: secret needs a top-level secret's name"
251                            )));
252                        }
253                        flex::EnvValue::Secret { secret } => {
254                            env.secrets.insert(k, secret);
255                        }
256                    }
257                }
258            }
259            flex::EnvMapOrList::List(l) => {
260                for item in l {
261                    let Some((k, v)) = item.split_once('=') else {
262                        return Err(D::Error::custom(format!(
263                            "environment entry {item:?} has no value: write {item}=VALUE"
264                        )));
265                    };
266                    env.vars.insert(k.to_string(), v.to_string());
267                }
268            }
269        }
270        Ok(env)
271    }
272}
273
274/// A named custom storage volume.
275#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
276#[serde(deny_unknown_fields)]
277pub struct NamedVolumeSpec {
278    /// The incus volume name. Default: `<project>_<key>`, or the key itself
279    /// for an `external` volume.
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub name: Option<String>,
282
283    /// Storage pool. `auto` (default) means the same pool the sandbox uses
284    /// (`storage`), resolved the same way.
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub pool: Option<String>,
287
288    /// Volume config keys (e.g. `size: 10GiB`), applied only at creation.
289    #[serde(
290        default,
291        deserialize_with = "flex::string_map",
292        skip_serializing_if = "BTreeMap::is_empty"
293    )]
294    #[schemars(with = "BTreeMap<String, flex::Scalar>")]
295    pub config: BTreeMap<String, String>,
296
297    /// The volume must already exist; isb never creates it.
298    #[serde(
299        default,
300        deserialize_with = "flex::bool",
301        skip_serializing_if = "std::ops::Not::not"
302    )]
303    #[schemars(with = "flex::BoolOrString")]
304    pub external: bool,
305}
306
307/// Instance type.
308#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
309#[serde(rename_all = "kebab-case")]
310pub enum InstanceType {
311    /// A system container (lxc): shares the host kernel, near-zero overhead,
312    /// idmapped bind mounts, proxies in both directions.
313    #[default]
314    Container,
315    /// A virtual machine (qemu): its own kernel. Needs a VM image and the incus
316    /// agent in the guest for exec. `vm` is accepted as shorthand.
317    #[serde(alias = "vm")]
318    VirtualMachine,
319}
320
321// Written by hand because schemars ignores `#[serde(alias)]`: the derived schema
322// would list only `container` and `virtual-machine`, and editors and the SDKs'
323// generated types would then reject `vm`, which isb accepts.
324impl JsonSchema for InstanceType {
325    fn schema_name() -> std::borrow::Cow<'static, str> {
326        "InstanceType".into()
327    }
328
329    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
330        schemars::json_schema!({
331            "description": "Instance type.",
332            "oneOf": [
333                {
334                    "type": "string",
335                    "const": "container",
336                    "description": "A system container (lxc): shares the host kernel, near-zero overhead, idmapped bind mounts, proxies in both directions."
337                },
338                {
339                    "type": "string",
340                    "const": "virtual-machine",
341                    "description": "A virtual machine (qemu): its own kernel. Needs a VM image and the incus agent in the guest for exec."
342                },
343                {
344                    "type": "string",
345                    "const": "vm",
346                    "description": "Shorthand for virtual-machine."
347                }
348            ]
349        })
350    }
351}
352
353impl InstanceType {
354    pub fn as_api(&self) -> &'static str {
355        match self {
356            InstanceType::Container => "container",
357            InstanceType::VirtualMachine => "virtual-machine",
358        }
359    }
360}
361
362/// Everything about one sandbox: a compose service.
363#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
364#[serde(deny_unknown_fields)]
365pub struct SandboxSpec {
366    /// incus instance name: at most 63 characters of `[a-z0-9-]` (case-insensitive),
367    /// starting with a letter. In a compose file it defaults to `<project>-<service>`.
368    #[serde(
369        default,
370        rename = "container_name",
371        skip_serializing_if = "Option::is_none"
372    )]
373    pub name: Option<String>,
374
375    /// Image: a local alias or fingerprint (`dev-base`), or `remote:alias` for a
376    /// well-known remote (`images:debian/12`, `ubuntu:24.04`). A local alias that
377    /// does not exist is an error before anything is created.
378    #[serde(default)]
379    pub image: String,
380
381    /// `container` (default) or `virtual-machine` (`vm`). Fixed at creation.
382    ///
383    /// A VM is a stronger boundary (its own kernel) at the cost of boot time
384    /// and memory. Container-only settings are refused for a VM: `privileged`,
385    /// and any explicit `idmap` (`idmap: auto` is a no-op there). Host paths are
386    /// shared into a VM over virtiofs, where inotify from host edits is not
387    /// delivered, so file watchers inside the VM need polling. Proxies into a
388    /// VM must be host-bound, and incus runs them in NAT mode (`nat: true`,
389    /// set automatically); `bind: guest` is not available for VMs.
390    #[serde(default, rename = "type", skip_serializing_if = "is_default")]
391    pub instance_type: InstanceType,
392
393    /// Storage pool for the root disk. `auto` (default): `incus-zfs` if it exists,
394    /// else `default`, else the first pool. Fixed at creation.
395    #[serde(default, skip_serializing_if = "Option::is_none")]
396    pub storage: Option<String>,
397
398    /// Number of CPUs (`limits.cpu`), a whole number like `8`.
399    #[serde(
400        default,
401        deserialize_with = "flex::opt_string",
402        skip_serializing_if = "Option::is_none"
403    )]
404    #[schemars(with = "Option<flex::IntOrString>")]
405    pub cpus: Option<String>,
406
407    /// CPUs to pin to (`limits.cpu`), e.g. `0-3` or `0,2`. Excludes `cpus`.
408    #[serde(
409        default,
410        deserialize_with = "flex::opt_string",
411        skip_serializing_if = "Option::is_none"
412    )]
413    #[schemars(with = "Option<flex::IntOrString>")]
414    pub cpuset: Option<String>,
415
416    /// Memory limit (`limits.memory`): docker units (`512m`, `8g`, bytes) or
417    /// incus ones (`8GiB`, `50%`).
418    #[serde(
419        default,
420        rename = "mem_limit",
421        deserialize_with = "flex::opt_string",
422        skip_serializing_if = "Option::is_none"
423    )]
424    #[schemars(with = "Option<flex::IntOrString>")]
425    pub memory: Option<String>,
426
427    /// Run privileged (`security.privileged`). Omit to leave the incus default
428    /// (unprivileged); `false` pins it explicitly.
429    #[serde(
430        default,
431        deserialize_with = "flex::opt_bool",
432        skip_serializing_if = "Option::is_none"
433    )]
434    #[schemars(with = "Option<flex::BoolOrString>")]
435    pub privileged: Option<bool>,
436
437    /// uid/gid mapping so a host user can write bind mounts. See [`IdmapSpec`].
438    #[serde(default, skip_serializing_if = "Option::is_none")]
439    pub idmap: Option<IdmapSpec>,
440
441    /// incus profiles to apply, in order. Default: `[default]`. Fixed at creation.
442    #[serde(
443        default,
444        rename = "incus_profiles",
445        skip_serializing_if = "Option::is_none"
446    )]
447    pub profiles: Option<Vec<String>>,
448
449    /// Labels, stored as `user.<key>` config keys: a map, or a list of
450    /// `KEY=VALUE`. Used by `isb ls --label` and `isb prune`. isb never removes
451    /// a label it was not told about.
452    #[serde(
453        default,
454        deserialize_with = "flex::string_map_or_list",
455        skip_serializing_if = "BTreeMap::is_empty"
456    )]
457    #[schemars(with = "flex::MapOrList")]
458    pub labels: BTreeMap<String, String>,
459
460    /// Instance environment (`environment.<KEY>`), seen by every exec: a map, or
461    /// a list of `KEY=VALUE`. A plain value is instance config, readable by
462    /// anyone who can read the instance. `KEY: {secret: NAME}` delivers the
463    /// top-level secret NAME as the variable (docs/guides/secrets.md).
464    #[serde(
465        default,
466        rename = "environment",
467        skip_serializing_if = "Environment::is_empty"
468    )]
469    #[schemars(with = "flex::EnvMapOrList")]
470    pub env: Environment,
471
472    /// Mounts: `SOURCE:TARGET[:OPTIONS]` or the long form. A source starting
473    /// with `/`, `.` or `~` is a host path; anything else is a named volume.
474    #[serde(default, skip_serializing_if = "Vec::is_empty")]
475    pub volumes: Vec<VolumeSpec>,
476
477    /// Published ports (`[HOST_IP:]PUBLISHED:TARGET[/PROTOCOL]` or the long
478    /// form), and incus proxies in either direction (`listen`/`connect`).
479    #[serde(default, skip_serializing_if = "Vec::is_empty")]
480    pub ports: Vec<PortSpec>,
481
482    /// Readiness checks, run in order after every start/ensure. Default:
483    /// `[running]` for a container, `[running, agent]` for a VM.
484    #[serde(default, skip_serializing_if = "Option::is_none")]
485    pub ready: Option<Vec<ReadyCheck>>,
486
487    /// Deadline for all readiness checks together, e.g. `90s`. Default: `60s`
488    /// for a container, `300s` for a VM.
489    #[serde(
490        default,
491        deserialize_with = "flex::opt_string",
492        skip_serializing_if = "Option::is_none"
493    )]
494    #[schemars(with = "Option<flex::IntOrString>")]
495    pub ready_timeout: Option<String>,
496
497    /// Guest user for `command`, `isb exec` and `path_writable`: a name
498    /// (`dev`), `uid`, `uid:gid` or `name:group`. Default root.
499    #[serde(
500        default,
501        deserialize_with = "flex::opt_string",
502        skip_serializing_if = "Option::is_none"
503    )]
504    #[schemars(with = "Option<flex::IntOrString>")]
505    pub user: Option<String>,
506
507    /// Working directory for `command` and `isb exec`. Default: the user's home.
508    #[serde(default, skip_serializing_if = "Option::is_none")]
509    pub working_dir: Option<String>,
510
511    /// More exec defaults: an exec-only environment and the login shell.
512    #[serde(default, skip_serializing_if = "ExecSpec::is_empty")]
513    pub exec: ExecSpec,
514
515    /// The sandbox's main command, run by a foreground `isb up` once the
516    /// sandbox is ready, as `user` in `working_dir`. Its output is streamed,
517    /// and `up` stops the sandbox when every command has exited. A list is
518    /// argv; a string is split like a shell would split it, without running
519    /// one. Never part of the instance, so changing it is not drift.
520    #[serde(
521        default,
522        deserialize_with = "flex::opt_command",
523        skip_serializing_if = "Option::is_none"
524    )]
525    #[schemars(with = "Option<flex::Command>")]
526    pub command: Option<Vec<String>>,
527
528    /// OCI images only: the entrypoint, run with `command` as its arguments.
529    /// On an OCI image `command` alone replaces the whole command line,
530    /// including the image's own entrypoint.
531    #[serde(
532        default,
533        deserialize_with = "flex::opt_command",
534        skip_serializing_if = "Option::is_none"
535    )]
536    #[schemars(with = "Option<flex::Command>")]
537    pub entrypoint: Option<Vec<String>>,
538
539    /// `no` (default), `always`, `on-failure` or `unless-stopped`. Anything but
540    /// `no` makes the service long-running: the instance starts with the host
541    /// (`boot.autostart`), and `command` is supervised inside the guest (a
542    /// systemd unit, or the instance itself for an OCI image) instead of being
543    /// held open by `isb up`, so it survives isb exiting.
544    #[serde(default, skip_serializing_if = "Option::is_none")]
545    pub restart: Option<RestartMode>,
546
547    /// A recurring health test, as in docker compose. `isb stack deploy`
548    /// routes traffic only to healthy replicas and replaces unhealthy ones;
549    /// `depends_on` can wait for it.
550    #[serde(default, skip_serializing_if = "Option::is_none")]
551    pub healthcheck: Option<Healthcheck>,
552
553    /// Services to bring up first: a list, or a map to `{condition:
554    /// service_started | service_healthy}`.
555    #[serde(
556        default,
557        deserialize_with = "depends_on",
558        skip_serializing_if = "BTreeMap::is_empty"
559    )]
560    #[schemars(with = "DependsOnRepr")]
561    pub depends_on: BTreeMap<String, Dependency>,
562
563    /// Replicas, rolling updates and restart policy for `isb stack deploy`.
564    #[serde(default, skip_serializing_if = "Option::is_none")]
565    pub deploy: Option<Deploy>,
566
567    /// Public hostnames `isb serve`'s ingress routes to this service's
568    /// replicas (docs/guides/domains.md). Only stacks use them; `isb up` ignores
569    /// them.
570    #[serde(default, skip_serializing_if = "Vec::is_empty")]
571    pub domains: Vec<DomainSpec>,
572
573    /// Secrets (top-level `secrets:`) to write under `/run/secrets` in the
574    /// guest: names, or `{source, target, uid, gid, mode}`.
575    #[serde(default, skip_serializing_if = "Vec::is_empty")]
576    pub secrets: Vec<SecretRef>,
577
578    /// Which hostnames the sandbox may reach, and secrets that never enter
579    /// it (docs/guides/egress.md). `none` denies all network; a list of
580    /// `host[:port]` (port 443 by default; `*.example.com` for subdomains)
581    /// denies everything else, public and private; `{allow, secrets}` adds
582    /// secrets the guest sees only as placeholders, put on the wire towards
583    /// their approved hosts. Omitted: open egress, as before. Needs
584    /// `isb serve` running for its proxy.
585    #[serde(default, skip_serializing_if = "Option::is_none")]
586    pub egress: Option<crate::egress::EgressSpec>,
587
588    /// Extra instance config keys, set verbatim (escape hatch).
589    #[serde(
590        default,
591        deserialize_with = "flex::string_map",
592        skip_serializing_if = "BTreeMap::is_empty"
593    )]
594    #[schemars(with = "BTreeMap<String, flex::Scalar>")]
595    pub raw_config: BTreeMap<String, String>,
596
597    /// Extra devices, set verbatim (escape hatch). Keys are device names.
598    #[serde(
599        default,
600        deserialize_with = "flex::string_map_map",
601        skip_serializing_if = "BTreeMap::is_empty"
602    )]
603    #[schemars(with = "BTreeMap<String, BTreeMap<String, flex::Scalar>>")]
604    pub raw_devices: BTreeMap<String, BTreeMap<String, String>>,
605    /// Nesting keys allowed: only the daemon sets it, for a workspace ([`crate::org::nesting`]).
606    #[serde(skip)]
607    pub workspace_nesting: bool,
608    /// UDP proxy devices allowed: only the stack controller sets it, for a
609    /// replica's published UDP ports ([`crate::org::check_proxies`]).
610    #[serde(skip)]
611    pub stack_udp: bool,
612}
613
614impl SandboxSpec {
615    /// The exec defaults this spec implies: `user`, `working_dir` and `exec`.
616    pub fn exec_defaults(&self) -> ExecDefaults {
617        ExecDefaults {
618            user: self.user.clone(),
619            cwd: self.working_dir.clone(),
620            env: self.exec.env.clone(),
621            login: self.exec.login,
622        }
623    }
624}
625
626fn is_default<T: Default + PartialEq>(v: &T) -> bool {
627    *v == T::default()
628}
629
630/// A hostname (and path) the ingress serves a service on.
631#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
632#[serde(deny_unknown_fields)]
633pub struct DomainSpec {
634    /// The hostname, e.g. `app.example.com`; `*.example.com` where the org
635    /// allows wildcards; or `auto` for a generated
636    /// `<service>-<stack>-<org>.<ip>.sslip.io` name.
637    pub host: String,
638
639    /// Path prefix (default `/`): `/api` matches `/api` and `/api/...`.
640    #[serde(default, skip_serializing_if = "Option::is_none")]
641    pub path: Option<String>,
642
643    /// The port the service listens on inside its replicas. Not needed with
644    /// `redirect`.
645    #[serde(default, skip_serializing_if = "Option::is_none")]
646    pub port: Option<u16>,
647
648    /// Serve over HTTPS with a certificate the ingress obtains (default
649    /// true), redirecting plain HTTP to it. `false` serves plain HTTP.
650    #[serde(
651        default,
652        deserialize_with = "flex::opt_bool",
653        skip_serializing_if = "Option::is_none"
654    )]
655    #[schemars(with = "Option<flex::BoolOrString>")]
656    pub https: Option<bool>,
657
658    /// Answer every request with a permanent redirect (308) to this URL
659    /// instead of proxying. A URL without a path keeps the request's path
660    /// and query (`https://example.com`); one with a path is used as is.
661    #[serde(default, skip_serializing_if = "Option::is_none")]
662    pub redirect: Option<String>,
663
664    /// Remove `path` from the request before passing it on.
665    #[serde(
666        default,
667        deserialize_with = "flex::bool",
668        skip_serializing_if = "std::ops::Not::not"
669    )]
670    #[schemars(with = "flex::BoolOrString")]
671    pub strip_prefix: bool,
672
673    /// Also serve `www.<host>`, redirecting it to `host`.
674    #[serde(
675        default,
676        deserialize_with = "flex::bool",
677        skip_serializing_if = "std::ops::Not::not"
678    )]
679    #[schemars(with = "flex::BoolOrString")]
680    pub www_redirect: bool,
681}
682
683/// idmap handling.
684///
685/// The usual need is "host uid/gid 1000 must be the guest's uid/gid 1000 so a
686/// bind-mounted checkout is writable". Whether that needs `raw.idmap` depends on
687/// the host: when root's subordinate id range (in `/etc/subuid`, `/etc/subgid`)
688/// already contains the host id, the default map covers it and asking for
689/// `raw.idmap` is refused by incus ("Host ID is in the range of subids"); when
690/// it does not (a separate `root:1000:1` delegation only permits the mapping),
691/// `raw.idmap` is required.
692///
693/// Forms:
694/// - `auto`: map 1000:1000 ↔ 1000:1000 only where needed (per id, uid and gid
695///   checked separately).
696/// - `none`: never set `raw.idmap`.
697/// - `always`: always set it for 1000 ↔ 1000.
698/// - `{mode: auto|always, host_uid, host_gid, guest_uid, guest_gid}`: other ids.
699/// - `{raw: "both 1000 1000"}`: an explicit `raw.idmap` value.
700#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
701#[serde(untagged)]
702pub enum IdmapSpec {
703    Mode(IdmapMode),
704    Map(IdmapMap),
705    Raw(IdmapRaw),
706}
707
708#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
709#[serde(rename_all = "snake_case")]
710pub enum IdmapMode {
711    #[default]
712    Auto,
713    None,
714    Always,
715}
716
717#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
718#[serde(deny_unknown_fields)]
719pub struct IdmapMap {
720    #[serde(default)]
721    pub mode: IdmapMode,
722    #[serde(default = "default_id")]
723    pub host_uid: u32,
724    #[serde(default = "default_id")]
725    pub host_gid: u32,
726    #[serde(default = "default_id")]
727    pub guest_uid: u32,
728    #[serde(default = "default_id")]
729    pub guest_gid: u32,
730}
731
732#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
733#[serde(deny_unknown_fields)]
734pub struct IdmapRaw {
735    /// Value for `raw.idmap`, verbatim.
736    pub raw: String,
737}
738
739fn default_id() -> u32 {
740    1000
741}
742
743/// What a mount's `source` is.
744#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
745#[serde(rename_all = "snake_case")]
746pub enum MountType {
747    /// A host path.
748    #[default]
749    Bind,
750    /// A named custom storage volume.
751    Volume,
752}
753
754/// A mount. Written as `SOURCE:TARGET[:OPTIONS]` or as the long form
755/// (`VolumeMount` in the schema); always serialized in the long form.
756#[derive(Debug, Clone, Default, PartialEq, Serialize)]
757pub struct VolumeSpec {
758    /// `bind` (a host path) or `volume` (a named volume).
759    #[serde(rename = "type")]
760    pub mount_type: MountType,
761    /// Host path (bind) or volume key (volume).
762    pub source: String,
763    /// Absolute path inside the guest.
764    pub target: String,
765    #[serde(skip_serializing_if = "std::ops::Not::not")]
766    pub read_only: bool,
767    #[serde(skip_serializing_if = "std::ops::Not::not")]
768    pub external: bool,
769    #[serde(skip_serializing_if = "Option::is_none")]
770    pub pool: Option<String>,
771    #[serde(skip_serializing_if = "Option::is_none")]
772    pub owner: Option<String>,
773    #[serde(skip_serializing_if = "Option::is_none")]
774    pub device: Option<String>,
775    #[serde(skip_serializing_if = "VolumeOptions::is_default")]
776    pub volume: VolumeOptions,
777    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
778    pub options: BTreeMap<String, String>,
779}
780
781/// docker's `volume:` block of a long-form mount.
782#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
783#[serde(deny_unknown_fields)]
784pub struct VolumeOptions {
785    /// Named volumes only: do not seed an empty volume with what the image has
786    /// at `target`. Seeding is docker's default; isb does it in containers
787    /// (incus `initial.copy`) when the server supports it.
788    #[serde(
789        default,
790        deserialize_with = "flex::bool",
791        skip_serializing_if = "std::ops::Not::not"
792    )]
793    #[schemars(with = "flex::BoolOrString")]
794    pub nocopy: bool,
795}
796
797impl VolumeOptions {
798    fn is_default(&self) -> bool {
799        *self == Self::default()
800    }
801}
802
803/// The long form of a mount.
804#[derive(Deserialize, JsonSchema)]
805#[serde(deny_unknown_fields)]
806#[allow(dead_code)]
807pub(crate) struct VolumeMount {
808    /// `bind` (a host path) or `volume` (a named volume). Default: `bind` when
809    /// `source` starts with `/`, `.` or `~`, else `volume`.
810    #[serde(default, rename = "type")]
811    mount_type: Option<MountType>,
812
813    /// Host path to bind-mount (relative paths resolve against the compose
814    /// file's directory, `~` expands, symlinks are resolved), or the key of a
815    /// named volume.
816    source: String,
817
818    /// Absolute path inside the guest.
819    target: String,
820
821    /// Mount read-only.
822    #[serde(default, deserialize_with = "flex::bool")]
823    #[schemars(with = "flex::BoolOrString")]
824    read_only: bool,
825
826    /// Named volumes only: the volume must already exist; isb never creates it.
827    #[serde(default, deserialize_with = "flex::bool")]
828    #[schemars(with = "flex::BoolOrString")]
829    external: bool,
830
831    /// Named volumes only: the storage pool. Default: the top-level volume's
832    /// pool, else the sandbox's root pool.
833    #[serde(default)]
834    pool: Option<String>,
835
836    /// Named volumes only: chown the mount point to this guest user (`dev`,
837    /// `dev:dev` or `1000:1000`) after it is attached, plus any root-owned
838    /// parents inside that user's home that the mount conjured.
839    #[serde(default, deserialize_with = "flex::opt_string")]
840    #[schemars(with = "Option<flex::IntOrString>")]
841    owner: Option<String>,
842
843    /// incus device name. Default: derived from the target. Set it to adopt an
844    /// existing device under a known name.
845    #[serde(default)]
846    device: Option<String>,
847
848    /// docker's volume options (`nocopy`).
849    #[serde(default)]
850    volume: VolumeOptions,
851
852    /// Extra disk device properties (`shift`, `propagation`, ...), verbatim.
853    #[serde(default, deserialize_with = "flex::string_map")]
854    #[schemars(with = "BTreeMap<String, flex::Scalar>")]
855    options: BTreeMap<String, String>,
856}
857
858/// Whether a mount source names a host path rather than a volume.
859pub(crate) fn is_host_path(source: &str) -> bool {
860    source.starts_with('/') || source.starts_with('.') || source.starts_with('~')
861}
862
863impl From<VolumeMount> for VolumeSpec {
864    fn from(m: VolumeMount) -> Self {
865        let mount_type = m.mount_type.unwrap_or(if is_host_path(&m.source) {
866            MountType::Bind
867        } else {
868            MountType::Volume
869        });
870        VolumeSpec {
871            mount_type,
872            source: m.source,
873            target: m.target,
874            read_only: m.read_only,
875            external: m.external,
876            pool: m.pool,
877            owner: m.owner,
878            device: m.device,
879            volume: m.volume,
880            options: m.options,
881        }
882    }
883}
884
885impl<'de> Deserialize<'de> for VolumeSpec {
886    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
887        use serde::de::Error as _;
888        match serde_json::Value::deserialize(d)? {
889            serde_json::Value::String(s) => {
890                crate::shorthand::volume(&s).map_err(|e| D::Error::custom(e.to_string()))
891            }
892            v @ serde_json::Value::Object(_) => serde_json::from_value::<VolumeMount>(v)
893                .map(Into::into)
894                .map_err(|e| D::Error::custom(format!("volume: {e}"))),
895            other => Err(D::Error::custom(format!(
896                "volume: expected SOURCE:TARGET[:OPTIONS] or {{type, source, target, ...}}, got {other}"
897            ))),
898        }
899    }
900}
901
902impl JsonSchema for VolumeSpec {
903    fn schema_name() -> std::borrow::Cow<'static, str> {
904        "VolumeSpec".into()
905    }
906
907    fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
908        let long = g.subschema_for::<VolumeMount>();
909        schemars::json_schema!({
910            "description": "A mount: `SOURCE:TARGET[:OPTIONS]` or the long form.",
911            "oneOf": [
912                {
913                    "type": "string",
914                    "description": "SOURCE:TARGET[:OPTIONS]. OPTIONS is a comma list of ro, rw, owner=USER, device=NAME, pool=POOL, external."
915                },
916                long
917            ]
918        })
919    }
920}
921
922/// Which side listens.
923#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
924#[serde(rename_all = "snake_case")]
925pub enum PortBind {
926    /// Listen on the host, connect inside the guest (publish a guest port).
927    #[default]
928    Host,
929    /// Listen inside the guest, connect on the host (reach a host service).
930    Guest,
931}
932
933impl PortBind {
934    pub fn as_str(&self) -> &'static str {
935        match self {
936            PortBind::Host => "host",
937            PortBind::Guest => "guest",
938        }
939    }
940}
941
942/// An incus proxy device. Written as docker's `[HOST_IP:]PUBLISHED:TARGET[/PROTOCOL]`,
943/// its long form (`PortMapping` in the schema), or the incus form (`ProxyPort`).
944#[derive(Debug, Clone, Default, PartialEq)]
945pub struct PortSpec {
946    /// Device name. Default: `port-<bind>-<listen port>`.
947    pub name: Option<String>,
948    pub bind: PortBind,
949    /// incus listen address (`tcp:HOST:PORT`, or any shorthand `normalize_addr`
950    /// accepts).
951    pub listen: String,
952    /// incus connect address, same forms.
953    pub connect: String,
954    /// Host-bound TCP/UDP only: if the listen port is taken, try the next one,
955    /// up to this many more. Written in the file as a published range
956    /// (`5173-5223:5173`).
957    pub search: Option<u16>,
958    /// Extra proxy device properties, verbatim.
959    pub options: BTreeMap<String, String>,
960}
961
962/// Docker's long port syntax.
963#[derive(Serialize, Deserialize, JsonSchema)]
964#[serde(deny_unknown_fields)]
965pub(crate) struct PortMapping {
966    /// incus device name. Default: `port-host-<published>`.
967    #[serde(default, skip_serializing_if = "Option::is_none")]
968    name: Option<String>,
969
970    /// Port in the guest, or a range as long as `published`'s.
971    #[serde(deserialize_with = "flex::string", serialize_with = "port_number")]
972    #[schemars(with = "flex::IntOrString")]
973    target: String,
974
975    /// Port on the host. A range (`5173-5223`) with a single `target` takes the
976    /// first free port in it.
977    #[serde(deserialize_with = "flex::string", serialize_with = "port_number")]
978    #[schemars(with = "flex::IntOrString")]
979    published: String,
980
981    /// Host address to listen on. Default `127.0.0.1` (docker's is `0.0.0.0`).
982    #[serde(default, skip_serializing_if = "Option::is_none")]
983    host_ip: Option<String>,
984
985    /// `tcp` (default) or `udp`.
986    #[serde(default, skip_serializing_if = "Option::is_none")]
987    protocol: Option<String>,
988
989    /// Extra proxy device properties (`proxy_protocol`, ...), verbatim.
990    #[serde(
991        default,
992        deserialize_with = "flex::string_map",
993        skip_serializing_if = "BTreeMap::is_empty"
994    )]
995    #[schemars(with = "BTreeMap<String, flex::Scalar>")]
996    options: BTreeMap<String, String>,
997}
998
999/// A single port as a number, a range as a string.
1000fn port_number<S: serde::Serializer>(p: &str, s: S) -> Result<S::Ok, S::Error> {
1001    match p.parse::<u16>() {
1002        Ok(n) => s.serialize_u16(n),
1003        Err(_) => s.serialize_str(p),
1004    }
1005}
1006
1007/// An incus proxy written out: either direction, any address incus takes.
1008#[derive(Serialize, Deserialize, JsonSchema)]
1009#[serde(deny_unknown_fields)]
1010pub(crate) struct ProxyPort {
1011    /// incus device name. Default: `port-<bind>-<listen port>`.
1012    #[serde(default, skip_serializing_if = "Option::is_none")]
1013    name: Option<String>,
1014
1015    /// `host` (default): listen on the host, connect in the guest. `guest`:
1016    /// listen in the guest, connect on the host (reach a host service).
1017    #[serde(default, skip_serializing_if = "is_default")]
1018    bind: PortBind,
1019
1020    /// Listen address: `5173`, `HOST:5173`, `5173/udp`, or the full
1021    /// `tcp:HOST:PORT` / `udp:HOST:PORT` / `unix:PATH`. The protocol defaults
1022    /// to tcp and the host to 127.0.0.1.
1023    #[serde(deserialize_with = "flex::string")]
1024    #[schemars(with = "flex::IntOrString")]
1025    listen: String,
1026
1027    /// Connect address, same forms as `listen`. The host defaults to 127.0.0.1
1028    /// (0.0.0.0 for a VM, which lets incus find the VM's address).
1029    #[serde(deserialize_with = "flex::string")]
1030    #[schemars(with = "flex::IntOrString")]
1031    connect: String,
1032
1033    /// Extra proxy device properties (`nat`, `proxy_protocol`, ...), verbatim.
1034    #[serde(
1035        default,
1036        deserialize_with = "flex::string_map",
1037        skip_serializing_if = "BTreeMap::is_empty"
1038    )]
1039    #[schemars(with = "BTreeMap<String, flex::Scalar>")]
1040    options: BTreeMap<String, String>,
1041}
1042
1043impl PortMapping {
1044    fn into_spec(self) -> crate::error::Result<PortSpec> {
1045        let proto = self.protocol.as_deref().unwrap_or("tcp");
1046        let mut p = crate::shorthand::docker_port(
1047            self.host_ip.as_deref(),
1048            &self.published,
1049            &self.target,
1050            proto,
1051        )?;
1052        p.name = self.name;
1053        p.options = self.options;
1054        Ok(p)
1055    }
1056}
1057
1058/// The port of an address with no explicit host (`5173`, `tcp:5173`), or with
1059/// one of the default connect hosts, which a searched port may not change.
1060fn connect_port(connect: &str) -> Option<(&str, &str)> {
1061    let (proto, rest) = match connect.split_once(':') {
1062        Some((p @ ("tcp" | "udp"), rest)) => (p, rest),
1063        _ => match connect.rsplit_once('/') {
1064            Some((rest, p @ ("tcp" | "udp"))) => (p, rest),
1065            _ => ("tcp", connect),
1066        },
1067    };
1068    let port = match rest.rsplit_once(':') {
1069        Some(("127.0.0.1" | "0.0.0.0", port)) => port,
1070        Some(_) => return None,
1071        None => rest,
1072    };
1073    port.parse::<u16>().ok().map(|_| (proto, port))
1074}
1075
1076impl PortSpec {
1077    /// The docker long form, when this port can be written as one: it is
1078    /// host-bound, listens on a single port and connects to a single port on
1079    /// the guest's default address.
1080    fn as_mapping(&self) -> Option<PortMapping> {
1081        if self.bind != PortBind::Host {
1082            return None;
1083        }
1084        let listen = crate::plan::normalize_addr(&self.listen, "127.0.0.1").ok()?;
1085        let (lproto, host, lport) = crate::plan::split_addr(&listen)?;
1086        let (cproto, cport) = connect_port(&self.connect)?;
1087        if lproto != cproto {
1088            return None;
1089        }
1090        let published = match self.search.filter(|n| *n > 0) {
1091            Some(n) => format!("{lport}-{}", lport.checked_add(n)?),
1092            None => lport.to_string(),
1093        };
1094        Some(PortMapping {
1095            name: self.name.clone(),
1096            target: cport.to_string(),
1097            published,
1098            host_ip: (host != "127.0.0.1").then(|| host.trim_matches(['[', ']']).to_string()),
1099            protocol: (lproto != "tcp").then(|| lproto.to_string()),
1100            options: self.options.clone(),
1101        })
1102    }
1103}
1104
1105impl Serialize for PortSpec {
1106    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1107        if let Some(m) = self.as_mapping() {
1108            return m.serialize(s);
1109        }
1110        ProxyPort {
1111            name: self.name.clone(),
1112            bind: self.bind,
1113            listen: self.listen.clone(),
1114            connect: self.connect.clone(),
1115            options: self.options.clone(),
1116        }
1117        .serialize(s)
1118    }
1119}
1120
1121impl<'de> Deserialize<'de> for PortSpec {
1122    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1123        use serde::de::Error as _;
1124        let v = serde_json::Value::deserialize(d)?;
1125        let custom = |e: String| D::Error::custom(format!("port: {e}"));
1126        match v {
1127            serde_json::Value::String(s) => {
1128                crate::shorthand::docker_short_port(&s).map_err(|e| custom(e.to_string()))
1129            }
1130            serde_json::Value::Number(n) => crate::shorthand::docker_short_port(&n.to_string())
1131                .map_err(|e| custom(e.to_string())),
1132            serde_json::Value::Object(ref m) if m.contains_key("search") => Err(custom(
1133                "search is not an isb key: publish a range instead, e.g. \"5173-5223:5173\" or published: 5173-5223".into(),
1134            )),
1135            serde_json::Value::Object(ref m)
1136                if ["listen", "connect", "bind"].iter().any(|k| m.contains_key(*k)) =>
1137            {
1138                let r: ProxyPort = serde_json::from_value(v).map_err(|e| custom(e.to_string()))?;
1139                Ok(PortSpec {
1140                    name: r.name,
1141                    bind: r.bind,
1142                    listen: r.listen,
1143                    connect: r.connect,
1144                    search: None,
1145                    options: r.options,
1146                })
1147            }
1148            v @ serde_json::Value::Object(_) => serde_json::from_value::<PortMapping>(v)
1149                .map_err(|e| custom(e.to_string()))?
1150                .into_spec()
1151                .map_err(|e| custom(e.to_string())),
1152            other => Err(custom(format!(
1153                "expected [HOST_IP:]PUBLISHED:TARGET[/PROTOCOL], {{target, published, ...}} or {{listen, connect, ...}}, got {other}"
1154            ))),
1155        }
1156    }
1157}
1158
1159impl JsonSchema for PortSpec {
1160    fn schema_name() -> std::borrow::Cow<'static, str> {
1161        "PortSpec".into()
1162    }
1163
1164    fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
1165        let mapping = g.subschema_for::<PortMapping>();
1166        let proxy = g.subschema_for::<ProxyPort>();
1167        schemars::json_schema!({
1168            "description": "A published port, docker style, or an incus proxy in either direction.",
1169            "oneOf": [
1170                {
1171                    "type": "string",
1172                    "description": "[HOST_IP:]PUBLISHED:TARGET[/PROTOCOL]. HOST_IP defaults to 127.0.0.1. PUBLISHED may be a range (5173-5223) to take the first free port."
1173                },
1174                mapping,
1175                proxy
1176            ]
1177        })
1178    }
1179}
1180
1181/// A readiness check. "Running" alone is not ready: networking comes up a beat
1182/// after the instance does.
1183#[derive(Debug, Clone, PartialEq, JsonSchema)]
1184#[serde(rename_all = "snake_case")]
1185pub enum ReadyCheck {
1186    /// The instance reports Running.
1187    Running,
1188    /// The incus agent answers exec (VMs; always true for a container once running).
1189    Agent,
1190    /// The guest has a default route (IPv4 or IPv6).
1191    DefaultRoute,
1192    /// `getent passwd <user>` succeeds in the guest.
1193    UserExists(String),
1194    /// The path is writable by the service's `user` (else root).
1195    PathWritable(String),
1196    /// This argv exits 0 in the guest (run as root).
1197    Command(Vec<String>),
1198}
1199
1200// YAML libraries disagree on externally tagged enums (serde_yaml_ng wants
1201// `!user_exists dev` tags), so the map form `{user_exists: dev}` is spelled out.
1202#[derive(Serialize, Deserialize)]
1203#[serde(untagged)]
1204enum ReadyRepr {
1205    Name(String),
1206    UserExists { user_exists: String },
1207    PathWritable { path_writable: String },
1208    Command { command: Vec<flex::Scalar> },
1209}
1210
1211impl Serialize for ReadyCheck {
1212    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1213        match self {
1214            ReadyCheck::Running => ReadyRepr::Name("running".into()),
1215            ReadyCheck::DefaultRoute => ReadyRepr::Name("default_route".into()),
1216            ReadyCheck::Agent => ReadyRepr::Name("agent".into()),
1217            ReadyCheck::UserExists(u) => ReadyRepr::UserExists {
1218                user_exists: u.clone(),
1219            },
1220            ReadyCheck::PathWritable(p) => ReadyRepr::PathWritable {
1221                path_writable: p.clone(),
1222            },
1223            ReadyCheck::Command(c) => ReadyRepr::Command {
1224                command: c.iter().cloned().map(flex::Scalar::String).collect(),
1225            },
1226        }
1227        .serialize(s)
1228    }
1229}
1230
1231impl<'de> Deserialize<'de> for ReadyCheck {
1232    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1233        use serde::de::Error as _;
1234        let r = ReadyRepr::deserialize(d).map_err(|_| {
1235            D::Error::custom(
1236                "expected running, agent, default_route, {user_exists: USER}, {path_writable: PATH} or {command: [ARGV...]}",
1237            )
1238        })?;
1239        Ok(match r {
1240            ReadyRepr::Name(n) => match n.as_str() {
1241                "running" => ReadyCheck::Running,
1242                "default_route" => ReadyCheck::DefaultRoute,
1243                "agent" => ReadyCheck::Agent,
1244                other => {
1245                    return Err(D::Error::custom(format!(
1246                        "unknown readiness check {other:?} (running, agent, default_route, user_exists, path_writable, command)"
1247                    )));
1248                }
1249            },
1250            ReadyRepr::UserExists { user_exists } => ReadyCheck::UserExists(user_exists),
1251            ReadyRepr::PathWritable { path_writable } => ReadyCheck::PathWritable(path_writable),
1252            ReadyRepr::Command { command } => {
1253                ReadyCheck::Command(command.into_iter().map(flex::Scalar::into_string).collect())
1254            }
1255        })
1256    }
1257}
1258
1259impl std::fmt::Display for ReadyCheck {
1260    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1261        match self {
1262            ReadyCheck::Running => write!(f, "running"),
1263            ReadyCheck::DefaultRoute => write!(f, "default_route"),
1264            ReadyCheck::Agent => write!(f, "agent"),
1265            ReadyCheck::UserExists(u) => write!(f, "user_exists({u})"),
1266            ReadyCheck::PathWritable(p) => write!(f, "path_writable({p})"),
1267            ReadyCheck::Command(c) => write!(f, "command({})", c.join(" ")),
1268        }
1269    }
1270}
1271
1272/// The `exec:` block of a service: exec defaults with no docker equivalent.
1273#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1274#[serde(deny_unknown_fields)]
1275pub struct ExecSpec {
1276    /// Environment for exec only (merged over `environment`, never stored in
1277    /// the instance).
1278    #[serde(
1279        default,
1280        deserialize_with = "flex::env_map_or_list",
1281        skip_serializing_if = "BTreeMap::is_empty"
1282    )]
1283    #[schemars(with = "flex::MapOrList")]
1284    pub env: BTreeMap<String, String>,
1285
1286    /// Run argv through the user's login shell (`$SHELL -l -c 'exec "$@"'`), so
1287    /// profile scripts run. argv is still passed as separate arguments.
1288    #[serde(
1289        default,
1290        deserialize_with = "flex::bool",
1291        skip_serializing_if = "std::ops::Not::not"
1292    )]
1293    #[schemars(with = "flex::BoolOrString")]
1294    pub login: bool,
1295}
1296
1297impl ExecSpec {
1298    pub fn is_empty(&self) -> bool {
1299        self == &ExecSpec::default()
1300    }
1301}
1302
1303/// Defaults for exec into a sandbox. Per-call options override them.
1304#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1305#[serde(deny_unknown_fields)]
1306pub struct ExecDefaults {
1307    /// Guest user: a name (`dev`), `uid`, or `uid:gid`. Names are resolved in the
1308    /// guest, and set HOME/USER/LOGNAME unless given in env.
1309    #[serde(
1310        default,
1311        deserialize_with = "flex::opt_string",
1312        skip_serializing_if = "Option::is_none"
1313    )]
1314    #[schemars(with = "Option<flex::IntOrString>")]
1315    pub user: Option<String>,
1316
1317    /// Working directory in the guest.
1318    #[serde(default, skip_serializing_if = "Option::is_none")]
1319    pub cwd: Option<String>,
1320
1321    /// Environment for exec (merged over the instance `env`).
1322    #[serde(
1323        default,
1324        deserialize_with = "flex::string_map",
1325        skip_serializing_if = "BTreeMap::is_empty"
1326    )]
1327    #[schemars(with = "BTreeMap<String, flex::Scalar>")]
1328    pub env: BTreeMap<String, String>,
1329
1330    /// Run argv through the user's login shell (`$SHELL -l -c 'exec "$@"'`), so
1331    /// profile scripts run. argv is still passed as separate arguments.
1332    #[serde(
1333        default,
1334        deserialize_with = "flex::bool",
1335        skip_serializing_if = "std::ops::Not::not"
1336    )]
1337    #[schemars(with = "flex::BoolOrString")]
1338    pub login: bool,
1339}
1340
1341impl ExecDefaults {
1342    pub fn is_empty(&self) -> bool {
1343        self == &ExecDefaults::default()
1344    }
1345}
1346
1347// ----------------------------------------------------------------------------
1348// Long-running services: restart, healthcheck, depends_on, deploy, secrets.
1349// ----------------------------------------------------------------------------
1350
1351/// docker's `restart:`.
1352#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, JsonSchema)]
1353#[serde(rename_all = "kebab-case")]
1354pub enum RestartMode {
1355    #[default]
1356    No,
1357    Always,
1358    OnFailure,
1359    UnlessStopped,
1360}
1361
1362// By hand so that YAML 1.1 habits (`restart: no` read as false) still work.
1363impl<'de> Deserialize<'de> for RestartMode {
1364    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1365        use serde::de::Error as _;
1366        let s = flex::Scalar::deserialize(d)?.into_string();
1367        Ok(match s.as_str() {
1368            "no" | "false" | "" => RestartMode::No,
1369            "always" => RestartMode::Always,
1370            "on-failure" => RestartMode::OnFailure,
1371            "unless-stopped" => RestartMode::UnlessStopped,
1372            other => {
1373                return Err(D::Error::custom(format!(
1374                    "unknown restart {other:?} (no, always, on-failure, unless-stopped)"
1375                )));
1376            }
1377        })
1378    }
1379}
1380
1381impl RestartMode {
1382    pub fn is_long_running(&self) -> bool {
1383        *self != RestartMode::No
1384    }
1385}
1386
1387/// docker compose's `healthcheck:`. Durations are strings (`30s`, `1m30s`).
1388#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1389#[serde(deny_unknown_fields)]
1390pub struct Healthcheck {
1391    /// `[CMD, argv...]`, `[CMD-SHELL, "a shell line"]`, a plain string (a shell
1392    /// line), or `[NONE]`. Runs in the guest as the service's `user`.
1393    #[serde(
1394        default,
1395        deserialize_with = "health_test",
1396        skip_serializing_if = "Vec::is_empty"
1397    )]
1398    #[schemars(with = "Option<flex::Command>")]
1399    pub test: Vec<String>,
1400
1401    /// Time between checks. Default `30s`.
1402    #[serde(
1403        default,
1404        deserialize_with = "flex::opt_string",
1405        skip_serializing_if = "Option::is_none"
1406    )]
1407    #[schemars(with = "Option<flex::IntOrString>")]
1408    pub interval: Option<String>,
1409
1410    /// One check's deadline. Default `30s`.
1411    #[serde(
1412        default,
1413        deserialize_with = "flex::opt_string",
1414        skip_serializing_if = "Option::is_none"
1415    )]
1416    #[schemars(with = "Option<flex::IntOrString>")]
1417    pub timeout: Option<String>,
1418
1419    /// Consecutive failures before unhealthy. Default 3.
1420    #[serde(default, skip_serializing_if = "Option::is_none")]
1421    pub retries: Option<u32>,
1422
1423    /// Grace after a start during which failures do not count. Default `0s`.
1424    #[serde(
1425        default,
1426        deserialize_with = "flex::opt_string",
1427        skip_serializing_if = "Option::is_none"
1428    )]
1429    #[schemars(with = "Option<flex::IntOrString>")]
1430    pub start_period: Option<String>,
1431
1432    /// Time between checks during `start_period`. Default `5s`.
1433    #[serde(
1434        default,
1435        deserialize_with = "flex::opt_string",
1436        skip_serializing_if = "Option::is_none"
1437    )]
1438    #[schemars(with = "Option<flex::IntOrString>")]
1439    pub start_interval: Option<String>,
1440
1441    /// Turn off a healthcheck set in another file.
1442    #[serde(
1443        default,
1444        deserialize_with = "flex::bool",
1445        skip_serializing_if = "std::ops::Not::not"
1446    )]
1447    #[schemars(with = "flex::BoolOrString")]
1448    pub disable: bool,
1449}
1450
1451fn health_test<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
1452    match flex::Command::deserialize(d)? {
1453        flex::Command::String(s) => Ok(vec!["CMD-SHELL".into(), s]),
1454        flex::Command::Argv(v) => Ok(v.into_iter().map(flex::Scalar::into_string).collect()),
1455    }
1456}
1457
1458/// A healthcheck resolved to argv and durations.
1459#[derive(Debug, Clone, PartialEq)]
1460pub struct HealthProbe {
1461    pub argv: Vec<String>,
1462    pub interval: std::time::Duration,
1463    pub timeout: std::time::Duration,
1464    pub retries: u32,
1465    pub start_period: std::time::Duration,
1466    pub start_interval: std::time::Duration,
1467}
1468
1469impl Healthcheck {
1470    /// The probe to run, or `None` when disabled or `[NONE]`.
1471    pub fn probe(&self) -> Result<Option<HealthProbe>, String> {
1472        if self.disable {
1473            return Ok(None);
1474        }
1475        let argv = match self.test.split_first() {
1476            None => return Err("healthcheck needs a test".into()),
1477            Some((k, _)) if k == "NONE" => return Ok(None),
1478            Some((k, rest)) if k == "CMD" => rest.to_vec(),
1479            Some((k, rest)) if k == "CMD-SHELL" => {
1480                if rest.len() != 1 {
1481                    return Err("CMD-SHELL takes exactly one shell line".into());
1482                }
1483                vec!["/bin/sh".into(), "-c".into(), rest[0].clone()]
1484            }
1485            Some((k, _)) => {
1486                return Err(format!(
1487                    "healthcheck test must start with CMD, CMD-SHELL or NONE, not {k:?} (a plain string is a shell line)"
1488                ));
1489            }
1490        };
1491        if argv.is_empty() {
1492            return Err("healthcheck test has no command".into());
1493        }
1494        let dur = |v: &Option<String>, default: u64| -> Result<std::time::Duration, String> {
1495            match v {
1496                Some(s) => flex::parse_duration(s),
1497                None => Ok(std::time::Duration::from_secs(default)),
1498            }
1499        };
1500        Ok(Some(HealthProbe {
1501            argv,
1502            interval: dur(&self.interval, 30)?,
1503            timeout: dur(&self.timeout, 30)?,
1504            retries: self.retries.unwrap_or(3).max(1),
1505            start_period: dur(&self.start_period, 0)?,
1506            start_interval: dur(&self.start_interval, 5)?,
1507        }))
1508    }
1509}
1510
1511/// What a dependency must reach before its dependents start.
1512#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1513#[serde(rename_all = "snake_case")]
1514pub enum DependCondition {
1515    #[default]
1516    ServiceStarted,
1517    ServiceHealthy,
1518}
1519
1520#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1521#[serde(deny_unknown_fields)]
1522pub struct Dependency {
1523    #[serde(default)]
1524    pub condition: DependCondition,
1525}
1526
1527#[derive(Deserialize, JsonSchema)]
1528#[serde(untagged)]
1529#[allow(dead_code)]
1530enum DependsOnRepr {
1531    List(Vec<String>),
1532    Map(BTreeMap<String, Dependency>),
1533}
1534
1535fn depends_on<'de, D: serde::Deserializer<'de>>(
1536    d: D,
1537) -> Result<BTreeMap<String, Dependency>, D::Error> {
1538    Ok(match DependsOnRepr::deserialize(d)? {
1539        DependsOnRepr::List(l) => l.into_iter().map(|s| (s, Dependency::default())).collect(),
1540        DependsOnRepr::Map(m) => m,
1541    })
1542}
1543
1544/// docker's `deploy:`, for `isb stack deploy`.
1545#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1546#[serde(deny_unknown_fields)]
1547pub struct Deploy {
1548    /// Only `replicated`.
1549    #[serde(default, skip_serializing_if = "Option::is_none")]
1550    pub mode: Option<String>,
1551
1552    /// Number of instances. Default 1. `isb up` handles at most 1.
1553    #[serde(default, skip_serializing_if = "Option::is_none")]
1554    pub replicas: Option<u32>,
1555
1556    /// How a changed service is rolled out.
1557    #[serde(default, skip_serializing_if = "Option::is_none")]
1558    pub update_config: Option<UpdateConfig>,
1559
1560    /// How a rollback is rolled out. Default: like `update_config`.
1561    #[serde(default, skip_serializing_if = "Option::is_none")]
1562    pub rollback_config: Option<UpdateConfig>,
1563
1564    /// When the daemon restarts an instance whose app failed.
1565    #[serde(default, skip_serializing_if = "Option::is_none")]
1566    pub restart_policy: Option<RestartPolicy>,
1567
1568    /// `limits.cpus` (whole CPUs) and `limits.memory`: the same as `cpus` and
1569    /// `mem_limit`.
1570    #[serde(default, skip_serializing_if = "Option::is_none")]
1571    pub resources: Option<Resources>,
1572
1573    /// Labels for the service's instances, merged over `labels`.
1574    #[serde(
1575        default,
1576        deserialize_with = "flex::string_map_or_list",
1577        skip_serializing_if = "BTreeMap::is_empty"
1578    )]
1579    #[schemars(with = "flex::MapOrList")]
1580    pub labels: BTreeMap<String, String>,
1581}
1582
1583#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1584#[serde(rename_all = "kebab-case")]
1585pub enum UpdateOrder {
1586    /// Stop the old instance, then start its replacement (docker's default;
1587    /// safe for a service that owns a volume).
1588    #[default]
1589    StopFirst,
1590    /// Start the replacement and wait for it to be healthy before removing
1591    /// the old one: no gap in service.
1592    StartFirst,
1593}
1594
1595#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1596#[serde(rename_all = "snake_case")]
1597pub enum FailureAction {
1598    /// Stop rolling out and leave the service as it is.
1599    #[default]
1600    Pause,
1601    /// Roll back to the previous deployment.
1602    Rollback,
1603    /// Carry on with the next batch.
1604    Continue,
1605}
1606
1607#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1608#[serde(deny_unknown_fields)]
1609pub struct UpdateConfig {
1610    /// Instances replaced at a time. Default 1; 0 means all at once.
1611    #[serde(default, skip_serializing_if = "Option::is_none")]
1612    pub parallelism: Option<u32>,
1613
1614    /// Wait between batches. Default `0s`.
1615    #[serde(
1616        default,
1617        deserialize_with = "flex::opt_string",
1618        skip_serializing_if = "Option::is_none"
1619    )]
1620    #[schemars(with = "Option<flex::IntOrString>")]
1621    pub delay: Option<String>,
1622
1623    /// `pause` (default), `rollback` or `continue`.
1624    #[serde(default, skip_serializing_if = "Option::is_none")]
1625    pub failure_action: Option<FailureAction>,
1626
1627    /// How long a new instance must stay healthy to count as a success.
1628    /// Default `5s`.
1629    #[serde(
1630        default,
1631        deserialize_with = "flex::opt_string",
1632        skip_serializing_if = "Option::is_none"
1633    )]
1634    #[schemars(with = "Option<flex::IntOrString>")]
1635    pub monitor: Option<String>,
1636
1637    /// `stop-first` (default) or `start-first`.
1638    #[serde(default, skip_serializing_if = "Option::is_none")]
1639    pub order: Option<UpdateOrder>,
1640}
1641
1642#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1643#[serde(rename_all = "kebab-case")]
1644pub enum RestartCondition {
1645    None,
1646    OnFailure,
1647    #[default]
1648    Any,
1649}
1650
1651#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1652#[serde(deny_unknown_fields)]
1653pub struct RestartPolicy {
1654    /// `none`, `on-failure` or `any` (default).
1655    #[serde(default, skip_serializing_if = "Option::is_none")]
1656    pub condition: Option<RestartCondition>,
1657
1658    /// Wait before restarting. Default `5s`.
1659    #[serde(
1660        default,
1661        deserialize_with = "flex::opt_string",
1662        skip_serializing_if = "Option::is_none"
1663    )]
1664    #[schemars(with = "Option<flex::IntOrString>")]
1665    pub delay: Option<String>,
1666
1667    /// Give up after this many restarts within `window`. Default: never.
1668    #[serde(default, skip_serializing_if = "Option::is_none")]
1669    pub max_attempts: Option<u32>,
1670
1671    /// The window `max_attempts` counts in. Default: forever.
1672    #[serde(
1673        default,
1674        deserialize_with = "flex::opt_string",
1675        skip_serializing_if = "Option::is_none"
1676    )]
1677    #[schemars(with = "Option<flex::IntOrString>")]
1678    pub window: Option<String>,
1679}
1680
1681#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1682#[serde(deny_unknown_fields)]
1683pub struct Resources {
1684    #[serde(default, skip_serializing_if = "Option::is_none")]
1685    pub limits: Option<ResourceLimits>,
1686}
1687
1688#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1689#[serde(deny_unknown_fields)]
1690pub struct ResourceLimits {
1691    #[serde(
1692        default,
1693        deserialize_with = "flex::opt_string",
1694        skip_serializing_if = "Option::is_none"
1695    )]
1696    #[schemars(with = "Option<flex::IntOrString>")]
1697    pub cpus: Option<String>,
1698
1699    #[serde(
1700        default,
1701        deserialize_with = "flex::opt_string",
1702        skip_serializing_if = "Option::is_none"
1703    )]
1704    #[schemars(with = "Option<flex::IntOrString>")]
1705    pub memory: Option<String>,
1706}
1707
1708/// A service's use of a secret.
1709#[derive(Debug, Clone, Default, PartialEq, Serialize, JsonSchema)]
1710#[serde(deny_unknown_fields)]
1711pub struct SecretRef {
1712    /// The top-level secret's key.
1713    pub source: String,
1714    /// File name under `/run/secrets`, or an absolute path. Default: `source`.
1715    #[serde(default, skip_serializing_if = "Option::is_none")]
1716    pub target: Option<String>,
1717    /// Owner in the guest: a uid. Default: the service's numeric `user`, else 0.
1718    #[serde(default, skip_serializing_if = "Option::is_none")]
1719    pub uid: Option<u32>,
1720    #[serde(default, skip_serializing_if = "Option::is_none")]
1721    pub gid: Option<u32>,
1722    /// Octal mode, e.g. `0400` (default) or `"0440"`.
1723    #[serde(
1724        default,
1725        deserialize_with = "flex::opt_string",
1726        skip_serializing_if = "Option::is_none"
1727    )]
1728    #[schemars(with = "Option<flex::IntOrString>")]
1729    pub mode: Option<String>,
1730}
1731
1732#[derive(Deserialize)]
1733#[serde(untagged)]
1734enum SecretRefRepr {
1735    Name(String),
1736    Long {
1737        source: String,
1738        #[serde(default)]
1739        target: Option<String>,
1740        #[serde(default)]
1741        uid: Option<flex::Scalar>,
1742        #[serde(default)]
1743        gid: Option<flex::Scalar>,
1744        #[serde(default)]
1745        mode: Option<flex::Scalar>,
1746    },
1747}
1748
1749impl<'de> Deserialize<'de> for SecretRef {
1750    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1751        use serde::de::Error as _;
1752        let id = |v: Option<flex::Scalar>, what: &str| -> Result<Option<u32>, D::Error> {
1753            v.map(|s| {
1754                let s = s.into_string();
1755                s.trim().parse().map_err(|_| {
1756                    D::Error::custom(format!("secret {what} must be a number, got {s:?}"))
1757                })
1758            })
1759            .transpose()
1760        };
1761        match SecretRefRepr::deserialize(d).map_err(|_| {
1762            D::Error::custom("expected a secret name or {source, target, uid, gid, mode}")
1763        })? {
1764            SecretRefRepr::Name(source) => Ok(SecretRef {
1765                source,
1766                ..Default::default()
1767            }),
1768            SecretRefRepr::Long {
1769                source,
1770                target,
1771                uid,
1772                gid,
1773                mode,
1774            } => Ok(SecretRef {
1775                source,
1776                target,
1777                uid: id(uid, "uid")?,
1778                gid: id(gid, "gid")?,
1779                // YAML reads an unquoted 0400 as the number 400; both mean octal.
1780                mode: mode.map(flex::Scalar::into_string),
1781            }),
1782        }
1783    }
1784}
1785
1786impl SecretRef {
1787    /// Absolute guest path of the file.
1788    pub fn guest_path(&self) -> String {
1789        let t = self.target.as_deref().unwrap_or(&self.source);
1790        if t.starts_with('/') {
1791            t.to_string()
1792        } else {
1793            format!("/run/secrets/{t}")
1794        }
1795    }
1796
1797    /// File mode, octal. Default 0400.
1798    pub fn file_mode(&self) -> Result<u32, String> {
1799        match &self.mode {
1800            None => Ok(0o400),
1801            Some(m) => u32::from_str_radix(m.trim().trim_start_matches("0o"), 8)
1802                .ok()
1803                .filter(|m| *m <= 0o7777)
1804                .ok_or_else(|| format!("secret mode {m:?} is not an octal mode like 0400")),
1805        }
1806    }
1807}
1808
1809impl SandboxSpec {
1810    /// Every top-level secret the service uses, as a file or a variable.
1811    pub fn secret_keys(&self) -> std::collections::BTreeSet<&str> {
1812        self.secrets
1813            .iter()
1814            .map(|r| r.source.as_str())
1815            .chain(self.env.secrets.values().map(String::as_str))
1816            .collect()
1817    }
1818
1819    /// `restart` is set to something that keeps the service running.
1820    pub fn long_running(&self) -> bool {
1821        self.restart.is_some_and(|r| r.is_long_running())
1822    }
1823
1824    /// `deploy.replicas`, default 1.
1825    pub fn replicas(&self) -> u32 {
1826        self.deploy.as_ref().and_then(|d| d.replicas).unwrap_or(1)
1827    }
1828
1829    /// The health probe, if any.
1830    pub fn health_probe(&self) -> Result<Option<HealthProbe>, String> {
1831        match &self.healthcheck {
1832            None => Ok(None),
1833            Some(h) => h.probe(),
1834        }
1835    }
1836}
1837
1838mod builder;
1839pub use builder::{PortBinding, Volume};
1840
1841/// JSON Schema for the compose file format.
1842pub fn compose_schema() -> serde_json::Value {
1843    serde_json::to_value(schemars::schema_for!(ComposeFile)).expect("schema serializes")
1844}
1845
1846#[cfg(test)]
1847mod tests;