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