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
930    let ready_timeout = match &spec.ready_timeout {
931        Some(s) => parse_duration(s).map_err(|e| Error::invalid(format!("{name}: {e}")))?,
932        // A container is usable about a second after Running; a VM boots a
933        // kernel and its agent (50-90s under nested virtualization, plus the
934        // reboot a cloud image does on first boot).
935        None if vm => Duration::from_secs(300),
936        None => Duration::from_secs(60),
937    };
938
939    Ok(Desired {
940        name,
941        instance_type: spec.instance_type,
942        image,
943        pool,
944        profiles: spec
945            .profiles
946            .clone()
947            .unwrap_or_else(|| vec!["default".into()]),
948        config,
949        devices,
950        volumes,
951        owners,
952        ready: spec.ready.clone().unwrap_or_else(|| {
953            if vm {
954                vec![ReadyCheck::Running, ReadyCheck::Agent]
955            } else {
956                vec![ReadyCheck::Running]
957            }
958        }),
959        ready_timeout,
960        exec: spec.exec_defaults(),
961        idmap_mode,
962        sensitive: spec
963            .env
964            .secrets
965            .keys()
966            .map(|k| format!("environment.{k}"))
967            .collect(),
968        egress,
969    })
970}
971
972/// Instance state as incus reports it.
973#[derive(Debug, Clone, Default, PartialEq)]
974pub struct Actual {
975    pub status: String,
976    pub config: Props,
977    /// Instance-local devices (not the ones inherited from profiles).
978    pub devices: BTreeMap<String, Props>,
979    pub profiles: Vec<String>,
980    pub instance_type: String,
981}
982
983impl Actual {
984    pub fn from_api(v: &Value) -> Actual {
985        let strmap = |v: Option<&Value>| -> Props {
986            v.and_then(Value::as_object)
987                .map(|m| {
988                    m.iter()
989                        .map(|(k, v)| {
990                            let s = match v {
991                                Value::String(s) => s.clone(),
992                                other => other.to_string(),
993                            };
994                            (k.clone(), s)
995                        })
996                        .collect()
997                })
998                .unwrap_or_default()
999        };
1000        let devices = v
1001            .get("devices")
1002            .and_then(Value::as_object)
1003            .map(|m| {
1004                m.iter()
1005                    .map(|(k, d)| (k.clone(), strmap(Some(d))))
1006                    .collect()
1007            })
1008            .unwrap_or_default();
1009        Actual {
1010            status: v
1011                .get("status")
1012                .and_then(Value::as_str)
1013                .unwrap_or("")
1014                .to_string(),
1015            config: strmap(v.get("config")),
1016            devices,
1017            profiles: v
1018                .get("profiles")
1019                .and_then(Value::as_array)
1020                .map(|a| {
1021                    a.iter()
1022                        .filter_map(|x| x.as_str().map(String::from))
1023                        .collect()
1024                })
1025                .unwrap_or_default(),
1026            instance_type: v
1027                .get("type")
1028                .and_then(Value::as_str)
1029                .unwrap_or("")
1030                .to_string(),
1031        }
1032    }
1033
1034    pub fn running(&self) -> bool {
1035        self.status.eq_ignore_ascii_case("running")
1036    }
1037}
1038
1039/// One step of a plan.
1040#[derive(Debug, Clone, PartialEq, Serialize)]
1041#[serde(tag = "action", rename_all = "snake_case")]
1042pub enum Action {
1043    CreateVolume {
1044        pool: String,
1045        volume: String,
1046        config: Props,
1047    },
1048    CreateInstance {
1049        image: String,
1050        pool: String,
1051        config: Props,
1052        devices: BTreeMap<String, Props>,
1053        profiles: Vec<String>,
1054    },
1055    SetConfig {
1056        key: String,
1057        #[serde(skip_serializing_if = "Option::is_none")]
1058        from: Option<String>,
1059        to: String,
1060        /// Only takes effect after a restart.
1061        restart: bool,
1062        /// A secret value: `from` and `to` say `(secret)`, and the value set
1063        /// is the desired config's.
1064        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1065        secret: bool,
1066    },
1067    AddDevice {
1068        device: String,
1069        props: Props,
1070    },
1071    /// Replace a wrong device. For a disk that is a remount inside the guest.
1072    ReplaceDevice {
1073        device: String,
1074        /// The existing device being replaced (may differ in name).
1075        replaces: String,
1076        from: Props,
1077        to: Props,
1078    },
1079    RemoveDevice {
1080        device: String,
1081        props: Props,
1082    },
1083    StartInstance,
1084    /// Add a host-bound proxy, moving up to `search` ports past a taken one.
1085    AddPort {
1086        device: String,
1087        props: Props,
1088        search: u16,
1089    },
1090    FixOwner {
1091        path: String,
1092        owner: String,
1093    },
1094    /// Something isb will not change (fixed at creation, or ambiguous). Informational.
1095    Note {
1096        message: String,
1097    },
1098}
1099
1100impl Action {
1101    /// Whether this action changes anything.
1102    pub fn is_change(&self) -> bool {
1103        !matches!(self, Action::Note { .. })
1104    }
1105}
1106
1107impl std::fmt::Display for Action {
1108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1109        let props = |p: &Props| {
1110            p.iter()
1111                .filter(|(k, _)| k.as_str() != "type")
1112                .map(|(k, v)| format!("{k}={v}"))
1113                .collect::<Vec<_>>()
1114                .join(" ")
1115        };
1116        match self {
1117            Action::CreateVolume { pool, volume, .. } => {
1118                write!(f, "+ volume {volume} (pool {pool})")
1119            }
1120            Action::CreateInstance {
1121                image,
1122                pool,
1123                devices,
1124                ..
1125            } => write!(
1126                f,
1127                "+ create from {image} on pool {pool} with {} device(s)",
1128                devices.len()
1129            ),
1130            Action::SetConfig {
1131                key,
1132                from,
1133                to,
1134                restart,
1135                ..
1136            } => write!(
1137                f,
1138                "~ config {key}: {} -> {to}{}",
1139                from.as_deref().unwrap_or("(unset)"),
1140                if *restart {
1141                    " (takes effect on restart)"
1142                } else {
1143                    ""
1144                }
1145            ),
1146            Action::AddDevice { device, props: p } => write!(f, "+ device {device}: {}", props(p)),
1147            Action::ReplaceDevice {
1148                device,
1149                replaces,
1150                from,
1151                to,
1152            } => {
1153                if device == replaces {
1154                    write!(f, "~ device {device}: {} -> {}", props(from), props(to))
1155                } else {
1156                    write!(
1157                        f,
1158                        "~ device {replaces} -> {device}: {} -> {}",
1159                        props(from),
1160                        props(to)
1161                    )
1162                }
1163            }
1164            Action::RemoveDevice { device, .. } => write!(f, "- device {device}"),
1165            Action::StartInstance => write!(f, "> start"),
1166            Action::AddPort {
1167                device,
1168                props: p,
1169                search,
1170            } => write!(f, "+ port {device}: {} (search {search})", props(p)),
1171            Action::FixOwner { path, owner } => write!(f, "~ chown {owner} {path}"),
1172            Action::Note { message } => write!(f, "  note: {message}"),
1173        }
1174    }
1175}
1176
1177/// The plan for one sandbox.
1178#[derive(Debug, Clone, Serialize)]
1179pub struct SandboxPlan {
1180    pub name: String,
1181    /// Existing status (`None`: does not exist yet).
1182    pub status: Option<String>,
1183    pub actions: Vec<Action>,
1184}
1185
1186impl SandboxPlan {
1187    /// No changes (notes only).
1188    pub fn is_noop(&self) -> bool {
1189        !self.actions.iter().any(Action::is_change)
1190    }
1191}
1192
1193/// Options for [`diff`].
1194#[derive(Debug, Clone, Copy, Default)]
1195pub struct DiffOptions {
1196    /// Remove instance-local devices not in the spec (never `root`).
1197    pub prune_devices: bool,
1198}
1199
1200/// Keys whose value is a boolean where `false` is the same as absent.
1201const FALSE_IS_ABSENT: &[&str] = &["readonly", "shift", "nat"];
1202
1203fn normalize(p: &Props) -> Props {
1204    let mut out = p.clone();
1205    for k in FALSE_IS_ABSENT {
1206        if out.get(*k).map(String::as_str) == Some("false") {
1207            out.remove(*k);
1208        }
1209    }
1210    if out.get("type").map(String::as_str) == Some("disk") {
1211        for k in ["source", "path"] {
1212            if let Some(v) = out.get_mut(k) {
1213                if v.len() > 1 {
1214                    *v = v.trim_end_matches('/').to_string();
1215                }
1216            }
1217        }
1218    }
1219    out
1220}
1221
1222/// Whether an existing device satisfies a desired one.
1223pub fn device_matches(desired: &DesiredDevice, actual: &Props) -> bool {
1224    let mut d = normalize(&desired.props);
1225    let mut a = normalize(actual);
1226    // initial.copy only acts the first time a volume is used, so a disk that
1227    // differs in nothing else is correct, and replacing it would remount it.
1228    if d.get("type").map(String::as_str) == Some("disk") {
1229        d.remove("initial.copy");
1230        a.remove("initial.copy");
1231    }
1232    if d == a {
1233        return true;
1234    }
1235    let Some(n) = desired.search else {
1236        return false;
1237    };
1238    // A searched port is correct anywhere in its range.
1239    let (Some(dl), Some(al)) = (d.get("listen"), a.get("listen")) else {
1240        return false;
1241    };
1242    let (Some((dp, dh, dport)), Some((ap, ah, aport))) = (split_addr(dl), split_addr(al)) else {
1243        return false;
1244    };
1245    if dp != ap || dh != ah || aport < dport || aport as u32 > dport as u32 + n as u32 {
1246        return false;
1247    }
1248    let strip = |m: &Props| {
1249        let mut m = m.clone();
1250        m.remove("listen");
1251        m
1252    };
1253    strip(&d) == strip(&a)
1254}
1255
1256/// What plans and reports show for a secret value.
1257pub const REDACTED: &str = "(secret)";
1258
1259fn restart_needed(key: &str) -> bool {
1260    key.starts_with("raw.") || key.starts_with("security.") || key.starts_with("oci.")
1261}
1262
1263/// Diff desired against actual (`None`: the instance does not exist).
1264/// `volumes_missing` lists named volumes (pool, name) that do not exist yet.
1265#[expect(
1266    clippy::too_many_lines,
1267    clippy::cognitive_complexity,
1268    reason = "predates the lint ratchet; split it when next changed"
1269)]
1270pub fn diff(
1271    desired: &Desired,
1272    actual: Option<&Actual>,
1273    volumes_missing: &[(String, String)],
1274    opts: DiffOptions,
1275) -> Result<SandboxPlan> {
1276    let mut actions = Vec::new();
1277    for v in &desired.volumes {
1278        if volumes_missing.contains(&(v.pool.clone(), v.name.clone())) {
1279            if v.external {
1280                return Err(Error::invalid(format!(
1281                    "volume {} is external but does not exist in pool {}",
1282                    v.name, v.pool
1283                )));
1284            }
1285            actions.push(Action::CreateVolume {
1286                pool: v.pool.clone(),
1287                volume: v.name.clone(),
1288                config: v.config.clone(),
1289            });
1290        }
1291    }
1292
1293    let Some(actual) = actual else {
1294        actions.push(Action::CreateInstance {
1295            image: desired.image.spec.clone(),
1296            pool: desired.pool.clone(),
1297            config: desired
1298                .config
1299                .iter()
1300                .map(|(k, v)| {
1301                    let v = if desired.sensitive.contains(k) {
1302                        REDACTED.to_string()
1303                    } else {
1304                        v.clone()
1305                    };
1306                    (k.clone(), v)
1307                })
1308                .collect(),
1309            devices: desired
1310                .devices
1311                .iter()
1312                .filter(|(_, d)| d.search.is_none())
1313                .map(|(k, d)| (k.clone(), d.props.clone()))
1314                .collect(),
1315            profiles: desired.profiles.clone(),
1316        });
1317        actions.push(Action::StartInstance);
1318        push_searched_ports(desired, &mut actions);
1319        for o in &desired.owners {
1320            actions.push(Action::FixOwner {
1321                path: o.path.clone(),
1322                owner: o.owner.clone(),
1323            });
1324        }
1325        return Ok(SandboxPlan {
1326            name: desired.name.clone(),
1327            status: None,
1328            actions,
1329        });
1330    };
1331
1332    // Fixed-at-creation properties: report, never change.
1333    if !actual.instance_type.is_empty() && actual.instance_type != desired.instance_type.as_api() {
1334        actions.push(Action::Note {
1335            message: format!(
1336                "type is {} (spec: {}); fixed at creation",
1337                actual.instance_type,
1338                desired.instance_type.as_api()
1339            ),
1340        });
1341    }
1342    if let Some(root) = actual.devices.get("root") {
1343        if let Some(p) = root.get("pool") {
1344            if *p != desired.pool {
1345                actions.push(Action::Note {
1346                    message: format!(
1347                        "root disk is on pool {p} (spec: {}); fixed at creation",
1348                        desired.pool
1349                    ),
1350                });
1351            }
1352        }
1353    }
1354    if actual.profiles != desired.profiles {
1355        actions.push(Action::Note {
1356            message: format!(
1357                "profiles are [{}] (spec: [{}]); fixed at creation",
1358                actual.profiles.join(", "),
1359                desired.profiles.join(", ")
1360            ),
1361        });
1362    }
1363
1364    for (k, v) in &desired.config {
1365        let cur = actual.config.get(k);
1366        if cur != Some(v) {
1367            let secret = desired.sensitive.contains(k);
1368            let hide = |s: &String| if secret { REDACTED.into() } else { s.clone() };
1369            actions.push(Action::SetConfig {
1370                key: k.clone(),
1371                from: cur.map(hide),
1372                to: hide(v),
1373                restart: restart_needed(k),
1374                secret,
1375            });
1376        }
1377    }
1378    if !desired.config.contains_key("raw.idmap") && actual.config.contains_key("raw.idmap") {
1379        let why = match desired.idmap_mode {
1380            Some(crate::spec::IdmapMode::Auto) => Some("not needed on this host"),
1381            Some(crate::spec::IdmapMode::None) => Some("the spec says idmap: none"),
1382            _ => None,
1383        };
1384        if let Some(why) = why {
1385            actions.push(Action::Note {
1386                message: format!(
1387                    "raw.idmap is set but {why}; isb never removes config keys, unset it by hand"
1388                ),
1389            });
1390        }
1391    }
1392
1393    let mut new_devices: Vec<String> = Vec::new();
1394    let mut claimed: BTreeSet<String> = BTreeSet::new();
1395    let mut deferred_ports = Vec::new();
1396    for (name, want) in &desired.devices {
1397        if name == "root" {
1398            continue;
1399        }
1400        if let Some(have) = actual.devices.get(name) {
1401            claimed.insert(name.clone());
1402            if !device_matches(want, have) && want.search.is_some() {
1403                // Out of its range or otherwise wrong: drop it and search again,
1404                // rather than replacing it at a port that may be taken.
1405                actions.push(Action::RemoveDevice {
1406                    device: name.clone(),
1407                    props: have.clone(),
1408                });
1409                deferred_ports.push(name.clone());
1410            } else if !device_matches(want, have) {
1411                actions.push(Action::ReplaceDevice {
1412                    device: name.clone(),
1413                    replaces: name.clone(),
1414                    from: have.clone(),
1415                    to: want.props.clone(),
1416                });
1417                new_devices.push(name.clone());
1418            }
1419            continue;
1420        }
1421        // Not present under this name. An equivalent device under another name
1422        // satisfies it (adopting what another tool created); a disk at the same
1423        // guest path that differs has to be replaced, since two disks cannot
1424        // share a mount point.
1425        let same_path = |p: &Props| {
1426            want.props.get("type").map(String::as_str) == Some("disk")
1427                && p.get("type").map(String::as_str) == Some("disk")
1428                && normalize(p).get("path") == normalize(&want.props).get("path")
1429        };
1430        if let Some((other, have)) = actual.devices.iter().find(|(n, p)| {
1431            *n != "root" && !desired.devices.contains_key(*n) && device_matches(want, p)
1432        }) {
1433            claimed.insert(other.clone());
1434            actions.push(Action::Note {
1435                message: format!("device {name} already present as {other}; left as is"),
1436            });
1437            let _ = have;
1438            continue;
1439        }
1440        if let Some((other, have)) = actual
1441            .devices
1442            .iter()
1443            .find(|(n, p)| *n != "root" && !desired.devices.contains_key(*n) && same_path(p))
1444        {
1445            claimed.insert(other.clone());
1446            actions.push(Action::ReplaceDevice {
1447                device: name.clone(),
1448                replaces: other.clone(),
1449                from: have.clone(),
1450                to: want.props.clone(),
1451            });
1452            new_devices.push(name.clone());
1453            continue;
1454        }
1455        if want.search.is_some() {
1456            deferred_ports.push(name.clone());
1457        } else {
1458            actions.push(Action::AddDevice {
1459                device: name.clone(),
1460                props: want.props.clone(),
1461            });
1462            new_devices.push(name.clone());
1463        }
1464    }
1465
1466    if opts.prune_devices {
1467        for (name, props) in &actual.devices {
1468            if name != "root" && !desired.devices.contains_key(name) && !claimed.contains(name) {
1469                actions.push(Action::RemoveDevice {
1470                    device: name.clone(),
1471                    props: props.clone(),
1472                });
1473            }
1474        }
1475    }
1476
1477    if !actual.running() {
1478        actions.push(Action::StartInstance);
1479    }
1480    for name in deferred_ports {
1481        let d = &desired.devices[&name];
1482        actions.push(Action::AddPort {
1483            device: name.clone(),
1484            props: d.props.clone(),
1485            search: d.search.unwrap_or(0),
1486        });
1487    }
1488    for o in &desired.owners {
1489        if new_devices.contains(&o.device) {
1490            actions.push(Action::FixOwner {
1491                path: o.path.clone(),
1492                owner: o.owner.clone(),
1493            });
1494        }
1495    }
1496
1497    Ok(SandboxPlan {
1498        name: desired.name.clone(),
1499        status: Some(actual.status.clone()),
1500        actions,
1501    })
1502}
1503
1504fn push_searched_ports(desired: &Desired, actions: &mut Vec<Action>) {
1505    for (name, d) in &desired.devices {
1506        if let Some(n) = d.search {
1507            actions.push(Action::AddPort {
1508                device: name.clone(),
1509                props: d.props.clone(),
1510                search: n,
1511            });
1512        }
1513    }
1514}
1515
1516#[cfg(test)]
1517mod tests;