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 let props = devices.iter().map(|(k, d)| (k, &d.props));
930 crate::org::check_proxies(&name, host.org.as_ref(), props, spec.stack_udp)?;
931
932 let ready_timeout = match &spec.ready_timeout {
933 Some(s) => parse_duration(s).map_err(|e| Error::invalid(format!("{name}: {e}")))?,
934 None if vm => Duration::from_secs(300),
938 None => Duration::from_secs(60),
939 };
940
941 Ok(Desired {
942 name,
943 instance_type: spec.instance_type,
944 image,
945 pool,
946 profiles: spec
947 .profiles
948 .clone()
949 .unwrap_or_else(|| vec!["default".into()]),
950 config,
951 devices,
952 volumes,
953 owners,
954 ready: spec.ready.clone().unwrap_or_else(|| {
955 if vm {
956 vec![ReadyCheck::Running, ReadyCheck::Agent]
957 } else {
958 vec![ReadyCheck::Running]
959 }
960 }),
961 ready_timeout,
962 exec: spec.exec_defaults(),
963 idmap_mode,
964 sensitive: spec
965 .env
966 .secrets
967 .keys()
968 .map(|k| format!("environment.{k}"))
969 .collect(),
970 egress,
971 })
972}
973
974#[derive(Debug, Clone, Default, PartialEq)]
976pub struct Actual {
977 pub status: String,
978 pub config: Props,
979 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#[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 restart: bool,
1064 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1067 secret: bool,
1068 },
1069 AddDevice {
1070 device: String,
1071 props: Props,
1072 },
1073 ReplaceDevice {
1075 device: String,
1076 replaces: String,
1078 from: Props,
1079 to: Props,
1080 },
1081 RemoveDevice {
1082 device: String,
1083 props: Props,
1084 },
1085 StartInstance,
1086 AddPort {
1088 device: String,
1089 props: Props,
1090 search: u16,
1091 },
1092 FixOwner {
1093 path: String,
1094 owner: String,
1095 },
1096 Note {
1098 message: String,
1099 },
1100}
1101
1102impl Action {
1103 pub fn is_change(&self) -> bool {
1105 !matches!(self, Action::Note { .. })
1106 }
1107}
1108
1109impl std::fmt::Display for Action {
1110 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1111 let props = |p: &Props| {
1112 p.iter()
1113 .filter(|(k, _)| k.as_str() != "type")
1114 .map(|(k, v)| format!("{k}={v}"))
1115 .collect::<Vec<_>>()
1116 .join(" ")
1117 };
1118 match self {
1119 Action::CreateVolume { pool, volume, .. } => {
1120 write!(f, "+ volume {volume} (pool {pool})")
1121 }
1122 Action::CreateInstance {
1123 image,
1124 pool,
1125 devices,
1126 ..
1127 } => write!(
1128 f,
1129 "+ create from {image} on pool {pool} with {} device(s)",
1130 devices.len()
1131 ),
1132 Action::SetConfig {
1133 key,
1134 from,
1135 to,
1136 restart,
1137 ..
1138 } => write!(
1139 f,
1140 "~ config {key}: {} -> {to}{}",
1141 from.as_deref().unwrap_or("(unset)"),
1142 if *restart {
1143 " (takes effect on restart)"
1144 } else {
1145 ""
1146 }
1147 ),
1148 Action::AddDevice { device, props: p } => write!(f, "+ device {device}: {}", props(p)),
1149 Action::ReplaceDevice {
1150 device,
1151 replaces,
1152 from,
1153 to,
1154 } => {
1155 if device == replaces {
1156 write!(f, "~ device {device}: {} -> {}", props(from), props(to))
1157 } else {
1158 write!(
1159 f,
1160 "~ device {replaces} -> {device}: {} -> {}",
1161 props(from),
1162 props(to)
1163 )
1164 }
1165 }
1166 Action::RemoveDevice { device, .. } => write!(f, "- device {device}"),
1167 Action::StartInstance => write!(f, "> start"),
1168 Action::AddPort {
1169 device,
1170 props: p,
1171 search,
1172 } => write!(f, "+ port {device}: {} (search {search})", props(p)),
1173 Action::FixOwner { path, owner } => write!(f, "~ chown {owner} {path}"),
1174 Action::Note { message } => write!(f, " note: {message}"),
1175 }
1176 }
1177}
1178
1179#[derive(Debug, Clone, Serialize)]
1181pub struct SandboxPlan {
1182 pub name: String,
1183 pub status: Option<String>,
1185 pub actions: Vec<Action>,
1186}
1187
1188impl SandboxPlan {
1189 pub fn is_noop(&self) -> bool {
1191 !self.actions.iter().any(Action::is_change)
1192 }
1193}
1194
1195#[derive(Debug, Clone, Copy, Default)]
1197pub struct DiffOptions {
1198 pub prune_devices: bool,
1200}
1201
1202const FALSE_IS_ABSENT: &[&str] = &["readonly", "shift", "nat"];
1204
1205fn normalize(p: &Props) -> Props {
1206 let mut out = p.clone();
1207 for k in FALSE_IS_ABSENT {
1208 if out.get(*k).map(String::as_str) == Some("false") {
1209 out.remove(*k);
1210 }
1211 }
1212 if out.get("type").map(String::as_str) == Some("disk") {
1213 for k in ["source", "path"] {
1214 if let Some(v) = out.get_mut(k) {
1215 if v.len() > 1 {
1216 *v = v.trim_end_matches('/').to_string();
1217 }
1218 }
1219 }
1220 }
1221 out
1222}
1223
1224pub fn device_matches(desired: &DesiredDevice, actual: &Props) -> bool {
1226 let mut d = normalize(&desired.props);
1227 let mut a = normalize(actual);
1228 if d.get("type").map(String::as_str) == Some("disk") {
1231 d.remove("initial.copy");
1232 a.remove("initial.copy");
1233 }
1234 if d == a {
1235 return true;
1236 }
1237 let Some(n) = desired.search else {
1238 return false;
1239 };
1240 let (Some(dl), Some(al)) = (d.get("listen"), a.get("listen")) else {
1242 return false;
1243 };
1244 let (Some((dp, dh, dport)), Some((ap, ah, aport))) = (split_addr(dl), split_addr(al)) else {
1245 return false;
1246 };
1247 if dp != ap || dh != ah || aport < dport || aport as u32 > dport as u32 + n as u32 {
1248 return false;
1249 }
1250 let strip = |m: &Props| {
1251 let mut m = m.clone();
1252 m.remove("listen");
1253 m
1254 };
1255 strip(&d) == strip(&a)
1256}
1257
1258pub const REDACTED: &str = "(secret)";
1260
1261fn restart_needed(key: &str) -> bool {
1262 key.starts_with("raw.") || key.starts_with("security.") || key.starts_with("oci.")
1263}
1264
1265#[expect(
1268 clippy::too_many_lines,
1269 clippy::cognitive_complexity,
1270 reason = "predates the lint ratchet; split it when next changed"
1271)]
1272pub fn diff(
1273 desired: &Desired,
1274 actual: Option<&Actual>,
1275 volumes_missing: &[(String, String)],
1276 opts: DiffOptions,
1277) -> Result<SandboxPlan> {
1278 let mut actions = Vec::new();
1279 for v in &desired.volumes {
1280 if volumes_missing.contains(&(v.pool.clone(), v.name.clone())) {
1281 if v.external {
1282 return Err(Error::invalid(format!(
1283 "volume {} is external but does not exist in pool {}",
1284 v.name, v.pool
1285 )));
1286 }
1287 actions.push(Action::CreateVolume {
1288 pool: v.pool.clone(),
1289 volume: v.name.clone(),
1290 config: v.config.clone(),
1291 });
1292 }
1293 }
1294
1295 let Some(actual) = actual else {
1296 actions.push(Action::CreateInstance {
1297 image: desired.image.spec.clone(),
1298 pool: desired.pool.clone(),
1299 config: desired
1300 .config
1301 .iter()
1302 .map(|(k, v)| {
1303 let v = if desired.sensitive.contains(k) {
1304 REDACTED.to_string()
1305 } else {
1306 v.clone()
1307 };
1308 (k.clone(), v)
1309 })
1310 .collect(),
1311 devices: desired
1312 .devices
1313 .iter()
1314 .filter(|(_, d)| d.search.is_none())
1315 .map(|(k, d)| (k.clone(), d.props.clone()))
1316 .collect(),
1317 profiles: desired.profiles.clone(),
1318 });
1319 actions.push(Action::StartInstance);
1320 push_searched_ports(desired, &mut actions);
1321 for o in &desired.owners {
1322 actions.push(Action::FixOwner {
1323 path: o.path.clone(),
1324 owner: o.owner.clone(),
1325 });
1326 }
1327 return Ok(SandboxPlan {
1328 name: desired.name.clone(),
1329 status: None,
1330 actions,
1331 });
1332 };
1333
1334 if !actual.instance_type.is_empty() && actual.instance_type != desired.instance_type.as_api() {
1336 actions.push(Action::Note {
1337 message: format!(
1338 "type is {} (spec: {}); fixed at creation",
1339 actual.instance_type,
1340 desired.instance_type.as_api()
1341 ),
1342 });
1343 }
1344 if let Some(root) = actual.devices.get("root") {
1345 if let Some(p) = root.get("pool") {
1346 if *p != desired.pool {
1347 actions.push(Action::Note {
1348 message: format!(
1349 "root disk is on pool {p} (spec: {}); fixed at creation",
1350 desired.pool
1351 ),
1352 });
1353 }
1354 }
1355 }
1356 if actual.profiles != desired.profiles {
1357 actions.push(Action::Note {
1358 message: format!(
1359 "profiles are [{}] (spec: [{}]); fixed at creation",
1360 actual.profiles.join(", "),
1361 desired.profiles.join(", ")
1362 ),
1363 });
1364 }
1365
1366 for (k, v) in &desired.config {
1367 let cur = actual.config.get(k);
1368 if cur != Some(v) {
1369 let secret = desired.sensitive.contains(k);
1370 let hide = |s: &String| if secret { REDACTED.into() } else { s.clone() };
1371 actions.push(Action::SetConfig {
1372 key: k.clone(),
1373 from: cur.map(hide),
1374 to: hide(v),
1375 restart: restart_needed(k),
1376 secret,
1377 });
1378 }
1379 }
1380 if !desired.config.contains_key("raw.idmap") && actual.config.contains_key("raw.idmap") {
1381 let why = match desired.idmap_mode {
1382 Some(crate::spec::IdmapMode::Auto) => Some("not needed on this host"),
1383 Some(crate::spec::IdmapMode::None) => Some("the spec says idmap: none"),
1384 _ => None,
1385 };
1386 if let Some(why) = why {
1387 actions.push(Action::Note {
1388 message: format!(
1389 "raw.idmap is set but {why}; isb never removes config keys, unset it by hand"
1390 ),
1391 });
1392 }
1393 }
1394
1395 let mut new_devices: Vec<String> = Vec::new();
1396 let mut claimed: BTreeSet<String> = BTreeSet::new();
1397 let mut deferred_ports = Vec::new();
1398 for (name, want) in &desired.devices {
1399 if name == "root" {
1400 continue;
1401 }
1402 if let Some(have) = actual.devices.get(name) {
1403 claimed.insert(name.clone());
1404 if !device_matches(want, have) && want.search.is_some() {
1405 actions.push(Action::RemoveDevice {
1408 device: name.clone(),
1409 props: have.clone(),
1410 });
1411 deferred_ports.push(name.clone());
1412 } else if !device_matches(want, have) {
1413 actions.push(Action::ReplaceDevice {
1414 device: name.clone(),
1415 replaces: name.clone(),
1416 from: have.clone(),
1417 to: want.props.clone(),
1418 });
1419 new_devices.push(name.clone());
1420 }
1421 continue;
1422 }
1423 let same_path = |p: &Props| {
1428 want.props.get("type").map(String::as_str) == Some("disk")
1429 && p.get("type").map(String::as_str) == Some("disk")
1430 && normalize(p).get("path") == normalize(&want.props).get("path")
1431 };
1432 if let Some((other, have)) = actual.devices.iter().find(|(n, p)| {
1433 *n != "root" && !desired.devices.contains_key(*n) && device_matches(want, p)
1434 }) {
1435 claimed.insert(other.clone());
1436 actions.push(Action::Note {
1437 message: format!("device {name} already present as {other}; left as is"),
1438 });
1439 let _ = have;
1440 continue;
1441 }
1442 if let Some((other, have)) = actual
1443 .devices
1444 .iter()
1445 .find(|(n, p)| *n != "root" && !desired.devices.contains_key(*n) && same_path(p))
1446 {
1447 claimed.insert(other.clone());
1448 actions.push(Action::ReplaceDevice {
1449 device: name.clone(),
1450 replaces: other.clone(),
1451 from: have.clone(),
1452 to: want.props.clone(),
1453 });
1454 new_devices.push(name.clone());
1455 continue;
1456 }
1457 if want.search.is_some() {
1458 deferred_ports.push(name.clone());
1459 } else {
1460 actions.push(Action::AddDevice {
1461 device: name.clone(),
1462 props: want.props.clone(),
1463 });
1464 new_devices.push(name.clone());
1465 }
1466 }
1467
1468 if opts.prune_devices {
1469 for (name, props) in &actual.devices {
1470 if name != "root" && !desired.devices.contains_key(name) && !claimed.contains(name) {
1471 actions.push(Action::RemoveDevice {
1472 device: name.clone(),
1473 props: props.clone(),
1474 });
1475 }
1476 }
1477 }
1478
1479 if !actual.running() {
1480 actions.push(Action::StartInstance);
1481 }
1482 for name in deferred_ports {
1483 let d = &desired.devices[&name];
1484 actions.push(Action::AddPort {
1485 device: name.clone(),
1486 props: d.props.clone(),
1487 search: d.search.unwrap_or(0),
1488 });
1489 }
1490 for o in &desired.owners {
1491 if new_devices.contains(&o.device) {
1492 actions.push(Action::FixOwner {
1493 path: o.path.clone(),
1494 owner: o.owner.clone(),
1495 });
1496 }
1497 }
1498
1499 Ok(SandboxPlan {
1500 name: desired.name.clone(),
1501 status: Some(actual.status.clone()),
1502 actions,
1503 })
1504}
1505
1506fn push_searched_ports(desired: &Desired, actions: &mut Vec<Action>) {
1507 for (name, d) in &desired.devices {
1508 if let Some(n) = d.search {
1509 actions.push(Action::AddPort {
1510 device: name.clone(),
1511 props: d.props.clone(),
1512 search: n,
1513 });
1514 }
1515 }
1516}
1517
1518#[cfg(test)]
1519mod tests;