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}
609
610impl SandboxSpec {
611    /// The exec defaults this spec implies: `user`, `working_dir` and `exec`.
612    pub fn exec_defaults(&self) -> ExecDefaults {
613        ExecDefaults {
614            user: self.user.clone(),
615            cwd: self.working_dir.clone(),
616            env: self.exec.env.clone(),
617            login: self.exec.login,
618        }
619    }
620}
621
622fn is_default<T: Default + PartialEq>(v: &T) -> bool {
623    *v == T::default()
624}
625
626/// A hostname (and path) the ingress serves a service on.
627#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
628#[serde(deny_unknown_fields)]
629pub struct DomainSpec {
630    /// The hostname, e.g. `app.example.com`; `*.example.com` where the org
631    /// allows wildcards; or `auto` for a generated
632    /// `<service>-<stack>-<org>.<ip>.sslip.io` name.
633    pub host: String,
634
635    /// Path prefix (default `/`): `/api` matches `/api` and `/api/...`.
636    #[serde(default, skip_serializing_if = "Option::is_none")]
637    pub path: Option<String>,
638
639    /// The port the service listens on inside its replicas. Not needed with
640    /// `redirect`.
641    #[serde(default, skip_serializing_if = "Option::is_none")]
642    pub port: Option<u16>,
643
644    /// Serve over HTTPS with a certificate the ingress obtains (default
645    /// true), redirecting plain HTTP to it. `false` serves plain HTTP.
646    #[serde(
647        default,
648        deserialize_with = "flex::opt_bool",
649        skip_serializing_if = "Option::is_none"
650    )]
651    #[schemars(with = "Option<flex::BoolOrString>")]
652    pub https: Option<bool>,
653
654    /// Answer every request with a permanent redirect (308) to this URL
655    /// instead of proxying. A URL without a path keeps the request's path
656    /// and query (`https://example.com`); one with a path is used as is.
657    #[serde(default, skip_serializing_if = "Option::is_none")]
658    pub redirect: Option<String>,
659
660    /// Remove `path` from the request before passing it on.
661    #[serde(
662        default,
663        deserialize_with = "flex::bool",
664        skip_serializing_if = "std::ops::Not::not"
665    )]
666    #[schemars(with = "flex::BoolOrString")]
667    pub strip_prefix: bool,
668
669    /// Also serve `www.<host>`, redirecting it to `host`.
670    #[serde(
671        default,
672        deserialize_with = "flex::bool",
673        skip_serializing_if = "std::ops::Not::not"
674    )]
675    #[schemars(with = "flex::BoolOrString")]
676    pub www_redirect: bool,
677}
678
679/// idmap handling.
680///
681/// The usual need is "host uid/gid 1000 must be the guest's uid/gid 1000 so a
682/// bind-mounted checkout is writable". Whether that needs `raw.idmap` depends on
683/// the host: when root's subordinate id range (in `/etc/subuid`, `/etc/subgid`)
684/// already contains the host id, the default map covers it and asking for
685/// `raw.idmap` is refused by incus ("Host ID is in the range of subids"); when
686/// it does not (a separate `root:1000:1` delegation only permits the mapping),
687/// `raw.idmap` is required.
688///
689/// Forms:
690/// - `auto`: map 1000:1000 ↔ 1000:1000 only where needed (per id, uid and gid
691///   checked separately).
692/// - `none`: never set `raw.idmap`.
693/// - `always`: always set it for 1000 ↔ 1000.
694/// - `{mode: auto|always, host_uid, host_gid, guest_uid, guest_gid}`: other ids.
695/// - `{raw: "both 1000 1000"}`: an explicit `raw.idmap` value.
696#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
697#[serde(untagged)]
698pub enum IdmapSpec {
699    Mode(IdmapMode),
700    Map(IdmapMap),
701    Raw(IdmapRaw),
702}
703
704#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
705#[serde(rename_all = "snake_case")]
706pub enum IdmapMode {
707    #[default]
708    Auto,
709    None,
710    Always,
711}
712
713#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
714#[serde(deny_unknown_fields)]
715pub struct IdmapMap {
716    #[serde(default)]
717    pub mode: IdmapMode,
718    #[serde(default = "default_id")]
719    pub host_uid: u32,
720    #[serde(default = "default_id")]
721    pub host_gid: u32,
722    #[serde(default = "default_id")]
723    pub guest_uid: u32,
724    #[serde(default = "default_id")]
725    pub guest_gid: u32,
726}
727
728#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
729#[serde(deny_unknown_fields)]
730pub struct IdmapRaw {
731    /// Value for `raw.idmap`, verbatim.
732    pub raw: String,
733}
734
735fn default_id() -> u32 {
736    1000
737}
738
739/// What a mount's `source` is.
740#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
741#[serde(rename_all = "snake_case")]
742pub enum MountType {
743    /// A host path.
744    #[default]
745    Bind,
746    /// A named custom storage volume.
747    Volume,
748}
749
750/// A mount. Written as `SOURCE:TARGET[:OPTIONS]` or as the long form
751/// (`VolumeMount` in the schema); always serialized in the long form.
752#[derive(Debug, Clone, Default, PartialEq, Serialize)]
753pub struct VolumeSpec {
754    /// `bind` (a host path) or `volume` (a named volume).
755    #[serde(rename = "type")]
756    pub mount_type: MountType,
757    /// Host path (bind) or volume key (volume).
758    pub source: String,
759    /// Absolute path inside the guest.
760    pub target: String,
761    #[serde(skip_serializing_if = "std::ops::Not::not")]
762    pub read_only: bool,
763    #[serde(skip_serializing_if = "std::ops::Not::not")]
764    pub external: bool,
765    #[serde(skip_serializing_if = "Option::is_none")]
766    pub pool: Option<String>,
767    #[serde(skip_serializing_if = "Option::is_none")]
768    pub owner: Option<String>,
769    #[serde(skip_serializing_if = "Option::is_none")]
770    pub device: Option<String>,
771    #[serde(skip_serializing_if = "VolumeOptions::is_default")]
772    pub volume: VolumeOptions,
773    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
774    pub options: BTreeMap<String, String>,
775}
776
777/// docker's `volume:` block of a long-form mount.
778#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
779#[serde(deny_unknown_fields)]
780pub struct VolumeOptions {
781    /// Named volumes only: do not seed an empty volume with what the image has
782    /// at `target`. Seeding is docker's default; isb does it in containers
783    /// (incus `initial.copy`) when the server supports it.
784    #[serde(
785        default,
786        deserialize_with = "flex::bool",
787        skip_serializing_if = "std::ops::Not::not"
788    )]
789    #[schemars(with = "flex::BoolOrString")]
790    pub nocopy: bool,
791}
792
793impl VolumeOptions {
794    fn is_default(&self) -> bool {
795        *self == Self::default()
796    }
797}
798
799/// The long form of a mount.
800#[derive(Deserialize, JsonSchema)]
801#[serde(deny_unknown_fields)]
802#[allow(dead_code)]
803pub(crate) struct VolumeMount {
804    /// `bind` (a host path) or `volume` (a named volume). Default: `bind` when
805    /// `source` starts with `/`, `.` or `~`, else `volume`.
806    #[serde(default, rename = "type")]
807    mount_type: Option<MountType>,
808
809    /// Host path to bind-mount (relative paths resolve against the compose
810    /// file's directory, `~` expands, symlinks are resolved), or the key of a
811    /// named volume.
812    source: String,
813
814    /// Absolute path inside the guest.
815    target: String,
816
817    /// Mount read-only.
818    #[serde(default, deserialize_with = "flex::bool")]
819    #[schemars(with = "flex::BoolOrString")]
820    read_only: bool,
821
822    /// Named volumes only: the volume must already exist; isb never creates it.
823    #[serde(default, deserialize_with = "flex::bool")]
824    #[schemars(with = "flex::BoolOrString")]
825    external: bool,
826
827    /// Named volumes only: the storage pool. Default: the top-level volume's
828    /// pool, else the sandbox's root pool.
829    #[serde(default)]
830    pool: Option<String>,
831
832    /// Named volumes only: chown the mount point to this guest user (`dev`,
833    /// `dev:dev` or `1000:1000`) after it is attached, plus any root-owned
834    /// parents inside that user's home that the mount conjured.
835    #[serde(default, deserialize_with = "flex::opt_string")]
836    #[schemars(with = "Option<flex::IntOrString>")]
837    owner: Option<String>,
838
839    /// incus device name. Default: derived from the target. Set it to adopt an
840    /// existing device under a known name.
841    #[serde(default)]
842    device: Option<String>,
843
844    /// docker's volume options (`nocopy`).
845    #[serde(default)]
846    volume: VolumeOptions,
847
848    /// Extra disk device properties (`shift`, `propagation`, ...), verbatim.
849    #[serde(default, deserialize_with = "flex::string_map")]
850    #[schemars(with = "BTreeMap<String, flex::Scalar>")]
851    options: BTreeMap<String, String>,
852}
853
854/// Whether a mount source names a host path rather than a volume.
855pub(crate) fn is_host_path(source: &str) -> bool {
856    source.starts_with('/') || source.starts_with('.') || source.starts_with('~')
857}
858
859impl From<VolumeMount> for VolumeSpec {
860    fn from(m: VolumeMount) -> Self {
861        let mount_type = m.mount_type.unwrap_or(if is_host_path(&m.source) {
862            MountType::Bind
863        } else {
864            MountType::Volume
865        });
866        VolumeSpec {
867            mount_type,
868            source: m.source,
869            target: m.target,
870            read_only: m.read_only,
871            external: m.external,
872            pool: m.pool,
873            owner: m.owner,
874            device: m.device,
875            volume: m.volume,
876            options: m.options,
877        }
878    }
879}
880
881impl<'de> Deserialize<'de> for VolumeSpec {
882    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
883        use serde::de::Error as _;
884        match serde_json::Value::deserialize(d)? {
885            serde_json::Value::String(s) => {
886                crate::shorthand::volume(&s).map_err(|e| D::Error::custom(e.to_string()))
887            }
888            v @ serde_json::Value::Object(_) => serde_json::from_value::<VolumeMount>(v)
889                .map(Into::into)
890                .map_err(|e| D::Error::custom(format!("volume: {e}"))),
891            other => Err(D::Error::custom(format!(
892                "volume: expected SOURCE:TARGET[:OPTIONS] or {{type, source, target, ...}}, got {other}"
893            ))),
894        }
895    }
896}
897
898impl JsonSchema for VolumeSpec {
899    fn schema_name() -> std::borrow::Cow<'static, str> {
900        "VolumeSpec".into()
901    }
902
903    fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
904        let long = g.subschema_for::<VolumeMount>();
905        schemars::json_schema!({
906            "description": "A mount: `SOURCE:TARGET[:OPTIONS]` or the long form.",
907            "oneOf": [
908                {
909                    "type": "string",
910                    "description": "SOURCE:TARGET[:OPTIONS]. OPTIONS is a comma list of ro, rw, owner=USER, device=NAME, pool=POOL, external."
911                },
912                long
913            ]
914        })
915    }
916}
917
918/// Which side listens.
919#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
920#[serde(rename_all = "snake_case")]
921pub enum PortBind {
922    /// Listen on the host, connect inside the guest (publish a guest port).
923    #[default]
924    Host,
925    /// Listen inside the guest, connect on the host (reach a host service).
926    Guest,
927}
928
929impl PortBind {
930    pub fn as_str(&self) -> &'static str {
931        match self {
932            PortBind::Host => "host",
933            PortBind::Guest => "guest",
934        }
935    }
936}
937
938/// An incus proxy device. Written as docker's `[HOST_IP:]PUBLISHED:TARGET[/PROTOCOL]`,
939/// its long form (`PortMapping` in the schema), or the incus form (`ProxyPort`).
940#[derive(Debug, Clone, Default, PartialEq)]
941pub struct PortSpec {
942    /// Device name. Default: `port-<bind>-<listen port>`.
943    pub name: Option<String>,
944    pub bind: PortBind,
945    /// incus listen address (`tcp:HOST:PORT`, or any shorthand `normalize_addr`
946    /// accepts).
947    pub listen: String,
948    /// incus connect address, same forms.
949    pub connect: String,
950    /// Host-bound TCP/UDP only: if the listen port is taken, try the next one,
951    /// up to this many more. Written in the file as a published range
952    /// (`5173-5223:5173`).
953    pub search: Option<u16>,
954    /// Extra proxy device properties, verbatim.
955    pub options: BTreeMap<String, String>,
956}
957
958/// Docker's long port syntax.
959#[derive(Serialize, Deserialize, JsonSchema)]
960#[serde(deny_unknown_fields)]
961pub(crate) struct PortMapping {
962    /// incus device name. Default: `port-host-<published>`.
963    #[serde(default, skip_serializing_if = "Option::is_none")]
964    name: Option<String>,
965
966    /// Port in the guest, or a range as long as `published`'s.
967    #[serde(deserialize_with = "flex::string", serialize_with = "port_number")]
968    #[schemars(with = "flex::IntOrString")]
969    target: String,
970
971    /// Port on the host. A range (`5173-5223`) with a single `target` takes the
972    /// first free port in it.
973    #[serde(deserialize_with = "flex::string", serialize_with = "port_number")]
974    #[schemars(with = "flex::IntOrString")]
975    published: String,
976
977    /// Host address to listen on. Default `127.0.0.1` (docker's is `0.0.0.0`).
978    #[serde(default, skip_serializing_if = "Option::is_none")]
979    host_ip: Option<String>,
980
981    /// `tcp` (default) or `udp`.
982    #[serde(default, skip_serializing_if = "Option::is_none")]
983    protocol: Option<String>,
984
985    /// Extra proxy device properties (`proxy_protocol`, ...), verbatim.
986    #[serde(
987        default,
988        deserialize_with = "flex::string_map",
989        skip_serializing_if = "BTreeMap::is_empty"
990    )]
991    #[schemars(with = "BTreeMap<String, flex::Scalar>")]
992    options: BTreeMap<String, String>,
993}
994
995/// A single port as a number, a range as a string.
996fn port_number<S: serde::Serializer>(p: &str, s: S) -> Result<S::Ok, S::Error> {
997    match p.parse::<u16>() {
998        Ok(n) => s.serialize_u16(n),
999        Err(_) => s.serialize_str(p),
1000    }
1001}
1002
1003/// An incus proxy written out: either direction, any address incus takes.
1004#[derive(Serialize, Deserialize, JsonSchema)]
1005#[serde(deny_unknown_fields)]
1006pub(crate) struct ProxyPort {
1007    /// incus device name. Default: `port-<bind>-<listen port>`.
1008    #[serde(default, skip_serializing_if = "Option::is_none")]
1009    name: Option<String>,
1010
1011    /// `host` (default): listen on the host, connect in the guest. `guest`:
1012    /// listen in the guest, connect on the host (reach a host service).
1013    #[serde(default, skip_serializing_if = "is_default")]
1014    bind: PortBind,
1015
1016    /// Listen address: `5173`, `HOST:5173`, `5173/udp`, or the full
1017    /// `tcp:HOST:PORT` / `udp:HOST:PORT` / `unix:PATH`. The protocol defaults
1018    /// to tcp and the host to 127.0.0.1.
1019    #[serde(deserialize_with = "flex::string")]
1020    #[schemars(with = "flex::IntOrString")]
1021    listen: String,
1022
1023    /// Connect address, same forms as `listen`. The host defaults to 127.0.0.1
1024    /// (0.0.0.0 for a VM, which lets incus find the VM's address).
1025    #[serde(deserialize_with = "flex::string")]
1026    #[schemars(with = "flex::IntOrString")]
1027    connect: String,
1028
1029    /// Extra proxy device properties (`nat`, `proxy_protocol`, ...), verbatim.
1030    #[serde(
1031        default,
1032        deserialize_with = "flex::string_map",
1033        skip_serializing_if = "BTreeMap::is_empty"
1034    )]
1035    #[schemars(with = "BTreeMap<String, flex::Scalar>")]
1036    options: BTreeMap<String, String>,
1037}
1038
1039impl PortMapping {
1040    fn into_spec(self) -> crate::error::Result<PortSpec> {
1041        let proto = self.protocol.as_deref().unwrap_or("tcp");
1042        let mut p = crate::shorthand::docker_port(
1043            self.host_ip.as_deref(),
1044            &self.published,
1045            &self.target,
1046            proto,
1047        )?;
1048        p.name = self.name;
1049        p.options = self.options;
1050        Ok(p)
1051    }
1052}
1053
1054/// The port of an address with no explicit host (`5173`, `tcp:5173`), or with
1055/// one of the default connect hosts, which a searched port may not change.
1056fn connect_port(connect: &str) -> Option<(&str, &str)> {
1057    let (proto, rest) = match connect.split_once(':') {
1058        Some((p @ ("tcp" | "udp"), rest)) => (p, rest),
1059        _ => match connect.rsplit_once('/') {
1060            Some((rest, p @ ("tcp" | "udp"))) => (p, rest),
1061            _ => ("tcp", connect),
1062        },
1063    };
1064    let port = match rest.rsplit_once(':') {
1065        Some(("127.0.0.1" | "0.0.0.0", port)) => port,
1066        Some(_) => return None,
1067        None => rest,
1068    };
1069    port.parse::<u16>().ok().map(|_| (proto, port))
1070}
1071
1072impl PortSpec {
1073    /// The docker long form, when this port can be written as one: it is
1074    /// host-bound, listens on a single port and connects to a single port on
1075    /// the guest's default address.
1076    fn as_mapping(&self) -> Option<PortMapping> {
1077        if self.bind != PortBind::Host {
1078            return None;
1079        }
1080        let listen = crate::plan::normalize_addr(&self.listen, "127.0.0.1").ok()?;
1081        let (lproto, host, lport) = crate::plan::split_addr(&listen)?;
1082        let (cproto, cport) = connect_port(&self.connect)?;
1083        if lproto != cproto {
1084            return None;
1085        }
1086        let published = match self.search.filter(|n| *n > 0) {
1087            Some(n) => format!("{lport}-{}", lport.checked_add(n)?),
1088            None => lport.to_string(),
1089        };
1090        Some(PortMapping {
1091            name: self.name.clone(),
1092            target: cport.to_string(),
1093            published,
1094            host_ip: (host != "127.0.0.1").then(|| host.trim_matches(['[', ']']).to_string()),
1095            protocol: (lproto != "tcp").then(|| lproto.to_string()),
1096            options: self.options.clone(),
1097        })
1098    }
1099}
1100
1101impl Serialize for PortSpec {
1102    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1103        if let Some(m) = self.as_mapping() {
1104            return m.serialize(s);
1105        }
1106        ProxyPort {
1107            name: self.name.clone(),
1108            bind: self.bind,
1109            listen: self.listen.clone(),
1110            connect: self.connect.clone(),
1111            options: self.options.clone(),
1112        }
1113        .serialize(s)
1114    }
1115}
1116
1117impl<'de> Deserialize<'de> for PortSpec {
1118    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1119        use serde::de::Error as _;
1120        let v = serde_json::Value::deserialize(d)?;
1121        let custom = |e: String| D::Error::custom(format!("port: {e}"));
1122        match v {
1123            serde_json::Value::String(s) => {
1124                crate::shorthand::docker_short_port(&s).map_err(|e| custom(e.to_string()))
1125            }
1126            serde_json::Value::Number(n) => crate::shorthand::docker_short_port(&n.to_string())
1127                .map_err(|e| custom(e.to_string())),
1128            serde_json::Value::Object(ref m) if m.contains_key("search") => Err(custom(
1129                "search is not an isb key: publish a range instead, e.g. \"5173-5223:5173\" or published: 5173-5223".into(),
1130            )),
1131            serde_json::Value::Object(ref m)
1132                if ["listen", "connect", "bind"].iter().any(|k| m.contains_key(*k)) =>
1133            {
1134                let r: ProxyPort = serde_json::from_value(v).map_err(|e| custom(e.to_string()))?;
1135                Ok(PortSpec {
1136                    name: r.name,
1137                    bind: r.bind,
1138                    listen: r.listen,
1139                    connect: r.connect,
1140                    search: None,
1141                    options: r.options,
1142                })
1143            }
1144            v @ serde_json::Value::Object(_) => serde_json::from_value::<PortMapping>(v)
1145                .map_err(|e| custom(e.to_string()))?
1146                .into_spec()
1147                .map_err(|e| custom(e.to_string())),
1148            other => Err(custom(format!(
1149                "expected [HOST_IP:]PUBLISHED:TARGET[/PROTOCOL], {{target, published, ...}} or {{listen, connect, ...}}, got {other}"
1150            ))),
1151        }
1152    }
1153}
1154
1155impl JsonSchema for PortSpec {
1156    fn schema_name() -> std::borrow::Cow<'static, str> {
1157        "PortSpec".into()
1158    }
1159
1160    fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
1161        let mapping = g.subschema_for::<PortMapping>();
1162        let proxy = g.subschema_for::<ProxyPort>();
1163        schemars::json_schema!({
1164            "description": "A published port, docker style, or an incus proxy in either direction.",
1165            "oneOf": [
1166                {
1167                    "type": "string",
1168                    "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."
1169                },
1170                mapping,
1171                proxy
1172            ]
1173        })
1174    }
1175}
1176
1177/// A readiness check. "Running" alone is not ready: networking comes up a beat
1178/// after the instance does.
1179#[derive(Debug, Clone, PartialEq, JsonSchema)]
1180#[serde(rename_all = "snake_case")]
1181pub enum ReadyCheck {
1182    /// The instance reports Running.
1183    Running,
1184    /// The incus agent answers exec (VMs; always true for a container once running).
1185    Agent,
1186    /// The guest has a default route (IPv4 or IPv6).
1187    DefaultRoute,
1188    /// `getent passwd <user>` succeeds in the guest.
1189    UserExists(String),
1190    /// The path is writable by the service's `user` (else root).
1191    PathWritable(String),
1192    /// This argv exits 0 in the guest (run as root).
1193    Command(Vec<String>),
1194}
1195
1196// YAML libraries disagree on externally tagged enums (serde_yaml_ng wants
1197// `!user_exists dev` tags), so the map form `{user_exists: dev}` is spelled out.
1198#[derive(Serialize, Deserialize)]
1199#[serde(untagged)]
1200enum ReadyRepr {
1201    Name(String),
1202    UserExists { user_exists: String },
1203    PathWritable { path_writable: String },
1204    Command { command: Vec<flex::Scalar> },
1205}
1206
1207impl Serialize for ReadyCheck {
1208    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1209        match self {
1210            ReadyCheck::Running => ReadyRepr::Name("running".into()),
1211            ReadyCheck::DefaultRoute => ReadyRepr::Name("default_route".into()),
1212            ReadyCheck::Agent => ReadyRepr::Name("agent".into()),
1213            ReadyCheck::UserExists(u) => ReadyRepr::UserExists {
1214                user_exists: u.clone(),
1215            },
1216            ReadyCheck::PathWritable(p) => ReadyRepr::PathWritable {
1217                path_writable: p.clone(),
1218            },
1219            ReadyCheck::Command(c) => ReadyRepr::Command {
1220                command: c.iter().cloned().map(flex::Scalar::String).collect(),
1221            },
1222        }
1223        .serialize(s)
1224    }
1225}
1226
1227impl<'de> Deserialize<'de> for ReadyCheck {
1228    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1229        use serde::de::Error as _;
1230        let r = ReadyRepr::deserialize(d).map_err(|_| {
1231            D::Error::custom(
1232                "expected running, agent, default_route, {user_exists: USER}, {path_writable: PATH} or {command: [ARGV...]}",
1233            )
1234        })?;
1235        Ok(match r {
1236            ReadyRepr::Name(n) => match n.as_str() {
1237                "running" => ReadyCheck::Running,
1238                "default_route" => ReadyCheck::DefaultRoute,
1239                "agent" => ReadyCheck::Agent,
1240                other => {
1241                    return Err(D::Error::custom(format!(
1242                        "unknown readiness check {other:?} (running, agent, default_route, user_exists, path_writable, command)"
1243                    )));
1244                }
1245            },
1246            ReadyRepr::UserExists { user_exists } => ReadyCheck::UserExists(user_exists),
1247            ReadyRepr::PathWritable { path_writable } => ReadyCheck::PathWritable(path_writable),
1248            ReadyRepr::Command { command } => {
1249                ReadyCheck::Command(command.into_iter().map(flex::Scalar::into_string).collect())
1250            }
1251        })
1252    }
1253}
1254
1255impl std::fmt::Display for ReadyCheck {
1256    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1257        match self {
1258            ReadyCheck::Running => write!(f, "running"),
1259            ReadyCheck::DefaultRoute => write!(f, "default_route"),
1260            ReadyCheck::Agent => write!(f, "agent"),
1261            ReadyCheck::UserExists(u) => write!(f, "user_exists({u})"),
1262            ReadyCheck::PathWritable(p) => write!(f, "path_writable({p})"),
1263            ReadyCheck::Command(c) => write!(f, "command({})", c.join(" ")),
1264        }
1265    }
1266}
1267
1268/// The `exec:` block of a service: exec defaults with no docker equivalent.
1269#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1270#[serde(deny_unknown_fields)]
1271pub struct ExecSpec {
1272    /// Environment for exec only (merged over `environment`, never stored in
1273    /// the instance).
1274    #[serde(
1275        default,
1276        deserialize_with = "flex::env_map_or_list",
1277        skip_serializing_if = "BTreeMap::is_empty"
1278    )]
1279    #[schemars(with = "flex::MapOrList")]
1280    pub env: BTreeMap<String, String>,
1281
1282    /// Run argv through the user's login shell (`$SHELL -l -c 'exec "$@"'`), so
1283    /// profile scripts run. argv is still passed as separate arguments.
1284    #[serde(
1285        default,
1286        deserialize_with = "flex::bool",
1287        skip_serializing_if = "std::ops::Not::not"
1288    )]
1289    #[schemars(with = "flex::BoolOrString")]
1290    pub login: bool,
1291}
1292
1293impl ExecSpec {
1294    pub fn is_empty(&self) -> bool {
1295        self == &ExecSpec::default()
1296    }
1297}
1298
1299/// Defaults for exec into a sandbox. Per-call options override them.
1300#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1301#[serde(deny_unknown_fields)]
1302pub struct ExecDefaults {
1303    /// Guest user: a name (`dev`), `uid`, or `uid:gid`. Names are resolved in the
1304    /// guest, and set HOME/USER/LOGNAME unless given in env.
1305    #[serde(
1306        default,
1307        deserialize_with = "flex::opt_string",
1308        skip_serializing_if = "Option::is_none"
1309    )]
1310    #[schemars(with = "Option<flex::IntOrString>")]
1311    pub user: Option<String>,
1312
1313    /// Working directory in the guest.
1314    #[serde(default, skip_serializing_if = "Option::is_none")]
1315    pub cwd: Option<String>,
1316
1317    /// Environment for exec (merged over the instance `env`).
1318    #[serde(
1319        default,
1320        deserialize_with = "flex::string_map",
1321        skip_serializing_if = "BTreeMap::is_empty"
1322    )]
1323    #[schemars(with = "BTreeMap<String, flex::Scalar>")]
1324    pub env: BTreeMap<String, String>,
1325
1326    /// Run argv through the user's login shell (`$SHELL -l -c 'exec "$@"'`), so
1327    /// profile scripts run. argv is still passed as separate arguments.
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 login: bool,
1335}
1336
1337impl ExecDefaults {
1338    pub fn is_empty(&self) -> bool {
1339        self == &ExecDefaults::default()
1340    }
1341}
1342
1343// ----------------------------------------------------------------------------
1344// Long-running services: restart, healthcheck, depends_on, deploy, secrets.
1345// ----------------------------------------------------------------------------
1346
1347/// docker's `restart:`.
1348#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, JsonSchema)]
1349#[serde(rename_all = "kebab-case")]
1350pub enum RestartMode {
1351    #[default]
1352    No,
1353    Always,
1354    OnFailure,
1355    UnlessStopped,
1356}
1357
1358// By hand so that YAML 1.1 habits (`restart: no` read as false) still work.
1359impl<'de> Deserialize<'de> for RestartMode {
1360    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1361        use serde::de::Error as _;
1362        let s = flex::Scalar::deserialize(d)?.into_string();
1363        Ok(match s.as_str() {
1364            "no" | "false" | "" => RestartMode::No,
1365            "always" => RestartMode::Always,
1366            "on-failure" => RestartMode::OnFailure,
1367            "unless-stopped" => RestartMode::UnlessStopped,
1368            other => {
1369                return Err(D::Error::custom(format!(
1370                    "unknown restart {other:?} (no, always, on-failure, unless-stopped)"
1371                )));
1372            }
1373        })
1374    }
1375}
1376
1377impl RestartMode {
1378    pub fn is_long_running(&self) -> bool {
1379        *self != RestartMode::No
1380    }
1381}
1382
1383/// docker compose's `healthcheck:`. Durations are strings (`30s`, `1m30s`).
1384#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1385#[serde(deny_unknown_fields)]
1386pub struct Healthcheck {
1387    /// `[CMD, argv...]`, `[CMD-SHELL, "a shell line"]`, a plain string (a shell
1388    /// line), or `[NONE]`. Runs in the guest as the service's `user`.
1389    #[serde(
1390        default,
1391        deserialize_with = "health_test",
1392        skip_serializing_if = "Vec::is_empty"
1393    )]
1394    #[schemars(with = "Option<flex::Command>")]
1395    pub test: Vec<String>,
1396
1397    /// Time between checks. Default `30s`.
1398    #[serde(
1399        default,
1400        deserialize_with = "flex::opt_string",
1401        skip_serializing_if = "Option::is_none"
1402    )]
1403    #[schemars(with = "Option<flex::IntOrString>")]
1404    pub interval: Option<String>,
1405
1406    /// One check's deadline. Default `30s`.
1407    #[serde(
1408        default,
1409        deserialize_with = "flex::opt_string",
1410        skip_serializing_if = "Option::is_none"
1411    )]
1412    #[schemars(with = "Option<flex::IntOrString>")]
1413    pub timeout: Option<String>,
1414
1415    /// Consecutive failures before unhealthy. Default 3.
1416    #[serde(default, skip_serializing_if = "Option::is_none")]
1417    pub retries: Option<u32>,
1418
1419    /// Grace after a start during which failures do not count. Default `0s`.
1420    #[serde(
1421        default,
1422        deserialize_with = "flex::opt_string",
1423        skip_serializing_if = "Option::is_none"
1424    )]
1425    #[schemars(with = "Option<flex::IntOrString>")]
1426    pub start_period: Option<String>,
1427
1428    /// Time between checks during `start_period`. Default `5s`.
1429    #[serde(
1430        default,
1431        deserialize_with = "flex::opt_string",
1432        skip_serializing_if = "Option::is_none"
1433    )]
1434    #[schemars(with = "Option<flex::IntOrString>")]
1435    pub start_interval: Option<String>,
1436
1437    /// Turn off a healthcheck set in another file.
1438    #[serde(
1439        default,
1440        deserialize_with = "flex::bool",
1441        skip_serializing_if = "std::ops::Not::not"
1442    )]
1443    #[schemars(with = "flex::BoolOrString")]
1444    pub disable: bool,
1445}
1446
1447fn health_test<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
1448    match flex::Command::deserialize(d)? {
1449        flex::Command::String(s) => Ok(vec!["CMD-SHELL".into(), s]),
1450        flex::Command::Argv(v) => Ok(v.into_iter().map(flex::Scalar::into_string).collect()),
1451    }
1452}
1453
1454/// A healthcheck resolved to argv and durations.
1455#[derive(Debug, Clone, PartialEq)]
1456pub struct HealthProbe {
1457    pub argv: Vec<String>,
1458    pub interval: std::time::Duration,
1459    pub timeout: std::time::Duration,
1460    pub retries: u32,
1461    pub start_period: std::time::Duration,
1462    pub start_interval: std::time::Duration,
1463}
1464
1465impl Healthcheck {
1466    /// The probe to run, or `None` when disabled or `[NONE]`.
1467    pub fn probe(&self) -> Result<Option<HealthProbe>, String> {
1468        if self.disable {
1469            return Ok(None);
1470        }
1471        let argv = match self.test.split_first() {
1472            None => return Err("healthcheck needs a test".into()),
1473            Some((k, _)) if k == "NONE" => return Ok(None),
1474            Some((k, rest)) if k == "CMD" => rest.to_vec(),
1475            Some((k, rest)) if k == "CMD-SHELL" => {
1476                if rest.len() != 1 {
1477                    return Err("CMD-SHELL takes exactly one shell line".into());
1478                }
1479                vec!["/bin/sh".into(), "-c".into(), rest[0].clone()]
1480            }
1481            Some((k, _)) => {
1482                return Err(format!(
1483                    "healthcheck test must start with CMD, CMD-SHELL or NONE, not {k:?} (a plain string is a shell line)"
1484                ));
1485            }
1486        };
1487        if argv.is_empty() {
1488            return Err("healthcheck test has no command".into());
1489        }
1490        let dur = |v: &Option<String>, default: u64| -> Result<std::time::Duration, String> {
1491            match v {
1492                Some(s) => flex::parse_duration(s),
1493                None => Ok(std::time::Duration::from_secs(default)),
1494            }
1495        };
1496        Ok(Some(HealthProbe {
1497            argv,
1498            interval: dur(&self.interval, 30)?,
1499            timeout: dur(&self.timeout, 30)?,
1500            retries: self.retries.unwrap_or(3).max(1),
1501            start_period: dur(&self.start_period, 0)?,
1502            start_interval: dur(&self.start_interval, 5)?,
1503        }))
1504    }
1505}
1506
1507/// What a dependency must reach before its dependents start.
1508#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1509#[serde(rename_all = "snake_case")]
1510pub enum DependCondition {
1511    #[default]
1512    ServiceStarted,
1513    ServiceHealthy,
1514}
1515
1516#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1517#[serde(deny_unknown_fields)]
1518pub struct Dependency {
1519    #[serde(default)]
1520    pub condition: DependCondition,
1521}
1522
1523#[derive(Deserialize, JsonSchema)]
1524#[serde(untagged)]
1525#[allow(dead_code)]
1526enum DependsOnRepr {
1527    List(Vec<String>),
1528    Map(BTreeMap<String, Dependency>),
1529}
1530
1531fn depends_on<'de, D: serde::Deserializer<'de>>(
1532    d: D,
1533) -> Result<BTreeMap<String, Dependency>, D::Error> {
1534    Ok(match DependsOnRepr::deserialize(d)? {
1535        DependsOnRepr::List(l) => l.into_iter().map(|s| (s, Dependency::default())).collect(),
1536        DependsOnRepr::Map(m) => m,
1537    })
1538}
1539
1540/// docker's `deploy:`, for `isb stack deploy`.
1541#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1542#[serde(deny_unknown_fields)]
1543pub struct Deploy {
1544    /// Only `replicated`.
1545    #[serde(default, skip_serializing_if = "Option::is_none")]
1546    pub mode: Option<String>,
1547
1548    /// Number of instances. Default 1. `isb up` handles at most 1.
1549    #[serde(default, skip_serializing_if = "Option::is_none")]
1550    pub replicas: Option<u32>,
1551
1552    /// How a changed service is rolled out.
1553    #[serde(default, skip_serializing_if = "Option::is_none")]
1554    pub update_config: Option<UpdateConfig>,
1555
1556    /// How a rollback is rolled out. Default: like `update_config`.
1557    #[serde(default, skip_serializing_if = "Option::is_none")]
1558    pub rollback_config: Option<UpdateConfig>,
1559
1560    /// When the daemon restarts an instance whose app failed.
1561    #[serde(default, skip_serializing_if = "Option::is_none")]
1562    pub restart_policy: Option<RestartPolicy>,
1563
1564    /// `limits.cpus` (whole CPUs) and `limits.memory`: the same as `cpus` and
1565    /// `mem_limit`.
1566    #[serde(default, skip_serializing_if = "Option::is_none")]
1567    pub resources: Option<Resources>,
1568
1569    /// Labels for the service's instances, merged over `labels`.
1570    #[serde(
1571        default,
1572        deserialize_with = "flex::string_map_or_list",
1573        skip_serializing_if = "BTreeMap::is_empty"
1574    )]
1575    #[schemars(with = "flex::MapOrList")]
1576    pub labels: BTreeMap<String, String>,
1577}
1578
1579#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1580#[serde(rename_all = "kebab-case")]
1581pub enum UpdateOrder {
1582    /// Stop the old instance, then start its replacement (docker's default;
1583    /// safe for a service that owns a volume).
1584    #[default]
1585    StopFirst,
1586    /// Start the replacement and wait for it to be healthy before removing
1587    /// the old one: no gap in service.
1588    StartFirst,
1589}
1590
1591#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1592#[serde(rename_all = "snake_case")]
1593pub enum FailureAction {
1594    /// Stop rolling out and leave the service as it is.
1595    #[default]
1596    Pause,
1597    /// Roll back to the previous deployment.
1598    Rollback,
1599    /// Carry on with the next batch.
1600    Continue,
1601}
1602
1603#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1604#[serde(deny_unknown_fields)]
1605pub struct UpdateConfig {
1606    /// Instances replaced at a time. Default 1; 0 means all at once.
1607    #[serde(default, skip_serializing_if = "Option::is_none")]
1608    pub parallelism: Option<u32>,
1609
1610    /// Wait between batches. Default `0s`.
1611    #[serde(
1612        default,
1613        deserialize_with = "flex::opt_string",
1614        skip_serializing_if = "Option::is_none"
1615    )]
1616    #[schemars(with = "Option<flex::IntOrString>")]
1617    pub delay: Option<String>,
1618
1619    /// `pause` (default), `rollback` or `continue`.
1620    #[serde(default, skip_serializing_if = "Option::is_none")]
1621    pub failure_action: Option<FailureAction>,
1622
1623    /// How long a new instance must stay healthy to count as a success.
1624    /// Default `5s`.
1625    #[serde(
1626        default,
1627        deserialize_with = "flex::opt_string",
1628        skip_serializing_if = "Option::is_none"
1629    )]
1630    #[schemars(with = "Option<flex::IntOrString>")]
1631    pub monitor: Option<String>,
1632
1633    /// `stop-first` (default) or `start-first`.
1634    #[serde(default, skip_serializing_if = "Option::is_none")]
1635    pub order: Option<UpdateOrder>,
1636}
1637
1638#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1639#[serde(rename_all = "kebab-case")]
1640pub enum RestartCondition {
1641    None,
1642    OnFailure,
1643    #[default]
1644    Any,
1645}
1646
1647#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1648#[serde(deny_unknown_fields)]
1649pub struct RestartPolicy {
1650    /// `none`, `on-failure` or `any` (default).
1651    #[serde(default, skip_serializing_if = "Option::is_none")]
1652    pub condition: Option<RestartCondition>,
1653
1654    /// Wait before restarting. Default `5s`.
1655    #[serde(
1656        default,
1657        deserialize_with = "flex::opt_string",
1658        skip_serializing_if = "Option::is_none"
1659    )]
1660    #[schemars(with = "Option<flex::IntOrString>")]
1661    pub delay: Option<String>,
1662
1663    /// Give up after this many restarts within `window`. Default: never.
1664    #[serde(default, skip_serializing_if = "Option::is_none")]
1665    pub max_attempts: Option<u32>,
1666
1667    /// The window `max_attempts` counts in. Default: forever.
1668    #[serde(
1669        default,
1670        deserialize_with = "flex::opt_string",
1671        skip_serializing_if = "Option::is_none"
1672    )]
1673    #[schemars(with = "Option<flex::IntOrString>")]
1674    pub window: Option<String>,
1675}
1676
1677#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1678#[serde(deny_unknown_fields)]
1679pub struct Resources {
1680    #[serde(default, skip_serializing_if = "Option::is_none")]
1681    pub limits: Option<ResourceLimits>,
1682}
1683
1684#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1685#[serde(deny_unknown_fields)]
1686pub struct ResourceLimits {
1687    #[serde(
1688        default,
1689        deserialize_with = "flex::opt_string",
1690        skip_serializing_if = "Option::is_none"
1691    )]
1692    #[schemars(with = "Option<flex::IntOrString>")]
1693    pub cpus: Option<String>,
1694
1695    #[serde(
1696        default,
1697        deserialize_with = "flex::opt_string",
1698        skip_serializing_if = "Option::is_none"
1699    )]
1700    #[schemars(with = "Option<flex::IntOrString>")]
1701    pub memory: Option<String>,
1702}
1703
1704/// A service's use of a secret.
1705#[derive(Debug, Clone, Default, PartialEq, Serialize, JsonSchema)]
1706#[serde(deny_unknown_fields)]
1707pub struct SecretRef {
1708    /// The top-level secret's key.
1709    pub source: String,
1710    /// File name under `/run/secrets`, or an absolute path. Default: `source`.
1711    #[serde(default, skip_serializing_if = "Option::is_none")]
1712    pub target: Option<String>,
1713    /// Owner in the guest: a uid. Default: the service's numeric `user`, else 0.
1714    #[serde(default, skip_serializing_if = "Option::is_none")]
1715    pub uid: Option<u32>,
1716    #[serde(default, skip_serializing_if = "Option::is_none")]
1717    pub gid: Option<u32>,
1718    /// Octal mode, e.g. `0400` (default) or `"0440"`.
1719    #[serde(
1720        default,
1721        deserialize_with = "flex::opt_string",
1722        skip_serializing_if = "Option::is_none"
1723    )]
1724    #[schemars(with = "Option<flex::IntOrString>")]
1725    pub mode: Option<String>,
1726}
1727
1728#[derive(Deserialize)]
1729#[serde(untagged)]
1730enum SecretRefRepr {
1731    Name(String),
1732    Long {
1733        source: String,
1734        #[serde(default)]
1735        target: Option<String>,
1736        #[serde(default)]
1737        uid: Option<flex::Scalar>,
1738        #[serde(default)]
1739        gid: Option<flex::Scalar>,
1740        #[serde(default)]
1741        mode: Option<flex::Scalar>,
1742    },
1743}
1744
1745impl<'de> Deserialize<'de> for SecretRef {
1746    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1747        use serde::de::Error as _;
1748        let id = |v: Option<flex::Scalar>, what: &str| -> Result<Option<u32>, D::Error> {
1749            v.map(|s| {
1750                let s = s.into_string();
1751                s.trim().parse().map_err(|_| {
1752                    D::Error::custom(format!("secret {what} must be a number, got {s:?}"))
1753                })
1754            })
1755            .transpose()
1756        };
1757        match SecretRefRepr::deserialize(d).map_err(|_| {
1758            D::Error::custom("expected a secret name or {source, target, uid, gid, mode}")
1759        })? {
1760            SecretRefRepr::Name(source) => Ok(SecretRef {
1761                source,
1762                ..Default::default()
1763            }),
1764            SecretRefRepr::Long {
1765                source,
1766                target,
1767                uid,
1768                gid,
1769                mode,
1770            } => Ok(SecretRef {
1771                source,
1772                target,
1773                uid: id(uid, "uid")?,
1774                gid: id(gid, "gid")?,
1775                // YAML reads an unquoted 0400 as the number 400; both mean octal.
1776                mode: mode.map(flex::Scalar::into_string),
1777            }),
1778        }
1779    }
1780}
1781
1782impl SecretRef {
1783    /// Absolute guest path of the file.
1784    pub fn guest_path(&self) -> String {
1785        let t = self.target.as_deref().unwrap_or(&self.source);
1786        if t.starts_with('/') {
1787            t.to_string()
1788        } else {
1789            format!("/run/secrets/{t}")
1790        }
1791    }
1792
1793    /// File mode, octal. Default 0400.
1794    pub fn file_mode(&self) -> Result<u32, String> {
1795        match &self.mode {
1796            None => Ok(0o400),
1797            Some(m) => u32::from_str_radix(m.trim().trim_start_matches("0o"), 8)
1798                .ok()
1799                .filter(|m| *m <= 0o7777)
1800                .ok_or_else(|| format!("secret mode {m:?} is not an octal mode like 0400")),
1801        }
1802    }
1803}
1804
1805impl SandboxSpec {
1806    /// Every top-level secret the service uses, as a file or a variable.
1807    pub fn secret_keys(&self) -> std::collections::BTreeSet<&str> {
1808        self.secrets
1809            .iter()
1810            .map(|r| r.source.as_str())
1811            .chain(self.env.secrets.values().map(String::as_str))
1812            .collect()
1813    }
1814
1815    /// `restart` is set to something that keeps the service running.
1816    pub fn long_running(&self) -> bool {
1817        self.restart.is_some_and(|r| r.is_long_running())
1818    }
1819
1820    /// `deploy.replicas`, default 1.
1821    pub fn replicas(&self) -> u32 {
1822        self.deploy.as_ref().and_then(|d| d.replicas).unwrap_or(1)
1823    }
1824
1825    /// The health probe, if any.
1826    pub fn health_probe(&self) -> Result<Option<HealthProbe>, String> {
1827        match &self.healthcheck {
1828            None => Ok(None),
1829            Some(h) => h.probe(),
1830        }
1831    }
1832}
1833
1834// ---- Builder API -----------------------------------------------------------
1835
1836/// Mount builders: `Volume::bind(host)`, `Volume::named(name)`.
1837pub struct Volume;
1838
1839impl Volume {
1840    /// Bind-mount a host path. The target is set by [`SandboxSpec::volume`].
1841    pub fn bind(host_path: impl Into<String>) -> VolumeSpec {
1842        VolumeSpec {
1843            mount_type: MountType::Bind,
1844            source: host_path.into(),
1845            ..Default::default()
1846        }
1847    }
1848
1849    /// Mount a named custom volume (created if missing).
1850    pub fn named(name: impl Into<String>) -> VolumeSpec {
1851        VolumeSpec {
1852            mount_type: MountType::Volume,
1853            source: name.into(),
1854            ..Default::default()
1855        }
1856    }
1857}
1858
1859impl VolumeSpec {
1860    /// Named volumes: the volume must already exist; isb never creates it.
1861    pub fn external(mut self, external: bool) -> Self {
1862        self.external = external;
1863        self
1864    }
1865    pub fn read_only(mut self, ro: bool) -> Self {
1866        self.read_only = ro;
1867        self
1868    }
1869    pub fn owner(mut self, owner: impl Into<String>) -> Self {
1870        self.owner = Some(owner.into());
1871        self
1872    }
1873    pub fn device(mut self, name: impl Into<String>) -> Self {
1874        self.device = Some(name.into());
1875        self
1876    }
1877    pub fn pool(mut self, pool: impl Into<String>) -> Self {
1878        self.pool = Some(pool.into());
1879        self
1880    }
1881    /// Named volumes: do not seed an empty volume from the image.
1882    pub fn nocopy(mut self, nocopy: bool) -> Self {
1883        self.volume.nocopy = nocopy;
1884        self
1885    }
1886    pub fn option(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
1887        self.options.insert(k.into(), v.into());
1888        self
1889    }
1890}
1891
1892/// Port binding builders.
1893pub struct PortBinding;
1894
1895impl PortBinding {
1896    /// Publish a guest port on the host: host listens on `listen`, connects to
1897    /// `connect` in the guest. Addresses are `tcp:IP:PORT`.
1898    pub fn host(listen: impl Into<String>, connect: impl Into<String>) -> PortSpec {
1899        PortSpec {
1900            bind: PortBind::Host,
1901            listen: listen.into(),
1902            connect: connect.into(),
1903            ..Default::default()
1904        }
1905    }
1906
1907    /// Reach a host service from the guest: guest listens on `listen`, host connects to `connect`.
1908    pub fn guest(listen: impl Into<String>, connect: impl Into<String>) -> PortSpec {
1909        PortSpec {
1910            bind: PortBind::Guest,
1911            listen: listen.into(),
1912            connect: connect.into(),
1913            ..Default::default()
1914        }
1915    }
1916}
1917
1918impl PortSpec {
1919    pub fn name(mut self, n: impl Into<String>) -> Self {
1920        self.name = Some(n.into());
1921        self
1922    }
1923    pub fn search(mut self, n: u16) -> Self {
1924        self.search = Some(n);
1925        self
1926    }
1927}
1928
1929impl SandboxSpec {
1930    pub fn new(name: impl Into<String>, image: impl Into<String>) -> Self {
1931        SandboxSpec {
1932            name: Some(name.into()),
1933            image: image.into(),
1934            ..Default::default()
1935        }
1936    }
1937    pub fn cpus(mut self, cpus: impl ToString) -> Self {
1938        self.cpus = Some(cpus.to_string());
1939        self
1940    }
1941    pub fn cpuset(mut self, set: impl Into<String>) -> Self {
1942        self.cpuset = Some(set.into());
1943        self
1944    }
1945    pub fn memory(mut self, m: impl Into<String>) -> Self {
1946        self.memory = Some(m.into());
1947        self
1948    }
1949    pub fn storage(mut self, pool: impl Into<String>) -> Self {
1950        self.storage = Some(pool.into());
1951        self
1952    }
1953    pub fn idmap(mut self, idmap: IdmapSpec) -> Self {
1954        self.idmap = Some(idmap);
1955        self
1956    }
1957    pub fn privileged(mut self, p: bool) -> Self {
1958        self.privileged = Some(p);
1959        self
1960    }
1961    pub fn label(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
1962        self.labels.insert(k.into(), v.into());
1963        self
1964    }
1965    pub fn env(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
1966        self.env.insert(k.into(), v.into());
1967        self
1968    }
1969    /// Restrict the sandbox's network: see [`crate::egress::EgressSpec`].
1970    pub fn egress(mut self, e: crate::egress::EgressSpec) -> Self {
1971        self.egress = Some(e);
1972        self
1973    }
1974    /// Mount `vol` at `guest_path`.
1975    pub fn volume(mut self, guest_path: impl Into<String>, mut vol: VolumeSpec) -> Self {
1976        vol.target = guest_path.into();
1977        self.volumes.push(vol);
1978        self
1979    }
1980    pub fn port(mut self, p: PortSpec) -> Self {
1981        self.ports.push(p);
1982        self
1983    }
1984    pub fn ready(mut self, checks: Vec<ReadyCheck>) -> Self {
1985        self.ready = Some(checks);
1986        self
1987    }
1988    pub fn ready_timeout(mut self, t: impl Into<String>) -> Self {
1989        self.ready_timeout = Some(t.into());
1990        self
1991    }
1992    pub fn user(mut self, u: impl Into<String>) -> Self {
1993        self.user = Some(u.into());
1994        self
1995    }
1996    pub fn working_dir(mut self, c: impl Into<String>) -> Self {
1997        self.working_dir = Some(c.into());
1998        self
1999    }
2000    pub fn raw_config(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
2001        self.raw_config.insert(k.into(), v.into());
2002        self
2003    }
2004    pub fn raw_device(mut self, name: impl Into<String>, props: BTreeMap<String, String>) -> Self {
2005        self.raw_devices.insert(name.into(), props);
2006        self
2007    }
2008}
2009
2010/// JSON Schema for the compose file format.
2011pub fn compose_schema() -> serde_json::Value {
2012    serde_json::to_value(schemars::schema_for!(ComposeFile)).expect("schema serializes")
2013}
2014
2015#[cfg(test)]
2016mod tests;