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
28mod owner;
29
30pub type Props = BTreeMap<String, String>;
31
32#[derive(Debug, Clone, Default)]
34pub struct HostFacts {
35 pub subids: SubIds,
36 pub pools: Vec<String>,
38 pub path_map: Option<(String, String)>,
42 pub initial_copy: bool,
44 pub initial_owner: bool,
46 pub incus_version: Option<String>,
48 pub invoking_ids: (u32, u32),
50 pub shared_root: Option<String>,
53 pub org: Option<crate::org::OrgId>,
56 pub registry: Option<String>,
58 pub project: String,
60}
61
62impl HostFacts {
63 pub fn detect_path_map() -> Option<(String, String)> {
72 if let Ok(m) = std::env::var("ISB_HOST_PATH_MAP") {
73 if let Some((a, b)) = m.split_once('=') {
74 if !a.is_empty() && !b.is_empty() {
75 return Some((
76 a.trim_end_matches('/').into(),
77 b.trim_end_matches('/').into(),
78 ));
79 }
80 }
81 return None;
82 }
83 let gh = std::fs::read_to_string("/etc/workspace/guest-home").ok()?;
84 let gh = gh.trim().trim_end_matches('/');
85 let home = std::env::var("HOME").ok()?;
86 let home = home.trim_end_matches('/');
87 if gh.is_empty() || home.is_empty() {
88 return None;
89 }
90 Some((home.to_string(), gh.to_string()))
91 }
92
93 pub fn translate(&self, path: &str) -> String {
94 if let Some((from, to)) = &self.path_map {
95 if let Some(rest) = path.strip_prefix(from.as_str()) {
96 if rest.is_empty() || rest.starts_with('/') {
97 return format!("{to}{rest}");
98 }
99 }
100 }
101 path.to_string()
102 }
103
104 pub fn pick_pool(&self, requested: Option<&str>) -> Result<String> {
106 match requested {
107 Some(p) if p != "auto" && !p.is_empty() => {
108 if self.pools.is_empty() || self.pools.iter().any(|x| x == p) {
109 Ok(p.to_string())
110 } else {
111 Err(Error::invalid(format!(
112 "storage pool {p:?} does not exist (have: {})",
113 self.pools.join(", ")
114 )))
115 }
116 }
117 _ => ["incus-zfs", "default"]
118 .iter()
119 .find(|c| self.pools.iter().any(|p| p == *c))
120 .map(|s| s.to_string())
121 .or_else(|| self.pools.first().cloned())
122 .ok_or_else(|| Error::invalid("no storage pools exist")),
123 }
124 }
125}
126
127#[derive(Debug, Clone, PartialEq, Serialize)]
129pub struct EnsureVolume {
130 pub pool: String,
131 pub name: String,
132 pub config: Props,
133 pub external: bool,
134}
135
136#[derive(Debug, Clone, PartialEq, Serialize)]
138pub struct OwnerFixup {
139 pub device: String,
140 pub path: String,
141 #[serde(skip_serializing_if = "Option::is_none")]
142 pub owner: Option<String>,
143 #[serde(skip_serializing_if = "Option::is_none")]
144 pub mode: Option<String>,
145 #[serde(skip_serializing_if = "Option::is_none")]
147 pub new_volume: Option<(String, String)>,
148}
149
150#[derive(Debug, Clone, PartialEq, Serialize)]
152pub struct DesiredDevice {
153 pub props: Props,
154 #[serde(skip_serializing_if = "Option::is_none")]
156 pub search: Option<u16>,
157}
158
159#[derive(Debug, Clone, PartialEq, Serialize)]
161pub struct ImageSource {
162 pub spec: String,
164 pub server: Option<String>,
166 pub protocol: Option<String>,
167 pub alias: String,
168 #[serde(skip_serializing_if = "std::ops::Not::not")]
171 pub local_registry: bool,
172}
173
174fn is_loopback_host(hostport: &str) -> bool {
177 let host = if let Some(rest) = hostport.strip_prefix('[') {
178 rest.split(']').next().unwrap_or(rest)
179 } else {
180 hostport
181 .rsplit_once(':')
182 .map(|(h, _)| h)
183 .unwrap_or(hostport)
184 };
185 let host = host.to_ascii_lowercase();
186 host == "localhost"
187 || host.ends_with(".localhost")
188 || host == "0.0.0.0"
189 || host
190 .parse::<std::net::IpAddr>()
191 .is_ok_and(|ip| ip.is_loopback() || ip.is_unspecified())
192}
193
194const OCI_REGISTRIES: &[(&str, &str)] = &[
196 ("docker", "https://docker.io"),
197 ("ghcr", "https://ghcr.io"),
198 ("quay", "https://quay.io"),
199];
200
201impl ImageSource {
202 pub fn parse(s: &str) -> Result<Self> {
203 if s.is_empty() {
204 return Err(Error::invalid("image is required"));
205 }
206 if let Some((remote, alias)) = s.split_once(':') {
207 if let Some((_, server)) = OCI_REGISTRIES.iter().find(|(k, _)| *k == remote) {
208 return Ok(ImageSource {
209 spec: s.into(),
210 server: Some(server.to_string()),
211 protocol: Some("oci".into()),
212 alias: oci_reference(alias, remote == "docker")?,
213 local_registry: false,
214 });
215 }
216 if remote == "registry" {
217 let r = crate::registry::ImageRef::parse(alias)?;
219 return Ok(ImageSource {
220 spec: s.into(),
221 server: None,
222 protocol: Some("oci".into()),
223 alias: r.render(),
224 local_registry: true,
225 });
226 }
227 if remote == "oci" {
228 let (host, path) = alias.split_once('/').ok_or_else(|| {
230 Error::invalid(format!("{s:?}: an oci: image is oci:REGISTRY/PATH[:TAG]"))
231 })?;
232 if is_loopback_host(host) {
235 return Err(Error::invalid(format!(
236 "{s:?}: a loopback registry is the local one; name its images as registry:APP:TAG"
237 )));
238 }
239 return Ok(ImageSource {
240 spec: s.into(),
241 server: Some(format!("https://{host}")),
242 protocol: Some("oci".into()),
243 alias: oci_reference(path, false)?,
244 local_registry: false,
245 });
246 }
247 let (server, protocol) = match remote {
248 "images" => ("https://images.linuxcontainers.org", "simplestreams"),
249 "ubuntu" => ("https://cloud-images.ubuntu.com/releases", "simplestreams"),
250 "ubuntu-daily" => ("https://cloud-images.ubuntu.com/daily", "simplestreams"),
251 "ubuntu-minimal" => (
252 "https://cloud-images.ubuntu.com/minimal/releases",
253 "simplestreams",
254 ),
255 other => {
256 return Err(Error::invalid(format!(
257 "unknown image remote {other:?} in {s:?} (known: images, ubuntu, ubuntu-daily, ubuntu-minimal, and OCI registries docker, ghcr, quay, oci:REGISTRY/...; local images need no prefix)"
258 )));
259 }
260 };
261 return Ok(ImageSource {
262 spec: s.into(),
263 server: Some(server.into()),
264 protocol: Some(protocol.into()),
265 alias: alias.into(),
266 local_registry: false,
267 });
268 }
269 Ok(ImageSource {
270 spec: s.into(),
271 server: None,
272 protocol: None,
273 alias: s.into(),
274 local_registry: false,
275 })
276 }
277
278 pub fn bind(mut self, org: Option<&crate::org::OrgId>, addr: Option<&str>) -> Result<Self> {
281 if !self.local_registry {
282 return Ok(self);
283 }
284 let org = org.ok_or_else(|| {
285 Error::invalid(format!(
286 "{:?}: registry: images belong to an org; this project is not one",
287 self.spec
288 ))
289 })?;
290 let addr = addr.ok_or_else(|| {
291 Error::invalid(format!(
292 "{:?}: no local registry on this host (isb registry setup)",
293 self.spec
294 ))
295 })?;
296 let r = crate::registry::ImageRef::parse(&self.alias)?;
297 self.alias = r.pull_alias(org);
298 self.server = Some(format!("https://{addr}"));
299 self.local_registry = false;
300 Ok(self)
301 }
302
303 pub fn is_oci(&self) -> bool {
306 self.protocol.as_deref() == Some("oci")
307 }
308
309 pub fn to_api(&self, local_fingerprint: Option<&str>) -> Value {
312 match (&self.server, local_fingerprint) {
313 (Some(server), _) => json!({
314 "type": "image", "mode": "pull", "server": server,
315 "protocol": self.protocol, "alias": self.alias,
316 }),
317 (None, Some(fp)) => json!({"type": "image", "fingerprint": fp}),
318 (None, None) => json!({"type": "image", "alias": self.alias}),
319 }
320 }
321}
322
323fn oci_reference(r: &str, docker_hub: bool) -> Result<String> {
325 if r.is_empty() || r.contains(char::is_whitespace) {
326 return Err(Error::invalid(format!("invalid OCI image reference {r:?}")));
327 }
328 let mut r = r.to_string();
329 if docker_hub && !r.contains('/') {
330 r = format!("library/{r}");
331 }
332 let last = r.rsplit('/').next().unwrap_or(&r);
333 if !last.contains(':') && !last.contains('@') {
334 r.push_str(":latest");
335 }
336 Ok(r)
337}
338
339mod oci;
340pub use oci::{BeforeStart, check_oci_command, oci_command_line};
341
342#[derive(Debug, Clone, Serialize)]
344pub struct Desired {
345 pub name: String,
346 pub instance_type: InstanceType,
347 pub image: ImageSource,
348 pub pool: String,
349 pub profiles: Vec<String>,
350 pub config: Props,
351 pub devices: BTreeMap<String, DesiredDevice>,
353 pub volumes: Vec<EnsureVolume>,
354 pub owners: Vec<OwnerFixup>,
355 pub ready: Vec<ReadyCheck>,
356 #[serde(skip)]
357 pub ready_timeout: Duration,
358 pub exec: ExecDefaults,
359 #[serde(skip)]
362 pub idmap_mode: Option<crate::spec::IdmapMode>,
363 #[serde(skip)]
366 pub sensitive: BTreeSet<String>,
367 #[serde(skip)]
369 pub egress: Option<crate::egress::Plumbing>,
370 #[serde(skip)]
374 pub before_start: Option<BeforeStart>,
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 for p in &spec.ports {
605 if p.bind == PortBind::Guest {
606 return Err(Error::invalid(format!(
607 "{name}: incus VMs only support bind: host proxies (in NAT mode)"
608 )));
609 }
610 }
611 }
612
613 let mut config = Props::new();
614 match (&spec.cpus, &spec.cpuset) {
615 (Some(_), Some(_)) => {
616 return Err(Error::invalid(format!(
617 "{name}: set cpus (a count) or cpuset (which CPUs), not both"
618 )));
619 }
620 (Some(c), None) => {
621 if !c.trim().parse::<u32>().is_ok_and(|n| n > 0) {
622 return Err(Error::invalid(format!(
623 "{name}: cpus is a whole number of CPUs, got {c:?} (pin CPUs with cpuset: \"0-3\")"
624 )));
625 }
626 config.insert("limits.cpu".into(), c.trim().to_string());
627 }
628 (None, Some(set)) => {
629 config.insert("limits.cpu".into(), set.clone());
630 }
631 (None, None) => {}
632 }
633 if let Some(m) = &spec.memory {
634 let m = memory_limit(m).map_err(|e| Error::invalid(format!("{name}: mem_limit: {e}")))?;
635 config.insert("limits.memory".into(), m);
636 }
637 if let Some(p) = spec.privileged {
638 config.insert("security.privileged".into(), p.to_string());
639 }
640 let (idmap_mode, raw_idmap) = if vm {
641 idmap::plan_vm(
642 &name,
643 spec,
644 host.incus_version.as_deref(),
645 host.invoking_ids,
646 )?
647 } else {
648 idmap::plan_container(spec.idmap.as_ref(), &host.subids)
649 };
650 if let Some(v) = raw_idmap {
651 config.insert("raw.idmap".into(), v);
652 }
653 for (k, v) in &spec.labels {
654 if k.is_empty() || k.contains(char::is_whitespace) {
655 return Err(Error::invalid(format!("{name}: invalid label key {k:?}")));
656 }
657 config.insert(format!("user.{k}"), v.clone());
658 }
659 for (k, v) in &spec.env {
660 config.insert(format!("environment.{k}"), v.clone());
661 }
662 for (k, v) in spec.env.file_vars() {
664 config.insert(format!("environment.{k}"), v);
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 at = (&*guest_norm, &*dname, &*vpool, n.as_str());
794 let (config, fixups) = owner::for_mount(&name, spec, v, def, host, at)?;
795 let ev = EnsureVolume {
796 pool: vpool,
797 name: n.clone(),
798 config,
799 external: v.external || def.is_some_and(|d| d.external),
800 };
801 if !volumes
802 .iter()
803 .any(|e| (&e.pool, &e.name) == (&ev.pool, &ev.name))
804 {
805 volumes.push(ev);
806 }
807 owners.extend(fixups);
808 }
809 }
810 if v.read_only {
811 props.insert("readonly".into(), "true".into());
812 }
813 for k in v.options.keys() {
814 if matches!(k.as_str(), "type" | "path" | "source" | "pool" | "readonly") {
815 return Err(Error::invalid(format!(
816 "{name}: {guest}: options.{k} would override a core property; use the field instead"
817 )));
818 }
819 }
820 for (k, val) in &v.options {
821 props.insert(k.clone(), val.clone());
822 }
823 add_dev(
824 dname,
825 DesiredDevice {
826 props,
827 search: None,
828 },
829 )?;
830 }
831
832 for p in &spec.ports {
833 let connect_host = if vm { "0.0.0.0" } else { "127.0.0.1" };
837 let listen = normalize_addr(&p.listen, "127.0.0.1")
838 .map_err(|e| Error::invalid(format!("{name}: port listen: {e}")))?;
839 let connect = normalize_addr(&p.connect, connect_host)
840 .map_err(|e| Error::invalid(format!("{name}: port connect: {e}")))?;
841 if p.search.is_some() {
842 if split_addr(&connect).is_none_or(|(_, h, _)| h != connect_host) {
843 return Err(Error::invalid(format!(
844 "{name}: a published port range connects to the guest's default address ({connect_host}), not {connect}"
845 )));
846 }
847 if p.bind != PortBind::Host {
848 return Err(Error::invalid(format!(
849 "{name}: port search only applies to bind: host"
850 )));
851 }
852 if split_addr(&listen).is_none() {
853 return Err(Error::invalid(format!(
854 "{name}: port search needs a single tcp or udp listen port"
855 )));
856 }
857 }
858 let dname = p
859 .name
860 .clone()
861 .unwrap_or_else(|| default_port_name(p.bind, &listen));
862 let mut props = Props::from([
863 ("type".into(), "proxy".into()),
864 ("bind".into(), p.bind.as_str().into()),
865 ("listen".into(), listen),
866 ("connect".into(), connect),
867 ]);
868 if vm {
869 props.insert("nat".into(), "true".into());
871 }
872 for (k, val) in &p.options {
873 if matches!(k.as_str(), "type" | "bind" | "listen" | "connect") {
874 return Err(Error::invalid(format!(
875 "{name}: port options.{k} would override a core property; use the field instead"
876 )));
877 }
878 props.insert(k.clone(), val.clone());
879 }
880 add_dev(
881 dname,
882 DesiredDevice {
883 props,
884 search: p.search.filter(|n| *n > 0),
885 },
886 )?;
887 }
888
889 let egress = match &spec.egress {
890 Some(e) => {
891 let c = crate::egress::contribute(e, &host.project, &name)?;
892 add_dev(
893 "eth0".into(),
894 DesiredDevice {
895 props: c.nic,
896 search: None,
897 },
898 )?;
899 config.extend(c.config);
900 Some(c.plumbing)
901 }
902 None => None,
903 };
904 let mut root_extra = Props::new();
905 for (dname, props) in &spec.raw_devices {
906 if dname == "root" {
907 root_extra.extend(props.clone());
909 continue;
910 }
911 if !props.contains_key("type") {
912 return Err(Error::invalid(format!(
913 "{name}: raw device {dname:?} needs a type"
914 )));
915 }
916 add_dev(
917 dname.clone(),
918 DesiredDevice {
919 props: props.clone(),
920 search: None,
921 },
922 )?;
923 }
924
925 if let Some(root) = devices.get_mut("root") {
926 root.props.extend(root_extra);
927 }
928 let props = devices.iter().map(|(k, d)| (k, &d.props));
929 crate::org::check_proxies(&name, host.org.as_ref(), props, spec.stack_udp)?;
930
931 let ready_timeout = match &spec.ready_timeout {
932 Some(s) => parse_duration(s).map_err(|e| Error::invalid(format!("{name}: {e}")))?,
933 None if vm => Duration::from_secs(300),
937 None => Duration::from_secs(60),
938 };
939
940 Ok(Desired {
941 name,
942 instance_type: spec.instance_type,
943 image,
944 pool,
945 profiles: spec
946 .profiles
947 .clone()
948 .unwrap_or_else(|| vec!["default".into()]),
949 config,
950 devices,
951 volumes,
952 owners,
953 ready: spec.ready.clone().unwrap_or_else(|| {
954 if vm {
955 vec![ReadyCheck::Running, ReadyCheck::Agent]
956 } else {
957 vec![ReadyCheck::Running]
958 }
959 }),
960 ready_timeout,
961 exec: spec.exec_defaults(),
962 idmap_mode,
963 sensitive: spec
964 .env
965 .secrets
966 .keys()
967 .map(|k| format!("environment.{k}"))
968 .collect(),
969 egress,
970 before_start: None,
971 })
972}
973
974#[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 #[serde(skip_serializing_if = "Option::is_none")]
1095 owner: Option<String>,
1096 #[serde(skip_serializing_if = "Option::is_none")]
1097 mode: Option<String>,
1098 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1101 fresh_only: bool,
1102 },
1103 Note {
1105 message: String,
1106 },
1107}
1108
1109impl Action {
1110 pub fn is_change(&self) -> bool {
1112 !matches!(self, Action::Note { .. })
1113 }
1114}
1115
1116impl std::fmt::Display for Action {
1117 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1118 let props = |p: &Props| {
1119 p.iter()
1120 .filter(|(k, _)| k.as_str() != "type")
1121 .map(|(k, v)| format!("{k}={v}"))
1122 .collect::<Vec<_>>()
1123 .join(" ")
1124 };
1125 match self {
1126 Action::CreateVolume { pool, volume, .. } => {
1127 write!(f, "+ volume {volume} (pool {pool})")
1128 }
1129 Action::CreateInstance {
1130 image,
1131 pool,
1132 devices,
1133 ..
1134 } => write!(
1135 f,
1136 "+ create from {image} on pool {pool} with {} device(s)",
1137 devices.len()
1138 ),
1139 Action::SetConfig {
1140 key,
1141 from,
1142 to,
1143 restart,
1144 ..
1145 } => write!(
1146 f,
1147 "~ config {key}: {} -> {to}{}",
1148 from.as_deref().unwrap_or("(unset)"),
1149 if *restart {
1150 " (takes effect on restart)"
1151 } else {
1152 ""
1153 }
1154 ),
1155 Action::AddDevice { device, props: p } => write!(f, "+ device {device}: {}", props(p)),
1156 Action::ReplaceDevice {
1157 device,
1158 replaces,
1159 from,
1160 to,
1161 } => {
1162 if device == replaces {
1163 write!(f, "~ device {device}: {} -> {}", props(from), props(to))
1164 } else {
1165 write!(
1166 f,
1167 "~ device {replaces} -> {device}: {} -> {}",
1168 props(from),
1169 props(to)
1170 )
1171 }
1172 }
1173 Action::RemoveDevice { device, .. } => write!(f, "- device {device}"),
1174 Action::StartInstance => write!(f, "> start"),
1175 Action::AddPort {
1176 device,
1177 props: p,
1178 search,
1179 } => write!(f, "+ port {device}: {} (search {search})", props(p)),
1180 a @ Action::FixOwner { .. } => owner::describe(f, a),
1181 Action::Note { message } => write!(f, " note: {message}"),
1182 }
1183 }
1184}
1185
1186#[derive(Debug, Clone, Serialize)]
1188pub struct SandboxPlan {
1189 pub name: String,
1190 pub status: Option<String>,
1192 pub actions: Vec<Action>,
1193}
1194
1195impl SandboxPlan {
1196 pub fn is_noop(&self) -> bool {
1198 !self.actions.iter().any(Action::is_change)
1199 }
1200}
1201
1202#[derive(Debug, Clone, Copy, Default)]
1204pub struct DiffOptions {
1205 pub prune_devices: bool,
1207}
1208
1209const FALSE_IS_ABSENT: &[&str] = &["readonly", "shift", "nat"];
1211
1212fn normalize(p: &Props) -> Props {
1213 let mut out = p.clone();
1214 for k in FALSE_IS_ABSENT {
1215 if out.get(*k).map(String::as_str) == Some("false") {
1216 out.remove(*k);
1217 }
1218 }
1219 if out.get("type").map(String::as_str) == Some("disk") {
1220 for k in ["source", "path"] {
1221 if let Some(v) = out.get_mut(k) {
1222 if v.len() > 1 {
1223 *v = v.trim_end_matches('/').to_string();
1224 }
1225 }
1226 }
1227 }
1228 out
1229}
1230
1231pub fn device_matches(desired: &DesiredDevice, actual: &Props) -> bool {
1233 let mut d = normalize(&desired.props);
1234 let mut a = normalize(actual);
1235 if d.get("type").map(String::as_str) == Some("disk") {
1238 d.remove("initial.copy");
1239 a.remove("initial.copy");
1240 }
1241 if d == a {
1242 return true;
1243 }
1244 let Some(n) = desired.search else {
1245 return false;
1246 };
1247 let (Some(dl), Some(al)) = (d.get("listen"), a.get("listen")) else {
1249 return false;
1250 };
1251 let (Some((dp, dh, dport)), Some((ap, ah, aport))) = (split_addr(dl), split_addr(al)) else {
1252 return false;
1253 };
1254 if dp != ap || dh != ah || aport < dport || aport as u32 > dport as u32 + n as u32 {
1255 return false;
1256 }
1257 let strip = |m: &Props| {
1258 let mut m = m.clone();
1259 m.remove("listen");
1260 m
1261 };
1262 strip(&d) == strip(&a)
1263}
1264
1265pub const REDACTED: &str = "(secret)";
1267
1268fn restart_needed(key: &str) -> bool {
1269 key.starts_with("raw.") || key.starts_with("security.") || key.starts_with("oci.")
1270}
1271
1272#[expect(
1275 clippy::too_many_lines,
1276 reason = "predates the lint ratchet; split it when next changed"
1277)]
1278pub fn diff(
1279 desired: &Desired,
1280 actual: Option<&Actual>,
1281 volumes_missing: &[(String, String)],
1282 opts: DiffOptions,
1283) -> Result<SandboxPlan> {
1284 let mut actions = Vec::new();
1285 for v in &desired.volumes {
1286 if volumes_missing.contains(&(v.pool.clone(), v.name.clone())) {
1287 if v.external {
1288 return Err(Error::invalid(format!(
1289 "volume {} is external but does not exist in pool {}",
1290 v.name, v.pool
1291 )));
1292 }
1293 actions.push(Action::CreateVolume {
1294 pool: v.pool.clone(),
1295 volume: v.name.clone(),
1296 config: v.config.clone(),
1297 });
1298 }
1299 }
1300
1301 let Some(actual) = actual else {
1302 actions.push(Action::CreateInstance {
1303 image: desired.image.spec.clone(),
1304 pool: desired.pool.clone(),
1305 config: desired
1306 .config
1307 .iter()
1308 .map(|(k, v)| {
1309 let v = if desired.sensitive.contains(k) {
1310 REDACTED.to_string()
1311 } else {
1312 v.clone()
1313 };
1314 (k.clone(), v)
1315 })
1316 .collect(),
1317 devices: desired
1318 .devices
1319 .iter()
1320 .filter(|(_, d)| d.search.is_none())
1321 .map(|(k, d)| (k.clone(), d.props.clone()))
1322 .collect(),
1323 profiles: desired.profiles.clone(),
1324 });
1325 actions.push(Action::StartInstance);
1326 push_searched_ports(desired, &mut actions);
1327 actions.extend(owner::actions(&desired.owners, &|_| true, volumes_missing));
1328 return Ok(SandboxPlan {
1329 name: desired.name.clone(),
1330 status: None,
1331 actions,
1332 });
1333 };
1334
1335 if !actual.instance_type.is_empty() && actual.instance_type != desired.instance_type.as_api() {
1337 actions.push(Action::Note {
1338 message: format!(
1339 "type is {} (spec: {}); fixed at creation",
1340 actual.instance_type,
1341 desired.instance_type.as_api()
1342 ),
1343 });
1344 }
1345 if let Some(root) = actual.devices.get("root") {
1346 if let Some(p) = root.get("pool") {
1347 if *p != desired.pool {
1348 actions.push(Action::Note {
1349 message: format!(
1350 "root disk is on pool {p} (spec: {}); fixed at creation",
1351 desired.pool
1352 ),
1353 });
1354 }
1355 }
1356 }
1357 if actual.profiles != desired.profiles {
1358 actions.push(Action::Note {
1359 message: format!(
1360 "profiles are [{}] (spec: [{}]); fixed at creation",
1361 actual.profiles.join(", "),
1362 desired.profiles.join(", ")
1363 ),
1364 });
1365 }
1366
1367 for (k, v) in &desired.config {
1368 let cur = actual.config.get(k);
1369 if cur != Some(v) {
1370 let secret = desired.sensitive.contains(k);
1371 let hide = |s: &String| if secret { REDACTED.into() } else { s.clone() };
1372 actions.push(Action::SetConfig {
1373 key: k.clone(),
1374 from: cur.map(hide),
1375 to: hide(v),
1376 restart: restart_needed(k),
1377 secret,
1378 });
1379 }
1380 }
1381 if !desired.config.contains_key("raw.idmap") && actual.config.contains_key("raw.idmap") {
1382 let why = match desired.idmap_mode {
1383 Some(crate::spec::IdmapMode::Auto) => Some("not needed on this host"),
1384 Some(crate::spec::IdmapMode::None) => Some("the spec says idmap: none"),
1385 _ => None,
1386 };
1387 if let Some(why) = why {
1388 actions.push(Action::Note {
1389 message: format!(
1390 "raw.idmap is set but {why}; isb never removes config keys, unset it by hand"
1391 ),
1392 });
1393 }
1394 }
1395
1396 let mut new_devices: Vec<String> = Vec::new();
1397 let mut claimed: BTreeSet<String> = BTreeSet::new();
1398 let mut deferred_ports = Vec::new();
1399 for (name, want) in &desired.devices {
1400 if name == "root" {
1401 continue;
1402 }
1403 if let Some(have) = actual.devices.get(name) {
1404 claimed.insert(name.clone());
1405 if !device_matches(want, have) && want.search.is_some() {
1406 actions.push(Action::RemoveDevice {
1409 device: name.clone(),
1410 props: have.clone(),
1411 });
1412 deferred_ports.push(name.clone());
1413 } else if !device_matches(want, have) {
1414 actions.push(Action::ReplaceDevice {
1415 device: name.clone(),
1416 replaces: name.clone(),
1417 from: have.clone(),
1418 to: want.props.clone(),
1419 });
1420 new_devices.push(name.clone());
1421 }
1422 continue;
1423 }
1424 let same_path = |p: &Props| {
1429 want.props.get("type").map(String::as_str) == Some("disk")
1430 && p.get("type").map(String::as_str) == Some("disk")
1431 && normalize(p).get("path") == normalize(&want.props).get("path")
1432 };
1433 if let Some((other, have)) = actual.devices.iter().find(|(n, p)| {
1434 *n != "root" && !desired.devices.contains_key(*n) && device_matches(want, p)
1435 }) {
1436 claimed.insert(other.clone());
1437 actions.push(Action::Note {
1438 message: format!("device {name} already present as {other}; left as is"),
1439 });
1440 let _ = have;
1441 continue;
1442 }
1443 if let Some((other, have)) = actual
1444 .devices
1445 .iter()
1446 .find(|(n, p)| *n != "root" && !desired.devices.contains_key(*n) && same_path(p))
1447 {
1448 claimed.insert(other.clone());
1449 actions.push(Action::ReplaceDevice {
1450 device: name.clone(),
1451 replaces: other.clone(),
1452 from: have.clone(),
1453 to: want.props.clone(),
1454 });
1455 new_devices.push(name.clone());
1456 continue;
1457 }
1458 if want.search.is_some() {
1459 deferred_ports.push(name.clone());
1460 } else {
1461 actions.push(Action::AddDevice {
1462 device: name.clone(),
1463 props: want.props.clone(),
1464 });
1465 new_devices.push(name.clone());
1466 }
1467 }
1468
1469 if opts.prune_devices {
1470 for (name, props) in &actual.devices {
1471 if name != "root" && !desired.devices.contains_key(name) && !claimed.contains(name) {
1472 actions.push(Action::RemoveDevice {
1473 device: name.clone(),
1474 props: props.clone(),
1475 });
1476 }
1477 }
1478 }
1479
1480 if !actual.running() {
1481 actions.push(Action::StartInstance);
1482 }
1483 for name in deferred_ports {
1484 let d = &desired.devices[&name];
1485 actions.push(Action::AddPort {
1486 device: name.clone(),
1487 props: d.props.clone(),
1488 search: d.search.unwrap_or(0),
1489 });
1490 }
1491 actions.extend(owner::actions(
1492 &desired.owners,
1493 &|d| new_devices.iter().any(|n| n == d),
1494 volumes_missing,
1495 ));
1496
1497 Ok(SandboxPlan {
1498 name: desired.name.clone(),
1499 status: Some(actual.status.clone()),
1500 actions,
1501 })
1502}
1503
1504fn push_searched_ports(desired: &Desired, actions: &mut Vec<Action>) {
1505 for (name, d) in &desired.devices {
1506 if let Some(n) = d.search {
1507 actions.push(Action::AddPort {
1508 device: name.clone(),
1509 props: d.props.clone(),
1510 search: n,
1511 });
1512 }
1513 }
1514}
1515
1516#[cfg(test)]
1517mod tests;