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