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