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
28mod owner;
29
30pub type Props = BTreeMap<String, String>;
31
32/// Facts about the host that resolution depends on.
33#[derive(Debug, Clone, Default)]
34pub struct HostFacts {
35    pub subids: SubIds,
36    /// Storage pools that exist, in server order.
37    pub pools: Vec<String>,
38    /// Rewrite bind sources starting with `.0` to start with `.1` instead: the
39    /// path incusd resolves can differ from the one this process sees (see
40    /// [`HostFacts::detect_path_map`]).
41    pub path_map: Option<(String, String)>,
42    /// The server can seed a new volume from the image (`disk_initial_copy`).
43    pub initial_copy: bool,
44    /// The server sets a new volume's owner and mode (`storage_initial_owner`).
45    pub initial_owner: bool,
46    /// The incus server version (`environment.server_version`).
47    pub incus_version: Option<String>,
48    /// The (uid, gid) of the user running isb: what a VM's bind mounts map to.
49    pub invoking_ids: (u32, u32),
50    /// The only directory incusd can see bind sources under, when it runs
51    /// elsewhere: on macOS, the home directory shared with the `isb machine`.
52    pub shared_root: Option<String>,
53    /// The org the sandbox goes in (from the client's incus project):
54    /// where `registry:` images resolve. `None` outside isb's projects.
55    pub org: Option<crate::org::OrgId>,
56    /// The local registry's `host:port`, when one is set up.
57    pub registry: Option<String>,
58    /// The incus project the sandbox goes in (its egress network is named from it).
59    pub project: String,
60}
61
62impl HostFacts {
63    /// Where bind sources must be translated before incusd sees them.
64    ///
65    /// `ISB_HOST_PATH_MAP=from=to` sets it explicitly. Otherwise, inside an
66    /// agent-workspace box, `$HOME` is a bind mount of a directory on the box's own
67    /// host and incusd resolves disk sources in its own mount view, where only the
68    /// host-side path exists; the box publishes that path in
69    /// `/etc/workspace/guest-home`. Without the rewrite, adding the device fails
70    /// with "Missing source path" for a directory `ls` lists happily.
71    pub fn detect_path_map() -> Option<(String, String)> {
72        if let Ok(m) = std::env::var("ISB_HOST_PATH_MAP") {
73            if let Some((a, b)) = m.split_once('=') {
74                if !a.is_empty() && !b.is_empty() {
75                    return Some((
76                        a.trim_end_matches('/').into(),
77                        b.trim_end_matches('/').into(),
78                    ));
79                }
80            }
81            return None;
82        }
83        let gh = std::fs::read_to_string("/etc/workspace/guest-home").ok()?;
84        let gh = gh.trim().trim_end_matches('/');
85        let home = std::env::var("HOME").ok()?;
86        let home = home.trim_end_matches('/');
87        if gh.is_empty() || home.is_empty() {
88            return None;
89        }
90        Some((home.to_string(), gh.to_string()))
91    }
92
93    pub fn translate(&self, path: &str) -> String {
94        if let Some((from, to)) = &self.path_map {
95            if let Some(rest) = path.strip_prefix(from.as_str()) {
96                if rest.is_empty() || rest.starts_with('/') {
97                    return format!("{to}{rest}");
98                }
99            }
100        }
101        path.to_string()
102    }
103
104    /// `auto`: `incus-zfs`, else `default`, else the first pool.
105    pub fn pick_pool(&self, requested: Option<&str>) -> Result<String> {
106        match requested {
107            Some(p) if p != "auto" && !p.is_empty() => {
108                if self.pools.is_empty() || self.pools.iter().any(|x| x == p) {
109                    Ok(p.to_string())
110                } else {
111                    Err(Error::invalid(format!(
112                        "storage pool {p:?} does not exist (have: {})",
113                        self.pools.join(", ")
114                    )))
115                }
116            }
117            _ => ["incus-zfs", "default"]
118                .iter()
119                .find(|c| self.pools.iter().any(|p| p == *c))
120                .map(|s| s.to_string())
121                .or_else(|| self.pools.first().cloned())
122                .ok_or_else(|| Error::invalid("no storage pools exist")),
123        }
124    }
125}
126
127/// A named volume that must exist before the instance.
128#[derive(Debug, Clone, PartialEq, Serialize)]
129pub struct EnsureVolume {
130    pub pool: String,
131    pub name: String,
132    pub config: Props,
133    pub external: bool,
134}
135
136/// chown (and root-owned parents in the owner's home) and/or chmod a mount point.
137#[derive(Debug, Clone, PartialEq, Serialize)]
138pub struct OwnerFixup {
139    pub device: String,
140    pub path: String,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub owner: Option<String>,
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub mode: Option<String>,
145    /// The service user's default: only if this (pool, name) is new and unseeded.
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub new_volume: Option<(String, String)>,
148}
149
150/// A desired device.
151#[derive(Debug, Clone, PartialEq, Serialize)]
152pub struct DesiredDevice {
153    pub props: Props,
154    /// Host-bound proxy that may move up to this many ports on conflict.
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub search: Option<u16>,
157}
158
159/// Where the image comes from.
160#[derive(Debug, Clone, PartialEq, Serialize)]
161pub struct ImageSource {
162    /// The spec string, for messages.
163    pub spec: String,
164    /// `None` for a local image.
165    pub server: Option<String>,
166    pub protocol: Option<String>,
167    pub alias: String,
168    /// A `registry:` image, not yet bound to an org and the local registry
169    /// ([`ImageSource::bind`]); `alias` is then `APP[:TAG][@DIGEST]`.
170    #[serde(skip_serializing_if = "std::ops::Not::not")]
171    pub local_registry: bool,
172}
173
174/// Whether a registry host (`host[:port]`) is this machine's loopback,
175/// where only the local registry listens.
176fn is_loopback_host(hostport: &str) -> bool {
177    let host = if let Some(rest) = hostport.strip_prefix('[') {
178        rest.split(']').next().unwrap_or(rest)
179    } else {
180        hostport
181            .rsplit_once(':')
182            .map(|(h, _)| h)
183            .unwrap_or(hostport)
184    };
185    let host = host.to_ascii_lowercase();
186    host == "localhost"
187        || host.ends_with(".localhost")
188        || host == "0.0.0.0"
189        || host
190            .parse::<std::net::IpAddr>()
191            .is_ok_and(|ip| ip.is_loopback() || ip.is_unspecified())
192}
193
194/// OCI registries known by a short prefix: `docker:nginx:1.27`.
195const OCI_REGISTRIES: &[(&str, &str)] = &[
196    ("docker", "https://docker.io"),
197    ("ghcr", "https://ghcr.io"),
198    ("quay", "https://quay.io"),
199];
200
201impl ImageSource {
202    pub fn parse(s: &str) -> Result<Self> {
203        if s.is_empty() {
204            return Err(Error::invalid("image is required"));
205        }
206        if let Some((remote, alias)) = s.split_once(':') {
207            if let Some((_, server)) = OCI_REGISTRIES.iter().find(|(k, _)| *k == remote) {
208                return Ok(ImageSource {
209                    spec: s.into(),
210                    server: Some(server.to_string()),
211                    protocol: Some("oci".into()),
212                    alias: oci_reference(alias, remote == "docker")?,
213                    local_registry: false,
214                });
215            }
216            if remote == "registry" {
217                // registry:app:tag, the org's own image in the local registry.
218                let r = crate::registry::ImageRef::parse(alias)?;
219                return Ok(ImageSource {
220                    spec: s.into(),
221                    server: None,
222                    protocol: Some("oci".into()),
223                    alias: r.render(),
224                    local_registry: true,
225                });
226            }
227            if remote == "oci" {
228                // oci:registry.example.com/team/app:tag
229                let (host, path) = alias.split_once('/').ok_or_else(|| {
230                    Error::invalid(format!("{s:?}: an oci: image is oci:REGISTRY/PATH[:TAG]"))
231                })?;
232                // The local registry is on loopback and holds every org's
233                // images: it is reached only as `registry:`, which stays in the org.
234                if is_loopback_host(host) {
235                    return Err(Error::invalid(format!(
236                        "{s:?}: a loopback registry is the local one; name its images as registry:APP:TAG"
237                    )));
238                }
239                return Ok(ImageSource {
240                    spec: s.into(),
241                    server: Some(format!("https://{host}")),
242                    protocol: Some("oci".into()),
243                    alias: oci_reference(path, false)?,
244                    local_registry: false,
245                });
246            }
247            let (server, protocol) = match remote {
248                "images" => ("https://images.linuxcontainers.org", "simplestreams"),
249                "ubuntu" => ("https://cloud-images.ubuntu.com/releases", "simplestreams"),
250                "ubuntu-daily" => ("https://cloud-images.ubuntu.com/daily", "simplestreams"),
251                "ubuntu-minimal" => (
252                    "https://cloud-images.ubuntu.com/minimal/releases",
253                    "simplestreams",
254                ),
255                other => {
256                    return Err(Error::invalid(format!(
257                        "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)"
258                    )));
259                }
260            };
261            return Ok(ImageSource {
262                spec: s.into(),
263                server: Some(server.into()),
264                protocol: Some(protocol.into()),
265                alias: alias.into(),
266                local_registry: false,
267            });
268        }
269        Ok(ImageSource {
270            spec: s.into(),
271            server: None,
272            protocol: None,
273            alias: s.into(),
274            local_registry: false,
275        })
276    }
277
278    /// Bind a `registry:` image to `org`'s repository in the local registry
279    /// at `addr`. Anything else is returned as is.
280    pub fn bind(mut self, org: Option<&crate::org::OrgId>, addr: Option<&str>) -> Result<Self> {
281        if !self.local_registry {
282            return Ok(self);
283        }
284        let org = org.ok_or_else(|| {
285            Error::invalid(format!(
286                "{:?}: registry: images belong to an org; this project is not one",
287                self.spec
288            ))
289        })?;
290        let addr = addr.ok_or_else(|| {
291            Error::invalid(format!(
292                "{:?}: no local registry on this host (isb registry setup)",
293                self.spec
294            ))
295        })?;
296        let r = crate::registry::ImageRef::parse(&self.alias)?;
297        self.alias = r.pull_alias(org);
298        self.server = Some(format!("https://{addr}"));
299        self.local_registry = false;
300        Ok(self)
301    }
302
303    /// An OCI (docker) image: an application container whose process is the
304    /// instance's init.
305    pub fn is_oci(&self) -> bool {
306        self.protocol.as_deref() == Some("oci")
307    }
308
309    /// The `source` object for `POST /1.0/instances`. A local image is given by
310    /// fingerprint once resolved, by alias otherwise.
311    pub fn to_api(&self, local_fingerprint: Option<&str>) -> Value {
312        match (&self.server, local_fingerprint) {
313            (Some(server), _) => json!({
314                "type": "image", "mode": "pull", "server": server,
315                "protocol": self.protocol, "alias": self.alias,
316            }),
317            (None, Some(fp)) => json!({"type": "image", "fingerprint": fp}),
318            (None, None) => json!({"type": "image", "alias": self.alias}),
319        }
320    }
321}
322
323/// `nginx` -> `library/nginx:latest` on Docker Hub, as docker spells it.
324fn oci_reference(r: &str, docker_hub: bool) -> Result<String> {
325    if r.is_empty() || r.contains(char::is_whitespace) {
326        return Err(Error::invalid(format!("invalid OCI image reference {r:?}")));
327    }
328    let mut r = r.to_string();
329    if docker_hub && !r.contains('/') {
330        r = format!("library/{r}");
331    }
332    let last = r.rsplit('/').next().unwrap_or(&r);
333    if !last.contains(':') && !last.contains('@') {
334        r.push_str(":latest");
335    }
336    Ok(r)
337}
338
339mod oci;
340pub use oci::{BeforeStart, check_oci_command, oci_command_line};
341
342/// A spec resolved against the host: exactly what incus should hold.
343#[derive(Debug, Clone, Serialize)]
344pub struct Desired {
345    pub name: String,
346    pub instance_type: InstanceType,
347    pub image: ImageSource,
348    pub pool: String,
349    pub profiles: Vec<String>,
350    pub config: Props,
351    /// Includes the `root` disk (create-only; never reconciled).
352    pub devices: BTreeMap<String, DesiredDevice>,
353    pub volumes: Vec<EnsureVolume>,
354    pub owners: Vec<OwnerFixup>,
355    pub ready: Vec<ReadyCheck>,
356    #[serde(skip)]
357    pub ready_timeout: Duration,
358    pub exec: ExecDefaults,
359    /// The idmap mode when the spec set one (not for `{raw: ...}`): an absent
360    /// `raw.idmap` is then a decision, not an omission.
361    #[serde(skip)]
362    pub idmap_mode: Option<crate::spec::IdmapMode>,
363    /// Config keys holding secret values (`environment.KEY` from
364    /// `{secret: NAME}`): set, but never shown in plans or reports.
365    #[serde(skip)]
366    pub sensitive: BTreeSet<String>,
367    /// The egress plumbing the spec asks for (`egress:`).
368    #[serde(skip)]
369    pub egress: Option<crate::egress::Plumbing>,
370    /// Run on an instance this apply created, before its first start: an
371    /// OCI app reads its secret files as it starts, and a container's files
372    /// can be written while it is stopped.
373    #[serde(skip)]
374    pub before_start: Option<BeforeStart>,
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        for p in &spec.ports {
605            if p.bind == PortBind::Guest {
606                return Err(Error::invalid(format!(
607                    "{name}: incus VMs only support bind: host proxies (in NAT mode)"
608                )));
609            }
610        }
611    }
612
613    let mut config = Props::new();
614    match (&spec.cpus, &spec.cpuset) {
615        (Some(_), Some(_)) => {
616            return Err(Error::invalid(format!(
617                "{name}: set cpus (a count) or cpuset (which CPUs), not both"
618            )));
619        }
620        (Some(c), None) => {
621            if !c.trim().parse::<u32>().is_ok_and(|n| n > 0) {
622                return Err(Error::invalid(format!(
623                    "{name}: cpus is a whole number of CPUs, got {c:?} (pin CPUs with cpuset: \"0-3\")"
624                )));
625            }
626            config.insert("limits.cpu".into(), c.trim().to_string());
627        }
628        (None, Some(set)) => {
629            config.insert("limits.cpu".into(), set.clone());
630        }
631        (None, None) => {}
632    }
633    if let Some(m) = &spec.memory {
634        let m = memory_limit(m).map_err(|e| Error::invalid(format!("{name}: mem_limit: {e}")))?;
635        config.insert("limits.memory".into(), m);
636    }
637    if let Some(p) = spec.privileged {
638        config.insert("security.privileged".into(), p.to_string());
639    }
640    let (idmap_mode, raw_idmap) = if vm {
641        idmap::plan_vm(
642            &name,
643            spec,
644            host.incus_version.as_deref(),
645            host.invoking_ids,
646        )?
647    } else {
648        idmap::plan_container(spec.idmap.as_ref(), &host.subids)
649    };
650    if let Some(v) = raw_idmap {
651        config.insert("raw.idmap".into(), v);
652    }
653    for (k, v) in &spec.labels {
654        if k.is_empty() || k.contains(char::is_whitespace) {
655            return Err(Error::invalid(format!("{name}: invalid label key {k:?}")));
656        }
657        config.insert(format!("user.{k}"), v.clone());
658    }
659    for (k, v) in &spec.env {
660        config.insert(format!("environment.{k}"), v.clone());
661    }
662    // `as: file` secrets: only the path is config; the value is a file.
663    for (k, v) in spec.env.file_vars() {
664        config.insert(format!("environment.{k}"), v);
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 at = (&*guest_norm, &*dname, &*vpool, n.as_str());
794                let (config, fixups) = owner::for_mount(&name, spec, v, def, host, at)?;
795                let ev = EnsureVolume {
796                    pool: vpool,
797                    name: n.clone(),
798                    config,
799                    external: v.external || def.is_some_and(|d| d.external),
800                };
801                if !volumes
802                    .iter()
803                    .any(|e| (&e.pool, &e.name) == (&ev.pool, &ev.name))
804                {
805                    volumes.push(ev);
806                }
807                owners.extend(fixups);
808            }
809        }
810        if v.read_only {
811            props.insert("readonly".into(), "true".into());
812        }
813        for k in v.options.keys() {
814            if matches!(k.as_str(), "type" | "path" | "source" | "pool" | "readonly") {
815                return Err(Error::invalid(format!(
816                    "{name}: {guest}: options.{k} would override a core property; use the field instead"
817                )));
818            }
819        }
820        for (k, val) in &v.options {
821            props.insert(k.clone(), val.clone());
822        }
823        add_dev(
824            dname,
825            DesiredDevice {
826                props,
827                search: None,
828            },
829        )?;
830    }
831
832    for p in &spec.ports {
833        // Docker-style shorthand: the protocol defaults to tcp and the host to
834        // 127.0.0.1. A VM's connect side defaults to 0.0.0.0 instead, which is
835        // how incus' NAT mode finds the VM's own address.
836        let connect_host = if vm { "0.0.0.0" } else { "127.0.0.1" };
837        let listen = normalize_addr(&p.listen, "127.0.0.1")
838            .map_err(|e| Error::invalid(format!("{name}: port listen: {e}")))?;
839        let connect = normalize_addr(&p.connect, connect_host)
840            .map_err(|e| Error::invalid(format!("{name}: port connect: {e}")))?;
841        if p.search.is_some() {
842            if split_addr(&connect).is_none_or(|(_, h, _)| h != connect_host) {
843                return Err(Error::invalid(format!(
844                    "{name}: a published port range connects to the guest's default address ({connect_host}), not {connect}"
845                )));
846            }
847            if p.bind != PortBind::Host {
848                return Err(Error::invalid(format!(
849                    "{name}: port search only applies to bind: host"
850                )));
851            }
852            if split_addr(&listen).is_none() {
853                return Err(Error::invalid(format!(
854                    "{name}: port search needs a single tcp or udp listen port"
855                )));
856            }
857        }
858        let dname = p
859            .name
860            .clone()
861            .unwrap_or_else(|| default_port_name(p.bind, &listen));
862        let mut props = Props::from([
863            ("type".into(), "proxy".into()),
864            ("bind".into(), p.bind.as_str().into()),
865            ("listen".into(), listen),
866            ("connect".into(), connect),
867        ]);
868        if vm {
869            // incus proxies into a VM only in NAT mode.
870            props.insert("nat".into(), "true".into());
871        }
872        for (k, val) in &p.options {
873            if matches!(k.as_str(), "type" | "bind" | "listen" | "connect") {
874                return Err(Error::invalid(format!(
875                    "{name}: port options.{k} would override a core property; use the field instead"
876                )));
877            }
878            props.insert(k.clone(), val.clone());
879        }
880        add_dev(
881            dname,
882            DesiredDevice {
883                props,
884                search: p.search.filter(|n| *n > 0),
885            },
886        )?;
887    }
888
889    let egress = match &spec.egress {
890        Some(e) => {
891            let c = crate::egress::contribute(e, &host.project, &name)?;
892            add_dev(
893                "eth0".into(),
894                DesiredDevice {
895                    props: c.nic,
896                    search: None,
897                },
898            )?;
899            config.extend(c.config);
900            Some(c.plumbing)
901        }
902        None => None,
903    };
904    let mut root_extra = Props::new();
905    for (dname, props) in &spec.raw_devices {
906        if dname == "root" {
907            // Tune the root disk (size, ...): merged over the generated one below.
908            root_extra.extend(props.clone());
909            continue;
910        }
911        if !props.contains_key("type") {
912            return Err(Error::invalid(format!(
913                "{name}: raw device {dname:?} needs a type"
914            )));
915        }
916        add_dev(
917            dname.clone(),
918            DesiredDevice {
919                props: props.clone(),
920                search: None,
921            },
922        )?;
923    }
924
925    if let Some(root) = devices.get_mut("root") {
926        root.props.extend(root_extra);
927    }
928    let props = devices.iter().map(|(k, d)| (k, &d.props));
929    crate::org::check_proxies(&name, host.org.as_ref(), props, spec.stack_udp)?;
930
931    let ready_timeout = match &spec.ready_timeout {
932        Some(s) => parse_duration(s).map_err(|e| Error::invalid(format!("{name}: {e}")))?,
933        // A container is usable about a second after Running; a VM boots a
934        // kernel and its agent (50-90s under nested virtualization, plus the
935        // reboot a cloud image does on first boot).
936        None if vm => Duration::from_secs(300),
937        None => Duration::from_secs(60),
938    };
939
940    Ok(Desired {
941        name,
942        instance_type: spec.instance_type,
943        image,
944        pool,
945        profiles: spec
946            .profiles
947            .clone()
948            .unwrap_or_else(|| vec!["default".into()]),
949        config,
950        devices,
951        volumes,
952        owners,
953        ready: spec.ready.clone().unwrap_or_else(|| {
954            if vm {
955                vec![ReadyCheck::Running, ReadyCheck::Agent]
956            } else {
957                vec![ReadyCheck::Running]
958            }
959        }),
960        ready_timeout,
961        exec: spec.exec_defaults(),
962        idmap_mode,
963        sensitive: spec
964            .env
965            .secrets
966            .keys()
967            .map(|k| format!("environment.{k}"))
968            .collect(),
969        egress,
970        before_start: None,
971    })
972}
973
974/// Instance state as incus reports it.
975#[derive(Debug, Clone, Default, PartialEq)]
976pub struct Actual {
977    pub status: String,
978    pub config: Props,
979    /// Instance-local devices (not the ones inherited from profiles).
980    pub devices: BTreeMap<String, Props>,
981    pub profiles: Vec<String>,
982    pub instance_type: String,
983}
984
985impl Actual {
986    pub fn from_api(v: &Value) -> Actual {
987        let strmap = |v: Option<&Value>| -> Props {
988            v.and_then(Value::as_object)
989                .map(|m| {
990                    m.iter()
991                        .map(|(k, v)| {
992                            let s = match v {
993                                Value::String(s) => s.clone(),
994                                other => other.to_string(),
995                            };
996                            (k.clone(), s)
997                        })
998                        .collect()
999                })
1000                .unwrap_or_default()
1001        };
1002        let devices = v
1003            .get("devices")
1004            .and_then(Value::as_object)
1005            .map(|m| {
1006                m.iter()
1007                    .map(|(k, d)| (k.clone(), strmap(Some(d))))
1008                    .collect()
1009            })
1010            .unwrap_or_default();
1011        Actual {
1012            status: v
1013                .get("status")
1014                .and_then(Value::as_str)
1015                .unwrap_or("")
1016                .to_string(),
1017            config: strmap(v.get("config")),
1018            devices,
1019            profiles: v
1020                .get("profiles")
1021                .and_then(Value::as_array)
1022                .map(|a| {
1023                    a.iter()
1024                        .filter_map(|x| x.as_str().map(String::from))
1025                        .collect()
1026                })
1027                .unwrap_or_default(),
1028            instance_type: v
1029                .get("type")
1030                .and_then(Value::as_str)
1031                .unwrap_or("")
1032                .to_string(),
1033        }
1034    }
1035
1036    pub fn running(&self) -> bool {
1037        self.status.eq_ignore_ascii_case("running")
1038    }
1039}
1040
1041/// One step of a plan.
1042#[derive(Debug, Clone, PartialEq, Serialize)]
1043#[serde(tag = "action", rename_all = "snake_case")]
1044pub enum Action {
1045    CreateVolume {
1046        pool: String,
1047        volume: String,
1048        config: Props,
1049    },
1050    CreateInstance {
1051        image: String,
1052        pool: String,
1053        config: Props,
1054        devices: BTreeMap<String, Props>,
1055        profiles: Vec<String>,
1056    },
1057    SetConfig {
1058        key: String,
1059        #[serde(skip_serializing_if = "Option::is_none")]
1060        from: Option<String>,
1061        to: String,
1062        /// Only takes effect after a restart.
1063        restart: bool,
1064        /// A secret value: `from` and `to` say `(secret)`, and the value set
1065        /// is the desired config's.
1066        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1067        secret: bool,
1068    },
1069    AddDevice {
1070        device: String,
1071        props: Props,
1072    },
1073    /// Replace a wrong device. For a disk that is a remount inside the guest.
1074    ReplaceDevice {
1075        device: String,
1076        /// The existing device being replaced (may differ in name).
1077        replaces: String,
1078        from: Props,
1079        to: Props,
1080    },
1081    RemoveDevice {
1082        device: String,
1083        props: Props,
1084    },
1085    StartInstance,
1086    /// Add a host-bound proxy, moving up to `search` ports past a taken one.
1087    AddPort {
1088        device: String,
1089        props: Props,
1090        search: u16,
1091    },
1092    FixOwner {
1093        path: String,
1094        #[serde(skip_serializing_if = "Option::is_none")]
1095        owner: Option<String>,
1096        #[serde(skip_serializing_if = "Option::is_none")]
1097        mode: Option<String>,
1098        /// The service user's default for a new volume: only if the image
1099        /// did not seed it.
1100        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1101        fresh_only: bool,
1102    },
1103    /// Something isb will not change (fixed at creation, or ambiguous). Informational.
1104    Note {
1105        message: String,
1106    },
1107}
1108
1109impl Action {
1110    /// Whether this action changes anything.
1111    pub fn is_change(&self) -> bool {
1112        !matches!(self, Action::Note { .. })
1113    }
1114}
1115
1116impl std::fmt::Display for Action {
1117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1118        let props = |p: &Props| {
1119            p.iter()
1120                .filter(|(k, _)| k.as_str() != "type")
1121                .map(|(k, v)| format!("{k}={v}"))
1122                .collect::<Vec<_>>()
1123                .join(" ")
1124        };
1125        match self {
1126            Action::CreateVolume { pool, volume, .. } => {
1127                write!(f, "+ volume {volume} (pool {pool})")
1128            }
1129            Action::CreateInstance {
1130                image,
1131                pool,
1132                devices,
1133                ..
1134            } => write!(
1135                f,
1136                "+ create from {image} on pool {pool} with {} device(s)",
1137                devices.len()
1138            ),
1139            Action::SetConfig {
1140                key,
1141                from,
1142                to,
1143                restart,
1144                ..
1145            } => write!(
1146                f,
1147                "~ config {key}: {} -> {to}{}",
1148                from.as_deref().unwrap_or("(unset)"),
1149                if *restart {
1150                    " (takes effect on restart)"
1151                } else {
1152                    ""
1153                }
1154            ),
1155            Action::AddDevice { device, props: p } => write!(f, "+ device {device}: {}", props(p)),
1156            Action::ReplaceDevice {
1157                device,
1158                replaces,
1159                from,
1160                to,
1161            } => {
1162                if device == replaces {
1163                    write!(f, "~ device {device}: {} -> {}", props(from), props(to))
1164                } else {
1165                    write!(
1166                        f,
1167                        "~ device {replaces} -> {device}: {} -> {}",
1168                        props(from),
1169                        props(to)
1170                    )
1171                }
1172            }
1173            Action::RemoveDevice { device, .. } => write!(f, "- device {device}"),
1174            Action::StartInstance => write!(f, "> start"),
1175            Action::AddPort {
1176                device,
1177                props: p,
1178                search,
1179            } => write!(f, "+ port {device}: {} (search {search})", props(p)),
1180            a @ Action::FixOwner { .. } => owner::describe(f, a),
1181            Action::Note { message } => write!(f, "  note: {message}"),
1182        }
1183    }
1184}
1185
1186/// The plan for one sandbox.
1187#[derive(Debug, Clone, Serialize)]
1188pub struct SandboxPlan {
1189    pub name: String,
1190    /// Existing status (`None`: does not exist yet).
1191    pub status: Option<String>,
1192    pub actions: Vec<Action>,
1193}
1194
1195impl SandboxPlan {
1196    /// No changes (notes only).
1197    pub fn is_noop(&self) -> bool {
1198        !self.actions.iter().any(Action::is_change)
1199    }
1200}
1201
1202/// Options for [`diff`].
1203#[derive(Debug, Clone, Copy, Default)]
1204pub struct DiffOptions {
1205    /// Remove instance-local devices not in the spec (never `root`).
1206    pub prune_devices: bool,
1207}
1208
1209/// Keys whose value is a boolean where `false` is the same as absent.
1210const FALSE_IS_ABSENT: &[&str] = &["readonly", "shift", "nat"];
1211
1212fn normalize(p: &Props) -> Props {
1213    let mut out = p.clone();
1214    for k in FALSE_IS_ABSENT {
1215        if out.get(*k).map(String::as_str) == Some("false") {
1216            out.remove(*k);
1217        }
1218    }
1219    if out.get("type").map(String::as_str) == Some("disk") {
1220        for k in ["source", "path"] {
1221            if let Some(v) = out.get_mut(k) {
1222                if v.len() > 1 {
1223                    *v = v.trim_end_matches('/').to_string();
1224                }
1225            }
1226        }
1227    }
1228    out
1229}
1230
1231/// Whether an existing device satisfies a desired one.
1232pub fn device_matches(desired: &DesiredDevice, actual: &Props) -> bool {
1233    let mut d = normalize(&desired.props);
1234    let mut a = normalize(actual);
1235    // initial.copy only acts the first time a volume is used, so a disk that
1236    // differs in nothing else is correct, and replacing it would remount it.
1237    if d.get("type").map(String::as_str) == Some("disk") {
1238        d.remove("initial.copy");
1239        a.remove("initial.copy");
1240    }
1241    if d == a {
1242        return true;
1243    }
1244    let Some(n) = desired.search else {
1245        return false;
1246    };
1247    // A searched port is correct anywhere in its range.
1248    let (Some(dl), Some(al)) = (d.get("listen"), a.get("listen")) else {
1249        return false;
1250    };
1251    let (Some((dp, dh, dport)), Some((ap, ah, aport))) = (split_addr(dl), split_addr(al)) else {
1252        return false;
1253    };
1254    if dp != ap || dh != ah || aport < dport || aport as u32 > dport as u32 + n as u32 {
1255        return false;
1256    }
1257    let strip = |m: &Props| {
1258        let mut m = m.clone();
1259        m.remove("listen");
1260        m
1261    };
1262    strip(&d) == strip(&a)
1263}
1264
1265/// What plans and reports show for a secret value.
1266pub const REDACTED: &str = "(secret)";
1267
1268fn restart_needed(key: &str) -> bool {
1269    key.starts_with("raw.") || key.starts_with("security.") || key.starts_with("oci.")
1270}
1271
1272/// Diff desired against actual (`None`: the instance does not exist).
1273/// `volumes_missing` lists named volumes (pool, name) that do not exist yet.
1274#[expect(
1275    clippy::too_many_lines,
1276    reason = "predates the lint ratchet; split it when next changed"
1277)]
1278pub fn diff(
1279    desired: &Desired,
1280    actual: Option<&Actual>,
1281    volumes_missing: &[(String, String)],
1282    opts: DiffOptions,
1283) -> Result<SandboxPlan> {
1284    let mut actions = Vec::new();
1285    for v in &desired.volumes {
1286        if volumes_missing.contains(&(v.pool.clone(), v.name.clone())) {
1287            if v.external {
1288                return Err(Error::invalid(format!(
1289                    "volume {} is external but does not exist in pool {}",
1290                    v.name, v.pool
1291                )));
1292            }
1293            actions.push(Action::CreateVolume {
1294                pool: v.pool.clone(),
1295                volume: v.name.clone(),
1296                config: v.config.clone(),
1297            });
1298        }
1299    }
1300
1301    let Some(actual) = actual else {
1302        actions.push(Action::CreateInstance {
1303            image: desired.image.spec.clone(),
1304            pool: desired.pool.clone(),
1305            config: desired
1306                .config
1307                .iter()
1308                .map(|(k, v)| {
1309                    let v = if desired.sensitive.contains(k) {
1310                        REDACTED.to_string()
1311                    } else {
1312                        v.clone()
1313                    };
1314                    (k.clone(), v)
1315                })
1316                .collect(),
1317            devices: desired
1318                .devices
1319                .iter()
1320                .filter(|(_, d)| d.search.is_none())
1321                .map(|(k, d)| (k.clone(), d.props.clone()))
1322                .collect(),
1323            profiles: desired.profiles.clone(),
1324        });
1325        actions.push(Action::StartInstance);
1326        push_searched_ports(desired, &mut actions);
1327        actions.extend(owner::actions(&desired.owners, &|_| true, volumes_missing));
1328        return Ok(SandboxPlan {
1329            name: desired.name.clone(),
1330            status: None,
1331            actions,
1332        });
1333    };
1334
1335    // Fixed-at-creation properties: report, never change.
1336    if !actual.instance_type.is_empty() && actual.instance_type != desired.instance_type.as_api() {
1337        actions.push(Action::Note {
1338            message: format!(
1339                "type is {} (spec: {}); fixed at creation",
1340                actual.instance_type,
1341                desired.instance_type.as_api()
1342            ),
1343        });
1344    }
1345    if let Some(root) = actual.devices.get("root") {
1346        if let Some(p) = root.get("pool") {
1347            if *p != desired.pool {
1348                actions.push(Action::Note {
1349                    message: format!(
1350                        "root disk is on pool {p} (spec: {}); fixed at creation",
1351                        desired.pool
1352                    ),
1353                });
1354            }
1355        }
1356    }
1357    if actual.profiles != desired.profiles {
1358        actions.push(Action::Note {
1359            message: format!(
1360                "profiles are [{}] (spec: [{}]); fixed at creation",
1361                actual.profiles.join(", "),
1362                desired.profiles.join(", ")
1363            ),
1364        });
1365    }
1366
1367    for (k, v) in &desired.config {
1368        let cur = actual.config.get(k);
1369        if cur != Some(v) {
1370            let secret = desired.sensitive.contains(k);
1371            let hide = |s: &String| if secret { REDACTED.into() } else { s.clone() };
1372            actions.push(Action::SetConfig {
1373                key: k.clone(),
1374                from: cur.map(hide),
1375                to: hide(v),
1376                restart: restart_needed(k),
1377                secret,
1378            });
1379        }
1380    }
1381    if !desired.config.contains_key("raw.idmap") && actual.config.contains_key("raw.idmap") {
1382        let why = match desired.idmap_mode {
1383            Some(crate::spec::IdmapMode::Auto) => Some("not needed on this host"),
1384            Some(crate::spec::IdmapMode::None) => Some("the spec says idmap: none"),
1385            _ => None,
1386        };
1387        if let Some(why) = why {
1388            actions.push(Action::Note {
1389                message: format!(
1390                    "raw.idmap is set but {why}; isb never removes config keys, unset it by hand"
1391                ),
1392            });
1393        }
1394    }
1395
1396    let mut new_devices: Vec<String> = Vec::new();
1397    let mut claimed: BTreeSet<String> = BTreeSet::new();
1398    let mut deferred_ports = Vec::new();
1399    for (name, want) in &desired.devices {
1400        if name == "root" {
1401            continue;
1402        }
1403        if let Some(have) = actual.devices.get(name) {
1404            claimed.insert(name.clone());
1405            if !device_matches(want, have) && want.search.is_some() {
1406                // Out of its range or otherwise wrong: drop it and search again,
1407                // rather than replacing it at a port that may be taken.
1408                actions.push(Action::RemoveDevice {
1409                    device: name.clone(),
1410                    props: have.clone(),
1411                });
1412                deferred_ports.push(name.clone());
1413            } else if !device_matches(want, have) {
1414                actions.push(Action::ReplaceDevice {
1415                    device: name.clone(),
1416                    replaces: name.clone(),
1417                    from: have.clone(),
1418                    to: want.props.clone(),
1419                });
1420                new_devices.push(name.clone());
1421            }
1422            continue;
1423        }
1424        // Not present under this name. An equivalent device under another name
1425        // satisfies it (adopting what another tool created); a disk at the same
1426        // guest path that differs has to be replaced, since two disks cannot
1427        // share a mount point.
1428        let same_path = |p: &Props| {
1429            want.props.get("type").map(String::as_str) == Some("disk")
1430                && p.get("type").map(String::as_str) == Some("disk")
1431                && normalize(p).get("path") == normalize(&want.props).get("path")
1432        };
1433        if let Some((other, have)) = actual.devices.iter().find(|(n, p)| {
1434            *n != "root" && !desired.devices.contains_key(*n) && device_matches(want, p)
1435        }) {
1436            claimed.insert(other.clone());
1437            actions.push(Action::Note {
1438                message: format!("device {name} already present as {other}; left as is"),
1439            });
1440            let _ = have;
1441            continue;
1442        }
1443        if let Some((other, have)) = actual
1444            .devices
1445            .iter()
1446            .find(|(n, p)| *n != "root" && !desired.devices.contains_key(*n) && same_path(p))
1447        {
1448            claimed.insert(other.clone());
1449            actions.push(Action::ReplaceDevice {
1450                device: name.clone(),
1451                replaces: other.clone(),
1452                from: have.clone(),
1453                to: want.props.clone(),
1454            });
1455            new_devices.push(name.clone());
1456            continue;
1457        }
1458        if want.search.is_some() {
1459            deferred_ports.push(name.clone());
1460        } else {
1461            actions.push(Action::AddDevice {
1462                device: name.clone(),
1463                props: want.props.clone(),
1464            });
1465            new_devices.push(name.clone());
1466        }
1467    }
1468
1469    if opts.prune_devices {
1470        for (name, props) in &actual.devices {
1471            if name != "root" && !desired.devices.contains_key(name) && !claimed.contains(name) {
1472                actions.push(Action::RemoveDevice {
1473                    device: name.clone(),
1474                    props: props.clone(),
1475                });
1476            }
1477        }
1478    }
1479
1480    if !actual.running() {
1481        actions.push(Action::StartInstance);
1482    }
1483    for name in deferred_ports {
1484        let d = &desired.devices[&name];
1485        actions.push(Action::AddPort {
1486            device: name.clone(),
1487            props: d.props.clone(),
1488            search: d.search.unwrap_or(0),
1489        });
1490    }
1491    actions.extend(owner::actions(
1492        &desired.owners,
1493        &|d| new_devices.iter().any(|n| n == d),
1494        volumes_missing,
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;