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`, only one an operator
465/// set), the org NIC, per-instance defaults and an isolated uid range per
466/// 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/// The root size the default profile keeps. isb gives the profile none
487/// (instances get their own, see [`disk`]); one an operator set stays. The
488/// [`limits::DEFAULT_ROOT_SIZE`] isb itself once put there goes, except
489/// while the org has a disk limit and some instance takes its root disk
490/// from the profile: incus would refuse it, so it stays and `report` says
491/// why. `on_profile_root` lists those instances.
492fn 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
523/// Write the default profile, its root size as [`kept_root_size`] decides.
524/// Taking the size off lifts it from the instances that use the profile's
525/// root disk; should incus refuse that, the size stays and `report` says so.
526fn 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
569/// Create an org, or bring an existing one in line with `opts`. The project's
570/// disk paths are `opts.bind_roots` plus the host-folder workspace homes
571/// recorded on it ([`allow_home`]), so rewriting the bind roots never
572/// drops a home.
573pub 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    // incus refuses a disk limit while an instance's root disk has no size
604    // (and isb never sizes them all through the profile): name them first.
605    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        // No directory on this host yet: off, and nothing changed.
655        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        // The directory is there now: the bridge is patched.
660        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        // Without the PATCH route the same call fails: it did try to patch.
673        let (_d, c) = serve(vec![bridge_route(net, json!({}))]);
674        assert!(converge_names(&c, &org, Some(dir), &mut |_| {}).is_err());
675
676        // Already on, or no bridge at all: left alone (no PATCH route to answer).
677        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        // `isb org create lab` again, without bind roots.
708        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        // With a bind root of its own.
717        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        // An org without homes or roots binds managed volumes only.
726        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        // A new project gets no null keys either.
750        let m = merged_config(&json!({}), &c);
751        assert!(m.as_object().unwrap().values().all(|v| !v.is_null()));
752
753        // Kept, set, or never there.
754        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        // isb never adds one, with a disk limit or without.
793        assert_eq!(keep(None, true, &[]), None);
794        assert_eq!(keep(None, false, &[]), None);
795        // An operator's own size stays.
796        assert_eq!(keep(Some("25GiB"), false, &["a"]), Some("25GiB".into()));
797        // isb's 10GiB goes without a limit, or when every instance has its own.
798        assert_eq!(keep(Some("10GiB"), false, &["a"]), None);
799        assert_eq!(keep(Some("10GiB"), true, &[]), None);
800        // Under the limit, while an instance takes its root from the
801        // profile, it stays (incus would refuse) and the report says why.
802        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}