isb-daemon 1.6.2

The isb serve daemon: the tools behind its API, MCP server and web UI. Use the `isb` crate.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
//! The `org_*` tools: orgs over MCP and REST, for the web UI's settings and
//! admin pages. Reading an org is for its members; creating, changing and
//! deleting one is for platform admins (`PLATFORM_TOOLS`), since limits and
//! egress exceptions are what keep one org from another and from the host.
//! Bind roots are host paths and stay with the CLI (`isb org create
//! --bind-root`).

use std::sync::Arc;

use serde::Deserialize;
use serde_json::{Value, json};

use super::{Daemon, args};
use crate::error::{Error, Result};
use crate::org::{self, Egress, OrgId, OrgInfo, OrgOptions};
use crate::server::{Caller, Registry, Tool};

/// An org-wide limit as the tools take it: a value, or `"none"` (or
/// `null`) to lift it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum LimitArg<T> {
    Set(T),
    Lift,
}

/// A present limit field: `null` or `"none"` lifts it, anything else is its
/// value. An absent one stays `None` (`#[serde(default)]`): kept.
fn limit_arg<'de, D, T>(d: D) -> std::result::Result<Option<LimitArg<T>>, D::Error>
where
    D: serde::Deserializer<'de>,
    T: serde::de::DeserializeOwned,
{
    match Value::deserialize(d)? {
        Value::Null => Ok(Some(LimitArg::Lift)),
        Value::String(s) if s.trim().eq_ignore_ascii_case("none") => Ok(Some(LimitArg::Lift)),
        v => serde_json::from_value(v)
            .map(|t| Some(LimitArg::Set(t)))
            .map_err(serde::de::Error::custom),
    }
}

/// Limits and egress as the tools take them. `None` keeps what the org has.
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct Settings {
    #[serde(default)]
    pub org: Option<String>,
    #[serde(default, deserialize_with = "limit_arg")]
    pub cpus: Option<LimitArg<u32>>,
    #[serde(default, deserialize_with = "limit_arg")]
    pub memory: Option<LimitArg<String>>,
    #[serde(default, deserialize_with = "limit_arg")]
    pub disk: Option<LimitArg<String>>,
    #[serde(default, deserialize_with = "limit_arg")]
    pub instances: Option<LimitArg<u32>>,
    #[serde(default)]
    pub default_cpus: Option<u32>,
    #[serde(default)]
    pub default_memory: Option<String>,
    /// Replaces the exceptions; `[]` clears them.
    #[serde(default)]
    pub egress: Option<Vec<String>>,
    /// UDP ports (`IP:PORT`) the org's stacks may publish. Replaces the
    /// list; `[]` clears it.
    #[serde(default)]
    pub udp: Option<Vec<String>>,
    /// Where the org runs: `local` (this daemon) or a server's name. A
    /// control plane routes a server placement before the tool runs.
    #[serde(default)]
    pub server: Option<String>,
    /// Where the org runs: `"local"`, `{"server": NAME}` or `{"vm": {cpus,
    /// memory, disk}}` (a dedicated VM). A control plane routes anything
    /// but local before the tool runs.
    #[serde(default)]
    pub placement: Option<Value>,
    /// With a dedicated VM: wait for it (default), or answer at once with
    /// the provisioning to follow. Read by the control plane's router.
    #[serde(default)]
    #[allow(dead_code)]
    pub wait: Option<bool>,
}

impl Settings {
    fn org(&self) -> Result<OrgId> {
        let mut where_ = json!({});
        if let Some(s) = &self.server {
            where_["server"] = json!(s);
        }
        if let Some(p) = &self.placement {
            where_["placement"] = p.clone();
        }
        match crate::servers::vm::placement(&where_)? {
            crate::servers::vm::Placement::Local => {}
            crate::servers::vm::Placement::Server(s) => {
                return Err(Error::invalid(format!(
                    "server {s}: this daemon places no orgs on servers (only a control plane does: docs/guides/servers.md)"
                )));
            }
            crate::servers::vm::Placement::Vm(_) => {
                return Err(Error::invalid(
                    "a dedicated VM is made by a control plane; this daemon is a server's agent (docs/guides/servers.md)",
                ));
            }
        }
        let o = OrgId::new(
            self.org
                .clone()
                .ok_or_else(|| Error::invalid("org is required"))?,
        )?;
        Ok(o)
    }

    /// The options for `org::ensure`: these settings over what the org has
    /// now (`current`), so a field left out is kept, bind roots included.
    pub(super) fn options(&self, current: Option<&OrgInfo>) -> Result<OrgOptions> {
        let egress = match &self.egress {
            Some(list) => {
                let e = list
                    .iter()
                    .map(|s| s.trim())
                    .filter(|s| !s.is_empty())
                    .map(Egress::parse)
                    .collect::<Result<Vec<_>>>()?;
                org::check_egress(&e)?;
                Some(e)
            }
            None => None,
        };
        let udp = match &self.udp {
            Some(list) => Some(
                list.iter()
                    .map(|s| s.trim())
                    .filter(|s| !s.is_empty())
                    .map(org::check_udp_port)
                    .collect::<Result<Vec<_>>>()?,
            ),
            None => None,
        };
        let parse_u32 = |v: Option<&String>| v.and_then(|s| s.parse::<u32>().ok());
        let set = |v: &Option<LimitArg<String>>| match v {
            Some(LimitArg::Set(s)) => Some(s.clone()),
            _ => None,
        };
        let count = |v: &Option<LimitArg<u32>>| match v {
            Some(LimitArg::Set(n)) => Some(*n),
            _ => None,
        };
        let (memory, disk) = (set(&self.memory), set(&self.disk));
        for (what, v) in [
            ("memory", &memory),
            ("disk", &disk),
            ("default_memory", &self.default_memory),
        ] {
            if let Some(v) = v {
                check_size(what, v)?;
            }
        }
        let (cpus, instances) = (count(&self.cpus), count(&self.instances));
        for (what, v) in [
            ("cpus", cpus),
            ("instances", instances),
            ("default_cpus", self.default_cpus),
        ] {
            if v == Some(0) {
                return Err(Error::invalid(format!(
                    "{what} must be at least 1 (\"none\" lifts the limit)"
                )));
            }
        }
        let lift = [
            (org::Limit::Cpus, matches!(self.cpus, Some(LimitArg::Lift))),
            (
                org::Limit::Memory,
                matches!(self.memory, Some(LimitArg::Lift)),
            ),
            (org::Limit::Disk, matches!(self.disk, Some(LimitArg::Lift))),
            (
                org::Limit::Instances,
                matches!(self.instances, Some(LimitArg::Lift)),
            ),
        ]
        .into_iter()
        .filter_map(|(l, lifted)| lifted.then_some(l))
        .collect();
        Ok(OrgOptions {
            cpus,
            memory,
            disk,
            instances,
            lift,
            default_cpus: self
                .default_cpus
                .or_else(|| parse_u32(current.and_then(|c| c.default_cpus.as_ref()))),
            default_memory: self
                .default_memory
                .clone()
                .or_else(|| current.and_then(|c| c.default_memory.clone())),
            bind_roots: current
                .map(|c| c.bind_roots.iter().map(Into::into).collect())
                .unwrap_or_default(),
            egress,
            udp,
            // Domain allowlist and ingress provider stay as they are: they
            // are set with `isb org create`, not through this tool.
            ..Default::default()
        })
    }
}

/// A size incus takes: digits, then an optional unit (`512MiB`, `16GiB`,
/// `100GB`).
fn check_size(what: &str, v: &str) -> Result<()> {
    let v = v.trim();
    let digits = v.chars().take_while(char::is_ascii_digit).count();
    let unit = &v[digits..];
    const UNITS: &[&str] = &[
        "", "B", "kB", "MB", "GB", "TB", "PB", "EB", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB",
    ];
    if digits == 0 || !UNITS.contains(&unit) {
        return Err(Error::invalid(format!(
            "{what} {v:?}: a size such as 512MiB or 16GiB"
        )));
    }
    Ok(())
}

/// An org as the tools answer it: the incus side, the service-name domain,
/// and how many members it has.
fn view(d: &Daemon, o: &OrgInfo) -> Value {
    let mut v = serde_json::to_value(o).unwrap_or_default();
    v["domain"] = json!(format!("{}.isb", o.name));
    v["service_names"] = json!(o.dns_dir.is_some());
    v["placement"] = super::servers::placement_view(d.servers.as_ref(), &o.name);
    v["members"] = json!(d.users.list_members(&o.name).map(|m| m.len()).unwrap_or(0));
    v["stacks"] = json!(
        d.ctl
            .definitions()
            .iter()
            .filter(|s| s.org == o.name)
            .count()
    );
    v
}

fn schema(extra: Value, required: &[&str], org_desc: &str) -> Value {
    let mut props = extra;
    props["org"] = json!({"type": "string", "description": org_desc});
    json!({"type": "object", "properties": props, "required": required, "additionalProperties": false})
}

fn settings_props() -> Value {
    json!({
        "cpus": {"anyOf": [{"type": "integer", "minimum": 1}, {"type": "string", "enum": ["none"]}, {"type": "null"}], "description": "CPUs across the org: the sum of every instance's limits.cpu, stopped ones included. \"none\" or null lifts the limit."},
        "memory": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Memory across the org, e.g. 16GiB: the sum of every instance's limits.memory, stopped ones included. \"none\" or null lifts the limit."},
        "disk": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Disk across the org, e.g. 100GiB: the sum of every root disk's and volume's size. While set, an instance without a root size gets 10GiB from the org's default profile. \"none\" or null lifts the limit."},
        "instances": {"anyOf": [{"type": "integer", "minimum": 1}, {"type": "string", "enum": ["none"]}, {"type": "null"}], "description": "Instances in the org, stopped ones included. \"none\" or null lifts the limit."},
        "default_cpus": {"type": "integer", "minimum": 1, "description": "CPUs an instance gets when its spec sets none."},
        "default_memory": {"type": "string", "description": "Memory an instance gets when its spec sets none, e.g. 512MiB."},
        "egress": {"type": "array", "items": {"type": "string"}, "description": "Private destinations the org may reach, CIDR[:PORTS[/tcp|udp]] (docs/concepts/orgs.md). Replaces the list; [] clears it."},
        "udp": {"type": "array", "items": {"type": "string"}, "description": "UDP ports the org's stacks may publish on the host, IP:PORT each (a specific host address, e.g. 203.0.113.7:10000), forwarded by incus to the service's one replica with the client's address kept (docs/concepts/stacks.md). Replaces the list; [] clears it."},
        "server": {"type": "string", "description": "Where the org runs: local (default) or a server's name (server_list). Set at creation; an org is not moved between servers. Same as placement {\"server\": NAME}."},
        "placement": {
            "description": "Where the org runs, set at creation: \"local\" (this host: an incus project sharing its kernel), {\"server\": NAME} (another host, server_list), or {\"vm\": {\"cpus\", \"memory\", \"disk\"}} (a dedicated VM this control plane makes on its own host: the org's own kernel; defaults 2 CPUs, 4GiB, 40GiB). An org is not moved afterwards.",
            "oneOf": [
                {"type": "string", "enum": ["local"]},
                {"type": "object", "properties": {"server": {"type": "string"}}, "required": ["server"], "additionalProperties": false},
                {"type": "object", "properties": {"vm": {"type": "object", "properties": {
                    "cpus": {"type": "integer", "minimum": 1, "maximum": 256},
                    "memory": {"type": "string", "description": "At least 2GiB (default 4GiB)."},
                    "disk": {"type": "string", "description": "At least 10GiB (default 40GiB)."}
                }, "additionalProperties": false}}, "required": ["vm"], "additionalProperties": false}
            ]
        },
        "wait": {"type": "boolean", "description": "With a dedicated VM: wait until it is made and the org created (default true; minutes). false answers at once with `provision`; follow it with server_provision_get (name vm-<org>)."}
    })
}

#[expect(
    clippy::too_many_lines,
    reason = "predates the lint ratchet; split it when next changed"
)]
pub(super) fn register(r: &mut Registry, d: Arc<Daemon>) -> Result<()> {
    let ro = json!({"readOnlyHint": true, "openWorldHint": false});
    let destructive = json!({"destructiveHint": true, "openWorldHint": false});
    let write = json!({"destructiveHint": false, "openWorldHint": false});

    macro_rules! tool {
        ($name:expr, $title:expr, $desc:expr, $schema:expr, $ann:expr, $f:expr) => {{
            let d = d.clone();
            let f = $f;
            r.register(
                Tool::new($name, $desc, $schema, move |a, c| f(&d, a, c))
                    .title($title)
                    .annotations($ann.clone()),
            )?;
        }};
    }

    tool!(
        "org_get",
        "Show an org",
        "An org's limits with what is allocated against each (`allocation`: limit, allocated, free; allocated is the sum of every instance's limit, stopped ones included, which is what incus enforces), per-instance defaults, network (bridge and subnet), egress exceptions, bind roots and service-name domain, with its instance, stack and member counts.",
        schema(json!({}), &[], "The org (default: default)."),
        ro,
        |d: &Daemon, a: Value, _c: &Caller| -> Result<Value> {
            #[derive(Deserialize)]
            #[serde(deny_unknown_fields)]
            struct A {
                #[serde(default)]
                org: Option<String>,
            }
            let a: A = args(a)?;
            let o = match a.org {
                Some(o) => OrgId::new(o)?,
                None => OrgId::default_org(),
            };
            Ok(view(d, &org::get(&d.client, &o)?))
        }
    );
    tool!(
        "org_list",
        "List orgs",
        "Platform admins: every org, as org_get shows one.",
        schema(json!({}), &[], "Ignored."),
        ro,
        |d: &Daemon, _a: Value, _c: &Caller| -> Result<Value> {
            let orgs: Vec<Value> = org::list(&d.client)?.iter().map(|o| view(d, o)).collect();
            Ok(json!({"orgs": orgs}))
        }
    );
    tool!(
        "org_create",
        "Create an org",
        "Platform admins: create an org (an incus project with its own bridge and network ACL), with optional limits and egress exceptions. Fails if it exists. Bind roots are set from the host's CLI only.",
        schema(
            settings_props(),
            &["org"],
            "The new org's name: [a-z0-9-], starts with a letter."
        ),
        write,
        |d: &Daemon, a: Value, _c: &Caller| -> Result<Value> {
            let s: Settings = args(a)?;
            let id = s.org()?;
            match org::get(&d.client, &id) {
                Ok(_) => return Err(Error::AlreadyExists(format!("org {id}"))),
                Err(e) if e.is_not_found() => {}
                Err(e) => return Err(e),
            }
            let opts = s.options(None)?;
            let mut notes = Vec::new();
            let info = org::ensure(&d.client, &id, &opts, &mut |m: &str| {
                notes.push(m.to_string())
            })?;
            // Services that were failing on a limit retry now.
            let woken = d.ctl.org_limits_changed(&id);
            if woken > 0 {
                notes.push(format!("{woken} service(s) waiting on a limit retry now"));
            }
            d.users
                .ensure_org(&info.name)
                .map_err(|e| Error::invalid(e.to_string()))?;
            let mut v = view(d, &info);
            v["notes"] = json!(notes);
            Ok(v)
        }
    );
    tool!(
        "org_update",
        "Change an org",
        "Platform admins: change an org's limits, per-instance defaults, egress exceptions or the UDP ports its stacks may publish. Fields left out keep their value; a limit given as \"none\" (or null) is lifted; `egress` and `udp` replace their lists. The same as `isb org update`.",
        schema(settings_props(), &["org"], "The org."),
        write,
        |d: &Daemon, a: Value, _c: &Caller| -> Result<Value> {
            let s: Settings = args(a)?;
            let id = s.org()?;
            let current = org::get(&d.client, &id)?;
            let opts = s.options(Some(&current))?;
            let mut notes = Vec::new();
            let info = org::ensure(&d.client, &id, &opts, &mut |m: &str| {
                notes.push(m.to_string())
            })?;
            // Services that were failing on a limit retry now.
            let woken = d.ctl.org_limits_changed(&id);
            if woken > 0 {
                notes.push(format!("{woken} service(s) waiting on a limit retry now"));
            }
            let mut v = view(d, &info);
            v["notes"] = json!(notes);
            Ok(v)
        }
    );
    tool!(
        "org_delete",
        "Delete an org",
        "Platform admins: delete an org: its project with its volumes, its network, ACL and service names, and its members, invitations and tokens. Refused while stacks are deployed in it (remove them first); with force=true its remaining sandboxes are deleted too. Its secrets stay on disk under the state directory.",
        schema(
            json!({
                "force": {"type": "boolean", "description": "Also delete the org's sandboxes."},
                "delete_vm": {"type": "boolean", "description": "For an org in a dedicated VM: delete the VM and its server registration too (default false: the VM keeps running as an empty server)."}
            }),
            &["org"],
            "The org to delete."
        ),
        destructive,
        |d: &Daemon, a: Value, _c: &Caller| -> Result<Value> {
            #[derive(Deserialize)]
            #[serde(deny_unknown_fields)]
            struct A {
                org: String,
                #[serde(default)]
                force: bool,
                // An org on this host has no VM of its own.
                #[serde(default)]
                #[allow(dead_code)]
                delete_vm: bool,
            }
            let a: A = args(a)?;
            let id = OrgId::new(a.org)?;
            if id.is_default() {
                return Err(Error::invalid("the default org cannot be removed"));
            }
            // The controller would recreate a stack's instances in a project
            // that no longer exists.
            let stacks: Vec<String> = d
                .ctl
                .definitions()
                .iter()
                .filter(|s| s.org == id)
                .map(|s| s.name.clone())
                .collect();
            if !stacks.is_empty() {
                return Err(Error::invalid(format!(
                    "org {id} has stacks deployed ({}); remove them first",
                    stacks.join(", ")
                )));
            }
            let mut notes = Vec::new();
            org::remove(&d.client, &id, a.force, &mut |m: &str| {
                notes.push(m.to_string())
            })?;
            d.users
                .delete_org(&id)
                .map_err(|e| Error::invalid(e.to_string()))?;
            Ok(json!({"ok": true, "notes": notes}))
        }
    );
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn info() -> OrgInfo {
        OrgInfo {
            name: OrgId::new("acme").unwrap(),
            project: "isb-acme".into(),
            network: Some("isbbr00000000".into()),
            subnet: Some("10.1.2.1/24".into()),
            cpus: Some("4".into()),
            memory: None,
            disk: None,
            instances_limit: None,
            default_cpus: Some("2".into()),
            default_memory: Some("1GiB".into()),
            default_disk: None,
            allocation: Default::default(),
            bind_roots: vec!["/srv/acme".into()],
            egress: vec!["10.9.0.0/16".into()],
            domains: vec![],
            ingress: "caddy".into(),
            udp: vec![],
            cloudflare_account: None,
            cloudflare_zone: None,
            dns_dir: None,
            instances: 0,
            allow_nesting: false,
        }
    }

    #[test]
    fn update_keeps_what_it_is_not_given() {
        let s = Settings {
            org: Some("acme".into()),
            memory: Some(LimitArg::Set("8GiB".into())),
            ..Default::default()
        };
        let o = s.options(Some(&info())).unwrap();
        assert_eq!(o.memory.as_deref(), Some("8GiB"));
        assert_eq!(o.cpus, None, "None keeps the project's limit");
        assert_eq!(o.default_cpus, Some(2));
        assert_eq!(o.default_memory.as_deref(), Some("1GiB"));
        assert_eq!(o.bind_roots, vec![std::path::PathBuf::from("/srv/acme")]);
        assert!(o.egress.is_none(), "egress left out is kept");
    }

    #[test]
    fn a_limit_given_as_none_or_null_is_lifted() {
        let s: Settings = serde_json::from_value(json!({
            "org": "acme", "cpus": "none", "disk": null, "memory": "4GiB", "instances": 3
        }))
        .unwrap();
        let o = s.options(Some(&info())).unwrap();
        assert_eq!(o.lift, vec![org::Limit::Cpus, org::Limit::Disk]);
        assert_eq!((o.cpus, o.disk), (None, None));
        assert_eq!(o.memory.as_deref(), Some("4GiB"));
        assert_eq!(o.instances, Some(3));
        // Left out: kept, not lifted.
        let s: Settings = serde_json::from_value(json!({"org": "acme"})).unwrap();
        assert!(s.options(Some(&info())).unwrap().lift.is_empty());
        let bad: std::result::Result<Settings, _> = serde_json::from_value(json!({"cpus": "lots"}));
        assert!(bad.is_err());
    }

    #[test]
    fn egress_replaces_and_clears() {
        let s = Settings {
            org: Some("acme".into()),
            egress: Some(vec!["100.79.171.47:1080/tcp".into(), " ".into()]),
            ..Default::default()
        };
        let e = s.options(Some(&info())).unwrap().egress.unwrap();
        assert_eq!(e.len(), 1);
        assert_eq!(e[0].render(), "100.79.171.47/32:1080/tcp");
        let s = Settings {
            egress: Some(vec![]),
            ..Default::default()
        };
        assert_eq!(s.options(None).unwrap().egress, Some(vec![]));
        let s = Settings {
            egress: Some(vec!["not-a-cidr".into()]),
            ..Default::default()
        };
        assert!(s.options(None).is_err());
    }

    #[test]
    fn udp_ports_replace_keep_and_are_checked() {
        let s = Settings {
            udp: Some(vec!["203.0.113.7:10000".into(), " ".into()]),
            ..Default::default()
        };
        let u = s.options(None).unwrap().udp.unwrap();
        assert_eq!(u, vec!["203.0.113.7:10000".parse().unwrap()]);
        assert!(
            Settings::default()
                .options(Some(&info()))
                .unwrap()
                .udp
                .is_none()
        );
        for bad in ["0.0.0.0:10000", "10000", "127.0.0.1:53"] {
            let s = Settings {
                udp: Some(vec![bad.into()]),
                ..Default::default()
            };
            assert!(s.options(None).is_err(), "{bad}");
        }
    }

    #[test]
    fn sizes_and_counts_are_checked() {
        for ok in ["512MiB", "16GiB", "100GB", "1024"] {
            assert!(check_size("memory", ok).is_ok(), "{ok}");
        }
        for bad in ["", "GiB", "16 gigs", "-1GiB", "16gib"] {
            assert!(check_size("memory", bad).is_err(), "{bad}");
        }
        let s = Settings {
            cpus: Some(LimitArg::Set(0)),
            ..Default::default()
        };
        assert!(s.options(None).is_err());
    }

    #[test]
    fn the_default_org_has_settings_like_any_other() {
        let s = Settings {
            org: Some("default".into()),
            ..Default::default()
        };
        assert!(s.org().unwrap().is_default());
        assert!(Settings::default().org().is_err());
    }

    #[test]
    fn only_local_placements_reach_the_tool() {
        let with = |p: Value| Settings {
            org: Some("acme".into()),
            placement: Some(p),
            ..Default::default()
        };
        assert!(with(json!("local")).org().is_ok());
        let e = with(json!({"vm": {}})).org().unwrap_err();
        assert!(e.to_string().contains("control plane"), "{e}");
        let e = with(json!({"server": "hel-1"})).org().unwrap_err();
        assert!(e.to_string().contains("server hel-1"), "{e}");
        let e = with(json!({"vm": {"memory": "1GiB"}})).org().unwrap_err();
        assert!(e.to_string().contains("at least 2 GiB"), "{e}");
        let both = Settings {
            org: Some("acme".into()),
            server: Some("x".into()),
            placement: Some(json!("local")),
            ..Default::default()
        };
        assert!(both.org().is_err());
    }
}