1use super::*;
6
7struct Kept {
9 egress: Vec<Egress>,
10 domains: String,
11 ingress: String,
12 cf_account: String,
13 cf_zone: String,
14 udp: String,
15}
16
17fn kept(opts: &OrgOptions, existing: Option<&Value>) -> Result<Kept> {
19 let keep = |key: &str| -> String {
20 existing
21 .and_then(|p| p["config"][key].as_str())
22 .unwrap_or_default()
23 .to_string()
24 };
25 let egress: Vec<Egress> = match &opts.egress {
26 Some(e) => e.clone(),
27 None => existing
28 .and_then(|p| p["config"][KEY_EGRESS].as_str())
29 .map(parse_egress_list)
30 .unwrap_or_default(),
31 };
32 check_egress(&egress)?;
33 let domains = match &opts.domains {
34 Some(d) => d
35 .iter()
36 .map(|s| check_domain_suffix(s))
37 .collect::<Result<Vec<_>>>()?
38 .join(" "),
39 None => keep(KEY_DOMAINS),
40 };
41 let ingress = match &opts.ingress {
42 Some(i) if i == INGRESS_CADDY || i == INGRESS_CLOUDFLARE_TUNNEL => i.clone(),
43 Some(i) => {
44 return Err(Error::invalid(format!(
45 "--ingress {i:?}: {INGRESS_CADDY} or {INGRESS_CLOUDFLARE_TUNNEL}"
46 )));
47 }
48 None => keep(KEY_INGRESS),
49 };
50 let cf_account = opts
51 .cloudflare_account
52 .clone()
53 .unwrap_or_else(|| keep(KEY_CF_ACCOUNT));
54 let cf_zone = opts
55 .cloudflare_zone
56 .clone()
57 .unwrap_or_else(|| keep(KEY_CF_ZONE));
58 for v in [&cf_account, &cf_zone] {
59 if !v.chars().all(|c| c.is_ascii_alphanumeric()) {
60 return Err(Error::invalid(format!(
61 "Cloudflare id {v:?}: letters and digits only"
62 )));
63 }
64 }
65 let udp = match &opts.udp {
66 Some(u) => {
67 for a in u {
68 check_udp_port(&a.to_string())?;
69 }
70 udp::render(u)
71 }
72 None => keep(KEY_UDP),
73 };
74 Ok(Kept {
75 egress,
76 domains,
77 ingress,
78 cf_account,
79 cf_zone,
80 udp,
81 })
82}
83
84fn no_directory(org: &OrgId) -> String {
86 format!(
87 "{org}: no writable {}: service names are off (run `sudo isb host setup`; a running `isb serve` then turns them on, or run this again)",
88 crate::discovery::root().display()
89 )
90}
91
92fn raw_dnsmasq(org: &OrgId, report: &mut dyn FnMut(&str)) -> Result<String> {
96 let dns_dir = crate::discovery::prepare_org(org)?;
97 if dns_dir.is_none() {
98 report(&no_directory(org));
99 }
100 Ok(dns_dir
101 .as_deref()
102 .map(crate::discovery::raw_dnsmasq)
103 .unwrap_or_default())
104}
105
106fn ensure_network(
108 h: &Client,
109 org: &OrgId,
110 raw_dnsmasq: &str,
111 report: &mut dyn FnMut(&str),
112) -> Result<Value> {
113 let bridge = bridge_name(org);
114 let net_path = format!("/1.0/networks/{}", encode_segment(&bridge));
115 if h.get_opt(&net_path)?.is_none() {
116 report(&format!("{org}: creating network {bridge}"));
117 let mut config = json!({
118 "ipv4.address": "auto",
119 "ipv4.nat": "true",
120 "ipv6.address": "none",
121 "dns.domain": format!("{org}.isb"),
122 });
123 if !raw_dnsmasq.is_empty() {
124 config["raw.dnsmasq"] = json!(raw_dnsmasq);
125 }
126 h.mutate(
127 "POST",
128 "/1.0/networks",
129 Some(&json!({
130 "name": bridge,
131 "type": "bridge",
132 "description": format!("isb org {org}"),
133 "config": config,
134 })),
135 &format!("create network {bridge}"),
136 h.get_timeouts().other,
137 )?;
138 }
139 h.get(&net_path)
140}
141
142fn ensure_acl(
145 h: &Client,
146 org: &OrgId,
147 net: &Value,
148 egress: &[Egress],
149 report: &mut dyn FnMut(&str),
150) -> Result<()> {
151 let subnet = net["config"]["ipv4.address"].as_str().unwrap_or_default();
152 let own = subnet_of(subnet).and_then(|s| parse_cidr(&s));
153 let acl = acl_name(org);
154 let acl_body = json!({
155 "description": format!("isb org {org}: allow within the org, deny other private networks"),
156 "egress": egress_rules(own, egress)?,
157 "ingress": [],
158 "config": {},
159 });
160 let acl_path = format!("/1.0/network-acls/{}", encode_segment(&acl));
161 if h.get_opt(&acl_path)?.is_none() {
162 report(&format!("{org}: creating ACL {acl}"));
163 let mut body = acl_body.clone();
164 body["name"] = json!(acl);
165 h.mutate(
166 "POST",
167 "/1.0/network-acls",
168 Some(&body),
169 &format!("create ACL {acl}"),
170 h.get_timeouts().other,
171 )?;
172 } else {
173 h.mutate(
174 "PUT",
175 &acl_path,
176 Some(&acl_body),
177 &format!("update ACL {acl}"),
178 h.get_timeouts().other,
179 )?;
180 }
181 Ok(())
182}
183
184fn attach(
186 h: &Client,
187 org: &OrgId,
188 net: &Value,
189 raw_dnsmasq: &str,
190 report: &mut dyn FnMut(&str),
191) -> Result<()> {
192 let bridge = bridge_name(org);
193 let acl = acl_name(org);
194 let mut cfg = net["config"].clone();
195 let mut changed = Vec::new();
196 if net["config"]["security.acls"].as_str() != Some(acl.as_str()) {
197 cfg["security.acls"] = json!(acl);
198 cfg["security.acls.default.egress.action"] = json!("allow");
201 cfg["security.acls.default.ingress.action"] = json!("allow");
202 changed.push("attach ACL");
203 }
204 if !raw_dnsmasq.is_empty() && net["config"]["raw.dnsmasq"].as_str() != Some(raw_dnsmasq) {
207 report(&format!(
208 "{org}: turning on service names (restarts {bridge}'s DNS)"
209 ));
210 cfg["raw.dnsmasq"] = json!(raw_dnsmasq);
211 changed.push("set raw.dnsmasq");
212 }
213 if !changed.is_empty() {
214 h.mutate(
215 "PATCH",
216 &format!("/1.0/networks/{}", encode_segment(&bridge)),
217 Some(&json!({"config": cfg})),
218 &format!("{} on {bridge}", changed.join(", ")),
219 h.get_timeouts().other,
220 )?;
221 }
222 Ok(())
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
227pub enum Names {
228 Present,
231 TurnedOn,
233 Unavailable,
235}
236
237fn with_hostsdir(current: &str, line: &str) -> Option<String> {
240 if current.lines().any(|l| l.trim().starts_with("hostsdir=")) {
241 return None;
242 }
243 let keep = current.trim_end();
244 Some(if keep.is_empty() {
245 line.to_string()
246 } else {
247 format!("{keep}\n{line}")
248 })
249}
250
251fn converge_names(
256 h: &Client,
257 org: &OrgId,
258 dns_dir: Option<&Path>,
259 report: &mut dyn FnMut(&str),
260) -> Result<Names> {
261 let bridge = bridge_name(org);
262 let net_path = format!("/1.0/networks/{}", encode_segment(&bridge));
263 let Some(net) = h.get_opt(&net_path)? else {
264 return Ok(Names::Present);
265 };
266 let current = net["config"]["raw.dnsmasq"].as_str().unwrap_or_default();
267 if current.lines().any(|l| l.trim().starts_with("hostsdir=")) {
268 return Ok(Names::Present);
269 }
270 let Some(dir) = dns_dir else {
271 return Ok(Names::Unavailable);
272 };
273 let Some(raw) = with_hostsdir(current, &crate::discovery::raw_dnsmasq(dir)) else {
274 return Ok(Names::Present);
275 };
276 report(&format!(
277 "{org}: turning on service names (restarts {bridge}'s DNS)"
278 ));
279 let mut cfg = net["config"].clone();
280 cfg["raw.dnsmasq"] = json!(raw);
281 h.mutate(
282 "PATCH",
283 &net_path,
284 Some(&json!({"config": cfg})),
285 &format!("set raw.dnsmasq on {bridge}"),
286 h.get_timeouts().other,
287 )?;
288 Ok(Names::TurnedOn)
289}
290
291pub fn ensure_service_names(
295 base: &Client,
296 org: &OrgId,
297 report: &mut dyn FnMut(&str),
298) -> Result<Names> {
299 ensure_service_names_in(base, org, &crate::discovery::prepare_org, report)
300}
301
302pub(super) type PrepareDir<'a> = &'a dyn Fn(&OrgId) -> Result<Option<PathBuf>>;
304
305pub(super) fn ensure_service_names_in(
307 base: &Client,
308 org: &OrgId,
309 prepare: PrepareDir,
310 report: &mut dyn FnMut(&str),
311) -> Result<Names> {
312 let h = host(base);
313 let dir = prepare(org)?;
314 let names = converge_names(&h, org, dir.as_deref(), report)?;
315 if names == Names::Unavailable {
316 report(&no_directory(org));
317 }
318 Ok(names)
319}
320
321fn network_access(bridge: &str, existing: Option<&Value>) -> String {
324 let mut names = vec![bridge.to_string()];
325 let old = existing
326 .and_then(|p| p["config"]["restricted.networks.access"].as_str())
327 .unwrap_or_default();
328 names.extend(
329 old.split(',')
330 .map(str::trim)
331 .filter(|n| n.starts_with(crate::egress::plumb::NET_PREFIX))
332 .map(String::from),
333 );
334 names.join(",")
335}
336
337fn project_config(org: &OrgId, k: &Kept, opts: &OrgOptions, existing: Option<&Value>) -> Value {
341 let bridge = bridge_name(org);
342 let uid = rustix::process::getuid().as_raw();
343 let gid = rustix::process::getgid().as_raw();
344 let mut config = json!({
345 "features.images": "false",
346 "features.profiles": "true",
347 "features.storage.volumes": "true",
348 "features.storage.buckets": "true",
349 "features.networks": "false",
350 "restricted": "true",
351 "restricted.containers.privilege": "unprivileged",
352 "restricted.snapshots": "allow",
354 "restricted.backups": "allow",
355 "restricted.networks.access": network_access(&bridge, existing),
356 "restricted.idmap.uid": uid.to_string(),
359 "restricted.idmap.gid": gid.to_string(),
360 KEY_ORG: org.as_str(),
361 KEY_NETWORK: bridge,
362 KEY_EGRESS: k.egress.iter().map(Egress::render).collect::<Vec<_>>().join(" "),
363 KEY_DOMAINS: k.domains,
364 KEY_INGRESS: k.ingress,
365 KEY_CF_ACCOUNT: k.cf_account,
366 KEY_CF_ZONE: k.cf_zone,
367 "restricted.devices.proxy": if k.udp.is_empty() { "block" } else { "allow" },
370 KEY_UDP: k.udp,
371 });
372 let roots: Vec<String> = opts
373 .bind_roots
374 .iter()
375 .map(|p| p.display().to_string())
376 .collect();
377 let homes = existing
378 .map(|p| homes::recorded(&p["config"]))
379 .unwrap_or_default();
380 let paths = homes::disk_paths(&roots, &homes);
381 if paths.is_empty() {
382 config["restricted.devices.disk"] = json!("managed");
383 } else {
384 config["restricted.devices.disk"] = json!("allow");
385 config["restricted.devices.disk.paths"] = json!(paths.join(","));
386 }
387 for (key, v) in [
388 ("limits.cpu", opts.cpus.map(|c| c.to_string())),
389 ("limits.memory", opts.memory.clone()),
390 ("limits.disk", opts.disk.clone()),
391 ("limits.instances", opts.instances.map(|c| c.to_string())),
392 ] {
393 if let Some(v) = v {
394 config[key] = json!(v);
395 }
396 }
397 for l in &opts.lift {
399 config[l.key()] = Value::Null;
400 }
401 config
402}
403
404fn disk_limited(config: &Value, existing: Option<&Value>) -> bool {
407 match config.get("limits.disk") {
408 Some(v) => v.is_string(),
409 None => existing.is_some_and(|p| p["config"]["limits.disk"].is_string()),
410 }
411}
412
413fn merged_config(current: &Value, config: &Value) -> Value {
416 let mut merged = current.clone();
417 if let (Some(m), Some(c)) = (merged.as_object_mut(), config.as_object()) {
418 for (k, v) in c {
419 if v.is_null() {
420 m.remove(k);
421 } else {
422 m.insert(k.clone(), v.clone());
423 }
424 }
425 if !c.contains_key("restricted.devices.disk.paths") {
426 m.remove("restricted.devices.disk.paths");
427 }
428 }
429 merged
430}
431
432fn put_project(
434 h: &Client,
435 org: &OrgId,
436 existing: Option<&Value>,
437 config: &Value,
438 report: &mut dyn FnMut(&str),
439) -> Result<()> {
440 let project = org.incus_project();
441 let Some(p) = existing else {
442 report(&format!("{org}: creating project {project}"));
443 let config = merged_config(&json!({}), config);
444 h.mutate(
445 "POST",
446 "/1.0/projects",
447 Some(&json!({"name": project, "description": format!("isb org {org}"), "config": config})),
448 &format!("create project {project}"),
449 h.get_timeouts().other,
450 )?;
451 return Ok(());
452 };
453 let merged = merged_config(&p["config"], config);
454 h.mutate(
455 "PUT",
456 &format!("/1.0/projects/{}", encode_segment(&project)),
457 Some(&json!({"description": p["description"], "config": merged})),
458 &format!("update project {project}"),
459 h.get_timeouts().other,
460 )?;
461 Ok(())
462}
463
464fn default_profile(org: &OrgId, pool: &str, opts: &OrgOptions, root_size: Option<&str>) -> Value {
468 let mut root = json!({"type": "disk", "path": "/", "pool": pool});
469 if let Some(size) = root_size {
470 root["size"] = json!(size);
471 }
472 json!({
473 "description": format!("isb org {org}"),
474 "config": {
475 "limits.cpu": opts.default_cpus.unwrap_or(1).to_string(),
476 "limits.memory": opts.default_memory.clone().unwrap_or_else(|| "512MiB".into()),
477 "security.idmap.isolated": "true",
478 },
479 "devices": {
480 "root": root,
481 "eth0": {"type": "nic", "name": "eth0", "network": bridge_name(org)},
482 },
483 })
484}
485
486fn kept_root_size(
493 org: &OrgId,
494 current: Option<&str>,
495 disk_limited: bool,
496 on_profile_root: &mut dyn FnMut() -> Result<Vec<String>>,
497 report: &mut dyn FnMut(&str),
498) -> Result<Option<String>> {
499 let Some(size) = current else {
500 return Ok(None);
501 };
502 if size != limits::DEFAULT_ROOT_SIZE {
503 return Ok(Some(size.to_string()));
504 }
505 if !disk_limited {
506 return Ok(None);
507 }
508 let users = on_profile_root()?;
509 if users.is_empty() {
510 return Ok(None);
511 }
512 report(&format!(
513 "{org}: the default profile keeps its root size {size}: under the disk limit, \
514 these instances take their root disk from it: {}. Give each a size of its own \
515 (incus config device override NAME root size={size} --project {}); \
516 the next org update then removes the profile's",
517 users.join(", "),
518 org.incus_project(),
519 ));
520 Ok(Some(size.to_string()))
521}
522
523fn set_default_profile(
527 base: &Client,
528 h: &Client,
529 org: &OrgId,
530 opts: &OrgOptions,
531 disk_limited: bool,
532 report: &mut dyn FnMut(&str),
533) -> Result<()> {
534 let oc = client(base, org);
535 let pool = crate::sandbox::host_facts(h)?.pick_pool(None)?;
536 let current = oc.get_opt("/1.0/profiles/default")?.unwrap_or_default();
537 let current = current["devices"]["root"]["size"].as_str();
538 let size = kept_root_size(
539 org,
540 current,
541 disk_limited,
542 &mut || disk::on_profile_root(&oc),
543 report,
544 )?;
545 let put = |size: Option<&str>| {
546 oc.mutate(
547 "PUT",
548 "/1.0/profiles/default",
549 Some(&default_profile(org, &pool, opts, size)),
550 &format!("set {org}'s default profile"),
551 oc.get_timeouts().other,
552 )
553 };
554 match put(size.as_deref()) {
555 Err(e) if size.is_none() && current.is_some() => {
556 report(&format!(
557 "{org}: kept the default profile's root size {}: removing it failed: {e}",
558 current.unwrap_or_default()
559 ));
560 put(current)?;
561 }
562 r => {
563 r?;
564 }
565 }
566 Ok(())
567}
568
569pub fn ensure(
574 base: &Client,
575 org: &OrgId,
576 opts: &OrgOptions,
577 report: &mut dyn FnMut(&str),
578) -> Result<OrgInfo> {
579 let h = host(base);
580 let project = org.incus_project();
581 let existing = h.get_opt(&format!("/1.0/projects/{}", encode_segment(&project)))?;
582 if let Some(p) = &existing {
583 if p["config"][KEY_ORG].as_str() != Some(org.as_str()) {
584 return Err(Error::AlreadyExists(format!(
585 "incus project {project} exists but is not isb org {org}"
586 )));
587 }
588 }
589 for l in &opts.lift {
590 let given = match l {
591 Limit::Cpus => opts.cpus.is_some(),
592 Limit::Memory => opts.memory.is_some(),
593 Limit::Disk => opts.disk.is_some(),
594 Limit::Instances => opts.instances.is_some(),
595 };
596 if given {
597 return Err(Error::invalid(format!(
598 "{}: set and lifted at once; give a value or none",
599 l.key()
600 )));
601 }
602 }
603 if existing.is_some() && opts.disk.is_some() {
606 let list = disk::unsized_roots(&client(base, org))?;
607 if !list.is_empty() {
608 return Err(disk::refuse_unsized(org, &list));
609 }
610 }
611 let k = kept(opts, existing.as_ref())?;
612 let raw_dnsmasq = raw_dnsmasq(org, report)?;
613 let net = ensure_network(&h, org, &raw_dnsmasq, report)?;
614 ensure_acl(&h, org, &net, &k.egress, report)?;
615 attach(&h, org, &net, &raw_dnsmasq, report)?;
616 let config = project_config(org, &k, opts, existing.as_ref());
617 let limited = disk_limited(&config, existing.as_ref());
618 put_project(&h, org, existing.as_ref(), &config, report)?;
619 set_default_profile(base, &h, org, opts, limited, report)?;
620 get(base, org)
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626 use crate::client::fake::{Route, serve};
627
628 fn bridge_route(prefix: &'static str, config: Value) -> Route {
629 Route {
630 prefix,
631 status: 200,
632 body: json!({"config": config}),
633 }
634 }
635
636 #[test]
637 fn hostsdir_is_added_beside_the_operators_own_lines() {
638 let line = "hostsdir=/var/lib/isb/dns/default";
639 assert_eq!(with_hostsdir("", line).as_deref(), Some(line));
640 assert_eq!(
641 with_hostsdir("log-queries\n", line).as_deref(),
642 Some("log-queries\nhostsdir=/var/lib/isb/dns/default")
643 );
644 assert_eq!(with_hostsdir("hostsdir=/elsewhere", line), None);
645 }
646
647 #[test]
648 fn an_org_made_before_host_setup_gets_service_names_afterwards() {
649 let org = OrgId::default_org();
650 let net = "GET /1.0/networks/";
651 let dir = std::path::Path::new("/var/lib/isb/dns/default");
652 let mut lines = Vec::new();
653
654 let (_d, c) = serve(vec![bridge_route(net, json!({"ipv4.address": "auto"}))]);
656 let n = converge_names(&c, &org, None, &mut |l| lines.push(l.to_string())).unwrap();
657 assert_eq!(n, Names::Unavailable);
658
659 let (_d, c) = serve(vec![
661 bridge_route(net, json!({"ipv4.address": "auto"})),
662 Route {
663 prefix: "PATCH /1.0/networks/",
664 status: 200,
665 body: json!({}),
666 },
667 ]);
668 let n = converge_names(&c, &org, Some(dir), &mut |l| lines.push(l.to_string())).unwrap();
669 assert_eq!(n, Names::TurnedOn);
670 assert!(lines.iter().any(|l| l.contains("turning on service names")));
671
672 let (_d, c) = serve(vec![bridge_route(net, json!({}))]);
674 assert!(converge_names(&c, &org, Some(dir), &mut |_| {}).is_err());
675
676 let (_d, c) = serve(vec![bridge_route(
678 net,
679 json!({"raw.dnsmasq": "hostsdir=/srv/dns"}),
680 )]);
681 let n = converge_names(&c, &org, Some(dir), &mut |_| {}).unwrap();
682 assert_eq!(n, Names::Present);
683 let (_d, c) = serve(vec![]);
684 let n = converge_names(&c, &org, Some(dir), &mut |_| {}).unwrap();
685 assert_eq!(n, Names::Present);
686 }
687
688 fn kept_default() -> Kept {
689 Kept {
690 egress: Vec::new(),
691 domains: String::new(),
692 ingress: INGRESS_CADDY.into(),
693 cf_account: String::new(),
694 cf_zone: String::new(),
695 udp: String::new(),
696 }
697 }
698
699 #[test]
700 fn rewriting_an_org_keeps_its_workspace_homes_bindable() {
701 let org = OrgId::new("lab").unwrap();
702 let existing = json!({"config": {
703 "restricted.devices.disk": "allow",
704 "restricted.devices.disk.paths": "/srv/ws/lab",
705 homes::KEY_WORKSPACE_HOMES: "/srv/ws/lab",
706 }});
707 let c = project_config(
709 &org,
710 &kept_default(),
711 &OrgOptions::default(),
712 Some(&existing),
713 );
714 assert_eq!(c["restricted.devices.disk"], "allow");
715 assert_eq!(c["restricted.devices.disk.paths"], "/srv/ws/lab");
716 let opts = OrgOptions {
718 bind_roots: vec!["/data/lab".into()],
719 cpus: Some(2),
720 ..Default::default()
721 };
722 let c = project_config(&org, &kept_default(), &opts, Some(&existing));
723 assert_eq!(c["restricted.devices.disk.paths"], "/data/lab,/srv/ws/lab");
724 assert_eq!(c["limits.cpu"], "2");
725 let c = project_config(&org, &kept_default(), &OrgOptions::default(), None);
727 assert_eq!(c["restricted.devices.disk"], "managed");
728 assert!(c.get("restricted.devices.disk.paths").is_none());
729 }
730
731 #[test]
732 fn a_lifted_limit_is_removed_and_others_are_kept() {
733 let org = OrgId::new("lab").unwrap();
734 let existing = json!({"config": {
735 "limits.cpu": "4", "limits.memory": "8GiB", "limits.disk": "50GiB",
736 }});
737 let opts = OrgOptions {
738 lift: vec![Limit::Disk, Limit::Cpus],
739 instances: Some(5),
740 ..Default::default()
741 };
742 let c = project_config(&org, &kept_default(), &opts, Some(&existing));
743 assert!(!disk_limited(&c, Some(&existing)));
744 let m = merged_config(&existing["config"], &c);
745 assert!(m.get("limits.disk").is_none(), "{m}");
746 assert!(m.get("limits.cpu").is_none(), "{m}");
747 assert_eq!(m["limits.memory"], "8GiB");
748 assert_eq!(m["limits.instances"], "5");
749 let m = merged_config(&json!({}), &c);
751 assert!(m.as_object().unwrap().values().all(|v| !v.is_null()));
752
753 let none = project_config(&org, &kept_default(), &OrgOptions::default(), None);
755 assert!(disk_limited(&none, Some(&existing)));
756 assert!(!disk_limited(&none, None));
757 let set = OrgOptions {
758 disk: Some("10GiB".into()),
759 ..Default::default()
760 };
761 let c = project_config(&org, &kept_default(), &set, None);
762 assert!(disk_limited(&c, None));
763 }
764
765 #[test]
766 fn the_default_profile_has_a_root_size_only_when_one_is_kept() {
767 let org = OrgId::new("lab").unwrap();
768 let opts = OrgOptions::default();
769 let p = default_profile(&org, "default", &opts, None);
770 assert!(p["devices"]["root"].get("size").is_none(), "{p}");
771 assert_eq!(p["config"]["limits.cpu"], "1");
772 assert_eq!(p["devices"]["root"]["pool"], "default");
773 let p = default_profile(&org, "default", &opts, Some("25GiB"));
774 assert_eq!(p["devices"]["root"]["size"], "25GiB");
775 }
776
777 #[test]
778 fn isbs_own_profile_root_size_goes_unless_instances_rely_on_it() {
779 let org = OrgId::new("lab").unwrap();
780 let mut lines = Vec::new();
781 let mut keep = |current: Option<&str>, limited: bool, users: &[&str]| {
782 let users: Vec<String> = users.iter().map(|u| u.to_string()).collect();
783 kept_root_size(
784 &org,
785 current,
786 limited,
787 &mut || Ok(users.clone()),
788 &mut |l| lines.push(l.to_string()),
789 )
790 .unwrap()
791 };
792 assert_eq!(keep(None, true, &[]), None);
794 assert_eq!(keep(None, false, &[]), None);
795 assert_eq!(keep(Some("25GiB"), false, &["a"]), Some("25GiB".into()));
797 assert_eq!(keep(Some("10GiB"), false, &["a"]), None);
799 assert_eq!(keep(Some("10GiB"), true, &[]), None);
800 assert_eq!(
803 keep(Some("10GiB"), true, &["build-1"]),
804 Some("10GiB".into())
805 );
806 assert_eq!(lines.len(), 1);
807 assert!(lines[0].contains("from it: build-1."), "{}", lines[0]);
808 assert!(
809 lines[0]
810 .contains("incus config device override NAME root size=10GiB --project isb-lab"),
811 "{}",
812 lines[0]
813 );
814 }
815}