1use 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#[derive(Debug, Clone, Default)]
32pub struct HostFacts {
33 pub subids: SubIds,
34 pub pools: Vec<String>,
36 pub path_map: Option<(String, String)>,
40 pub initial_copy: bool,
42 pub incus_version: Option<String>,
44 pub invoking_ids: (u32, u32),
46 pub shared_root: Option<String>,
49 pub org: Option<crate::org::OrgId>,
52 pub registry: Option<String>,
54 pub project: String,
56}
57
58impl HostFacts {
59 pub fn detect_path_map() -> Option<(String, String)> {
68 if let Ok(m) = std::env::var("ISB_HOST_PATH_MAP") {
69 if let Some((a, b)) = m.split_once('=') {
70 if !a.is_empty() && !b.is_empty() {
71 return Some((
72 a.trim_end_matches('/').into(),
73 b.trim_end_matches('/').into(),
74 ));
75 }
76 }
77 return None;
78 }
79 let gh = std::fs::read_to_string("/etc/workspace/guest-home").ok()?;
80 let gh = gh.trim().trim_end_matches('/');
81 let home = std::env::var("HOME").ok()?;
82 let home = home.trim_end_matches('/');
83 if gh.is_empty() || home.is_empty() {
84 return None;
85 }
86 Some((home.to_string(), gh.to_string()))
87 }
88
89 pub fn translate(&self, path: &str) -> String {
90 if let Some((from, to)) = &self.path_map {
91 if let Some(rest) = path.strip_prefix(from.as_str()) {
92 if rest.is_empty() || rest.starts_with('/') {
93 return format!("{to}{rest}");
94 }
95 }
96 }
97 path.to_string()
98 }
99
100 pub fn pick_pool(&self, requested: Option<&str>) -> Result<String> {
102 match requested {
103 Some(p) if p != "auto" && !p.is_empty() => {
104 if self.pools.is_empty() || self.pools.iter().any(|x| x == p) {
105 Ok(p.to_string())
106 } else {
107 Err(Error::invalid(format!(
108 "storage pool {p:?} does not exist (have: {})",
109 self.pools.join(", ")
110 )))
111 }
112 }
113 _ => ["incus-zfs", "default"]
114 .iter()
115 .find(|c| self.pools.iter().any(|p| p == *c))
116 .map(|s| s.to_string())
117 .or_else(|| self.pools.first().cloned())
118 .ok_or_else(|| Error::invalid("no storage pools exist")),
119 }
120 }
121}
122
123#[derive(Debug, Clone, PartialEq, Serialize)]
125pub struct EnsureVolume {
126 pub pool: String,
127 pub name: String,
128 pub config: Props,
129 pub external: bool,
130}
131
132#[derive(Debug, Clone, PartialEq, Serialize)]
134pub struct OwnerFixup {
135 pub device: String,
136 pub path: String,
137 pub owner: String,
138}
139
140#[derive(Debug, Clone, PartialEq, Serialize)]
142pub struct DesiredDevice {
143 pub props: Props,
144 #[serde(skip_serializing_if = "Option::is_none")]
146 pub search: Option<u16>,
147}
148
149#[derive(Debug, Clone, PartialEq, Serialize)]
151pub struct ImageSource {
152 pub spec: String,
154 pub server: Option<String>,
156 pub protocol: Option<String>,
157 pub alias: String,
158 #[serde(skip_serializing_if = "std::ops::Not::not")]
161 pub local_registry: bool,
162}
163
164fn is_loopback_host(hostport: &str) -> bool {
167 let host = if let Some(rest) = hostport.strip_prefix('[') {
168 rest.split(']').next().unwrap_or(rest)
169 } else {
170 hostport
171 .rsplit_once(':')
172 .map(|(h, _)| h)
173 .unwrap_or(hostport)
174 };
175 let host = host.to_ascii_lowercase();
176 host == "localhost"
177 || host.ends_with(".localhost")
178 || host == "0.0.0.0"
179 || host
180 .parse::<std::net::IpAddr>()
181 .is_ok_and(|ip| ip.is_loopback() || ip.is_unspecified())
182}
183
184const OCI_REGISTRIES: &[(&str, &str)] = &[
186 ("docker", "https://docker.io"),
187 ("ghcr", "https://ghcr.io"),
188 ("quay", "https://quay.io"),
189];
190
191impl ImageSource {
192 pub fn parse(s: &str) -> Result<Self> {
193 if s.is_empty() {
194 return Err(Error::invalid("image is required"));
195 }
196 if let Some((remote, alias)) = s.split_once(':') {
197 if let Some((_, server)) = OCI_REGISTRIES.iter().find(|(k, _)| *k == remote) {
198 return Ok(ImageSource {
199 spec: s.into(),
200 server: Some(server.to_string()),
201 protocol: Some("oci".into()),
202 alias: oci_reference(alias, remote == "docker")?,
203 local_registry: false,
204 });
205 }
206 if remote == "registry" {
207 let r = crate::registry::ImageRef::parse(alias)?;
209 return Ok(ImageSource {
210 spec: s.into(),
211 server: None,
212 protocol: Some("oci".into()),
213 alias: r.render(),
214 local_registry: true,
215 });
216 }
217 if remote == "oci" {
218 let (host, path) = alias.split_once('/').ok_or_else(|| {
220 Error::invalid(format!("{s:?}: an oci: image is oci:REGISTRY/PATH[:TAG]"))
221 })?;
222 if is_loopback_host(host) {
225 return Err(Error::invalid(format!(
226 "{s:?}: a loopback registry is the local one; name its images as registry:APP:TAG"
227 )));
228 }
229 return Ok(ImageSource {
230 spec: s.into(),
231 server: Some(format!("https://{host}")),
232 protocol: Some("oci".into()),
233 alias: oci_reference(path, false)?,
234 local_registry: false,
235 });
236 }
237 let (server, protocol) = match remote {
238 "images" => ("https://images.linuxcontainers.org", "simplestreams"),
239 "ubuntu" => ("https://cloud-images.ubuntu.com/releases", "simplestreams"),
240 "ubuntu-daily" => ("https://cloud-images.ubuntu.com/daily", "simplestreams"),
241 "ubuntu-minimal" => (
242 "https://cloud-images.ubuntu.com/minimal/releases",
243 "simplestreams",
244 ),
245 other => {
246 return Err(Error::invalid(format!(
247 "unknown image remote {other:?} in {s:?} (known: images, ubuntu, ubuntu-daily, ubuntu-minimal, and OCI registries docker, ghcr, quay, oci:REGISTRY/...; local images need no prefix)"
248 )));
249 }
250 };
251 return Ok(ImageSource {
252 spec: s.into(),
253 server: Some(server.into()),
254 protocol: Some(protocol.into()),
255 alias: alias.into(),
256 local_registry: false,
257 });
258 }
259 Ok(ImageSource {
260 spec: s.into(),
261 server: None,
262 protocol: None,
263 alias: s.into(),
264 local_registry: false,
265 })
266 }
267
268 pub fn bind(mut self, org: Option<&crate::org::OrgId>, addr: Option<&str>) -> Result<Self> {
271 if !self.local_registry {
272 return Ok(self);
273 }
274 let org = org.ok_or_else(|| {
275 Error::invalid(format!(
276 "{:?}: registry: images belong to an org; this project is not one",
277 self.spec
278 ))
279 })?;
280 let addr = addr.ok_or_else(|| {
281 Error::invalid(format!(
282 "{:?}: no local registry on this host (isb registry setup)",
283 self.spec
284 ))
285 })?;
286 let r = crate::registry::ImageRef::parse(&self.alias)?;
287 self.alias = r.pull_alias(org);
288 self.server = Some(format!("https://{addr}"));
289 self.local_registry = false;
290 Ok(self)
291 }
292
293 pub fn is_oci(&self) -> bool {
296 self.protocol.as_deref() == Some("oci")
297 }
298
299 pub fn to_api(&self, local_fingerprint: Option<&str>) -> Value {
302 match (&self.server, local_fingerprint) {
303 (Some(server), _) => json!({
304 "type": "image", "mode": "pull", "server": server,
305 "protocol": self.protocol, "alias": self.alias,
306 }),
307 (None, Some(fp)) => json!({"type": "image", "fingerprint": fp}),
308 (None, None) => json!({"type": "image", "alias": self.alias}),
309 }
310 }
311}
312
313fn oci_reference(r: &str, docker_hub: bool) -> Result<String> {
315 if r.is_empty() || r.contains(char::is_whitespace) {
316 return Err(Error::invalid(format!("invalid OCI image reference {r:?}")));
317 }
318 let mut r = r.to_string();
319 if docker_hub && !r.contains('/') {
320 r = format!("library/{r}");
321 }
322 let last = r.rsplit('/').next().unwrap_or(&r);
323 if !last.contains(':') && !last.contains('@') {
324 r.push_str(":latest");
325 }
326 Ok(r)
327}
328
329pub fn oci_command_line(argv: &[String]) -> std::result::Result<String, String> {
333 argv.iter()
334 .map(|a| {
335 if !a.is_empty() && !a.contains(|c: char| c.is_whitespace() || c == '"' || c == '\'') {
336 Ok(a.clone())
337 } else if !a.contains('"') {
338 Ok(format!("\"{a}\""))
339 } else if !a.contains('\'') {
340 Ok(format!("'{a}'"))
341 } else {
342 Err(format!(
343 "argument {a:?} has both ' and \" in it, which an OCI command line cannot carry; use a script"
344 ))
345 }
346 })
347 .collect::<std::result::Result<Vec<_>, _>>()
348 .map(|v| v.join(" "))
349}
350
351#[derive(Debug, Clone, Serialize)]
353pub struct Desired {
354 pub name: String,
355 pub instance_type: InstanceType,
356 pub image: ImageSource,
357 pub pool: String,
358 pub profiles: Vec<String>,
359 pub config: Props,
360 pub devices: BTreeMap<String, DesiredDevice>,
362 pub volumes: Vec<EnsureVolume>,
363 pub owners: Vec<OwnerFixup>,
364 pub ready: Vec<ReadyCheck>,
365 #[serde(skip)]
366 pub ready_timeout: Duration,
367 pub exec: ExecDefaults,
368 #[serde(skip)]
371 pub idmap_mode: Option<crate::spec::IdmapMode>,
372 #[serde(skip)]
375 pub sensitive: BTreeSet<String>,
376 #[serde(skip)]
378 pub egress: Option<crate::egress::Plumbing>,
379}
380
381pub type VolumeDefs = BTreeMap<String, crate::spec::NamedVolumeSpec>;
384
385pub fn validate_instance_name(name: &str) -> Result<()> {
388 let ok = !name.is_empty()
389 && name.len() <= 63
390 && name.starts_with(|c: char| c.is_ascii_alphabetic())
391 && !name.ends_with('-')
392 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-');
393 if ok {
394 Ok(())
395 } else {
396 Err(Error::invalid(format!(
397 "invalid sandbox name {name:?}: use at most 63 of [a-z0-9-], starting with a letter"
398 )))
399 }
400}
401
402pub fn device_name_for_path(guest: &str) -> String {
404 let mut s = String::new();
405 for c in guest.to_ascii_lowercase().chars() {
406 if c.is_ascii_alphanumeric() {
407 s.push(c);
408 } else if !s.ends_with('-') {
409 s.push('-');
410 }
411 }
412 let s = s.trim_matches('-').to_string();
413 let s = if s.is_empty() { "mount".to_string() } else { s };
414 if s.len() <= 48 {
415 return s;
416 }
417 format!(
418 "{}-{:08x}",
419 s[s.len() - 39..].trim_start_matches('-'),
420 fnv32(guest)
421 )
422}
423
424fn fnv32(s: &str) -> u32 {
425 let mut h: u32 = 0x811c9dc5;
426 for b in s.bytes() {
427 h ^= b as u32;
428 h = h.wrapping_mul(0x01000193);
429 }
430 h
431}
432
433pub fn split_addr(addr: &str) -> Option<(&str, &str, u16)> {
435 let (proto, rest) = addr.split_once(':')?;
436 if !matches!(proto, "tcp" | "udp") {
437 return None;
438 }
439 let (host, port) = rest.rsplit_once(':')?;
440 Some((proto, host, port.parse().ok()?))
441}
442
443pub fn normalize_addr(addr: &str, default_host: &str) -> std::result::Result<String, String> {
451 let a = addr.trim();
452 if a.is_empty() {
453 return Err("empty address".into());
454 }
455 if let Some(path) = a.strip_prefix("unix:") {
456 if path.is_empty() {
457 return Err(format!("{addr:?}: unix: needs a path"));
458 }
459 return Ok(a.to_string());
460 }
461 let (proto, rest) = match a.split_once(':') {
462 Some((p @ ("tcp" | "udp"), rest)) => (p, rest),
463 _ => match a.rsplit_once('/') {
464 Some((rest, p @ ("tcp" | "udp"))) => (p, rest),
465 Some((_, other)) if !other.contains(':') => {
466 return Err(format!("{addr:?}: unknown protocol {other:?} (tcp or udp)"));
467 }
468 _ => ("tcp", a),
469 },
470 };
471 let (host, port) = if rest.starts_with('[') {
472 let end = rest
473 .find(']')
474 .ok_or_else(|| format!("{addr:?}: unclosed [ in IPv6 host"))?;
475 let port = rest[end + 1..]
476 .strip_prefix(':')
477 .ok_or_else(|| format!("{addr:?}: expected [IPv6]:PORT"))?;
478 (&rest[..=end], port)
479 } else {
480 match rest.rsplit_once(':') {
481 Some((h, _)) if h.contains(':') => {
482 return Err(format!(
483 "{addr:?}: put an IPv6 host in brackets, e.g. [::1]:5173"
484 ));
485 }
486 Some((h, p)) => (h, p),
487 None => (default_host, rest),
488 }
489 };
490 if host.is_empty() {
491 return Err(format!("{addr:?}: empty host"));
492 }
493 let valid_port = !port.is_empty()
494 && port.split(',').all(|part| {
495 let mut ends = part.splitn(2, '-');
496 ends.all(|n| n.parse::<u16>().is_ok_and(|n| n > 0))
497 });
498 if !valid_port {
499 return Err(format!(
500 "{addr:?}: expected PORT, HOST:PORT or PROTO:HOST:PORT (e.g. 5173, 0.0.0.0:5173, udp:5353)"
501 ));
502 }
503 Ok(format!("{proto}:{host}:{port}"))
504}
505
506fn default_port_name(bind: PortBind, listen: &str) -> String {
507 match split_addr(listen) {
508 Some(("tcp", _, port)) => format!("port-{}-{port}", bind.as_str()),
509 Some((proto, _, port)) => format!("port-{}-{proto}-{port}", bind.as_str()),
510 None => format!("port-{}-{}", bind.as_str(), device_name_for_path(listen)),
511 }
512}
513
514fn expand_home(p: &str) -> String {
515 if p == "~" || p.starts_with("~/") {
516 if let Ok(h) = std::env::var("HOME") {
517 return format!("{}{}", h.trim_end_matches('/'), &p[1..]);
518 }
519 }
520 p.to_string()
521}
522
523pub fn resolve_host_path(p: &str, base: &Path) -> Result<String> {
526 let expanded = expand_home(p);
527 let path = PathBuf::from(&expanded);
528 let abs = if path.is_absolute() {
529 path
530 } else {
531 base.join(path)
532 };
533 let canon = abs.canonicalize().map_err(|e| {
534 Error::invalid(format!("bind source {} does not exist: {e}", abs.display()))
535 })?;
536 Ok(canon.to_string_lossy().into_owned())
537}
538
539pub fn memory_limit(m: &str) -> std::result::Result<String, String> {
543 let t = m.trim();
544 let split = t
545 .find(|c: char| !c.is_ascii_digit() && c != '.')
546 .unwrap_or(t.len());
547 let (num, unit) = (&t[..split], t[split..].trim());
548 if num.is_empty() || num.parse::<u64>().is_err() {
549 return Err(format!("{m:?} is not a whole size (e.g. 512m, 8g, 8GiB)"));
551 }
552 let suffix = match unit.to_ascii_lowercase().as_str() {
553 "" | "b" => "",
554 "k" | "kb" => "KiB",
555 "m" | "mb" => "MiB",
556 "g" | "gb" => "GiB",
557 "t" | "tb" => "TiB",
558 "%" | "kib" | "mib" | "gib" | "tib" => return Ok(t.to_string()),
560 _ => {
561 return Err(format!(
562 "{m:?}: unknown unit {unit:?} (b, k, m, g, t, KiB, MiB, GiB, TiB or %)"
563 ));
564 }
565 };
566 Ok(format!("{num}{suffix}"))
567}
568
569#[expect(
571 clippy::too_many_lines,
572 clippy::cognitive_complexity,
573 reason = "predates the lint ratchet; split it when next changed"
574)]
575pub fn resolve(
576 spec: &SandboxSpec,
577 defs: &VolumeDefs,
578 host: &HostFacts,
579 base: &Path,
580) -> Result<Desired> {
581 let name = spec
582 .name
583 .clone()
584 .ok_or_else(|| Error::invalid("sandbox name is required"))?;
585 validate_instance_name(&name)?;
586 let image = ImageSource::parse(&spec.image)
587 .and_then(|i| i.bind(host.org.as_ref(), host.registry.as_deref()))
588 .map_err(|e| Error::invalid(format!("{name}: {e}")))?;
589 let pool = host.pick_pool(spec.storage.as_deref())?;
590 let vm = spec.instance_type == InstanceType::VirtualMachine;
591 let oci = image.is_oci();
592 if oci && vm {
593 return Err(Error::invalid(format!(
594 "{name}: OCI images run as containers, not VMs"
595 )));
596 }
597 if spec.entrypoint.is_some() && !oci {
598 return Err(Error::invalid(format!(
599 "{name}: entrypoint is for OCI images; use command"
600 )));
601 }
602 if vm {
603 if spec.privileged.is_some() {
604 return Err(Error::invalid(format!(
605 "{name}: privileged is container-only"
606 )));
607 }
608 for p in &spec.ports {
609 if p.bind == PortBind::Guest {
610 return Err(Error::invalid(format!(
611 "{name}: incus VMs only support bind: host proxies (in NAT mode)"
612 )));
613 }
614 }
615 }
616
617 let mut config = Props::new();
618 match (&spec.cpus, &spec.cpuset) {
619 (Some(_), Some(_)) => {
620 return Err(Error::invalid(format!(
621 "{name}: set cpus (a count) or cpuset (which CPUs), not both"
622 )));
623 }
624 (Some(c), None) => {
625 if !c.trim().parse::<u32>().is_ok_and(|n| n > 0) {
626 return Err(Error::invalid(format!(
627 "{name}: cpus is a whole number of CPUs, got {c:?} (pin CPUs with cpuset: \"0-3\")"
628 )));
629 }
630 config.insert("limits.cpu".into(), c.trim().to_string());
631 }
632 (None, Some(set)) => {
633 config.insert("limits.cpu".into(), set.clone());
634 }
635 (None, None) => {}
636 }
637 if let Some(m) = &spec.memory {
638 let m = memory_limit(m).map_err(|e| Error::invalid(format!("{name}: mem_limit: {e}")))?;
639 config.insert("limits.memory".into(), m);
640 }
641 if let Some(p) = spec.privileged {
642 config.insert("security.privileged".into(), p.to_string());
643 }
644 let (idmap_mode, raw_idmap) = if vm {
645 idmap::plan_vm(
646 &name,
647 spec,
648 host.incus_version.as_deref(),
649 host.invoking_ids,
650 )?
651 } else {
652 idmap::plan_container(spec.idmap.as_ref(), &host.subids)
653 };
654 if let Some(v) = raw_idmap {
655 config.insert("raw.idmap".into(), v);
656 }
657 for (k, v) in &spec.labels {
658 if k.is_empty() || k.contains(char::is_whitespace) {
659 return Err(Error::invalid(format!("{name}: invalid label key {k:?}")));
660 }
661 config.insert(format!("user.{k}"), v.clone());
662 }
663 for (k, v) in &spec.env {
664 config.insert(format!("environment.{k}"), v.clone());
665 }
666 if let Some(r) = spec.restart {
667 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 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 if !vm && host.initial_copy && !v.volume.nocopy {
791 props.insert("initial.copy".into(), "true".into());
792 }
793 let ev = EnsureVolume {
794 pool: vpool,
795 name: n.clone(),
796 config: def.map(|d| d.config.clone()).unwrap_or_default(),
797 external: v.external || def.is_some_and(|d| d.external),
798 };
799 if !volumes.contains(&ev) {
800 volumes.push(ev);
801 }
802 if let Some(o) = &v.owner {
803 owners.push(OwnerFixup {
804 device: dname.clone(),
805 path: guest_norm.clone(),
806 owner: o.clone(),
807 });
808 }
809 }
810 }
811 if v.read_only {
812 props.insert("readonly".into(), "true".into());
813 }
814 for k in v.options.keys() {
815 if matches!(k.as_str(), "type" | "path" | "source" | "pool" | "readonly") {
816 return Err(Error::invalid(format!(
817 "{name}: {guest}: options.{k} would override a core property; use the field instead"
818 )));
819 }
820 }
821 for (k, val) in &v.options {
822 props.insert(k.clone(), val.clone());
823 }
824 add_dev(
825 dname,
826 DesiredDevice {
827 props,
828 search: None,
829 },
830 )?;
831 }
832
833 for p in &spec.ports {
834 let connect_host = if vm { "0.0.0.0" } else { "127.0.0.1" };
838 let listen = normalize_addr(&p.listen, "127.0.0.1")
839 .map_err(|e| Error::invalid(format!("{name}: port listen: {e}")))?;
840 let connect = normalize_addr(&p.connect, connect_host)
841 .map_err(|e| Error::invalid(format!("{name}: port connect: {e}")))?;
842 if p.search.is_some() {
843 if split_addr(&connect).is_none_or(|(_, h, _)| h != connect_host) {
844 return Err(Error::invalid(format!(
845 "{name}: a published port range connects to the guest's default address ({connect_host}), not {connect}"
846 )));
847 }
848 if p.bind != PortBind::Host {
849 return Err(Error::invalid(format!(
850 "{name}: port search only applies to bind: host"
851 )));
852 }
853 if split_addr(&listen).is_none() {
854 return Err(Error::invalid(format!(
855 "{name}: port search needs a single tcp or udp listen port"
856 )));
857 }
858 }
859 let dname = p
860 .name
861 .clone()
862 .unwrap_or_else(|| default_port_name(p.bind, &listen));
863 let mut props = Props::from([
864 ("type".into(), "proxy".into()),
865 ("bind".into(), p.bind.as_str().into()),
866 ("listen".into(), listen),
867 ("connect".into(), connect),
868 ]);
869 if vm {
870 props.insert("nat".into(), "true".into());
872 }
873 for (k, val) in &p.options {
874 if matches!(k.as_str(), "type" | "bind" | "listen" | "connect") {
875 return Err(Error::invalid(format!(
876 "{name}: port options.{k} would override a core property; use the field instead"
877 )));
878 }
879 props.insert(k.clone(), val.clone());
880 }
881 add_dev(
882 dname,
883 DesiredDevice {
884 props,
885 search: p.search.filter(|n| *n > 0),
886 },
887 )?;
888 }
889
890 let egress = match &spec.egress {
891 Some(e) => {
892 let c = crate::egress::contribute(e, &host.project, &name)?;
893 add_dev(
894 "eth0".into(),
895 DesiredDevice {
896 props: c.nic,
897 search: None,
898 },
899 )?;
900 config.extend(c.config);
901 Some(c.plumbing)
902 }
903 None => None,
904 };
905 let mut root_extra = Props::new();
906 for (dname, props) in &spec.raw_devices {
907 if dname == "root" {
908 root_extra.extend(props.clone());
910 continue;
911 }
912 if !props.contains_key("type") {
913 return Err(Error::invalid(format!(
914 "{name}: raw device {dname:?} needs a type"
915 )));
916 }
917 add_dev(
918 dname.clone(),
919 DesiredDevice {
920 props: props.clone(),
921 search: None,
922 },
923 )?;
924 }
925
926 if let Some(root) = devices.get_mut("root") {
927 root.props.extend(root_extra);
928 }
929
930 let ready_timeout = match &spec.ready_timeout {
931 Some(s) => parse_duration(s).map_err(|e| Error::invalid(format!("{name}: {e}")))?,
932 None if vm => Duration::from_secs(300),
936 None => Duration::from_secs(60),
937 };
938
939 Ok(Desired {
940 name,
941 instance_type: spec.instance_type,
942 image,
943 pool,
944 profiles: spec
945 .profiles
946 .clone()
947 .unwrap_or_else(|| vec!["default".into()]),
948 config,
949 devices,
950 volumes,
951 owners,
952 ready: spec.ready.clone().unwrap_or_else(|| {
953 if vm {
954 vec![ReadyCheck::Running, ReadyCheck::Agent]
955 } else {
956 vec![ReadyCheck::Running]
957 }
958 }),
959 ready_timeout,
960 exec: spec.exec_defaults(),
961 idmap_mode,
962 sensitive: spec
963 .env
964 .secrets
965 .keys()
966 .map(|k| format!("environment.{k}"))
967 .collect(),
968 egress,
969 })
970}
971
972#[derive(Debug, Clone, Default, PartialEq)]
974pub struct Actual {
975 pub status: String,
976 pub config: Props,
977 pub devices: BTreeMap<String, Props>,
979 pub profiles: Vec<String>,
980 pub instance_type: String,
981}
982
983impl Actual {
984 pub fn from_api(v: &Value) -> Actual {
985 let strmap = |v: Option<&Value>| -> Props {
986 v.and_then(Value::as_object)
987 .map(|m| {
988 m.iter()
989 .map(|(k, v)| {
990 let s = match v {
991 Value::String(s) => s.clone(),
992 other => other.to_string(),
993 };
994 (k.clone(), s)
995 })
996 .collect()
997 })
998 .unwrap_or_default()
999 };
1000 let devices = v
1001 .get("devices")
1002 .and_then(Value::as_object)
1003 .map(|m| {
1004 m.iter()
1005 .map(|(k, d)| (k.clone(), strmap(Some(d))))
1006 .collect()
1007 })
1008 .unwrap_or_default();
1009 Actual {
1010 status: v
1011 .get("status")
1012 .and_then(Value::as_str)
1013 .unwrap_or("")
1014 .to_string(),
1015 config: strmap(v.get("config")),
1016 devices,
1017 profiles: v
1018 .get("profiles")
1019 .and_then(Value::as_array)
1020 .map(|a| {
1021 a.iter()
1022 .filter_map(|x| x.as_str().map(String::from))
1023 .collect()
1024 })
1025 .unwrap_or_default(),
1026 instance_type: v
1027 .get("type")
1028 .and_then(Value::as_str)
1029 .unwrap_or("")
1030 .to_string(),
1031 }
1032 }
1033
1034 pub fn running(&self) -> bool {
1035 self.status.eq_ignore_ascii_case("running")
1036 }
1037}
1038
1039#[derive(Debug, Clone, PartialEq, Serialize)]
1041#[serde(tag = "action", rename_all = "snake_case")]
1042pub enum Action {
1043 CreateVolume {
1044 pool: String,
1045 volume: String,
1046 config: Props,
1047 },
1048 CreateInstance {
1049 image: String,
1050 pool: String,
1051 config: Props,
1052 devices: BTreeMap<String, Props>,
1053 profiles: Vec<String>,
1054 },
1055 SetConfig {
1056 key: String,
1057 #[serde(skip_serializing_if = "Option::is_none")]
1058 from: Option<String>,
1059 to: String,
1060 restart: bool,
1062 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1065 secret: bool,
1066 },
1067 AddDevice {
1068 device: String,
1069 props: Props,
1070 },
1071 ReplaceDevice {
1073 device: String,
1074 replaces: String,
1076 from: Props,
1077 to: Props,
1078 },
1079 RemoveDevice {
1080 device: String,
1081 props: Props,
1082 },
1083 StartInstance,
1084 AddPort {
1086 device: String,
1087 props: Props,
1088 search: u16,
1089 },
1090 FixOwner {
1091 path: String,
1092 owner: String,
1093 },
1094 Note {
1096 message: String,
1097 },
1098}
1099
1100impl Action {
1101 pub fn is_change(&self) -> bool {
1103 !matches!(self, Action::Note { .. })
1104 }
1105}
1106
1107impl std::fmt::Display for Action {
1108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1109 let props = |p: &Props| {
1110 p.iter()
1111 .filter(|(k, _)| k.as_str() != "type")
1112 .map(|(k, v)| format!("{k}={v}"))
1113 .collect::<Vec<_>>()
1114 .join(" ")
1115 };
1116 match self {
1117 Action::CreateVolume { pool, volume, .. } => {
1118 write!(f, "+ volume {volume} (pool {pool})")
1119 }
1120 Action::CreateInstance {
1121 image,
1122 pool,
1123 devices,
1124 ..
1125 } => write!(
1126 f,
1127 "+ create from {image} on pool {pool} with {} device(s)",
1128 devices.len()
1129 ),
1130 Action::SetConfig {
1131 key,
1132 from,
1133 to,
1134 restart,
1135 ..
1136 } => write!(
1137 f,
1138 "~ config {key}: {} -> {to}{}",
1139 from.as_deref().unwrap_or("(unset)"),
1140 if *restart {
1141 " (takes effect on restart)"
1142 } else {
1143 ""
1144 }
1145 ),
1146 Action::AddDevice { device, props: p } => write!(f, "+ device {device}: {}", props(p)),
1147 Action::ReplaceDevice {
1148 device,
1149 replaces,
1150 from,
1151 to,
1152 } => {
1153 if device == replaces {
1154 write!(f, "~ device {device}: {} -> {}", props(from), props(to))
1155 } else {
1156 write!(
1157 f,
1158 "~ device {replaces} -> {device}: {} -> {}",
1159 props(from),
1160 props(to)
1161 )
1162 }
1163 }
1164 Action::RemoveDevice { device, .. } => write!(f, "- device {device}"),
1165 Action::StartInstance => write!(f, "> start"),
1166 Action::AddPort {
1167 device,
1168 props: p,
1169 search,
1170 } => write!(f, "+ port {device}: {} (search {search})", props(p)),
1171 Action::FixOwner { path, owner } => write!(f, "~ chown {owner} {path}"),
1172 Action::Note { message } => write!(f, " note: {message}"),
1173 }
1174 }
1175}
1176
1177#[derive(Debug, Clone, Serialize)]
1179pub struct SandboxPlan {
1180 pub name: String,
1181 pub status: Option<String>,
1183 pub actions: Vec<Action>,
1184}
1185
1186impl SandboxPlan {
1187 pub fn is_noop(&self) -> bool {
1189 !self.actions.iter().any(Action::is_change)
1190 }
1191}
1192
1193#[derive(Debug, Clone, Copy, Default)]
1195pub struct DiffOptions {
1196 pub prune_devices: bool,
1198}
1199
1200const FALSE_IS_ABSENT: &[&str] = &["readonly", "shift", "nat"];
1202
1203fn normalize(p: &Props) -> Props {
1204 let mut out = p.clone();
1205 for k in FALSE_IS_ABSENT {
1206 if out.get(*k).map(String::as_str) == Some("false") {
1207 out.remove(*k);
1208 }
1209 }
1210 if out.get("type").map(String::as_str) == Some("disk") {
1211 for k in ["source", "path"] {
1212 if let Some(v) = out.get_mut(k) {
1213 if v.len() > 1 {
1214 *v = v.trim_end_matches('/').to_string();
1215 }
1216 }
1217 }
1218 }
1219 out
1220}
1221
1222pub fn device_matches(desired: &DesiredDevice, actual: &Props) -> bool {
1224 let mut d = normalize(&desired.props);
1225 let mut a = normalize(actual);
1226 if d.get("type").map(String::as_str) == Some("disk") {
1229 d.remove("initial.copy");
1230 a.remove("initial.copy");
1231 }
1232 if d == a {
1233 return true;
1234 }
1235 let Some(n) = desired.search else {
1236 return false;
1237 };
1238 let (Some(dl), Some(al)) = (d.get("listen"), a.get("listen")) else {
1240 return false;
1241 };
1242 let (Some((dp, dh, dport)), Some((ap, ah, aport))) = (split_addr(dl), split_addr(al)) else {
1243 return false;
1244 };
1245 if dp != ap || dh != ah || aport < dport || aport as u32 > dport as u32 + n as u32 {
1246 return false;
1247 }
1248 let strip = |m: &Props| {
1249 let mut m = m.clone();
1250 m.remove("listen");
1251 m
1252 };
1253 strip(&d) == strip(&a)
1254}
1255
1256pub const REDACTED: &str = "(secret)";
1258
1259fn restart_needed(key: &str) -> bool {
1260 key.starts_with("raw.") || key.starts_with("security.") || key.starts_with("oci.")
1261}
1262
1263#[expect(
1266 clippy::too_many_lines,
1267 clippy::cognitive_complexity,
1268 reason = "predates the lint ratchet; split it when next changed"
1269)]
1270pub fn diff(
1271 desired: &Desired,
1272 actual: Option<&Actual>,
1273 volumes_missing: &[(String, String)],
1274 opts: DiffOptions,
1275) -> Result<SandboxPlan> {
1276 let mut actions = Vec::new();
1277 for v in &desired.volumes {
1278 if volumes_missing.contains(&(v.pool.clone(), v.name.clone())) {
1279 if v.external {
1280 return Err(Error::invalid(format!(
1281 "volume {} is external but does not exist in pool {}",
1282 v.name, v.pool
1283 )));
1284 }
1285 actions.push(Action::CreateVolume {
1286 pool: v.pool.clone(),
1287 volume: v.name.clone(),
1288 config: v.config.clone(),
1289 });
1290 }
1291 }
1292
1293 let Some(actual) = actual else {
1294 actions.push(Action::CreateInstance {
1295 image: desired.image.spec.clone(),
1296 pool: desired.pool.clone(),
1297 config: desired
1298 .config
1299 .iter()
1300 .map(|(k, v)| {
1301 let v = if desired.sensitive.contains(k) {
1302 REDACTED.to_string()
1303 } else {
1304 v.clone()
1305 };
1306 (k.clone(), v)
1307 })
1308 .collect(),
1309 devices: desired
1310 .devices
1311 .iter()
1312 .filter(|(_, d)| d.search.is_none())
1313 .map(|(k, d)| (k.clone(), d.props.clone()))
1314 .collect(),
1315 profiles: desired.profiles.clone(),
1316 });
1317 actions.push(Action::StartInstance);
1318 push_searched_ports(desired, &mut actions);
1319 for o in &desired.owners {
1320 actions.push(Action::FixOwner {
1321 path: o.path.clone(),
1322 owner: o.owner.clone(),
1323 });
1324 }
1325 return Ok(SandboxPlan {
1326 name: desired.name.clone(),
1327 status: None,
1328 actions,
1329 });
1330 };
1331
1332 if !actual.instance_type.is_empty() && actual.instance_type != desired.instance_type.as_api() {
1334 actions.push(Action::Note {
1335 message: format!(
1336 "type is {} (spec: {}); fixed at creation",
1337 actual.instance_type,
1338 desired.instance_type.as_api()
1339 ),
1340 });
1341 }
1342 if let Some(root) = actual.devices.get("root") {
1343 if let Some(p) = root.get("pool") {
1344 if *p != desired.pool {
1345 actions.push(Action::Note {
1346 message: format!(
1347 "root disk is on pool {p} (spec: {}); fixed at creation",
1348 desired.pool
1349 ),
1350 });
1351 }
1352 }
1353 }
1354 if actual.profiles != desired.profiles {
1355 actions.push(Action::Note {
1356 message: format!(
1357 "profiles are [{}] (spec: [{}]); fixed at creation",
1358 actual.profiles.join(", "),
1359 desired.profiles.join(", ")
1360 ),
1361 });
1362 }
1363
1364 for (k, v) in &desired.config {
1365 let cur = actual.config.get(k);
1366 if cur != Some(v) {
1367 let secret = desired.sensitive.contains(k);
1368 let hide = |s: &String| if secret { REDACTED.into() } else { s.clone() };
1369 actions.push(Action::SetConfig {
1370 key: k.clone(),
1371 from: cur.map(hide),
1372 to: hide(v),
1373 restart: restart_needed(k),
1374 secret,
1375 });
1376 }
1377 }
1378 if !desired.config.contains_key("raw.idmap") && actual.config.contains_key("raw.idmap") {
1379 let why = match desired.idmap_mode {
1380 Some(crate::spec::IdmapMode::Auto) => Some("not needed on this host"),
1381 Some(crate::spec::IdmapMode::None) => Some("the spec says idmap: none"),
1382 _ => None,
1383 };
1384 if let Some(why) = why {
1385 actions.push(Action::Note {
1386 message: format!(
1387 "raw.idmap is set but {why}; isb never removes config keys, unset it by hand"
1388 ),
1389 });
1390 }
1391 }
1392
1393 let mut new_devices: Vec<String> = Vec::new();
1394 let mut claimed: BTreeSet<String> = BTreeSet::new();
1395 let mut deferred_ports = Vec::new();
1396 for (name, want) in &desired.devices {
1397 if name == "root" {
1398 continue;
1399 }
1400 if let Some(have) = actual.devices.get(name) {
1401 claimed.insert(name.clone());
1402 if !device_matches(want, have) && want.search.is_some() {
1403 actions.push(Action::RemoveDevice {
1406 device: name.clone(),
1407 props: have.clone(),
1408 });
1409 deferred_ports.push(name.clone());
1410 } else if !device_matches(want, have) {
1411 actions.push(Action::ReplaceDevice {
1412 device: name.clone(),
1413 replaces: name.clone(),
1414 from: have.clone(),
1415 to: want.props.clone(),
1416 });
1417 new_devices.push(name.clone());
1418 }
1419 continue;
1420 }
1421 let same_path = |p: &Props| {
1426 want.props.get("type").map(String::as_str) == Some("disk")
1427 && p.get("type").map(String::as_str) == Some("disk")
1428 && normalize(p).get("path") == normalize(&want.props).get("path")
1429 };
1430 if let Some((other, have)) = actual.devices.iter().find(|(n, p)| {
1431 *n != "root" && !desired.devices.contains_key(*n) && device_matches(want, p)
1432 }) {
1433 claimed.insert(other.clone());
1434 actions.push(Action::Note {
1435 message: format!("device {name} already present as {other}; left as is"),
1436 });
1437 let _ = have;
1438 continue;
1439 }
1440 if let Some((other, have)) = actual
1441 .devices
1442 .iter()
1443 .find(|(n, p)| *n != "root" && !desired.devices.contains_key(*n) && same_path(p))
1444 {
1445 claimed.insert(other.clone());
1446 actions.push(Action::ReplaceDevice {
1447 device: name.clone(),
1448 replaces: other.clone(),
1449 from: have.clone(),
1450 to: want.props.clone(),
1451 });
1452 new_devices.push(name.clone());
1453 continue;
1454 }
1455 if want.search.is_some() {
1456 deferred_ports.push(name.clone());
1457 } else {
1458 actions.push(Action::AddDevice {
1459 device: name.clone(),
1460 props: want.props.clone(),
1461 });
1462 new_devices.push(name.clone());
1463 }
1464 }
1465
1466 if opts.prune_devices {
1467 for (name, props) in &actual.devices {
1468 if name != "root" && !desired.devices.contains_key(name) && !claimed.contains(name) {
1469 actions.push(Action::RemoveDevice {
1470 device: name.clone(),
1471 props: props.clone(),
1472 });
1473 }
1474 }
1475 }
1476
1477 if !actual.running() {
1478 actions.push(Action::StartInstance);
1479 }
1480 for name in deferred_ports {
1481 let d = &desired.devices[&name];
1482 actions.push(Action::AddPort {
1483 device: name.clone(),
1484 props: d.props.clone(),
1485 search: d.search.unwrap_or(0),
1486 });
1487 }
1488 for o in &desired.owners {
1489 if new_devices.contains(&o.device) {
1490 actions.push(Action::FixOwner {
1491 path: o.path.clone(),
1492 owner: o.owner.clone(),
1493 });
1494 }
1495 }
1496
1497 Ok(SandboxPlan {
1498 name: desired.name.clone(),
1499 status: Some(actual.status.clone()),
1500 actions,
1501 })
1502}
1503
1504fn push_searched_ports(desired: &Desired, actions: &mut Vec<Action>) {
1505 for (name, d) in &desired.devices {
1506 if let Some(n) = d.search {
1507 actions.push(Action::AddPort {
1508 device: name.clone(),
1509 props: d.props.clone(),
1510 search: n,
1511 });
1512 }
1513 }
1514}
1515
1516#[cfg(test)]
1517mod tests;