Skip to main content

isb_core/
plan.rs

1//! Resolve a spec into desired incus state, and diff it against actual state.
2//!
3//! Both halves are pure (host facts and actual state are passed in), which is what
4//! makes the reconcile rules unit-testable. The rules that matter most:
5//!
6//! - A device that is already correct is never touched. Re-adding a disk device
7//!   remounts it, which silently kills inotify watches a live dev server holds
8//!   (Vite keeps answering 200 while HMR goes quiet).
9//! - Device names are deterministic, from the spec, never random.
10//! - isb never removes config keys or devices it was not told about, unless asked
11//!   to prune devices. Another tool (or another spec for the same instance) may
12//!   own them.
13
14use std::collections::{BTreeMap, BTreeSet};
15use std::path::{Path, PathBuf};
16use std::time::Duration;
17
18use serde::Serialize;
19use serde_json::{Value, json};
20
21use crate::error::{Error, Result};
22use crate::flex::parse_duration;
23use crate::idmap::{self, SubIds};
24use crate::spec::{
25    ExecDefaults, InstanceType, MountType, PortBind, ReadyCheck, RestartMode, SandboxSpec,
26};
27
28pub type Props = BTreeMap<String, String>;
29
30/// Facts about the host that resolution depends on.
31#[derive(Debug, Clone, Default)]
32pub struct HostFacts {
33    pub subids: SubIds,
34    /// Storage pools that exist, in server order.
35    pub pools: Vec<String>,
36    /// Rewrite bind sources starting with `.0` to start with `.1` instead: the
37    /// path incusd resolves can differ from the one this process sees (see
38    /// [`HostFacts::detect_path_map`]).
39    pub path_map: Option<(String, String)>,
40    /// The server can seed a new volume from the image (`disk_initial_copy`).
41    pub initial_copy: bool,
42    /// The incus server version (`environment.server_version`).
43    pub incus_version: Option<String>,
44    /// The (uid, gid) of the user running isb: what a VM's bind mounts map to.
45    pub invoking_ids: (u32, u32),
46    /// The only directory incusd can see bind sources under, when it runs
47    /// elsewhere: on macOS, the home directory shared with the `isb machine`.
48    pub shared_root: Option<String>,
49    /// The org the sandbox goes in (from the client's incus project):
50    /// where `registry:` images resolve. `None` outside isb's projects.
51    pub org: Option<crate::org::OrgId>,
52    /// The local registry's `host:port`, when one is set up.
53    pub registry: Option<String>,
54    /// The incus project the sandbox goes in (its egress network is named from it).
55    pub project: String,
56}
57
58impl HostFacts {
59    /// Where bind sources must be translated before incusd sees them.
60    ///
61    /// `ISB_HOST_PATH_MAP=from=to` sets it explicitly. Otherwise, inside an
62    /// agent-workspace box, `$HOME` is a bind mount of a directory on the box's own
63    /// host and incusd resolves disk sources in its own mount view, where only the
64    /// host-side path exists; the box publishes that path in
65    /// `/etc/workspace/guest-home`. Without the rewrite, adding the device fails
66    /// with "Missing source path" for a directory `ls` lists happily.
67    pub fn detect_path_map() -> Option<(String, String)> {
68        if let Ok(m) = std::env::var("ISB_HOST_PATH_MAP") {
69            if let Some((a, b)) = m.split_once('=') {
70                if !a.is_empty() && !b.is_empty() {
71                    return Some((
72                        a.trim_end_matches('/').into(),
73                        b.trim_end_matches('/').into(),
74                    ));
75                }
76            }
77            return None;
78        }
79        let gh = std::fs::read_to_string("/etc/workspace/guest-home").ok()?;
80        let gh = gh.trim().trim_end_matches('/');
81        let home = std::env::var("HOME").ok()?;
82        let home = home.trim_end_matches('/');
83        if gh.is_empty() || home.is_empty() {
84            return None;
85        }
86        Some((home.to_string(), gh.to_string()))
87    }
88
89    pub fn translate(&self, path: &str) -> String {
90        if let Some((from, to)) = &self.path_map {
91            if let Some(rest) = path.strip_prefix(from.as_str()) {
92                if rest.is_empty() || rest.starts_with('/') {
93                    return format!("{to}{rest}");
94                }
95            }
96        }
97        path.to_string()
98    }
99
100    /// `auto`: `incus-zfs`, else `default`, else the first pool.
101    pub fn pick_pool(&self, requested: Option<&str>) -> Result<String> {
102        match requested {
103            Some(p) if p != "auto" && !p.is_empty() => {
104                if self.pools.is_empty() || self.pools.iter().any(|x| x == p) {
105                    Ok(p.to_string())
106                } else {
107                    Err(Error::invalid(format!(
108                        "storage pool {p:?} does not exist (have: {})",
109                        self.pools.join(", ")
110                    )))
111                }
112            }
113            _ => ["incus-zfs", "default"]
114                .iter()
115                .find(|c| self.pools.iter().any(|p| p == *c))
116                .map(|s| s.to_string())
117                .or_else(|| self.pools.first().cloned())
118                .ok_or_else(|| Error::invalid("no storage pools exist")),
119        }
120    }
121}
122
123/// A named volume that must exist before the instance.
124#[derive(Debug, Clone, PartialEq, Serialize)]
125pub struct EnsureVolume {
126    pub pool: String,
127    pub name: String,
128    pub config: Props,
129    pub external: bool,
130}
131
132/// chown a mount point (and root-owned parents inside the owner's home).
133#[derive(Debug, Clone, PartialEq, Serialize)]
134pub struct OwnerFixup {
135    pub device: String,
136    pub path: String,
137    pub owner: String,
138}
139
140/// A desired device.
141#[derive(Debug, Clone, PartialEq, Serialize)]
142pub struct DesiredDevice {
143    pub props: Props,
144    /// Host-bound proxy that may move up to this many ports on conflict.
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub search: Option<u16>,
147}
148
149/// Where the image comes from.
150#[derive(Debug, Clone, PartialEq, Serialize)]
151pub struct ImageSource {
152    /// The spec string, for messages.
153    pub spec: String,
154    /// `None` for a local image.
155    pub server: Option<String>,
156    pub protocol: Option<String>,
157    pub alias: String,
158    /// A `registry:` image, not yet bound to an org and the local registry
159    /// ([`ImageSource::bind`]); `alias` is then `APP[:TAG][@DIGEST]`.
160    #[serde(skip_serializing_if = "std::ops::Not::not")]
161    pub local_registry: bool,
162}
163
164/// Whether a registry host (`host[:port]`) is this machine's loopback,
165/// where only the local registry listens.
166fn is_loopback_host(hostport: &str) -> bool {
167    let host = if let Some(rest) = hostport.strip_prefix('[') {
168        rest.split(']').next().unwrap_or(rest)
169    } else {
170        hostport
171            .rsplit_once(':')
172            .map(|(h, _)| h)
173            .unwrap_or(hostport)
174    };
175    let host = host.to_ascii_lowercase();
176    host == "localhost"
177        || host.ends_with(".localhost")
178        || host == "0.0.0.0"
179        || host
180            .parse::<std::net::IpAddr>()
181            .is_ok_and(|ip| ip.is_loopback() || ip.is_unspecified())
182}
183
184/// OCI registries known by a short prefix: `docker:nginx:1.27`.
185const OCI_REGISTRIES: &[(&str, &str)] = &[
186    ("docker", "https://docker.io"),
187    ("ghcr", "https://ghcr.io"),
188    ("quay", "https://quay.io"),
189];
190
191impl ImageSource {
192    pub fn parse(s: &str) -> Result<Self> {
193        if s.is_empty() {
194            return Err(Error::invalid("image is required"));
195        }
196        if let Some((remote, alias)) = s.split_once(':') {
197            if let Some((_, server)) = OCI_REGISTRIES.iter().find(|(k, _)| *k == remote) {
198                return Ok(ImageSource {
199                    spec: s.into(),
200                    server: Some(server.to_string()),
201                    protocol: Some("oci".into()),
202                    alias: oci_reference(alias, remote == "docker")?,
203                    local_registry: false,
204                });
205            }
206            if remote == "registry" {
207                // registry:app:tag, the org's own image in the local registry.
208                let r = crate::registry::ImageRef::parse(alias)?;
209                return Ok(ImageSource {
210                    spec: s.into(),
211                    server: None,
212                    protocol: Some("oci".into()),
213                    alias: r.render(),
214                    local_registry: true,
215                });
216            }
217            if remote == "oci" {
218                // oci:registry.example.com/team/app:tag
219                let (host, path) = alias.split_once('/').ok_or_else(|| {
220                    Error::invalid(format!("{s:?}: an oci: image is oci:REGISTRY/PATH[:TAG]"))
221                })?;
222                // The local registry is on loopback and holds every org's
223                // images: it is reached only as `registry:`, which stays in the org.
224                if is_loopback_host(host) {
225                    return Err(Error::invalid(format!(
226                        "{s:?}: a loopback registry is the local one; name its images as registry:APP:TAG"
227                    )));
228                }
229                return Ok(ImageSource {
230                    spec: s.into(),
231                    server: Some(format!("https://{host}")),
232                    protocol: Some("oci".into()),
233                    alias: oci_reference(path, false)?,
234                    local_registry: false,
235                });
236            }
237            let (server, protocol) = match remote {
238                "images" => ("https://images.linuxcontainers.org", "simplestreams"),
239                "ubuntu" => ("https://cloud-images.ubuntu.com/releases", "simplestreams"),
240                "ubuntu-daily" => ("https://cloud-images.ubuntu.com/daily", "simplestreams"),
241                "ubuntu-minimal" => (
242                    "https://cloud-images.ubuntu.com/minimal/releases",
243                    "simplestreams",
244                ),
245                other => {
246                    return Err(Error::invalid(format!(
247                        "unknown image remote {other:?} in {s:?} (known: images, ubuntu, ubuntu-daily, ubuntu-minimal, and OCI registries docker, ghcr, quay, oci:REGISTRY/...; local images need no prefix)"
248                    )));
249                }
250            };
251            return Ok(ImageSource {
252                spec: s.into(),
253                server: Some(server.into()),
254                protocol: Some(protocol.into()),
255                alias: alias.into(),
256                local_registry: false,
257            });
258        }
259        Ok(ImageSource {
260            spec: s.into(),
261            server: None,
262            protocol: None,
263            alias: s.into(),
264            local_registry: false,
265        })
266    }
267
268    /// Bind a `registry:` image to `org`'s repository in the local registry
269    /// at `addr`. Anything else is returned as is.
270    pub fn bind(mut self, org: Option<&crate::org::OrgId>, addr: Option<&str>) -> Result<Self> {
271        if !self.local_registry {
272            return Ok(self);
273        }
274        let org = org.ok_or_else(|| {
275            Error::invalid(format!(
276                "{:?}: registry: images belong to an org; this project is not one",
277                self.spec
278            ))
279        })?;
280        let addr = addr.ok_or_else(|| {
281            Error::invalid(format!(
282                "{:?}: no local registry on this host (isb registry setup)",
283                self.spec
284            ))
285        })?;
286        let r = crate::registry::ImageRef::parse(&self.alias)?;
287        self.alias = r.pull_alias(org);
288        self.server = Some(format!("https://{addr}"));
289        self.local_registry = false;
290        Ok(self)
291    }
292
293    /// An OCI (docker) image: an application container whose process is the
294    /// instance's init.
295    pub fn is_oci(&self) -> bool {
296        self.protocol.as_deref() == Some("oci")
297    }
298
299    /// The `source` object for `POST /1.0/instances`. A local image is given by
300    /// fingerprint once resolved, by alias otherwise.
301    pub fn to_api(&self, local_fingerprint: Option<&str>) -> Value {
302        match (&self.server, local_fingerprint) {
303            (Some(server), _) => json!({
304                "type": "image", "mode": "pull", "server": server,
305                "protocol": self.protocol, "alias": self.alias,
306            }),
307            (None, Some(fp)) => json!({"type": "image", "fingerprint": fp}),
308            (None, None) => json!({"type": "image", "alias": self.alias}),
309        }
310    }
311}
312
313/// `nginx` -> `library/nginx:latest` on Docker Hub, as docker spells it.
314fn oci_reference(r: &str, docker_hub: bool) -> Result<String> {
315    if r.is_empty() || r.contains(char::is_whitespace) {
316        return Err(Error::invalid(format!("invalid OCI image reference {r:?}")));
317    }
318    let mut r = r.to_string();
319    if docker_hub && !r.contains('/') {
320        r = format!("library/{r}");
321    }
322    let last = r.rsplit('/').next().unwrap_or(&r);
323    if !last.contains(':') && !last.contains('@') {
324        r.push_str(":latest");
325    }
326    Ok(r)
327}
328
329/// Quote argv for `oci.entrypoint`, which incus splits on whitespace with
330/// quotes grouping. There is no escape character, so an argument may not
331/// contain both kinds of quote.
332pub fn oci_command_line(argv: &[String]) -> std::result::Result<String, String> {
333    argv.iter()
334        .map(|a| {
335            if !a.is_empty() && !a.contains(|c: char| c.is_whitespace() || c == '"' || c == '\'') {
336                Ok(a.clone())
337            } else if !a.contains('"') {
338                Ok(format!("\"{a}\""))
339            } else if !a.contains('\'') {
340                Ok(format!("'{a}'"))
341            } else {
342                Err(format!(
343                    "argument {a:?} has both ' and \" in it, which an OCI command line cannot carry; use a script"
344                ))
345            }
346        })
347        .collect::<std::result::Result<Vec<_>, _>>()
348        .map(|v| v.join(" "))
349}
350
351/// A spec resolved against the host: exactly what incus should hold.
352#[derive(Debug, Clone, Serialize)]
353pub struct Desired {
354    pub name: String,
355    pub instance_type: InstanceType,
356    pub image: ImageSource,
357    pub pool: String,
358    pub profiles: Vec<String>,
359    pub config: Props,
360    /// Includes the `root` disk (create-only; never reconciled).
361    pub devices: BTreeMap<String, DesiredDevice>,
362    pub volumes: Vec<EnsureVolume>,
363    pub owners: Vec<OwnerFixup>,
364    pub ready: Vec<ReadyCheck>,
365    #[serde(skip)]
366    pub ready_timeout: Duration,
367    pub exec: ExecDefaults,
368    /// The idmap mode when the spec set one (not for `{raw: ...}`): an absent
369    /// `raw.idmap` is then a decision, not an omission.
370    #[serde(skip)]
371    pub idmap_mode: Option<crate::spec::IdmapMode>,
372    /// Config keys holding secret values (`environment.KEY` from
373    /// `{secret: NAME}`): set, but never shown in plans or reports.
374    #[serde(skip)]
375    pub sensitive: BTreeSet<String>,
376    /// The egress plumbing the spec asks for (`egress:`).
377    #[serde(skip)]
378    pub egress: Option<crate::egress::Plumbing>,
379}
380
381/// Named-volume definitions available to a sandbox (from a compose file's
382/// top-level `volumes:`).
383pub type VolumeDefs = BTreeMap<String, crate::spec::NamedVolumeSpec>;
384
385/// Valid incus instance name: <= 63 chars, [a-zA-Z0-9-], starts with a letter,
386/// does not end with '-'.
387pub fn validate_instance_name(name: &str) -> Result<()> {
388    let ok = !name.is_empty()
389        && name.len() <= 63
390        && name.starts_with(|c: char| c.is_ascii_alphabetic())
391        && !name.ends_with('-')
392        && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-');
393    if ok {
394        Ok(())
395    } else {
396        Err(Error::invalid(format!(
397            "invalid sandbox name {name:?}: use at most 63 of [a-z0-9-], starting with a letter"
398        )))
399    }
400}
401
402/// Deterministic device name for a guest mount path.
403pub fn device_name_for_path(guest: &str) -> String {
404    let mut s = String::new();
405    for c in guest.to_ascii_lowercase().chars() {
406        if c.is_ascii_alphanumeric() {
407            s.push(c);
408        } else if !s.ends_with('-') {
409            s.push('-');
410        }
411    }
412    let s = s.trim_matches('-').to_string();
413    let s = if s.is_empty() { "mount".to_string() } else { s };
414    if s.len() <= 48 {
415        return s;
416    }
417    format!(
418        "{}-{:08x}",
419        s[s.len() - 39..].trim_start_matches('-'),
420        fnv32(guest)
421    )
422}
423
424fn fnv32(s: &str) -> u32 {
425    let mut h: u32 = 0x811c9dc5;
426    for b in s.bytes() {
427        h ^= b as u32;
428        h = h.wrapping_mul(0x01000193);
429    }
430    h
431}
432
433/// Split `tcp:1.2.3.4:5173` into ("tcp", "1.2.3.4", 5173). IPv6 in brackets works.
434pub fn split_addr(addr: &str) -> Option<(&str, &str, u16)> {
435    let (proto, rest) = addr.split_once(':')?;
436    if !matches!(proto, "tcp" | "udp") {
437        return None;
438    }
439    let (host, port) = rest.rsplit_once(':')?;
440    Some((proto, host, port.parse().ok()?))
441}
442
443/// Expand a proxy address to incus' `proto:host:port` form.
444///
445/// Accepts `5173`, `HOST:5173`, `5173/udp`, `tcp:5173`, and full
446/// `tcp:HOST:PORT` / `udp:HOST:PORT` / `unix:PATH`. The protocol defaults to
447/// tcp and the host to `default_host`. IPv6 hosts go in brackets
448/// (`[::1]:5173`). The port may be a range or list as incus allows
449/// (`8000-8010`, `80,443`).
450pub fn normalize_addr(addr: &str, default_host: &str) -> std::result::Result<String, String> {
451    let a = addr.trim();
452    if a.is_empty() {
453        return Err("empty address".into());
454    }
455    if let Some(path) = a.strip_prefix("unix:") {
456        if path.is_empty() {
457            return Err(format!("{addr:?}: unix: needs a path"));
458        }
459        return Ok(a.to_string());
460    }
461    let (proto, rest) = match a.split_once(':') {
462        Some((p @ ("tcp" | "udp"), rest)) => (p, rest),
463        _ => match a.rsplit_once('/') {
464            Some((rest, p @ ("tcp" | "udp"))) => (p, rest),
465            Some((_, other)) if !other.contains(':') => {
466                return Err(format!("{addr:?}: unknown protocol {other:?} (tcp or udp)"));
467            }
468            _ => ("tcp", a),
469        },
470    };
471    let (host, port) = if rest.starts_with('[') {
472        let end = rest
473            .find(']')
474            .ok_or_else(|| format!("{addr:?}: unclosed [ in IPv6 host"))?;
475        let port = rest[end + 1..]
476            .strip_prefix(':')
477            .ok_or_else(|| format!("{addr:?}: expected [IPv6]:PORT"))?;
478        (&rest[..=end], port)
479    } else {
480        match rest.rsplit_once(':') {
481            Some((h, _)) if h.contains(':') => {
482                return Err(format!(
483                    "{addr:?}: put an IPv6 host in brackets, e.g. [::1]:5173"
484                ));
485            }
486            Some((h, p)) => (h, p),
487            None => (default_host, rest),
488        }
489    };
490    if host.is_empty() {
491        return Err(format!("{addr:?}: empty host"));
492    }
493    let valid_port = !port.is_empty()
494        && port.split(',').all(|part| {
495            let mut ends = part.splitn(2, '-');
496            ends.all(|n| n.parse::<u16>().is_ok_and(|n| n > 0))
497        });
498    if !valid_port {
499        return Err(format!(
500            "{addr:?}: expected PORT, HOST:PORT or PROTO:HOST:PORT (e.g. 5173, 0.0.0.0:5173, udp:5353)"
501        ));
502    }
503    Ok(format!("{proto}:{host}:{port}"))
504}
505
506fn default_port_name(bind: PortBind, listen: &str) -> String {
507    match split_addr(listen) {
508        Some(("tcp", _, port)) => format!("port-{}-{port}", bind.as_str()),
509        Some((proto, _, port)) => format!("port-{}-{proto}-{port}", bind.as_str()),
510        None => format!("port-{}-{}", bind.as_str(), device_name_for_path(listen)),
511    }
512}
513
514fn expand_home(p: &str) -> String {
515    if p == "~" || p.starts_with("~/") {
516        if let Ok(h) = std::env::var("HOME") {
517            return format!("{}{}", h.trim_end_matches('/'), &p[1..]);
518        }
519    }
520    p.to_string()
521}
522
523/// Make a bind source absolute (against `base`) and resolve symlinks, so the
524/// device source does not depend on how the caller reached the directory.
525pub fn resolve_host_path(p: &str, base: &Path) -> Result<String> {
526    let expanded = expand_home(p);
527    let path = PathBuf::from(&expanded);
528    let abs = if path.is_absolute() {
529        path
530    } else {
531        base.join(path)
532    };
533    let canon = abs.canonicalize().map_err(|e| {
534        Error::invalid(format!("bind source {} does not exist: {e}", abs.display()))
535    })?;
536    Ok(canon.to_string_lossy().into_owned())
537}
538
539/// Translate a docker memory size (`512m`, `8g`, `1073741824`) to
540/// incus' units (`512MiB`, `8GiB`, bytes). Docker's units are binary, so `g`
541/// and `GB` are GiB. incus' binary units (`8GiB`) and `50%` pass through.
542pub fn memory_limit(m: &str) -> std::result::Result<String, String> {
543    let t = m.trim();
544    let split = t
545        .find(|c: char| !c.is_ascii_digit() && c != '.')
546        .unwrap_or(t.len());
547    let (num, unit) = (&t[..split], t[split..].trim());
548    if num.is_empty() || num.parse::<u64>().is_err() {
549        // incus parses sizes as integers, so 1.5g has to be written 1536m.
550        return Err(format!("{m:?} is not a whole size (e.g. 512m, 8g, 8GiB)"));
551    }
552    let suffix = match unit.to_ascii_lowercase().as_str() {
553        "" | "b" => "",
554        "k" | "kb" => "KiB",
555        "m" | "mb" => "MiB",
556        "g" | "gb" => "GiB",
557        "t" | "tb" => "TiB",
558        // incus' own binary spellings, and percentages.
559        "%" | "kib" | "mib" | "gib" | "tib" => return Ok(t.to_string()),
560        _ => {
561            return Err(format!(
562                "{m:?}: unknown unit {unit:?} (b, k, m, g, t, KiB, MiB, GiB, TiB or %)"
563            ));
564        }
565    };
566    Ok(format!("{num}{suffix}"))
567}
568
569/// Resolve a spec. `base` anchors relative bind paths.
570#[expect(
571    clippy::too_many_lines,
572    clippy::cognitive_complexity,
573    reason = "predates the lint ratchet; split it when next changed"
574)]
575pub fn resolve(
576    spec: &SandboxSpec,
577    defs: &VolumeDefs,
578    host: &HostFacts,
579    base: &Path,
580) -> Result<Desired> {
581    let name = spec
582        .name
583        .clone()
584        .ok_or_else(|| Error::invalid("sandbox name is required"))?;
585    validate_instance_name(&name)?;
586    let image = ImageSource::parse(&spec.image)
587        .and_then(|i| i.bind(host.org.as_ref(), host.registry.as_deref()))
588        .map_err(|e| Error::invalid(format!("{name}: {e}")))?;
589    let pool = host.pick_pool(spec.storage.as_deref())?;
590    let vm = spec.instance_type == InstanceType::VirtualMachine;
591    let oci = image.is_oci();
592    if oci && vm {
593        return Err(Error::invalid(format!(
594            "{name}: OCI images run as containers, not VMs"
595        )));
596    }
597    if spec.entrypoint.is_some() && !oci {
598        return Err(Error::invalid(format!(
599            "{name}: entrypoint is for OCI images; use command"
600        )));
601    }
602    if vm {
603        if spec.privileged.is_some() {
604            return Err(Error::invalid(format!(
605                "{name}: privileged is container-only"
606            )));
607        }
608        for p in &spec.ports {
609            if p.bind == PortBind::Guest {
610                return Err(Error::invalid(format!(
611                    "{name}: incus VMs only support bind: host proxies (in NAT mode)"
612                )));
613            }
614        }
615    }
616
617    let mut config = Props::new();
618    match (&spec.cpus, &spec.cpuset) {
619        (Some(_), Some(_)) => {
620            return Err(Error::invalid(format!(
621                "{name}: set cpus (a count) or cpuset (which CPUs), not both"
622            )));
623        }
624        (Some(c), None) => {
625            if !c.trim().parse::<u32>().is_ok_and(|n| n > 0) {
626                return Err(Error::invalid(format!(
627                    "{name}: cpus is a whole number of CPUs, got {c:?} (pin CPUs with cpuset: \"0-3\")"
628                )));
629            }
630            config.insert("limits.cpu".into(), c.trim().to_string());
631        }
632        (None, Some(set)) => {
633            config.insert("limits.cpu".into(), set.clone());
634        }
635        (None, None) => {}
636    }
637    if let Some(m) = &spec.memory {
638        let m = memory_limit(m).map_err(|e| Error::invalid(format!("{name}: mem_limit: {e}")))?;
639        config.insert("limits.memory".into(), m);
640    }
641    if let Some(p) = spec.privileged {
642        config.insert("security.privileged".into(), p.to_string());
643    }
644    let (idmap_mode, raw_idmap) = if vm {
645        idmap::plan_vm(
646            &name,
647            spec,
648            host.incus_version.as_deref(),
649            host.invoking_ids,
650        )?
651    } else {
652        idmap::plan_container(spec.idmap.as_ref(), &host.subids)
653    };
654    if let Some(v) = raw_idmap {
655        config.insert("raw.idmap".into(), v);
656    }
657    for (k, v) in &spec.labels {
658        if k.is_empty() || k.contains(char::is_whitespace) {
659            return Err(Error::invalid(format!("{name}: invalid label key {k:?}")));
660        }
661        config.insert(format!("user.{k}"), v.clone());
662    }
663    for (k, v) in &spec.env {
664        config.insert(format!("environment.{k}"), v.clone());
665    }
666    if let Some(r) = spec.restart {
667        // incus' default (no boot.autostart) already restores the state the
668        // instance had at shutdown, which is exactly unless-stopped.
669        if matches!(r, RestartMode::Always | RestartMode::OnFailure) {
670            config.insert("boot.autostart".into(), "true".into());
671        }
672        if r.is_long_running() {
673            config.insert("boot.autorestart".into(), "true".into());
674        }
675    }
676    if oci {
677        let mut line: Vec<String> = spec.entrypoint.clone().unwrap_or_default();
678        line.extend(spec.command.clone().unwrap_or_default());
679        if !line.is_empty() {
680            let l = oci_command_line(&line).map_err(|e| Error::invalid(format!("{name}: {e}")))?;
681            config.insert("oci.entrypoint".into(), l);
682        }
683        if let Some(w) = &spec.working_dir {
684            config.insert("oci.cwd".into(), w.clone());
685        }
686        if let Some(u) = &spec.user {
687            let (uid, gid) = u.split_once(':').unwrap_or((u, u));
688            if uid.parse::<u32>().is_err() || gid.parse::<u32>().is_err() {
689                return Err(Error::invalid(format!(
690                    "{name}: an OCI image's user must be numeric (uid or uid:gid), got {u:?}"
691                )));
692            }
693            config.insert("oci.uid".into(), uid.into());
694            config.insert("oci.gid".into(), gid.into());
695        }
696    }
697    for (k, v) in &spec.raw_config {
698        config.insert(k.clone(), v.clone());
699    }
700    crate::org::nesting::check_config(&name, host.org.as_ref(), &config, spec.workspace_nesting)?;
701
702    let mut devices: BTreeMap<String, DesiredDevice> = BTreeMap::new();
703    let mut add_dev = |dname: String, dev: DesiredDevice| -> Result<()> {
704        if devices.insert(dname.clone(), dev).is_some() {
705            return Err(Error::invalid(format!(
706                "{name}: device name {dname:?} is used twice"
707            )));
708        }
709        Ok(())
710    };
711    add_dev(
712        "root".into(),
713        DesiredDevice {
714            props: Props::from([
715                ("type".into(), "disk".into()),
716                ("path".into(), "/".into()),
717                ("pool".into(), pool.clone()),
718            ]),
719            search: None,
720        },
721    )?;
722
723    let mut volumes: Vec<EnsureVolume> = Vec::new();
724    let mut owners = Vec::new();
725    let mut guest_paths = BTreeSet::new();
726    for v in &spec.volumes {
727        let guest = &v.target;
728        if !guest.starts_with('/') {
729            return Err(Error::invalid(format!(
730                "{name}: mount path {guest:?} must be absolute"
731            )));
732        }
733        let guest_norm = guest.trim_end_matches('/').to_string();
734        let guest_norm = if guest_norm.is_empty() {
735            "/".to_string()
736        } else {
737            guest_norm
738        };
739        if !guest_paths.insert(guest_norm.clone()) {
740            return Err(Error::invalid(format!("{name}: {guest} is mounted twice")));
741        }
742        let dname = v
743            .device
744            .clone()
745            .unwrap_or_else(|| device_name_for_path(&guest_norm));
746        let mut props = Props::from([
747            ("type".into(), "disk".into()),
748            ("path".into(), guest_norm.clone()),
749        ]);
750        match v.mount_type {
751            MountType::Bind => {
752                if v.owner.is_some() {
753                    return Err(Error::invalid(format!(
754                        "{name}: {guest}: owner is only for named volumes (isb never chowns host paths)"
755                    )));
756                }
757                if v.pool.is_some() || v.external || v.volume.nocopy {
758                    return Err(Error::invalid(format!(
759                        "{name}: {guest}: pool, external and nocopy are only for named volumes"
760                    )));
761                }
762                let src = resolve_host_path(&v.source, base)?;
763                if let Some(root) = &host.shared_root {
764                    let r = root.trim_end_matches('/');
765                    if src != r && !src.starts_with(&format!("{r}/")) {
766                        return Err(Error::invalid(format!(
767                            "{name}: {guest}: bind source {src} is outside {r}, the only \
768                             directory shared with the isb machine"
769                        )));
770                    }
771                }
772                props.insert("source".into(), host.translate(&src));
773            }
774            MountType::Volume => {
775                let def = defs.get(&v.source);
776                // A compose file names its volumes `<project>_<key>`; a bare
777                // spec names the incus volume directly.
778                let n = &def
779                    .and_then(|d| d.name.clone())
780                    .unwrap_or_else(|| v.source.clone());
781                let vpool = match v.pool.as_deref().or(def.and_then(|d| d.pool.as_deref())) {
782                    Some(p) if p != "auto" => host.pick_pool(Some(p))?,
783                    _ => pool.clone(),
784                };
785                props.insert("pool".into(), vpool.clone());
786                props.insert("source".into(), n.clone());
787                // docker seeds an empty named volume with the image's content at
788                // the target. incus does the same with initial.copy, containers
789                // only; an older server just mounts it empty, as isb always did.
790                if !vm && host.initial_copy && !v.volume.nocopy {
791                    props.insert("initial.copy".into(), "true".into());
792                }
793                let ev = EnsureVolume {
794                    pool: vpool,
795                    name: n.clone(),
796                    config: def.map(|d| d.config.clone()).unwrap_or_default(),
797                    external: v.external || def.is_some_and(|d| d.external),
798                };
799                if !volumes.contains(&ev) {
800                    volumes.push(ev);
801                }
802                if let Some(o) = &v.owner {
803                    owners.push(OwnerFixup {
804                        device: dname.clone(),
805                        path: guest_norm.clone(),
806                        owner: o.clone(),
807                    });
808                }
809            }
810        }
811        if v.read_only {
812            props.insert("readonly".into(), "true".into());
813        }
814        for k in v.options.keys() {
815            if matches!(k.as_str(), "type" | "path" | "source" | "pool" | "readonly") {
816                return Err(Error::invalid(format!(
817                    "{name}: {guest}: options.{k} would override a core property; use the field instead"
818                )));
819            }
820        }
821        for (k, val) in &v.options {
822            props.insert(k.clone(), val.clone());
823        }
824        add_dev(
825            dname,
826            DesiredDevice {
827                props,
828                search: None,
829            },
830        )?;
831    }
832
833    for p in &spec.ports {
834        // Docker-style shorthand: the protocol defaults to tcp and the host to
835        // 127.0.0.1. A VM's connect side defaults to 0.0.0.0 instead, which is
836        // how incus' NAT mode finds the VM's own address.
837        let connect_host = if vm { "0.0.0.0" } else { "127.0.0.1" };
838        let listen = normalize_addr(&p.listen, "127.0.0.1")
839            .map_err(|e| Error::invalid(format!("{name}: port listen: {e}")))?;
840        let connect = normalize_addr(&p.connect, connect_host)
841            .map_err(|e| Error::invalid(format!("{name}: port connect: {e}")))?;
842        if p.search.is_some() {
843            if split_addr(&connect).is_none_or(|(_, h, _)| h != connect_host) {
844                return Err(Error::invalid(format!(
845                    "{name}: a published port range connects to the guest's default address ({connect_host}), not {connect}"
846                )));
847            }
848            if p.bind != PortBind::Host {
849                return Err(Error::invalid(format!(
850                    "{name}: port search only applies to bind: host"
851                )));
852            }
853            if split_addr(&listen).is_none() {
854                return Err(Error::invalid(format!(
855                    "{name}: port search needs a single tcp or udp listen port"
856                )));
857            }
858        }
859        let dname = p
860            .name
861            .clone()
862            .unwrap_or_else(|| default_port_name(p.bind, &listen));
863        let mut props = Props::from([
864            ("type".into(), "proxy".into()),
865            ("bind".into(), p.bind.as_str().into()),
866            ("listen".into(), listen),
867            ("connect".into(), connect),
868        ]);
869        if vm {
870            // incus proxies into a VM only in NAT mode.
871            props.insert("nat".into(), "true".into());
872        }
873        for (k, val) in &p.options {
874            if matches!(k.as_str(), "type" | "bind" | "listen" | "connect") {
875                return Err(Error::invalid(format!(
876                    "{name}: port options.{k} would override a core property; use the field instead"
877                )));
878            }
879            props.insert(k.clone(), val.clone());
880        }
881        add_dev(
882            dname,
883            DesiredDevice {
884                props,
885                search: p.search.filter(|n| *n > 0),
886            },
887        )?;
888    }
889
890    let egress = match &spec.egress {
891        Some(e) => {
892            let c = crate::egress::contribute(e, &host.project, &name)?;
893            add_dev(
894                "eth0".into(),
895                DesiredDevice {
896                    props: c.nic,
897                    search: None,
898                },
899            )?;
900            config.extend(c.config);
901            Some(c.plumbing)
902        }
903        None => None,
904    };
905    let mut root_extra = Props::new();
906    for (dname, props) in &spec.raw_devices {
907        if dname == "root" {
908            // Tune the root disk (size, ...): merged over the generated one below.
909            root_extra.extend(props.clone());
910            continue;
911        }
912        if !props.contains_key("type") {
913            return Err(Error::invalid(format!(
914                "{name}: raw device {dname:?} needs a type"
915            )));
916        }
917        add_dev(
918            dname.clone(),
919            DesiredDevice {
920                props: props.clone(),
921                search: None,
922            },
923        )?;
924    }
925
926    if let Some(root) = devices.get_mut("root") {
927        root.props.extend(root_extra);
928    }
929    let props = devices.iter().map(|(k, d)| (k, &d.props));
930    crate::org::check_proxies(&name, host.org.as_ref(), props, spec.stack_udp)?;
931
932    let ready_timeout = match &spec.ready_timeout {
933        Some(s) => parse_duration(s).map_err(|e| Error::invalid(format!("{name}: {e}")))?,
934        // A container is usable about a second after Running; a VM boots a
935        // kernel and its agent (50-90s under nested virtualization, plus the
936        // reboot a cloud image does on first boot).
937        None if vm => Duration::from_secs(300),
938        None => Duration::from_secs(60),
939    };
940
941    Ok(Desired {
942        name,
943        instance_type: spec.instance_type,
944        image,
945        pool,
946        profiles: spec
947            .profiles
948            .clone()
949            .unwrap_or_else(|| vec!["default".into()]),
950        config,
951        devices,
952        volumes,
953        owners,
954        ready: spec.ready.clone().unwrap_or_else(|| {
955            if vm {
956                vec![ReadyCheck::Running, ReadyCheck::Agent]
957            } else {
958                vec![ReadyCheck::Running]
959            }
960        }),
961        ready_timeout,
962        exec: spec.exec_defaults(),
963        idmap_mode,
964        sensitive: spec
965            .env
966            .secrets
967            .keys()
968            .map(|k| format!("environment.{k}"))
969            .collect(),
970        egress,
971    })
972}
973
974/// Instance state as incus reports it.
975#[derive(Debug, Clone, Default, PartialEq)]
976pub struct Actual {
977    pub status: String,
978    pub config: Props,
979    /// Instance-local devices (not the ones inherited from profiles).
980    pub devices: BTreeMap<String, Props>,
981    pub profiles: Vec<String>,
982    pub instance_type: String,
983}
984
985impl Actual {
986    pub fn from_api(v: &Value) -> Actual {
987        let strmap = |v: Option<&Value>| -> Props {
988            v.and_then(Value::as_object)
989                .map(|m| {
990                    m.iter()
991                        .map(|(k, v)| {
992                            let s = match v {
993                                Value::String(s) => s.clone(),
994                                other => other.to_string(),
995                            };
996                            (k.clone(), s)
997                        })
998                        .collect()
999                })
1000                .unwrap_or_default()
1001        };
1002        let devices = v
1003            .get("devices")
1004            .and_then(Value::as_object)
1005            .map(|m| {
1006                m.iter()
1007                    .map(|(k, d)| (k.clone(), strmap(Some(d))))
1008                    .collect()
1009            })
1010            .unwrap_or_default();
1011        Actual {
1012            status: v
1013                .get("status")
1014                .and_then(Value::as_str)
1015                .unwrap_or("")
1016                .to_string(),
1017            config: strmap(v.get("config")),
1018            devices,
1019            profiles: v
1020                .get("profiles")
1021                .and_then(Value::as_array)
1022                .map(|a| {
1023                    a.iter()
1024                        .filter_map(|x| x.as_str().map(String::from))
1025                        .collect()
1026                })
1027                .unwrap_or_default(),
1028            instance_type: v
1029                .get("type")
1030                .and_then(Value::as_str)
1031                .unwrap_or("")
1032                .to_string(),
1033        }
1034    }
1035
1036    pub fn running(&self) -> bool {
1037        self.status.eq_ignore_ascii_case("running")
1038    }
1039}
1040
1041/// One step of a plan.
1042#[derive(Debug, Clone, PartialEq, Serialize)]
1043#[serde(tag = "action", rename_all = "snake_case")]
1044pub enum Action {
1045    CreateVolume {
1046        pool: String,
1047        volume: String,
1048        config: Props,
1049    },
1050    CreateInstance {
1051        image: String,
1052        pool: String,
1053        config: Props,
1054        devices: BTreeMap<String, Props>,
1055        profiles: Vec<String>,
1056    },
1057    SetConfig {
1058        key: String,
1059        #[serde(skip_serializing_if = "Option::is_none")]
1060        from: Option<String>,
1061        to: String,
1062        /// Only takes effect after a restart.
1063        restart: bool,
1064        /// A secret value: `from` and `to` say `(secret)`, and the value set
1065        /// is the desired config's.
1066        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1067        secret: bool,
1068    },
1069    AddDevice {
1070        device: String,
1071        props: Props,
1072    },
1073    /// Replace a wrong device. For a disk that is a remount inside the guest.
1074    ReplaceDevice {
1075        device: String,
1076        /// The existing device being replaced (may differ in name).
1077        replaces: String,
1078        from: Props,
1079        to: Props,
1080    },
1081    RemoveDevice {
1082        device: String,
1083        props: Props,
1084    },
1085    StartInstance,
1086    /// Add a host-bound proxy, moving up to `search` ports past a taken one.
1087    AddPort {
1088        device: String,
1089        props: Props,
1090        search: u16,
1091    },
1092    FixOwner {
1093        path: String,
1094        owner: String,
1095    },
1096    /// Something isb will not change (fixed at creation, or ambiguous). Informational.
1097    Note {
1098        message: String,
1099    },
1100}
1101
1102impl Action {
1103    /// Whether this action changes anything.
1104    pub fn is_change(&self) -> bool {
1105        !matches!(self, Action::Note { .. })
1106    }
1107}
1108
1109impl std::fmt::Display for Action {
1110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1111        let props = |p: &Props| {
1112            p.iter()
1113                .filter(|(k, _)| k.as_str() != "type")
1114                .map(|(k, v)| format!("{k}={v}"))
1115                .collect::<Vec<_>>()
1116                .join(" ")
1117        };
1118        match self {
1119            Action::CreateVolume { pool, volume, .. } => {
1120                write!(f, "+ volume {volume} (pool {pool})")
1121            }
1122            Action::CreateInstance {
1123                image,
1124                pool,
1125                devices,
1126                ..
1127            } => write!(
1128                f,
1129                "+ create from {image} on pool {pool} with {} device(s)",
1130                devices.len()
1131            ),
1132            Action::SetConfig {
1133                key,
1134                from,
1135                to,
1136                restart,
1137                ..
1138            } => write!(
1139                f,
1140                "~ config {key}: {} -> {to}{}",
1141                from.as_deref().unwrap_or("(unset)"),
1142                if *restart {
1143                    " (takes effect on restart)"
1144                } else {
1145                    ""
1146                }
1147            ),
1148            Action::AddDevice { device, props: p } => write!(f, "+ device {device}: {}", props(p)),
1149            Action::ReplaceDevice {
1150                device,
1151                replaces,
1152                from,
1153                to,
1154            } => {
1155                if device == replaces {
1156                    write!(f, "~ device {device}: {} -> {}", props(from), props(to))
1157                } else {
1158                    write!(
1159                        f,
1160                        "~ device {replaces} -> {device}: {} -> {}",
1161                        props(from),
1162                        props(to)
1163                    )
1164                }
1165            }
1166            Action::RemoveDevice { device, .. } => write!(f, "- device {device}"),
1167            Action::StartInstance => write!(f, "> start"),
1168            Action::AddPort {
1169                device,
1170                props: p,
1171                search,
1172            } => write!(f, "+ port {device}: {} (search {search})", props(p)),
1173            Action::FixOwner { path, owner } => write!(f, "~ chown {owner} {path}"),
1174            Action::Note { message } => write!(f, "  note: {message}"),
1175        }
1176    }
1177}
1178
1179/// The plan for one sandbox.
1180#[derive(Debug, Clone, Serialize)]
1181pub struct SandboxPlan {
1182    pub name: String,
1183    /// Existing status (`None`: does not exist yet).
1184    pub status: Option<String>,
1185    pub actions: Vec<Action>,
1186}
1187
1188impl SandboxPlan {
1189    /// No changes (notes only).
1190    pub fn is_noop(&self) -> bool {
1191        !self.actions.iter().any(Action::is_change)
1192    }
1193}
1194
1195/// Options for [`diff`].
1196#[derive(Debug, Clone, Copy, Default)]
1197pub struct DiffOptions {
1198    /// Remove instance-local devices not in the spec (never `root`).
1199    pub prune_devices: bool,
1200}
1201
1202/// Keys whose value is a boolean where `false` is the same as absent.
1203const FALSE_IS_ABSENT: &[&str] = &["readonly", "shift", "nat"];
1204
1205fn normalize(p: &Props) -> Props {
1206    let mut out = p.clone();
1207    for k in FALSE_IS_ABSENT {
1208        if out.get(*k).map(String::as_str) == Some("false") {
1209            out.remove(*k);
1210        }
1211    }
1212    if out.get("type").map(String::as_str) == Some("disk") {
1213        for k in ["source", "path"] {
1214            if let Some(v) = out.get_mut(k) {
1215                if v.len() > 1 {
1216                    *v = v.trim_end_matches('/').to_string();
1217                }
1218            }
1219        }
1220    }
1221    out
1222}
1223
1224/// Whether an existing device satisfies a desired one.
1225pub fn device_matches(desired: &DesiredDevice, actual: &Props) -> bool {
1226    let mut d = normalize(&desired.props);
1227    let mut a = normalize(actual);
1228    // initial.copy only acts the first time a volume is used, so a disk that
1229    // differs in nothing else is correct, and replacing it would remount it.
1230    if d.get("type").map(String::as_str) == Some("disk") {
1231        d.remove("initial.copy");
1232        a.remove("initial.copy");
1233    }
1234    if d == a {
1235        return true;
1236    }
1237    let Some(n) = desired.search else {
1238        return false;
1239    };
1240    // A searched port is correct anywhere in its range.
1241    let (Some(dl), Some(al)) = (d.get("listen"), a.get("listen")) else {
1242        return false;
1243    };
1244    let (Some((dp, dh, dport)), Some((ap, ah, aport))) = (split_addr(dl), split_addr(al)) else {
1245        return false;
1246    };
1247    if dp != ap || dh != ah || aport < dport || aport as u32 > dport as u32 + n as u32 {
1248        return false;
1249    }
1250    let strip = |m: &Props| {
1251        let mut m = m.clone();
1252        m.remove("listen");
1253        m
1254    };
1255    strip(&d) == strip(&a)
1256}
1257
1258/// What plans and reports show for a secret value.
1259pub const REDACTED: &str = "(secret)";
1260
1261fn restart_needed(key: &str) -> bool {
1262    key.starts_with("raw.") || key.starts_with("security.") || key.starts_with("oci.")
1263}
1264
1265/// Diff desired against actual (`None`: the instance does not exist).
1266/// `volumes_missing` lists named volumes (pool, name) that do not exist yet.
1267#[expect(
1268    clippy::too_many_lines,
1269    clippy::cognitive_complexity,
1270    reason = "predates the lint ratchet; split it when next changed"
1271)]
1272pub fn diff(
1273    desired: &Desired,
1274    actual: Option<&Actual>,
1275    volumes_missing: &[(String, String)],
1276    opts: DiffOptions,
1277) -> Result<SandboxPlan> {
1278    let mut actions = Vec::new();
1279    for v in &desired.volumes {
1280        if volumes_missing.contains(&(v.pool.clone(), v.name.clone())) {
1281            if v.external {
1282                return Err(Error::invalid(format!(
1283                    "volume {} is external but does not exist in pool {}",
1284                    v.name, v.pool
1285                )));
1286            }
1287            actions.push(Action::CreateVolume {
1288                pool: v.pool.clone(),
1289                volume: v.name.clone(),
1290                config: v.config.clone(),
1291            });
1292        }
1293    }
1294
1295    let Some(actual) = actual else {
1296        actions.push(Action::CreateInstance {
1297            image: desired.image.spec.clone(),
1298            pool: desired.pool.clone(),
1299            config: desired
1300                .config
1301                .iter()
1302                .map(|(k, v)| {
1303                    let v = if desired.sensitive.contains(k) {
1304                        REDACTED.to_string()
1305                    } else {
1306                        v.clone()
1307                    };
1308                    (k.clone(), v)
1309                })
1310                .collect(),
1311            devices: desired
1312                .devices
1313                .iter()
1314                .filter(|(_, d)| d.search.is_none())
1315                .map(|(k, d)| (k.clone(), d.props.clone()))
1316                .collect(),
1317            profiles: desired.profiles.clone(),
1318        });
1319        actions.push(Action::StartInstance);
1320        push_searched_ports(desired, &mut actions);
1321        for o in &desired.owners {
1322            actions.push(Action::FixOwner {
1323                path: o.path.clone(),
1324                owner: o.owner.clone(),
1325            });
1326        }
1327        return Ok(SandboxPlan {
1328            name: desired.name.clone(),
1329            status: None,
1330            actions,
1331        });
1332    };
1333
1334    // Fixed-at-creation properties: report, never change.
1335    if !actual.instance_type.is_empty() && actual.instance_type != desired.instance_type.as_api() {
1336        actions.push(Action::Note {
1337            message: format!(
1338                "type is {} (spec: {}); fixed at creation",
1339                actual.instance_type,
1340                desired.instance_type.as_api()
1341            ),
1342        });
1343    }
1344    if let Some(root) = actual.devices.get("root") {
1345        if let Some(p) = root.get("pool") {
1346            if *p != desired.pool {
1347                actions.push(Action::Note {
1348                    message: format!(
1349                        "root disk is on pool {p} (spec: {}); fixed at creation",
1350                        desired.pool
1351                    ),
1352                });
1353            }
1354        }
1355    }
1356    if actual.profiles != desired.profiles {
1357        actions.push(Action::Note {
1358            message: format!(
1359                "profiles are [{}] (spec: [{}]); fixed at creation",
1360                actual.profiles.join(", "),
1361                desired.profiles.join(", ")
1362            ),
1363        });
1364    }
1365
1366    for (k, v) in &desired.config {
1367        let cur = actual.config.get(k);
1368        if cur != Some(v) {
1369            let secret = desired.sensitive.contains(k);
1370            let hide = |s: &String| if secret { REDACTED.into() } else { s.clone() };
1371            actions.push(Action::SetConfig {
1372                key: k.clone(),
1373                from: cur.map(hide),
1374                to: hide(v),
1375                restart: restart_needed(k),
1376                secret,
1377            });
1378        }
1379    }
1380    if !desired.config.contains_key("raw.idmap") && actual.config.contains_key("raw.idmap") {
1381        let why = match desired.idmap_mode {
1382            Some(crate::spec::IdmapMode::Auto) => Some("not needed on this host"),
1383            Some(crate::spec::IdmapMode::None) => Some("the spec says idmap: none"),
1384            _ => None,
1385        };
1386        if let Some(why) = why {
1387            actions.push(Action::Note {
1388                message: format!(
1389                    "raw.idmap is set but {why}; isb never removes config keys, unset it by hand"
1390                ),
1391            });
1392        }
1393    }
1394
1395    let mut new_devices: Vec<String> = Vec::new();
1396    let mut claimed: BTreeSet<String> = BTreeSet::new();
1397    let mut deferred_ports = Vec::new();
1398    for (name, want) in &desired.devices {
1399        if name == "root" {
1400            continue;
1401        }
1402        if let Some(have) = actual.devices.get(name) {
1403            claimed.insert(name.clone());
1404            if !device_matches(want, have) && want.search.is_some() {
1405                // Out of its range or otherwise wrong: drop it and search again,
1406                // rather than replacing it at a port that may be taken.
1407                actions.push(Action::RemoveDevice {
1408                    device: name.clone(),
1409                    props: have.clone(),
1410                });
1411                deferred_ports.push(name.clone());
1412            } else if !device_matches(want, have) {
1413                actions.push(Action::ReplaceDevice {
1414                    device: name.clone(),
1415                    replaces: name.clone(),
1416                    from: have.clone(),
1417                    to: want.props.clone(),
1418                });
1419                new_devices.push(name.clone());
1420            }
1421            continue;
1422        }
1423        // Not present under this name. An equivalent device under another name
1424        // satisfies it (adopting what another tool created); a disk at the same
1425        // guest path that differs has to be replaced, since two disks cannot
1426        // share a mount point.
1427        let same_path = |p: &Props| {
1428            want.props.get("type").map(String::as_str) == Some("disk")
1429                && p.get("type").map(String::as_str) == Some("disk")
1430                && normalize(p).get("path") == normalize(&want.props).get("path")
1431        };
1432        if let Some((other, have)) = actual.devices.iter().find(|(n, p)| {
1433            *n != "root" && !desired.devices.contains_key(*n) && device_matches(want, p)
1434        }) {
1435            claimed.insert(other.clone());
1436            actions.push(Action::Note {
1437                message: format!("device {name} already present as {other}; left as is"),
1438            });
1439            let _ = have;
1440            continue;
1441        }
1442        if let Some((other, have)) = actual
1443            .devices
1444            .iter()
1445            .find(|(n, p)| *n != "root" && !desired.devices.contains_key(*n) && same_path(p))
1446        {
1447            claimed.insert(other.clone());
1448            actions.push(Action::ReplaceDevice {
1449                device: name.clone(),
1450                replaces: other.clone(),
1451                from: have.clone(),
1452                to: want.props.clone(),
1453            });
1454            new_devices.push(name.clone());
1455            continue;
1456        }
1457        if want.search.is_some() {
1458            deferred_ports.push(name.clone());
1459        } else {
1460            actions.push(Action::AddDevice {
1461                device: name.clone(),
1462                props: want.props.clone(),
1463            });
1464            new_devices.push(name.clone());
1465        }
1466    }
1467
1468    if opts.prune_devices {
1469        for (name, props) in &actual.devices {
1470            if name != "root" && !desired.devices.contains_key(name) && !claimed.contains(name) {
1471                actions.push(Action::RemoveDevice {
1472                    device: name.clone(),
1473                    props: props.clone(),
1474                });
1475            }
1476        }
1477    }
1478
1479    if !actual.running() {
1480        actions.push(Action::StartInstance);
1481    }
1482    for name in deferred_ports {
1483        let d = &desired.devices[&name];
1484        actions.push(Action::AddPort {
1485            device: name.clone(),
1486            props: d.props.clone(),
1487            search: d.search.unwrap_or(0),
1488        });
1489    }
1490    for o in &desired.owners {
1491        if new_devices.contains(&o.device) {
1492            actions.push(Action::FixOwner {
1493                path: o.path.clone(),
1494                owner: o.owner.clone(),
1495            });
1496        }
1497    }
1498
1499    Ok(SandboxPlan {
1500        name: desired.name.clone(),
1501        status: Some(actual.status.clone()),
1502        actions,
1503    })
1504}
1505
1506fn push_searched_ports(desired: &Desired, actions: &mut Vec<Action>) {
1507    for (name, d) in &desired.devices {
1508        if let Some(n) = d.search {
1509            actions.push(Action::AddPort {
1510                device: name.clone(),
1511                props: d.props.clone(),
1512                search: n,
1513            });
1514        }
1515    }
1516}
1517
1518#[cfg(test)]
1519mod tests;