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 shared_root: Option<String>,
45 pub org: Option<crate::org::OrgId>,
48 pub registry: Option<String>,
50 pub project: String,
52}
53
54impl HostFacts {
55 pub fn detect_path_map() -> Option<(String, String)> {
64 if let Ok(m) = std::env::var("ISB_HOST_PATH_MAP") {
65 if let Some((a, b)) = m.split_once('=') {
66 if !a.is_empty() && !b.is_empty() {
67 return Some((
68 a.trim_end_matches('/').into(),
69 b.trim_end_matches('/').into(),
70 ));
71 }
72 }
73 return None;
74 }
75 let gh = std::fs::read_to_string("/etc/workspace/guest-home").ok()?;
76 let gh = gh.trim().trim_end_matches('/');
77 let home = std::env::var("HOME").ok()?;
78 let home = home.trim_end_matches('/');
79 if gh.is_empty() || home.is_empty() {
80 return None;
81 }
82 Some((home.to_string(), gh.to_string()))
83 }
84
85 pub fn translate(&self, path: &str) -> String {
86 if let Some((from, to)) = &self.path_map {
87 if let Some(rest) = path.strip_prefix(from.as_str()) {
88 if rest.is_empty() || rest.starts_with('/') {
89 return format!("{to}{rest}");
90 }
91 }
92 }
93 path.to_string()
94 }
95
96 pub fn pick_pool(&self, requested: Option<&str>) -> Result<String> {
98 match requested {
99 Some(p) if p != "auto" && !p.is_empty() => {
100 if self.pools.is_empty() || self.pools.iter().any(|x| x == p) {
101 Ok(p.to_string())
102 } else {
103 Err(Error::invalid(format!(
104 "storage pool {p:?} does not exist (have: {})",
105 self.pools.join(", ")
106 )))
107 }
108 }
109 _ => ["incus-zfs", "default"]
110 .iter()
111 .find(|c| self.pools.iter().any(|p| p == *c))
112 .map(|s| s.to_string())
113 .or_else(|| self.pools.first().cloned())
114 .ok_or_else(|| Error::invalid("no storage pools exist")),
115 }
116 }
117}
118
119#[derive(Debug, Clone, PartialEq, Serialize)]
121pub struct EnsureVolume {
122 pub pool: String,
123 pub name: String,
124 pub config: Props,
125 pub external: bool,
126}
127
128#[derive(Debug, Clone, PartialEq, Serialize)]
130pub struct OwnerFixup {
131 pub device: String,
132 pub path: String,
133 pub owner: String,
134}
135
136#[derive(Debug, Clone, PartialEq, Serialize)]
138pub struct DesiredDevice {
139 pub props: Props,
140 #[serde(skip_serializing_if = "Option::is_none")]
142 pub search: Option<u16>,
143}
144
145#[derive(Debug, Clone, PartialEq, Serialize)]
147pub struct ImageSource {
148 pub spec: String,
150 pub server: Option<String>,
152 pub protocol: Option<String>,
153 pub alias: String,
154 #[serde(skip_serializing_if = "std::ops::Not::not")]
157 pub local_registry: bool,
158}
159
160fn is_loopback_host(hostport: &str) -> bool {
163 let host = if let Some(rest) = hostport.strip_prefix('[') {
164 rest.split(']').next().unwrap_or(rest)
165 } else {
166 hostport
167 .rsplit_once(':')
168 .map(|(h, _)| h)
169 .unwrap_or(hostport)
170 };
171 let host = host.to_ascii_lowercase();
172 host == "localhost"
173 || host.ends_with(".localhost")
174 || host == "0.0.0.0"
175 || host
176 .parse::<std::net::IpAddr>()
177 .is_ok_and(|ip| ip.is_loopback() || ip.is_unspecified())
178}
179
180const OCI_REGISTRIES: &[(&str, &str)] = &[
182 ("docker", "https://docker.io"),
183 ("ghcr", "https://ghcr.io"),
184 ("quay", "https://quay.io"),
185];
186
187impl ImageSource {
188 pub fn parse(s: &str) -> Result<Self> {
189 if s.is_empty() {
190 return Err(Error::invalid("image is required"));
191 }
192 if let Some((remote, alias)) = s.split_once(':') {
193 if let Some((_, server)) = OCI_REGISTRIES.iter().find(|(k, _)| *k == remote) {
194 return Ok(ImageSource {
195 spec: s.into(),
196 server: Some(server.to_string()),
197 protocol: Some("oci".into()),
198 alias: oci_reference(alias, remote == "docker")?,
199 local_registry: false,
200 });
201 }
202 if remote == "registry" {
203 let r = crate::registry::ImageRef::parse(alias)?;
205 return Ok(ImageSource {
206 spec: s.into(),
207 server: None,
208 protocol: Some("oci".into()),
209 alias: r.render(),
210 local_registry: true,
211 });
212 }
213 if remote == "oci" {
214 let (host, path) = alias.split_once('/').ok_or_else(|| {
216 Error::invalid(format!("{s:?}: an oci: image is oci:REGISTRY/PATH[:TAG]"))
217 })?;
218 if is_loopback_host(host) {
221 return Err(Error::invalid(format!(
222 "{s:?}: a loopback registry is the local one; name its images as registry:APP:TAG"
223 )));
224 }
225 return Ok(ImageSource {
226 spec: s.into(),
227 server: Some(format!("https://{host}")),
228 protocol: Some("oci".into()),
229 alias: oci_reference(path, false)?,
230 local_registry: false,
231 });
232 }
233 let (server, protocol) = match remote {
234 "images" => ("https://images.linuxcontainers.org", "simplestreams"),
235 "ubuntu" => ("https://cloud-images.ubuntu.com/releases", "simplestreams"),
236 "ubuntu-daily" => ("https://cloud-images.ubuntu.com/daily", "simplestreams"),
237 "ubuntu-minimal" => (
238 "https://cloud-images.ubuntu.com/minimal/releases",
239 "simplestreams",
240 ),
241 other => {
242 return Err(Error::invalid(format!(
243 "unknown image remote {other:?} in {s:?} (known: images, ubuntu, ubuntu-daily, ubuntu-minimal, and OCI registries docker, ghcr, quay, oci:REGISTRY/...; local images need no prefix)"
244 )));
245 }
246 };
247 return Ok(ImageSource {
248 spec: s.into(),
249 server: Some(server.into()),
250 protocol: Some(protocol.into()),
251 alias: alias.into(),
252 local_registry: false,
253 });
254 }
255 Ok(ImageSource {
256 spec: s.into(),
257 server: None,
258 protocol: None,
259 alias: s.into(),
260 local_registry: false,
261 })
262 }
263
264 pub fn bind(mut self, org: Option<&crate::org::OrgId>, addr: Option<&str>) -> Result<Self> {
267 if !self.local_registry {
268 return Ok(self);
269 }
270 let org = org.ok_or_else(|| {
271 Error::invalid(format!(
272 "{:?}: registry: images belong to an org; this project is not one",
273 self.spec
274 ))
275 })?;
276 let addr = addr.ok_or_else(|| {
277 Error::invalid(format!(
278 "{:?}: no local registry on this host (isb registry setup)",
279 self.spec
280 ))
281 })?;
282 let r = crate::registry::ImageRef::parse(&self.alias)?;
283 self.alias = r.pull_alias(org);
284 self.server = Some(format!("https://{addr}"));
285 self.local_registry = false;
286 Ok(self)
287 }
288
289 pub fn is_oci(&self) -> bool {
292 self.protocol.as_deref() == Some("oci")
293 }
294
295 pub fn to_api(&self, local_fingerprint: Option<&str>) -> Value {
298 match (&self.server, local_fingerprint) {
299 (Some(server), _) => json!({
300 "type": "image", "mode": "pull", "server": server,
301 "protocol": self.protocol, "alias": self.alias,
302 }),
303 (None, Some(fp)) => json!({"type": "image", "fingerprint": fp}),
304 (None, None) => json!({"type": "image", "alias": self.alias}),
305 }
306 }
307}
308
309fn oci_reference(r: &str, docker_hub: bool) -> Result<String> {
311 if r.is_empty() || r.contains(char::is_whitespace) {
312 return Err(Error::invalid(format!("invalid OCI image reference {r:?}")));
313 }
314 let mut r = r.to_string();
315 if docker_hub && !r.contains('/') {
316 r = format!("library/{r}");
317 }
318 let last = r.rsplit('/').next().unwrap_or(&r);
319 if !last.contains(':') && !last.contains('@') {
320 r.push_str(":latest");
321 }
322 Ok(r)
323}
324
325pub fn oci_command_line(argv: &[String]) -> std::result::Result<String, String> {
329 argv.iter()
330 .map(|a| {
331 if !a.is_empty() && !a.contains(|c: char| c.is_whitespace() || c == '"' || c == '\'') {
332 Ok(a.clone())
333 } else if !a.contains('"') {
334 Ok(format!("\"{a}\""))
335 } else if !a.contains('\'') {
336 Ok(format!("'{a}'"))
337 } else {
338 Err(format!(
339 "argument {a:?} has both ' and \" in it, which an OCI command line cannot carry; use a script"
340 ))
341 }
342 })
343 .collect::<std::result::Result<Vec<_>, _>>()
344 .map(|v| v.join(" "))
345}
346
347#[derive(Debug, Clone, Serialize)]
349pub struct Desired {
350 pub name: String,
351 pub instance_type: InstanceType,
352 pub image: ImageSource,
353 pub pool: String,
354 pub profiles: Vec<String>,
355 pub config: Props,
356 pub devices: BTreeMap<String, DesiredDevice>,
358 pub volumes: Vec<EnsureVolume>,
359 pub owners: Vec<OwnerFixup>,
360 pub ready: Vec<ReadyCheck>,
361 #[serde(skip)]
362 pub ready_timeout: Duration,
363 pub exec: ExecDefaults,
364 #[serde(skip)]
367 pub idmap_mode: Option<crate::spec::IdmapMode>,
368 #[serde(skip)]
371 pub sensitive: BTreeSet<String>,
372 #[serde(skip)]
374 pub egress: Option<crate::egress::Plumbing>,
375}
376
377pub type VolumeDefs = BTreeMap<String, crate::spec::NamedVolumeSpec>;
380
381pub 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
398pub 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
429pub 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
439pub 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
519pub 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
535pub 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 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 "%" | "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#[expect(
567 clippy::too_many_lines,
568 clippy::cognitive_complexity,
569 reason = "predates the lint ratchet; split it when next changed"
570)]
571pub fn resolve(
572 spec: &SandboxSpec,
573 defs: &VolumeDefs,
574 host: &HostFacts,
575 base: &Path,
576) -> Result<Desired> {
577 let name = spec
578 .name
579 .clone()
580 .ok_or_else(|| Error::invalid("sandbox name is required"))?;
581 validate_instance_name(&name)?;
582 let image = ImageSource::parse(&spec.image)
583 .and_then(|i| i.bind(host.org.as_ref(), host.registry.as_deref()))
584 .map_err(|e| Error::invalid(format!("{name}: {e}")))?;
585 let pool = host.pick_pool(spec.storage.as_deref())?;
586 let vm = spec.instance_type == InstanceType::VirtualMachine;
587 let oci = image.is_oci();
588 if oci && vm {
589 return Err(Error::invalid(format!(
590 "{name}: OCI images run as containers, not VMs"
591 )));
592 }
593 if spec.entrypoint.is_some() && !oci {
594 return Err(Error::invalid(format!(
595 "{name}: entrypoint is for OCI images; use command"
596 )));
597 }
598 if vm {
599 if spec.privileged.is_some() {
600 return Err(Error::invalid(format!(
601 "{name}: privileged is container-only"
602 )));
603 }
604 if let Some(i) = &spec.idmap {
605 if !matches!(
606 i,
607 crate::spec::IdmapSpec::Mode(
608 crate::spec::IdmapMode::Auto | crate::spec::IdmapMode::None
609 )
610 ) {
611 return Err(Error::invalid(format!(
612 "{name}: idmap is container-only (VM shares go over virtiofs)"
613 )));
614 }
615 }
616 for p in &spec.ports {
617 if p.bind == PortBind::Guest {
618 return Err(Error::invalid(format!(
619 "{name}: incus VMs only support bind: host proxies (in NAT mode)"
620 )));
621 }
622 }
623 }
624
625 let mut config = Props::new();
626 match (&spec.cpus, &spec.cpuset) {
627 (Some(_), Some(_)) => {
628 return Err(Error::invalid(format!(
629 "{name}: set cpus (a count) or cpuset (which CPUs), not both"
630 )));
631 }
632 (Some(c), None) => {
633 if !c.trim().parse::<u32>().is_ok_and(|n| n > 0) {
634 return Err(Error::invalid(format!(
635 "{name}: cpus is a whole number of CPUs, got {c:?} (pin CPUs with cpuset: \"0-3\")"
636 )));
637 }
638 config.insert("limits.cpu".into(), c.trim().to_string());
639 }
640 (None, Some(set)) => {
641 config.insert("limits.cpu".into(), set.clone());
642 }
643 (None, None) => {}
644 }
645 if let Some(m) = &spec.memory {
646 let m = memory_limit(m).map_err(|e| Error::invalid(format!("{name}: mem_limit: {e}")))?;
647 config.insert("limits.memory".into(), m);
648 }
649 if let Some(p) = spec.privileged {
650 config.insert("security.privileged".into(), p.to_string());
651 }
652 let mut idmap_mode = None;
653 if let Some(i) = spec.idmap.as_ref().filter(|_| !vm) {
654 idmap_mode = match i {
655 crate::spec::IdmapSpec::Mode(m) => Some(*m),
656 crate::spec::IdmapSpec::Map(m) => Some(m.mode),
657 crate::spec::IdmapSpec::Raw(_) => None,
658 };
659 if let Some(v) = idmap::resolve(i, &host.subids) {
660 config.insert("raw.idmap".into(), v);
661 }
662 }
663 for (k, v) in &spec.labels {
664 if k.is_empty() || k.contains(char::is_whitespace) {
665 return Err(Error::invalid(format!("{name}: invalid label key {k:?}")));
666 }
667 config.insert(format!("user.{k}"), v.clone());
668 }
669 for (k, v) in &spec.env {
670 config.insert(format!("environment.{k}"), v.clone());
671 }
672 if let Some(r) = spec.restart {
673 if matches!(r, RestartMode::Always | RestartMode::OnFailure) {
676 config.insert("boot.autostart".into(), "true".into());
677 }
678 if r.is_long_running() {
679 config.insert("boot.autorestart".into(), "true".into());
680 }
681 }
682 if oci {
683 let mut line: Vec<String> = spec.entrypoint.clone().unwrap_or_default();
684 line.extend(spec.command.clone().unwrap_or_default());
685 if !line.is_empty() {
686 let l = oci_command_line(&line).map_err(|e| Error::invalid(format!("{name}: {e}")))?;
687 config.insert("oci.entrypoint".into(), l);
688 }
689 if let Some(w) = &spec.working_dir {
690 config.insert("oci.cwd".into(), w.clone());
691 }
692 if let Some(u) = &spec.user {
693 let (uid, gid) = u.split_once(':').unwrap_or((u, u));
694 if uid.parse::<u32>().is_err() || gid.parse::<u32>().is_err() {
695 return Err(Error::invalid(format!(
696 "{name}: an OCI image's user must be numeric (uid or uid:gid), got {u:?}"
697 )));
698 }
699 config.insert("oci.uid".into(), uid.into());
700 config.insert("oci.gid".into(), gid.into());
701 }
702 }
703 for (k, v) in &spec.raw_config {
704 config.insert(k.clone(), v.clone());
705 }
706 crate::org::nesting::check_config(&name, host.org.as_ref(), &config, spec.workspace_nesting)?;
707
708 let mut devices: BTreeMap<String, DesiredDevice> = BTreeMap::new();
709 let mut add_dev = |dname: String, dev: DesiredDevice| -> Result<()> {
710 if devices.insert(dname.clone(), dev).is_some() {
711 return Err(Error::invalid(format!(
712 "{name}: device name {dname:?} is used twice"
713 )));
714 }
715 Ok(())
716 };
717 add_dev(
718 "root".into(),
719 DesiredDevice {
720 props: Props::from([
721 ("type".into(), "disk".into()),
722 ("path".into(), "/".into()),
723 ("pool".into(), pool.clone()),
724 ]),
725 search: None,
726 },
727 )?;
728
729 let mut volumes: Vec<EnsureVolume> = Vec::new();
730 let mut owners = Vec::new();
731 let mut guest_paths = BTreeSet::new();
732 for v in &spec.volumes {
733 let guest = &v.target;
734 if !guest.starts_with('/') {
735 return Err(Error::invalid(format!(
736 "{name}: mount path {guest:?} must be absolute"
737 )));
738 }
739 let guest_norm = guest.trim_end_matches('/').to_string();
740 let guest_norm = if guest_norm.is_empty() {
741 "/".to_string()
742 } else {
743 guest_norm
744 };
745 if !guest_paths.insert(guest_norm.clone()) {
746 return Err(Error::invalid(format!("{name}: {guest} is mounted twice")));
747 }
748 let dname = v
749 .device
750 .clone()
751 .unwrap_or_else(|| device_name_for_path(&guest_norm));
752 let mut props = Props::from([
753 ("type".into(), "disk".into()),
754 ("path".into(), guest_norm.clone()),
755 ]);
756 match v.mount_type {
757 MountType::Bind => {
758 if v.owner.is_some() {
759 return Err(Error::invalid(format!(
760 "{name}: {guest}: owner is only for named volumes (isb never chowns host paths)"
761 )));
762 }
763 if v.pool.is_some() || v.external || v.volume.nocopy {
764 return Err(Error::invalid(format!(
765 "{name}: {guest}: pool, external and nocopy are only for named volumes"
766 )));
767 }
768 let src = resolve_host_path(&v.source, base)?;
769 if let Some(root) = &host.shared_root {
770 let r = root.trim_end_matches('/');
771 if src != r && !src.starts_with(&format!("{r}/")) {
772 return Err(Error::invalid(format!(
773 "{name}: {guest}: bind source {src} is outside {r}, the only \
774 directory shared with the isb machine"
775 )));
776 }
777 }
778 props.insert("source".into(), host.translate(&src));
779 }
780 MountType::Volume => {
781 let def = defs.get(&v.source);
782 let n = &def
785 .and_then(|d| d.name.clone())
786 .unwrap_or_else(|| v.source.clone());
787 let vpool = match v.pool.as_deref().or(def.and_then(|d| d.pool.as_deref())) {
788 Some(p) if p != "auto" => host.pick_pool(Some(p))?,
789 _ => pool.clone(),
790 };
791 props.insert("pool".into(), vpool.clone());
792 props.insert("source".into(), n.clone());
793 if !vm && host.initial_copy && !v.volume.nocopy {
797 props.insert("initial.copy".into(), "true".into());
798 }
799 let ev = EnsureVolume {
800 pool: vpool,
801 name: n.clone(),
802 config: def.map(|d| d.config.clone()).unwrap_or_default(),
803 external: v.external || def.is_some_and(|d| d.external),
804 };
805 if !volumes.contains(&ev) {
806 volumes.push(ev);
807 }
808 if let Some(o) = &v.owner {
809 owners.push(OwnerFixup {
810 device: dname.clone(),
811 path: guest_norm.clone(),
812 owner: o.clone(),
813 });
814 }
815 }
816 }
817 if v.read_only {
818 props.insert("readonly".into(), "true".into());
819 }
820 for k in v.options.keys() {
821 if matches!(k.as_str(), "type" | "path" | "source" | "pool" | "readonly") {
822 return Err(Error::invalid(format!(
823 "{name}: {guest}: options.{k} would override a core property; use the field instead"
824 )));
825 }
826 }
827 for (k, val) in &v.options {
828 props.insert(k.clone(), val.clone());
829 }
830 add_dev(
831 dname,
832 DesiredDevice {
833 props,
834 search: None,
835 },
836 )?;
837 }
838
839 for p in &spec.ports {
840 let connect_host = if vm { "0.0.0.0" } else { "127.0.0.1" };
844 let listen = normalize_addr(&p.listen, "127.0.0.1")
845 .map_err(|e| Error::invalid(format!("{name}: port listen: {e}")))?;
846 let connect = normalize_addr(&p.connect, connect_host)
847 .map_err(|e| Error::invalid(format!("{name}: port connect: {e}")))?;
848 if p.search.is_some() {
849 if split_addr(&connect).is_none_or(|(_, h, _)| h != connect_host) {
850 return Err(Error::invalid(format!(
851 "{name}: a published port range connects to the guest's default address ({connect_host}), not {connect}"
852 )));
853 }
854 if p.bind != PortBind::Host {
855 return Err(Error::invalid(format!(
856 "{name}: port search only applies to bind: host"
857 )));
858 }
859 if split_addr(&listen).is_none() {
860 return Err(Error::invalid(format!(
861 "{name}: port search needs a single tcp or udp listen port"
862 )));
863 }
864 }
865 let dname = p
866 .name
867 .clone()
868 .unwrap_or_else(|| default_port_name(p.bind, &listen));
869 let mut props = Props::from([
870 ("type".into(), "proxy".into()),
871 ("bind".into(), p.bind.as_str().into()),
872 ("listen".into(), listen),
873 ("connect".into(), connect),
874 ]);
875 if vm {
876 props.insert("nat".into(), "true".into());
878 }
879 for (k, val) in &p.options {
880 if matches!(k.as_str(), "type" | "bind" | "listen" | "connect") {
881 return Err(Error::invalid(format!(
882 "{name}: port options.{k} would override a core property; use the field instead"
883 )));
884 }
885 props.insert(k.clone(), val.clone());
886 }
887 add_dev(
888 dname,
889 DesiredDevice {
890 props,
891 search: p.search.filter(|n| *n > 0),
892 },
893 )?;
894 }
895
896 let egress = match &spec.egress {
897 Some(e) => {
898 let c = crate::egress::contribute(e, &host.project, &name)?;
899 add_dev(
900 "eth0".into(),
901 DesiredDevice {
902 props: c.nic,
903 search: None,
904 },
905 )?;
906 config.extend(c.config);
907 Some(c.plumbing)
908 }
909 None => None,
910 };
911 let mut root_extra = Props::new();
912 for (dname, props) in &spec.raw_devices {
913 if dname == "root" {
914 root_extra.extend(props.clone());
916 continue;
917 }
918 if !props.contains_key("type") {
919 return Err(Error::invalid(format!(
920 "{name}: raw device {dname:?} needs a type"
921 )));
922 }
923 add_dev(
924 dname.clone(),
925 DesiredDevice {
926 props: props.clone(),
927 search: None,
928 },
929 )?;
930 }
931
932 if let Some(root) = devices.get_mut("root") {
933 root.props.extend(root_extra);
934 }
935
936 let ready_timeout = match &spec.ready_timeout {
937 Some(s) => parse_duration(s).map_err(|e| Error::invalid(format!("{name}: {e}")))?,
938 None if vm => Duration::from_secs(300),
942 None => Duration::from_secs(60),
943 };
944
945 Ok(Desired {
946 name,
947 instance_type: spec.instance_type,
948 image,
949 pool,
950 profiles: spec
951 .profiles
952 .clone()
953 .unwrap_or_else(|| vec!["default".into()]),
954 config,
955 devices,
956 volumes,
957 owners,
958 ready: spec.ready.clone().unwrap_or_else(|| {
959 if vm {
960 vec![ReadyCheck::Running, ReadyCheck::Agent]
961 } else {
962 vec![ReadyCheck::Running]
963 }
964 }),
965 ready_timeout,
966 exec: spec.exec_defaults(),
967 idmap_mode,
968 sensitive: spec
969 .env
970 .secrets
971 .keys()
972 .map(|k| format!("environment.{k}"))
973 .collect(),
974 egress,
975 })
976}
977
978#[derive(Debug, Clone, Default, PartialEq)]
980pub struct Actual {
981 pub status: String,
982 pub config: Props,
983 pub devices: BTreeMap<String, Props>,
985 pub profiles: Vec<String>,
986 pub instance_type: String,
987}
988
989impl Actual {
990 pub fn from_api(v: &Value) -> Actual {
991 let strmap = |v: Option<&Value>| -> Props {
992 v.and_then(Value::as_object)
993 .map(|m| {
994 m.iter()
995 .map(|(k, v)| {
996 let s = match v {
997 Value::String(s) => s.clone(),
998 other => other.to_string(),
999 };
1000 (k.clone(), s)
1001 })
1002 .collect()
1003 })
1004 .unwrap_or_default()
1005 };
1006 let devices = v
1007 .get("devices")
1008 .and_then(Value::as_object)
1009 .map(|m| {
1010 m.iter()
1011 .map(|(k, d)| (k.clone(), strmap(Some(d))))
1012 .collect()
1013 })
1014 .unwrap_or_default();
1015 Actual {
1016 status: v
1017 .get("status")
1018 .and_then(Value::as_str)
1019 .unwrap_or("")
1020 .to_string(),
1021 config: strmap(v.get("config")),
1022 devices,
1023 profiles: v
1024 .get("profiles")
1025 .and_then(Value::as_array)
1026 .map(|a| {
1027 a.iter()
1028 .filter_map(|x| x.as_str().map(String::from))
1029 .collect()
1030 })
1031 .unwrap_or_default(),
1032 instance_type: v
1033 .get("type")
1034 .and_then(Value::as_str)
1035 .unwrap_or("")
1036 .to_string(),
1037 }
1038 }
1039
1040 pub fn running(&self) -> bool {
1041 self.status.eq_ignore_ascii_case("running")
1042 }
1043}
1044
1045#[derive(Debug, Clone, PartialEq, Serialize)]
1047#[serde(tag = "action", rename_all = "snake_case")]
1048pub enum Action {
1049 CreateVolume {
1050 pool: String,
1051 volume: String,
1052 config: Props,
1053 },
1054 CreateInstance {
1055 image: String,
1056 pool: String,
1057 config: Props,
1058 devices: BTreeMap<String, Props>,
1059 profiles: Vec<String>,
1060 },
1061 SetConfig {
1062 key: String,
1063 #[serde(skip_serializing_if = "Option::is_none")]
1064 from: Option<String>,
1065 to: String,
1066 restart: bool,
1068 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1071 secret: bool,
1072 },
1073 AddDevice {
1074 device: String,
1075 props: Props,
1076 },
1077 ReplaceDevice {
1079 device: String,
1080 replaces: String,
1082 from: Props,
1083 to: Props,
1084 },
1085 RemoveDevice {
1086 device: String,
1087 props: Props,
1088 },
1089 StartInstance,
1090 AddPort {
1092 device: String,
1093 props: Props,
1094 search: u16,
1095 },
1096 FixOwner {
1097 path: String,
1098 owner: String,
1099 },
1100 Note {
1102 message: String,
1103 },
1104}
1105
1106impl Action {
1107 pub fn is_change(&self) -> bool {
1109 !matches!(self, Action::Note { .. })
1110 }
1111}
1112
1113impl std::fmt::Display for Action {
1114 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1115 let props = |p: &Props| {
1116 p.iter()
1117 .filter(|(k, _)| k.as_str() != "type")
1118 .map(|(k, v)| format!("{k}={v}"))
1119 .collect::<Vec<_>>()
1120 .join(" ")
1121 };
1122 match self {
1123 Action::CreateVolume { pool, volume, .. } => {
1124 write!(f, "+ volume {volume} (pool {pool})")
1125 }
1126 Action::CreateInstance {
1127 image,
1128 pool,
1129 devices,
1130 ..
1131 } => write!(
1132 f,
1133 "+ create from {image} on pool {pool} with {} device(s)",
1134 devices.len()
1135 ),
1136 Action::SetConfig {
1137 key,
1138 from,
1139 to,
1140 restart,
1141 ..
1142 } => write!(
1143 f,
1144 "~ config {key}: {} -> {to}{}",
1145 from.as_deref().unwrap_or("(unset)"),
1146 if *restart {
1147 " (takes effect on restart)"
1148 } else {
1149 ""
1150 }
1151 ),
1152 Action::AddDevice { device, props: p } => write!(f, "+ device {device}: {}", props(p)),
1153 Action::ReplaceDevice {
1154 device,
1155 replaces,
1156 from,
1157 to,
1158 } => {
1159 if device == replaces {
1160 write!(f, "~ device {device}: {} -> {}", props(from), props(to))
1161 } else {
1162 write!(
1163 f,
1164 "~ device {replaces} -> {device}: {} -> {}",
1165 props(from),
1166 props(to)
1167 )
1168 }
1169 }
1170 Action::RemoveDevice { device, .. } => write!(f, "- device {device}"),
1171 Action::StartInstance => write!(f, "> start"),
1172 Action::AddPort {
1173 device,
1174 props: p,
1175 search,
1176 } => write!(f, "+ port {device}: {} (search {search})", props(p)),
1177 Action::FixOwner { path, owner } => write!(f, "~ chown {owner} {path}"),
1178 Action::Note { message } => write!(f, " note: {message}"),
1179 }
1180 }
1181}
1182
1183#[derive(Debug, Clone, Serialize)]
1185pub struct SandboxPlan {
1186 pub name: String,
1187 pub status: Option<String>,
1189 pub actions: Vec<Action>,
1190}
1191
1192impl SandboxPlan {
1193 pub fn is_noop(&self) -> bool {
1195 !self.actions.iter().any(Action::is_change)
1196 }
1197}
1198
1199#[derive(Debug, Clone, Copy, Default)]
1201pub struct DiffOptions {
1202 pub prune_devices: bool,
1204}
1205
1206const FALSE_IS_ABSENT: &[&str] = &["readonly", "shift", "nat"];
1208
1209fn normalize(p: &Props) -> Props {
1210 let mut out = p.clone();
1211 for k in FALSE_IS_ABSENT {
1212 if out.get(*k).map(String::as_str) == Some("false") {
1213 out.remove(*k);
1214 }
1215 }
1216 if out.get("type").map(String::as_str) == Some("disk") {
1217 for k in ["source", "path"] {
1218 if let Some(v) = out.get_mut(k) {
1219 if v.len() > 1 {
1220 *v = v.trim_end_matches('/').to_string();
1221 }
1222 }
1223 }
1224 }
1225 out
1226}
1227
1228pub fn device_matches(desired: &DesiredDevice, actual: &Props) -> bool {
1230 let mut d = normalize(&desired.props);
1231 let mut a = normalize(actual);
1232 if d.get("type").map(String::as_str) == Some("disk") {
1235 d.remove("initial.copy");
1236 a.remove("initial.copy");
1237 }
1238 if d == a {
1239 return true;
1240 }
1241 let Some(n) = desired.search else {
1242 return false;
1243 };
1244 let (Some(dl), Some(al)) = (d.get("listen"), a.get("listen")) else {
1246 return false;
1247 };
1248 let (Some((dp, dh, dport)), Some((ap, ah, aport))) = (split_addr(dl), split_addr(al)) else {
1249 return false;
1250 };
1251 if dp != ap || dh != ah || aport < dport || aport as u32 > dport as u32 + n as u32 {
1252 return false;
1253 }
1254 let strip = |m: &Props| {
1255 let mut m = m.clone();
1256 m.remove("listen");
1257 m
1258 };
1259 strip(&d) == strip(&a)
1260}
1261
1262pub const REDACTED: &str = "(secret)";
1264
1265fn restart_needed(key: &str) -> bool {
1266 key.starts_with("raw.") || key.starts_with("security.") || key.starts_with("oci.")
1267}
1268
1269#[expect(
1272 clippy::too_many_lines,
1273 clippy::cognitive_complexity,
1274 reason = "predates the lint ratchet; split it when next changed"
1275)]
1276pub fn diff(
1277 desired: &Desired,
1278 actual: Option<&Actual>,
1279 volumes_missing: &[(String, String)],
1280 opts: DiffOptions,
1281) -> Result<SandboxPlan> {
1282 let mut actions = Vec::new();
1283 for v in &desired.volumes {
1284 if volumes_missing.contains(&(v.pool.clone(), v.name.clone())) {
1285 if v.external {
1286 return Err(Error::invalid(format!(
1287 "volume {} is external but does not exist in pool {}",
1288 v.name, v.pool
1289 )));
1290 }
1291 actions.push(Action::CreateVolume {
1292 pool: v.pool.clone(),
1293 volume: v.name.clone(),
1294 config: v.config.clone(),
1295 });
1296 }
1297 }
1298
1299 let Some(actual) = actual else {
1300 actions.push(Action::CreateInstance {
1301 image: desired.image.spec.clone(),
1302 pool: desired.pool.clone(),
1303 config: desired
1304 .config
1305 .iter()
1306 .map(|(k, v)| {
1307 let v = if desired.sensitive.contains(k) {
1308 REDACTED.to_string()
1309 } else {
1310 v.clone()
1311 };
1312 (k.clone(), v)
1313 })
1314 .collect(),
1315 devices: desired
1316 .devices
1317 .iter()
1318 .filter(|(_, d)| d.search.is_none())
1319 .map(|(k, d)| (k.clone(), d.props.clone()))
1320 .collect(),
1321 profiles: desired.profiles.clone(),
1322 });
1323 actions.push(Action::StartInstance);
1324 push_searched_ports(desired, &mut actions);
1325 for o in &desired.owners {
1326 actions.push(Action::FixOwner {
1327 path: o.path.clone(),
1328 owner: o.owner.clone(),
1329 });
1330 }
1331 return Ok(SandboxPlan {
1332 name: desired.name.clone(),
1333 status: None,
1334 actions,
1335 });
1336 };
1337
1338 if !actual.instance_type.is_empty() && actual.instance_type != desired.instance_type.as_api() {
1340 actions.push(Action::Note {
1341 message: format!(
1342 "type is {} (spec: {}); fixed at creation",
1343 actual.instance_type,
1344 desired.instance_type.as_api()
1345 ),
1346 });
1347 }
1348 if let Some(root) = actual.devices.get("root") {
1349 if let Some(p) = root.get("pool") {
1350 if *p != desired.pool {
1351 actions.push(Action::Note {
1352 message: format!(
1353 "root disk is on pool {p} (spec: {}); fixed at creation",
1354 desired.pool
1355 ),
1356 });
1357 }
1358 }
1359 }
1360 if actual.profiles != desired.profiles {
1361 actions.push(Action::Note {
1362 message: format!(
1363 "profiles are [{}] (spec: [{}]); fixed at creation",
1364 actual.profiles.join(", "),
1365 desired.profiles.join(", ")
1366 ),
1367 });
1368 }
1369
1370 for (k, v) in &desired.config {
1371 let cur = actual.config.get(k);
1372 if cur != Some(v) {
1373 let secret = desired.sensitive.contains(k);
1374 let hide = |s: &String| if secret { REDACTED.into() } else { s.clone() };
1375 actions.push(Action::SetConfig {
1376 key: k.clone(),
1377 from: cur.map(hide),
1378 to: hide(v),
1379 restart: restart_needed(k),
1380 secret,
1381 });
1382 }
1383 }
1384 if !desired.config.contains_key("raw.idmap") && actual.config.contains_key("raw.idmap") {
1385 let why = match desired.idmap_mode {
1386 Some(crate::spec::IdmapMode::Auto) => Some("not needed on this host"),
1387 Some(crate::spec::IdmapMode::None) => Some("the spec says idmap: none"),
1388 _ => None,
1389 };
1390 if let Some(why) = why {
1391 actions.push(Action::Note {
1392 message: format!(
1393 "raw.idmap is set but {why}; isb never removes config keys, unset it by hand"
1394 ),
1395 });
1396 }
1397 }
1398
1399 let mut new_devices: Vec<String> = Vec::new();
1400 let mut claimed: BTreeSet<String> = BTreeSet::new();
1401 let mut deferred_ports = Vec::new();
1402 for (name, want) in &desired.devices {
1403 if name == "root" {
1404 continue;
1405 }
1406 if let Some(have) = actual.devices.get(name) {
1407 claimed.insert(name.clone());
1408 if !device_matches(want, have) && want.search.is_some() {
1409 actions.push(Action::RemoveDevice {
1412 device: name.clone(),
1413 props: have.clone(),
1414 });
1415 deferred_ports.push(name.clone());
1416 } else if !device_matches(want, have) {
1417 actions.push(Action::ReplaceDevice {
1418 device: name.clone(),
1419 replaces: name.clone(),
1420 from: have.clone(),
1421 to: want.props.clone(),
1422 });
1423 new_devices.push(name.clone());
1424 }
1425 continue;
1426 }
1427 let same_path = |p: &Props| {
1432 want.props.get("type").map(String::as_str) == Some("disk")
1433 && p.get("type").map(String::as_str) == Some("disk")
1434 && normalize(p).get("path") == normalize(&want.props).get("path")
1435 };
1436 if let Some((other, have)) = actual.devices.iter().find(|(n, p)| {
1437 *n != "root" && !desired.devices.contains_key(*n) && device_matches(want, p)
1438 }) {
1439 claimed.insert(other.clone());
1440 actions.push(Action::Note {
1441 message: format!("device {name} already present as {other}; left as is"),
1442 });
1443 let _ = have;
1444 continue;
1445 }
1446 if let Some((other, have)) = actual
1447 .devices
1448 .iter()
1449 .find(|(n, p)| *n != "root" && !desired.devices.contains_key(*n) && same_path(p))
1450 {
1451 claimed.insert(other.clone());
1452 actions.push(Action::ReplaceDevice {
1453 device: name.clone(),
1454 replaces: other.clone(),
1455 from: have.clone(),
1456 to: want.props.clone(),
1457 });
1458 new_devices.push(name.clone());
1459 continue;
1460 }
1461 if want.search.is_some() {
1462 deferred_ports.push(name.clone());
1463 } else {
1464 actions.push(Action::AddDevice {
1465 device: name.clone(),
1466 props: want.props.clone(),
1467 });
1468 new_devices.push(name.clone());
1469 }
1470 }
1471
1472 if opts.prune_devices {
1473 for (name, props) in &actual.devices {
1474 if name != "root" && !desired.devices.contains_key(name) && !claimed.contains(name) {
1475 actions.push(Action::RemoveDevice {
1476 device: name.clone(),
1477 props: props.clone(),
1478 });
1479 }
1480 }
1481 }
1482
1483 if !actual.running() {
1484 actions.push(Action::StartInstance);
1485 }
1486 for name in deferred_ports {
1487 let d = &desired.devices[&name];
1488 actions.push(Action::AddPort {
1489 device: name.clone(),
1490 props: d.props.clone(),
1491 search: d.search.unwrap_or(0),
1492 });
1493 }
1494 for o in &desired.owners {
1495 if new_devices.contains(&o.device) {
1496 actions.push(Action::FixOwner {
1497 path: o.path.clone(),
1498 owner: o.owner.clone(),
1499 });
1500 }
1501 }
1502
1503 Ok(SandboxPlan {
1504 name: desired.name.clone(),
1505 status: Some(actual.status.clone()),
1506 actions,
1507 })
1508}
1509
1510fn push_searched_ports(desired: &Desired, actions: &mut Vec<Action>) {
1511 for (name, d) in &desired.devices {
1512 if let Some(n) = d.search {
1513 actions.push(Action::AddPort {
1514 device: name.clone(),
1515 props: d.props.clone(),
1516 search: n,
1517 });
1518 }
1519 }
1520}
1521
1522#[cfg(test)]
1523mod tests;