Skip to main content

isb_core/org/
ensure.rs

1//! [`ensure`]: create an org, or bring an existing one in line with its
2//! options: its bridge, its network ACL, its restricted project and its
3//! default profile.
4
5use super::*;
6
7/// The org's settings that `opts` may leave to what the org has now.
8struct Kept {
9    egress: Vec<Egress>,
10    domains: String,
11    ingress: String,
12    cf_account: String,
13    cf_zone: String,
14    udp: String,
15}
16
17/// `opts` over the existing project's settings, checked.
18fn 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
84/// What an org's service names are waiting for.
85fn 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
92/// Service discovery: the org's dnsmasq reads its hosts directory. Set at
93/// creation, since changing raw.dnsmasq later restarts dnsmasq. Empty when
94/// the host has no directory for it.
95fn 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
106/// The org's bridge, made if missing; its current state.
107fn 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
142/// Deny private ranges, except the org's own subnet (which holds its DNS)
143/// and its exceptions: the ACL made or rewritten.
144fn 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
184/// The ACL attached to the bridge, and service names turned on.
185fn 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        // Traffic no rule matches passes: ingress from the host and the
199        // balancer, egress to the internet.
200        cfg["security.acls.default.egress.action"] = json!("allow");
201        cfg["security.acls.default.ingress.action"] = json!("allow");
202        changed.push("attach ACL");
203    }
204    // Only ever added: a host without the directory leaves an org's
205    // existing setting alone.
206    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/// Where an org stands on service names.
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
227pub enum Names {
228    /// The org's bridge already reads its hosts directory (or the org has
229    /// no bridge to change).
230    Present,
231    /// Just turned on: the bridge's `raw.dnsmasq` now names the directory.
232    TurnedOn,
233    /// Off, because this host has no writable directory for it yet.
234    Unavailable,
235}
236
237/// `raw.dnsmasq` with the `hostsdir=` line for `line` in it, or `None` when
238/// it already names a hosts directory. Other lines the operator set stay.
239fn 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
251/// Turn service names on for an existing org whose bridge does not read a
252/// hosts directory yet, as `isb org create` does: the bridge's
253/// `raw.dnsmasq` gets `hostsdir=<dir>`. `dns_dir` is the org's hosts
254/// directory, or `None` when the host has none.
255fn 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
291/// Bring one existing org's service names in line: when the host has the
292/// hosts directory now (`isb host setup` ran after the org was made), make
293/// the org's directory and point its bridge at it.
294pub 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
302/// The directory of an org's hosts files, made if the host allows it.
303pub(super) type PrepareDir<'a> = &'a dyn Fn(&OrgId) -> Result<Option<PathBuf>>;
304
305/// [`ensure_service_names`] with the directory step given.
306pub(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
321/// The networks the project's instances may use: the org's bridge, and the
322/// egress bridges of its sandboxes (`isbbrx...`), which isb adds one by one.
323fn 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
337/// The project's config: restricted to the org's bridge and uid, its
338/// limits, isb's own keys, and the disk paths it may bind (the bind roots
339/// and its workspaces' host-folder homes).
340fn 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        // Volume snapshots and exports (crate::volume_backup).
353        "restricted.snapshots": "allow",
354        "restricted.backups": "allow",
355        "restricted.networks.access": network_access(&bridge, existing),
356        // The daemon's own uid may be mapped 1:1, so `idmap: auto` keeps
357        // bind-mounted files writable; root never.
358        "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        // A stack's UDP ports are NAT proxy devices: allowed in the project
368        // once the org has any, and kept to them by `check_proxies`.
369        "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    // Null: removed from the project by `put_project`.
398    for l in &opts.lift {
399        config[l.key()] = Value::Null;
400    }
401    config
402}
403
404/// Whether the project has `limits.disk` once `config` is written over
405/// `existing`.
406fn 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
413/// `config` written over the project's current one: a null removes the key
414/// (a lifted limit), and bind paths not given are dropped.
415fn 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
432/// Create the project, or write `config` over what it has.
433fn 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
464/// The default profile: root disk (with `root_size`, while the org has a
465/// disk limit), the org NIC, per-instance defaults and an isolated uid range
466/// per instance.
467fn 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
486/// Write the default profile. Under a disk limit incus refuses an instance
487/// whose root disk has no size, so the profile gives one to every instance
488/// whose spec sets none (stack replicas, job runs, apps): the size the
489/// profile has, else [`limits::DEFAULT_ROOT_SIZE`]. incus applies a change to
490/// the instances that take their root from the profile, resizing their root
491/// volumes. Without a disk limit the profile has no size.
492fn set_default_profile(
493    base: &Client,
494    h: &Client,
495    org: &OrgId,
496    opts: &OrgOptions,
497    disk_limited: bool,
498) -> Result<()> {
499    let oc = client(base, org);
500    let pool = crate::sandbox::host_facts(h)?.pick_pool(None)?;
501    let root_size = if disk_limited {
502        let current = oc.get_opt("/1.0/profiles/default")?.unwrap_or_default();
503        Some(
504            current["devices"]["root"]["size"]
505                .as_str()
506                .unwrap_or(limits::DEFAULT_ROOT_SIZE)
507                .to_string(),
508        )
509    } else {
510        None
511    };
512    let profile = default_profile(org, &pool, opts, root_size.as_deref());
513    oc.mutate(
514        "PUT",
515        "/1.0/profiles/default",
516        Some(&profile),
517        &format!("set {org}'s default profile"),
518        oc.get_timeouts().other,
519    )?;
520    Ok(())
521}
522
523/// Create an org, or bring an existing one in line with `opts`. The project's
524/// disk paths are `opts.bind_roots` plus the host-folder workspace homes
525/// recorded on it ([`allow_home`]), so rewriting the bind roots never
526/// drops a home.
527pub fn ensure(
528    base: &Client,
529    org: &OrgId,
530    opts: &OrgOptions,
531    report: &mut dyn FnMut(&str),
532) -> Result<OrgInfo> {
533    let h = host(base);
534    let project = org.incus_project();
535    let existing = h.get_opt(&format!("/1.0/projects/{}", encode_segment(&project)))?;
536    if let Some(p) = &existing {
537        if p["config"][KEY_ORG].as_str() != Some(org.as_str()) {
538            return Err(Error::AlreadyExists(format!(
539                "incus project {project} exists but is not isb org {org}"
540            )));
541        }
542    }
543    for l in &opts.lift {
544        let given = match l {
545            Limit::Cpus => opts.cpus.is_some(),
546            Limit::Memory => opts.memory.is_some(),
547            Limit::Disk => opts.disk.is_some(),
548            Limit::Instances => opts.instances.is_some(),
549        };
550        if given {
551            return Err(Error::invalid(format!(
552                "{}: set and lifted at once; give a value or none",
553                l.key()
554            )));
555        }
556    }
557    let k = kept(opts, existing.as_ref())?;
558    let raw_dnsmasq = raw_dnsmasq(org, report)?;
559    let net = ensure_network(&h, org, &raw_dnsmasq, report)?;
560    ensure_acl(&h, org, &net, &k.egress, report)?;
561    attach(&h, org, &net, &raw_dnsmasq, report)?;
562    let config = project_config(org, &k, opts, existing.as_ref());
563    let disk = disk_limited(&config, existing.as_ref());
564    if existing.is_some() && disk {
565        // incus refuses a disk limit while an instance has no root size, so
566        // the profile gives them one first.
567        set_default_profile(base, &h, org, opts, disk)?;
568        put_project(&h, org, existing.as_ref(), &config, report)?;
569    } else {
570        // A lifted disk limit goes before the profile loses its size: incus
571        // refuses a sizeless root while the limit stands.
572        put_project(&h, org, existing.as_ref(), &config, report)?;
573        set_default_profile(base, &h, org, opts, disk)?;
574    }
575    get(base, org)
576}
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581    use crate::client::fake::{Route, serve};
582
583    fn bridge_route(prefix: &'static str, config: Value) -> Route {
584        Route {
585            prefix,
586            status: 200,
587            body: json!({"config": config}),
588        }
589    }
590
591    #[test]
592    fn hostsdir_is_added_beside_the_operators_own_lines() {
593        let line = "hostsdir=/var/lib/isb/dns/default";
594        assert_eq!(with_hostsdir("", line).as_deref(), Some(line));
595        assert_eq!(
596            with_hostsdir("log-queries\n", line).as_deref(),
597            Some("log-queries\nhostsdir=/var/lib/isb/dns/default")
598        );
599        assert_eq!(with_hostsdir("hostsdir=/elsewhere", line), None);
600    }
601
602    #[test]
603    fn an_org_made_before_host_setup_gets_service_names_afterwards() {
604        let org = OrgId::default_org();
605        let net = "GET /1.0/networks/";
606        let dir = std::path::Path::new("/var/lib/isb/dns/default");
607        let mut lines = Vec::new();
608
609        // No directory on this host yet: off, and nothing changed.
610        let (_d, c) = serve(vec![bridge_route(net, json!({"ipv4.address": "auto"}))]);
611        let n = converge_names(&c, &org, None, &mut |l| lines.push(l.to_string())).unwrap();
612        assert_eq!(n, Names::Unavailable);
613
614        // The directory is there now: the bridge is patched.
615        let (_d, c) = serve(vec![
616            bridge_route(net, json!({"ipv4.address": "auto"})),
617            Route {
618                prefix: "PATCH /1.0/networks/",
619                status: 200,
620                body: json!({}),
621            },
622        ]);
623        let n = converge_names(&c, &org, Some(dir), &mut |l| lines.push(l.to_string())).unwrap();
624        assert_eq!(n, Names::TurnedOn);
625        assert!(lines.iter().any(|l| l.contains("turning on service names")));
626
627        // Without the PATCH route the same call fails: it did try to patch.
628        let (_d, c) = serve(vec![bridge_route(net, json!({}))]);
629        assert!(converge_names(&c, &org, Some(dir), &mut |_| {}).is_err());
630
631        // Already on, or no bridge at all: left alone (no PATCH route to answer).
632        let (_d, c) = serve(vec![bridge_route(
633            net,
634            json!({"raw.dnsmasq": "hostsdir=/srv/dns"}),
635        )]);
636        let n = converge_names(&c, &org, Some(dir), &mut |_| {}).unwrap();
637        assert_eq!(n, Names::Present);
638        let (_d, c) = serve(vec![]);
639        let n = converge_names(&c, &org, Some(dir), &mut |_| {}).unwrap();
640        assert_eq!(n, Names::Present);
641    }
642
643    fn kept_default() -> Kept {
644        Kept {
645            egress: Vec::new(),
646            domains: String::new(),
647            ingress: INGRESS_CADDY.into(),
648            cf_account: String::new(),
649            cf_zone: String::new(),
650            udp: String::new(),
651        }
652    }
653
654    #[test]
655    fn rewriting_an_org_keeps_its_workspace_homes_bindable() {
656        let org = OrgId::new("lab").unwrap();
657        let existing = json!({"config": {
658            "restricted.devices.disk": "allow",
659            "restricted.devices.disk.paths": "/srv/ws/lab",
660            homes::KEY_WORKSPACE_HOMES: "/srv/ws/lab",
661        }});
662        // `isb org create lab` again, without bind roots.
663        let c = project_config(
664            &org,
665            &kept_default(),
666            &OrgOptions::default(),
667            Some(&existing),
668        );
669        assert_eq!(c["restricted.devices.disk"], "allow");
670        assert_eq!(c["restricted.devices.disk.paths"], "/srv/ws/lab");
671        // With a bind root of its own.
672        let opts = OrgOptions {
673            bind_roots: vec!["/data/lab".into()],
674            cpus: Some(2),
675            ..Default::default()
676        };
677        let c = project_config(&org, &kept_default(), &opts, Some(&existing));
678        assert_eq!(c["restricted.devices.disk.paths"], "/data/lab,/srv/ws/lab");
679        assert_eq!(c["limits.cpu"], "2");
680        // An org without homes or roots binds managed volumes only.
681        let c = project_config(&org, &kept_default(), &OrgOptions::default(), None);
682        assert_eq!(c["restricted.devices.disk"], "managed");
683        assert!(c.get("restricted.devices.disk.paths").is_none());
684    }
685
686    #[test]
687    fn a_lifted_limit_is_removed_and_others_are_kept() {
688        let org = OrgId::new("lab").unwrap();
689        let existing = json!({"config": {
690            "limits.cpu": "4", "limits.memory": "8GiB", "limits.disk": "50GiB",
691        }});
692        let opts = OrgOptions {
693            lift: vec![Limit::Disk, Limit::Cpus],
694            instances: Some(5),
695            ..Default::default()
696        };
697        let c = project_config(&org, &kept_default(), &opts, Some(&existing));
698        assert!(!disk_limited(&c, Some(&existing)));
699        let m = merged_config(&existing["config"], &c);
700        assert!(m.get("limits.disk").is_none(), "{m}");
701        assert!(m.get("limits.cpu").is_none(), "{m}");
702        assert_eq!(m["limits.memory"], "8GiB");
703        assert_eq!(m["limits.instances"], "5");
704        // A new project gets no null keys either.
705        let m = merged_config(&json!({}), &c);
706        assert!(m.as_object().unwrap().values().all(|v| !v.is_null()));
707
708        // Kept, set, or never there.
709        let none = project_config(&org, &kept_default(), &OrgOptions::default(), None);
710        assert!(disk_limited(&none, Some(&existing)));
711        assert!(!disk_limited(&none, None));
712        let set = OrgOptions {
713            disk: Some("10GiB".into()),
714            ..Default::default()
715        };
716        let c = project_config(&org, &kept_default(), &set, None);
717        assert!(disk_limited(&c, None));
718    }
719
720    #[test]
721    fn the_default_profile_sizes_the_root_disk_only_under_a_disk_limit() {
722        let org = OrgId::new("lab").unwrap();
723        let opts = OrgOptions::default();
724        let p = default_profile(&org, "default", &opts, None);
725        assert!(p["devices"]["root"].get("size").is_none(), "{p}");
726        assert_eq!(p["config"]["limits.cpu"], "1");
727        let p = default_profile(&org, "default", &opts, Some(limits::DEFAULT_ROOT_SIZE));
728        assert_eq!(p["devices"]["root"]["size"], "10GiB");
729        assert_eq!(p["devices"]["root"]["pool"], "default");
730    }
731}