Skip to main content

isb_core/
machine.rs

1//! `isb machine`: incus runs only on Linux, so on macOS isb manages a Lima VM
2//! that runs it, the way `podman machine` does.
3//!
4//! The VM is plain Lima (`limactl`, installed separately) driven by a YAML
5//! file isb generates into `~/.isb/machine/<name>/lima.yaml`:
6//!
7//! - Ubuntu 24.04 with incus from Zabbly's stable channel. incus's socket is
8//!   owned by the Lima user (a `SocketUser` drop-in), because Lima forwards it
9//!   over an ssh connection that was opened before any group change.
10//! - `$HOME` mounted writable at the same path (virtiofs), so a bind source
11//!   like `./app` resolves to the same absolute path on the Mac and in the VM.
12//!   Apple's virtiofs reports every file as owned by whoever asks and writes
13//!   as the Mac user, so any container uid can write a bind mount and
14//!   `idmap: auto` maps nothing (`ISB_BIND_CALLER_OWNED` tells the daemon).
15//! - The incus socket and `isb serve`'s socket forwarded to
16//!   `~/.isb/machine/<name>/{incus,serve}.sock`, and loopback listeners (incus
17//!   proxy devices, the stack balancer, the daemon's 8092) forwarded to the
18//!   Mac's 127.0.0.1 by Lima.
19//! - `isb serve` runs in the VM as a system unit, as the Linux binary, next to
20//!   the bridges its balancer must reach.
21
22use std::io::Read;
23use std::path::{Path, PathBuf};
24use std::process::{Command, Stdio};
25use std::time::{Duration, Instant};
26
27use serde::Serialize;
28use serde_json::Value;
29
30use crate::client::{Client, Timeouts};
31use crate::error::{Error, Result};
32#[doc(hidden)]
33pub use crate::self_update::hex;
34pub(crate) use crate::self_update::{download_asset, fetch};
35
36/// The machine every command uses unless told otherwise, and the one whose
37/// sockets [`Client::default_socket`] and the serve socket fall back to.
38pub const DEFAULT_NAME: &str = "isb";
39/// The daemon's HTTP listener in the VM, forwarded to the same address on the Mac.
40pub const SERVE_LISTEN: &str = "127.0.0.1:8092";
41/// launchd label of the agent `isb serve install` writes on macOS.
42pub const LAUNCH_AGENT_LABEL: &str = "dev.isb.machine";
43
44const GUEST_INCUS_SOCKET: &str = "/var/lib/incus/unix.socket";
45const GUEST_SERVE_SOCKET: &str = "/run/isb/serve.sock";
46/// Written by the provisioning script once incus is installed and initialised.
47const PROVISIONED_MARKER: &str = "/var/lib/isb-machine/provisioned";
48
49fn home() -> Result<PathBuf> {
50    std::env::var_os("HOME")
51        .filter(|h| !h.is_empty())
52        .map(PathBuf::from)
53        .ok_or_else(|| Error::invalid("HOME is not set"))
54}
55
56/// A machine name doubles as the Lima instance name.
57pub fn validate_name(name: &str) -> Result<()> {
58    let ok = !name.is_empty()
59        && name.len() <= 32
60        && name.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit())
61        && name
62            .chars()
63            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
64    if ok {
65        Ok(())
66    } else {
67        Err(Error::invalid(format!(
68            "machine name {name:?}: use 1-32 lowercase letters, digits and dashes"
69        )))
70    }
71}
72
73/// `~/.isb/machine/<name>`: the Lima YAML, the staged guest binary and the
74/// forwarded sockets.
75pub fn dir(name: &str) -> Result<PathBuf> {
76    Ok(home()?.join(".isb/machine").join(name))
77}
78
79pub fn incus_socket(name: &str) -> Result<PathBuf> {
80    Ok(dir(name)?.join("incus.sock"))
81}
82
83pub fn serve_socket(name: &str) -> Result<PathBuf> {
84    Ok(dir(name)?.join("serve.sock"))
85}
86
87/// What the generated Lima YAML depends on.
88#[derive(Debug, Clone)]
89pub struct LimaConfig {
90    pub name: String,
91    pub cpus: u32,
92    pub memory: String,
93    pub disk: String,
94    /// The Mac user's home, mounted at the same path.
95    pub home: PathBuf,
96    /// The guest user: the Mac user's name when Linux accepts it.
97    pub user: String,
98    /// The guest user's uid: the Mac user's, as Lima defaults it.
99    pub uid: u32,
100}
101
102/// A JSON string is a valid YAML double-quoted scalar.
103fn q(s: &str) -> String {
104    Value::String(s.to_string()).to_string()
105}
106
107/// The guest user name: the Mac user's when it is a valid Linux name, else
108/// `lima`, as Lima itself would choose.
109pub fn guest_user(mac_user: &str) -> String {
110    let ok = !mac_user.is_empty()
111        && mac_user.len() <= 32
112        && mac_user.starts_with(|c: char| c.is_ascii_lowercase() || c == '_')
113        && mac_user
114            .chars()
115            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-');
116    if ok {
117        mac_user.to_string()
118    } else {
119        "lima".to_string()
120    }
121}
122
123/// `4GiB`, `512MiB`, `10G`: digits, an optional fraction, an optional unit.
124fn check_size(what: &str, v: &str) -> Result<()> {
125    let digits = v.trim_end_matches(|c: char| c.is_ascii_alphabetic());
126    let unit = &v[digits.len()..];
127    let ok = !digits.is_empty()
128        && digits.chars().all(|c| c.is_ascii_digit() || c == '.')
129        && digits.chars().next().is_some_and(|c| c.is_ascii_digit())
130        && matches!(
131            unit,
132            "" | "K" | "M" | "G" | "T" | "KiB" | "MiB" | "GiB" | "TiB" | "KB" | "MB" | "GB" | "TB"
133        );
134    if ok {
135        Ok(())
136    } else {
137        Err(Error::invalid(format!(
138            "{what} {v:?}: expected a size like 4GiB or 512MiB"
139        )))
140    }
141}
142
143/// The provisioning script. Lima runs it as root on every boot, so every step
144/// is idempotent.
145fn provision_script(c: &LimaConfig) -> String {
146    format!(
147        r#"#!/bin/bash
148set -eux -o pipefail
149# Containers get their own range, and root may map 1000 through (raw.idmap)
150# for `idmap: always`, as on a Linux host.
151grep -qx 'root:1000000:1000000000' /etc/subuid || echo 'root:1000000:1000000000' >> /etc/subuid
152grep -qx 'root:1000000:1000000000' /etc/subgid || echo 'root:1000000:1000000000' >> /etc/subgid
153grep -qx 'root:1000:1' /etc/subuid || echo 'root:1000:1' >> /etc/subuid
154grep -qx 'root:1000:1' /etc/subgid || echo 'root:1000:1' >> /etc/subgid
155# Lima forwards the socket over an ssh connection opened before the user
156# joins incus-admin, so the user owns the socket instead.
157mkdir -p /etc/systemd/system/incus.socket.d
158printf '[Socket]\nSocketUser={user}\n' > /etc/systemd/system/incus.socket.d/isb-machine.conf
159systemctl daemon-reload
160if ! command -v incus >/dev/null 2>&1; then
161  export DEBIAN_FRONTEND=noninteractive
162  install -d -m 0755 /etc/apt/keyrings
163  curl -fsSL https://pkgs.zabbly.com/key.asc -o /etc/apt/keyrings/zabbly.asc
164  cat > /etc/apt/sources.list.d/zabbly-incus-stable.sources <<EOF
165Enabled: yes
166Types: deb
167URIs: https://pkgs.zabbly.com/incus/stable
168Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")
169Components: main
170Architectures: $(dpkg --print-architecture)
171Signed-By: /etc/apt/keyrings/zabbly.asc
172EOF
173  apt-get update
174  apt-get install -y --no-install-recommends incus
175fi
176usermod -aG incus-admin {user}
177if [ ! -e {marker} ]; then
178  # Not --auto: behind Lima's NAT every probed subnet answers, so incus
179  # finds no free one. The bridge subnet only has to be private to the VM.
180  incus admin init --preseed <<EOF
181networks:
182- name: incusbr0
183  type: bridge
184  config:
185    ipv4.address: {bridge}
186    ipv4.nat: "true"
187    ipv6.address: none
188storage_pools:
189- name: default
190  driver: dir
191profiles:
192- name: default
193  devices:
194    root:
195      type: disk
196      path: /
197      pool: default
198    eth0:
199      type: nic
200      name: eth0
201      network: incusbr0
202EOF
203  mkdir -p "$(dirname {marker})"
204  touch {marker}
205fi
206"#,
207        user = c.user,
208        marker = PROVISIONED_MARKER,
209        bridge = BRIDGE_ADDRESS,
210    )
211}
212
213/// incusbr0's address in the VM.
214const BRIDGE_ADDRESS: &str = "10.177.0.1/24";
215
216/// The Lima instance definition `isb machine init` starts.
217pub fn render_lima_yaml(c: &LimaConfig) -> String {
218    let home = c.home.to_string_lossy();
219    let dir = c.home.join(".isb/machine").join(&c.name);
220    let sock = |f: &str| q(&dir.join(f).to_string_lossy());
221    let script = provision_script(c)
222        .lines()
223        .map(|l| {
224            if l.is_empty() {
225                String::new()
226            } else {
227                format!("    {l}")
228            }
229        })
230        .collect::<Vec<_>>()
231        .join("\n");
232    format!(
233        r#"# Generated by `isb machine init {name}`; isb owns this file.
234# An Ubuntu 24.04 VM running incus, for isb on macOS.
235minimumLimaVersion: 2.0.0
236base:
237- template:_images/ubuntu-24.04
238vmType: vz
239cpus: {cpus}
240memory: {memory}
241disk: {disk}
242user:
243  name: {user}
244  uid: {uid}
245# The Mac home at the same path, so bind sources resolve identically.
246mounts:
247- location: {home}
248  mountPoint: {home}
249  writable: true
250mountType: virtiofs
251containerd:
252  system: false
253  user: false
254# For isb run inside the VM (`isb machine ssh`), as the daemon has them.
255env:
256  {caller_owned}: "1"
257  ISB_SERVE_SOCKET: {guest_serve}
258provision:
259- mode: system
260  script: |
261{script}
262probes:
263- mode: readiness
264  description: incus installed and initialised
265  script: |
266    #!/bin/bash
267    set -eu -o pipefail
268    if ! timeout 30s bash -c "until test -e {marker}; do sleep 2; done"; then
269      echo >&2 "incus is not set up yet"
270      exit 1
271    fi
272  hint: See /var/log/cloud-init-output.log in the guest (`isb machine ssh {name}`).
273portForwards:
274- guestSocket: {guest_incus}
275  hostSocket: {incus_sock}
276- guestSocket: {guest_serve}
277  hostSocket: {serve_sock}
278- guestIP: 127.0.0.1
279  guestPort: {serve_port}
280  hostIP: 127.0.0.1
281  hostPort: {serve_port}
282# Lima can forward a port below 1024 only by listening on every Mac
283# interface (macOS lets a user bind those on the wildcard address alone),
284# which would expose a published port to the network. It also keeps incus's
285# DHCP server (udp/67) off the Mac.
286- guestIP: 127.0.0.1
287  guestPortRange: [1, 1023]
288  proto: any
289  ignore: true
290# Lima's built-in last rule forwards every other guest loopback listener
291# (published ports, the stack balancer) to the same port on the Mac.
292"#,
293        name = c.name,
294        cpus = c.cpus,
295        memory = q(&c.memory),
296        disk = q(&c.disk),
297        user = q(&c.user),
298        uid = c.uid,
299        caller_owned = crate::idmap::CALLER_OWNED_ENV,
300        home = q(&home),
301        guest_serve = q(GUEST_SERVE_SOCKET),
302        guest_incus = q(GUEST_INCUS_SOCKET),
303        incus_sock = sock("incus.sock"),
304        serve_sock = sock("serve.sock"),
305        serve_port = SERVE_LISTEN.rsplit(':').next().unwrap_or("8092"),
306        marker = PROVISIONED_MARKER,
307    )
308}
309
310/// The `isb serve` unit in the guest. `@HOME@` is filled in there.
311pub fn render_guest_unit(user: &str) -> String {
312    let caller_owned = crate::idmap::CALLER_OWNED_ENV;
313    format!(
314        "[Unit]
315Description=isb serve (managed by isb machine)
316After=network-online.target incus.socket
317Wants=network-online.target incus.socket
318
319[Service]
320Type=simple
321User={user}
322SupplementaryGroups=incus-admin
323WorkingDirectory=@HOME@
324Environment=HOME=@HOME@
325Environment=ISB_SERVE_SOCKET={GUEST_SERVE_SOCKET}
326Environment=ISB_SERVE_LISTEN={SERVE_LISTEN}
327Environment={caller_owned}=1
328RuntimeDirectory=isb
329RuntimeDirectoryMode=0700
330ExecStart=/usr/local/bin/isb serve
331Restart=always
332RestartSec=2
333
334[Install]
335WantedBy=multi-user.target
336"
337    )
338}
339
340/// Install the staged binary and the unit, then (re)start the daemon. Runs as
341/// root in the guest.
342fn guest_setup_script(staged: &Path, unit: &str) -> String {
343    format!(
344        r#"set -euo pipefail
345install -m 0755 {staged} /usr/local/bin/isb
346home=$(getent passwd "$SUDO_USER" | cut -d: -f6)
347cat > /etc/systemd/system/isb.service.tmp <<'ISB_UNIT_EOF'
348{unit}ISB_UNIT_EOF
349sed "s|@HOME@|$home|g" /etc/systemd/system/isb.service.tmp > /etc/systemd/system/isb.service
350rm /etc/systemd/system/isb.service.tmp
351systemctl daemon-reload
352systemctl enable isb.service
353systemctl restart isb.service
354"#,
355        staged = shell_quote(&staged.to_string_lossy()),
356    )
357}
358
359fn shell_quote(s: &str) -> String {
360    format!("'{}'", s.replace('\'', r"'\''"))
361}
362
363// ---------------------------------------------------------------------------
364// limactl
365
366fn limactl() -> Command {
367    let mut c = Command::new("limactl");
368    c.stdin(Stdio::null());
369    c
370}
371
372fn missing_lima(e: std::io::Error) -> Error {
373    if e.kind() == std::io::ErrorKind::NotFound {
374        Error::invalid(
375            "limactl not found: isb machine runs incus in a Lima VM; install Lima with \
376             `brew install lima` (https://lima-vm.io)",
377        )
378    } else {
379        Error::Io(e)
380    }
381}
382
383/// Run limactl, capturing its output; fail with its stderr.
384fn limactl_output(args: &[&str]) -> Result<String> {
385    let out = limactl().args(args).output().map_err(missing_lima)?;
386    if !out.status.success() {
387        return Err(Error::OperationFailed {
388            step: format!("limactl {}", args.join(" ")),
389            message: String::from_utf8_lossy(&out.stderr).trim().to_string(),
390        });
391    }
392    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
393}
394
395/// Run limactl with its output on our stderr (progress the user should see).
396fn limactl_passthrough(args: &[&str]) -> Result<()> {
397    let st = limactl()
398        .args(args)
399        .stdout(Stdio::inherit())
400        .stderr(Stdio::inherit())
401        .status()
402        .map_err(missing_lima)?;
403    if !st.success() {
404        return Err(Error::OperationFailed {
405            step: format!("limactl {}", args.join(" ")),
406            message: format!("exited with {st}"),
407        });
408    }
409    Ok(())
410}
411
412/// `limactl version 2.2.0` → (2, 2).
413fn parse_lima_version(s: &str) -> Option<(u32, u32)> {
414    let v = s.split_whitespace().last()?.trim_start_matches('v');
415    let mut it = v.split(['.', '-']);
416    Some((it.next()?.parse().ok()?, it.next()?.parse().ok()?))
417}
418
419fn check_lima_version() -> Result<()> {
420    let out = limactl_output(&["--version"])?;
421    match parse_lima_version(out.trim()) {
422        Some((major, _)) if major >= 2 => Ok(()),
423        Some(_) => Err(Error::invalid(format!(
424            "{}: isb machine needs Lima 2.0 or later (`brew upgrade lima`)",
425            out.trim()
426        ))),
427        None => Err(Error::invalid(format!(
428            "cannot read the Lima version from {:?}",
429            out.trim()
430        ))),
431    }
432}
433
434/// The Lima instance, from `limactl list --json` (one object per line).
435fn lima_instance(name: &str) -> Result<Option<Value>> {
436    let out = limactl_output(&["list", "--json"])?;
437    Ok(out
438        .lines()
439        .filter_map(|l| serde_json::from_str::<Value>(l).ok())
440        .find(|v| v["name"] == name))
441}
442
443fn require_macos() -> Result<()> {
444    if cfg!(target_os = "macos") {
445        Ok(())
446    } else {
447        Err(Error::invalid(
448            "isb machine is for macOS, where incus cannot run natively; on Linux, install \
449             incus on the host (https://linuxcontainers.org/incus/docs/main/installing/)",
450        ))
451    }
452}
453
454fn require_ours(name: &str) -> Result<PathBuf> {
455    validate_name(name)?;
456    let d = dir(name)?;
457    if !d.join("lima.yaml").exists() {
458        return Err(Error::NotFound(format!(
459            "machine {name} ({} has no lima.yaml; create it with `isb machine init {name}`)",
460            d.display()
461        )));
462    }
463    Ok(d)
464}
465
466// ---------------------------------------------------------------------------
467// init / start / stop / rm / status
468
469#[derive(Debug, Clone)]
470pub struct InitOptions {
471    pub name: String,
472    pub cpus: u32,
473    pub memory: String,
474    pub disk: String,
475    /// A Linux isb binary for the guest; default: this version's release asset.
476    pub isb_binary: Option<PathBuf>,
477    /// Deadline for the first boot (image download, incus install).
478    pub timeout: Duration,
479}
480
481impl Default for InitOptions {
482    fn default() -> Self {
483        InitOptions {
484            name: DEFAULT_NAME.into(),
485            cpus: 4,
486            memory: "4GiB".into(),
487            disk: "10GiB".into(),
488            isb_binary: None,
489            timeout: Duration::from_secs(20 * 60),
490        }
491    }
492}
493
494/// Create and start the machine, install incus and the guest's `isb serve`.
495/// `log` receives one progress line per step.
496pub fn init(opts: &InitOptions, log: &dyn Fn(&str)) -> Result<Status> {
497    require_macos()?;
498    validate_name(&opts.name)?;
499    check_size("--memory", &opts.memory)?;
500    check_size("--disk", &opts.disk)?;
501    if opts.cpus == 0 {
502        return Err(Error::invalid("--cpus must be at least 1"));
503    }
504    check_lima_version()?;
505    if lima_instance(&opts.name)?.is_some() {
506        return Err(Error::invalid(format!(
507            "a Lima instance named {} already exists (`limactl list`); remove it with \
508             `isb machine rm {0}` if isb created it, or pick another name",
509            opts.name
510        )));
511    }
512    let home = home()?;
513    let d = dir(&opts.name)?;
514    {
515        use std::os::unix::fs::DirBuilderExt;
516        std::fs::DirBuilder::new()
517            .recursive(true)
518            .mode(0o700)
519            .create(&d)?;
520    }
521
522    let staged = d.join("isb-linux");
523    match &opts.isb_binary {
524        Some(p) => {
525            log(&format!("using guest isb {}", p.display()));
526            stage_binary(p, &staged)?;
527        }
528        None => {
529            let ver = env!("CARGO_PKG_VERSION");
530            log(&format!("downloading isb {ver} for Linux"));
531            download_release(ver, std::env::consts::ARCH, &d, &staged)?;
532        }
533    }
534
535    let uid = rustix::process::getuid().as_raw();
536    let user = guest_user(&std::env::var("USER").unwrap_or_default());
537    let cfg = LimaConfig {
538        name: opts.name.clone(),
539        cpus: opts.cpus,
540        memory: opts.memory.clone(),
541        disk: opts.disk.clone(),
542        home,
543        user: user.clone(),
544        uid,
545    };
546    let yaml = d.join("lima.yaml");
547    std::fs::write(&yaml, render_lima_yaml(&cfg))?;
548
549    log(&format!(
550        "starting Lima instance {} (first boot downloads Ubuntu and installs incus)",
551        opts.name
552    ));
553    let timeout = format!("--timeout={}s", opts.timeout.as_secs());
554    let name_arg = format!("--name={}", opts.name);
555    let yaml_s = yaml.to_string_lossy().into_owned();
556    limactl_passthrough(&["start", &name_arg, "--tty=false", &timeout, &yaml_s]).map_err(|e| {
557        Error::OperationFailed {
558            step: format!("first boot of machine {}", opts.name),
559            message: format!(
560                "{e}; look inside with `isb machine ssh {0} -- sudo tail -50 \
561                 /var/log/cloud-init-output.log`, start over with `isb machine rm {0}`",
562                opts.name
563            ),
564        }
565    })?;
566
567    log("installing isb serve in the machine");
568    guest_shell_root(
569        &opts.name,
570        &guest_setup_script(&staged, &render_guest_unit(&user)),
571    )?;
572    wait_ready(&opts.name, Duration::from_secs(90))?;
573    status(&opts.name)
574}
575
576/// Run a bash script as root in the guest, fed on stdin.
577fn guest_shell_root(name: &str, script: &str) -> Result<()> {
578    use std::io::Write;
579    let mut child = Command::new("limactl")
580        .args(["shell", "--workdir", "/", name, "sudo", "bash", "-s"])
581        .stdin(Stdio::piped())
582        .stdout(Stdio::inherit())
583        .stderr(Stdio::inherit())
584        .spawn()
585        .map_err(missing_lima)?;
586    child
587        .stdin
588        .take()
589        .expect("piped stdin")
590        .write_all(script.as_bytes())?;
591    let st = child.wait()?;
592    if !st.success() {
593        return Err(Error::OperationFailed {
594            step: format!("guest setup in machine {name}"),
595            message: format!("exited with {st}"),
596        });
597    }
598    Ok(())
599}
600
601fn stage_binary(src: &Path, dst: &Path) -> Result<()> {
602    let mut magic = [0u8; 4];
603    std::fs::File::open(src)
604        .and_then(|mut f| f.read_exact(&mut magic))
605        .map_err(|e| Error::invalid(format!("--isb-binary {}: {e}", src.display())))?;
606    if &magic != b"\x7fELF" {
607        return Err(Error::invalid(format!(
608            "--isb-binary {}: not a Linux (ELF) binary; the guest needs the \
609             {}-unknown-linux-musl build",
610            src.display(),
611            std::env::consts::ARCH
612        )));
613    }
614    std::fs::copy(src, dst)?;
615    set_mode(dst, 0o755)
616}
617
618pub(crate) fn set_mode(p: &Path, mode: u32) -> Result<()> {
619    use std::os::unix::fs::PermissionsExt;
620    std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode))?;
621    Ok(())
622}
623
624/// The release asset name for a version and architecture.
625pub fn release_asset(version: &str, arch: &str) -> String {
626    format!("isb-v{version}-{arch}-unknown-linux-musl")
627}
628
629/// Download the release tarball, check it against SHA256SUMS, unpack the binary.
630#[doc(hidden)]
631pub fn download_release(version: &str, arch: &str, dir: &Path, dst: &Path) -> Result<()> {
632    download_asset(
633        version,
634        &release_asset(version, arch),
635        "pass a Linux build with --isb-binary",
636        dir,
637        dst,
638    )
639}
640
641/// Wait until both forwarded sockets answer.
642fn wait_ready(name: &str, timeout: Duration) -> Result<()> {
643    let started = Instant::now();
644    loop {
645        let (incus, serve) = (probe_incus(name), probe_serve(name));
646        match (&incus, &serve) {
647            (Ok(_), Ok(())) => return Ok(()),
648            _ if started.elapsed() >= timeout => {
649                let why = incus
650                    .err()
651                    .map(|e| format!("incus: {e}"))
652                    .or(serve.err().map(|e| format!("isb serve: {e}")))
653                    .unwrap_or_default();
654                return Err(Error::OperationFailed {
655                    step: format!("wait for machine {name}"),
656                    message: format!(
657                        "not ready after {timeout:?} ({why}); look inside with `isb machine ssh {name}`"
658                    ),
659                });
660            }
661            _ => std::thread::sleep(Duration::from_millis(500)),
662        }
663    }
664}
665
666fn probe_incus(name: &str) -> Result<String> {
667    let mut c = Client::with_socket(incus_socket(name)?);
668    c.timeouts = Timeouts {
669        request: Duration::from_secs(5),
670        ..Timeouts::default()
671    };
672    let info = c.server_info()?;
673    Ok(info
674        .pointer("/environment/server_version")
675        .and_then(Value::as_str)
676        .unwrap_or("")
677        .to_string())
678}
679
680fn probe_serve(name: &str) -> Result<()> {
681    crate::serve_client::list_tools(&serve_socket(name)?, Duration::from_secs(5)).map(|_| ())
682}
683
684/// Start a stopped machine (a no-op when it is running) and wait for its sockets.
685pub fn start(name: &str) -> Result<()> {
686    require_macos()?;
687    require_ours(name)?;
688    let inst = lima_instance(name)?.ok_or_else(|| {
689        Error::NotFound(format!(
690            "Lima instance {name} (its files are in {}; remove them with `isb machine rm {name}`)",
691            dir(name)
692                .map(|d| d.display().to_string())
693                .unwrap_or_default()
694        ))
695    })?;
696    if inst["status"] != "Running" {
697        limactl_passthrough(&["start", "--tty=false", name])?;
698    }
699    wait_ready(name, Duration::from_secs(90))
700}
701
702pub fn stop(name: &str) -> Result<()> {
703    require_macos()?;
704    require_ours(name)?;
705    match lima_instance(name)? {
706        Some(i) if i["status"] == "Running" => limactl_passthrough(&["stop", name]),
707        _ => Ok(()),
708    }
709}
710
711/// Delete the VM, its files and a LaunchAgent that starts it.
712pub fn remove(name: &str) -> Result<()> {
713    require_macos()?;
714    let d = require_ours(name)?;
715    if lima_instance(name)?.is_some() {
716        limactl_passthrough(&["delete", "--force", name])?;
717    }
718    if launch_agent_machine()?.as_deref() == Some(name) {
719        uninstall_launch_agent()?;
720    }
721    std::fs::remove_dir_all(&d)?;
722    Ok(())
723}
724
725/// What `isb machine status` reports.
726#[derive(Debug, Clone, Serialize)]
727pub struct Status {
728    pub name: String,
729    /// `Running`, `Stopped`, ... as Lima reports it; `Missing` with no instance.
730    pub state: String,
731    pub cpus: Option<u64>,
732    pub memory_bytes: Option<u64>,
733    pub disk_bytes: Option<u64>,
734    pub arch: Option<String>,
735    pub lima_dir: Option<String>,
736    pub incus_socket: PathBuf,
737    /// incus's version when its socket answers.
738    pub incus_version: Option<String>,
739    pub incus_error: Option<String>,
740    pub serve_socket: PathBuf,
741    pub serve_ok: bool,
742    pub serve_listen: String,
743    /// Whether isb uses this machine without `INCUS_SOCKET`/`ISB_SERVE_SOCKET`.
744    pub default: bool,
745}
746
747pub fn status(name: &str) -> Result<Status> {
748    require_macos()?;
749    require_ours(name)?;
750    let inst = lima_instance(name)?;
751    let running = inst.as_ref().is_some_and(|i| i["status"] == "Running");
752    let (incus_version, incus_error) = if running {
753        match probe_incus(name) {
754            Ok(v) => (Some(v), None),
755            Err(e) => (None, Some(e.to_string())),
756        }
757    } else {
758        (None, None)
759    };
760    let field = |k: &str| inst.as_ref().and_then(|i| i[k].as_u64());
761    Ok(Status {
762        name: name.to_string(),
763        state: inst
764            .as_ref()
765            .and_then(|i| i["status"].as_str())
766            .unwrap_or("Missing")
767            .to_string(),
768        cpus: field("cpus"),
769        memory_bytes: field("memory"),
770        disk_bytes: field("disk"),
771        arch: inst
772            .as_ref()
773            .and_then(|i| i["arch"].as_str())
774            .map(String::from),
775        lima_dir: inst
776            .as_ref()
777            .and_then(|i| i["dir"].as_str())
778            .map(String::from),
779        incus_socket: incus_socket(name)?,
780        incus_version,
781        incus_error,
782        serve_socket: serve_socket(name)?,
783        serve_ok: running && probe_serve(name).is_ok(),
784        serve_listen: SERVE_LISTEN.to_string(),
785        default: name == DEFAULT_NAME,
786    })
787}
788
789/// `limactl shell NAME [argv]`, for the caller to exec.
790pub fn shell_command(name: &str, argv: &[String]) -> Result<Command> {
791    require_macos()?;
792    require_ours(name)?;
793    let mut c = Command::new("limactl");
794    c.arg("shell").arg(name).args(argv);
795    Ok(c)
796}
797
798// ---------------------------------------------------------------------------
799// LaunchAgent: `isb serve install` on macOS
800
801fn launch_agent_path() -> Result<PathBuf> {
802    Ok(home()?
803        .join("Library/LaunchAgents")
804        .join(format!("{LAUNCH_AGENT_LABEL}.plist")))
805}
806
807fn xml_escape(s: &str) -> String {
808    s.replace('&', "&amp;")
809        .replace('<', "&lt;")
810        .replace('>', "&gt;")
811        .replace('"', "&quot;")
812}
813
814/// The LaunchAgent: run `isb machine start NAME` at login. `path` is the PATH
815/// it runs with, which must find `limactl`.
816pub fn render_launch_agent(exe: &Path, name: &str, path: &str, log: &Path) -> String {
817    let s = |v: &str| format!("<string>{}</string>", xml_escape(v));
818    format!(
819        r#"<?xml version="1.0" encoding="UTF-8"?>
820<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
821<!-- Written by `isb serve install`: starts the isb machine, where isb serve runs, at login. -->
822<plist version="1.0">
823<dict>
824  <key>Label</key>
825  {label}
826  <key>ProgramArguments</key>
827  <array>
828    {exe}
829    <string>machine</string>
830    <string>start</string>
831    {name}
832  </array>
833  <key>EnvironmentVariables</key>
834  <dict>
835    <key>PATH</key>
836    {path}
837  </dict>
838  <key>RunAtLoad</key>
839  <true/>
840  <key>StandardOutPath</key>
841  {log}
842  <key>StandardErrorPath</key>
843  {log}
844</dict>
845</plist>
846"#,
847        label = s(LAUNCH_AGENT_LABEL),
848        exe = s(&exe.to_string_lossy()),
849        name = s(name),
850        path = s(path),
851        log = s(&log.to_string_lossy()),
852    )
853}
854
855/// The machine an installed LaunchAgent starts, if there is one.
856fn launch_agent_machine() -> Result<Option<String>> {
857    let Ok(text) = std::fs::read_to_string(launch_agent_path()?) else {
858        return Ok(None);
859    };
860    // The argument after `start` in ProgramArguments.
861    let mut it = text
862        .split("<string>")
863        .skip(1)
864        .map(|s| s.split('<').next().unwrap_or(""));
865    while let Some(v) = it.next() {
866        if v == "start" {
867            return Ok(it.next().map(String::from));
868        }
869    }
870    Ok(None)
871}
872
873#[derive(Debug, Clone, Serialize)]
874pub struct LaunchAgentInstall {
875    pub plist: PathBuf,
876    pub exe: PathBuf,
877    pub machine: String,
878    pub serve_socket: PathBuf,
879    pub health_url: String,
880}
881
882fn launchctl(args: &[&str]) -> Result<std::process::Output> {
883    Ok(Command::new("launchctl")
884        .args(args)
885        .stdin(Stdio::null())
886        .output()?)
887}
888
889/// Write the LaunchAgent, load it (which starts the machine), and wait for the
890/// daemon in the machine to answer.
891pub fn install_launch_agent(name: &str) -> Result<LaunchAgentInstall> {
892    require_macos()?;
893    require_ours(name)?;
894    let exe = std::env::current_exe()?.canonicalize()?;
895    let limactl_dir = find_in_path("limactl").and_then(|p| p.parent().map(Path::to_path_buf));
896    let mut path: Vec<String> = Vec::new();
897    if let Some(d) = limactl_dir {
898        path.push(d.to_string_lossy().into_owned());
899    }
900    for d in [
901        "/opt/homebrew/bin",
902        "/usr/local/bin",
903        "/usr/bin",
904        "/bin",
905        "/usr/sbin",
906        "/sbin",
907    ] {
908        if !path.iter().any(|p| p == d) {
909            path.push(d.into());
910        }
911    }
912    let plist = launch_agent_path()?;
913    let log = dir(name)?.join("launchd.log");
914    let text = render_launch_agent(&exe, name, &path.join(":"), &log);
915    if let Some(d) = plist.parent() {
916        std::fs::create_dir_all(d)?;
917    }
918    std::fs::write(&plist, text)?;
919
920    let domain = format!("gui/{}", rustix::process::getuid().as_raw());
921    let target = format!("{domain}/{LAUNCH_AGENT_LABEL}");
922    // Reload: bootout fails harmlessly when it is not loaded.
923    let _ = launchctl(&["bootout", &target]);
924    let plist_s = plist.to_string_lossy().into_owned();
925    let out = launchctl(&["bootstrap", &domain, &plist_s])?;
926    if !out.status.success() {
927        return Err(Error::OperationFailed {
928            step: format!("launchctl bootstrap {domain} {plist_s}"),
929            message: String::from_utf8_lossy(&out.stderr).trim().to_string(),
930        });
931    }
932    wait_ready(name, Duration::from_secs(300)).map_err(|e| Error::OperationFailed {
933        step: format!("start machine {name} from {LAUNCH_AGENT_LABEL}"),
934        message: format!("{e}; its log is {}", log.display()),
935    })?;
936    Ok(LaunchAgentInstall {
937        plist,
938        exe,
939        machine: name.to_string(),
940        serve_socket: serve_socket(name)?,
941        health_url: format!("http://{SERVE_LISTEN}/healthz"),
942    })
943}
944
945/// Unload and delete the LaunchAgent (a no-op without one).
946pub fn uninstall_launch_agent() -> Result<bool> {
947    let plist = launch_agent_path()?;
948    if !plist.exists() {
949        return Ok(false);
950    }
951    let target = format!(
952        "gui/{}/{LAUNCH_AGENT_LABEL}",
953        rustix::process::getuid().as_raw()
954    );
955    let _ = launchctl(&["bootout", &target]);
956    std::fs::remove_file(&plist)?;
957    Ok(true)
958}
959
960fn find_in_path(bin: &str) -> Option<PathBuf> {
961    std::env::var_os("PATH").and_then(|p| {
962        std::env::split_paths(&p)
963            .map(|d| d.join(bin))
964            .find(|c| c.is_file())
965    })
966}
967
968#[cfg(test)]
969mod tests {
970    use super::*;
971
972    fn cfg() -> LimaConfig {
973        LimaConfig {
974            name: "isb".into(),
975            cpus: 4,
976            memory: "4GiB".into(),
977            disk: "10GiB".into(),
978            home: "/Users/me".into(),
979            user: "me".into(),
980            uid: 501,
981        }
982    }
983
984    #[test]
985    fn lima_yaml() {
986        let y = render_lima_yaml(&cfg());
987        let v: serde_yaml_ng::Value = serde_yaml_ng::from_str(&y).expect("valid YAML");
988        assert_eq!(v["cpus"], 4);
989        assert_eq!(v["memory"], "4GiB");
990        assert_eq!(v["disk"], "10GiB");
991        assert_eq!(v["vmType"], "vz");
992        assert_eq!(v["user"]["uid"], 501);
993        assert_eq!(v["mounts"][0]["location"], "/Users/me");
994        assert_eq!(v["mounts"][0]["mountPoint"], "/Users/me");
995        assert_eq!(v["mounts"][0]["writable"], true);
996        assert_eq!(v["env"]["ISB_BIND_CALLER_OWNED"], "1");
997        assert_eq!(v["portForwards"][3]["ignore"], true);
998        assert_eq!(v["portForwards"][3]["guestPortRange"][1], 1023);
999        let pf = &v["portForwards"];
1000        assert_eq!(pf[0]["guestSocket"], "/var/lib/incus/unix.socket");
1001        assert_eq!(pf[0]["hostSocket"], "/Users/me/.isb/machine/isb/incus.sock");
1002        assert_eq!(pf[1]["guestSocket"], "/run/isb/serve.sock");
1003        assert_eq!(pf[1]["hostSocket"], "/Users/me/.isb/machine/isb/serve.sock");
1004        assert_eq!(pf[2]["guestPort"], 8092);
1005        assert_eq!(pf[2]["hostPort"], 8092);
1006        let script = v["provision"][0]["script"].as_str().unwrap();
1007        assert!(script.starts_with("#!/bin/bash\n"), "{script}");
1008        assert!(script.contains("root:1000:1"));
1009        assert!(script.contains("SocketUser=me"));
1010        assert!(script.contains("pkgs.zabbly.com/incus/stable"));
1011        assert!(script.contains("ipv4.address: 10.177.0.1/24"));
1012        assert!(
1013            v["probes"][0]["script"]
1014                .as_str()
1015                .unwrap()
1016                .contains(PROVISIONED_MARKER)
1017        );
1018        // A home with odd characters stays one YAML scalar.
1019        let mut c = cfg();
1020        c.home = "/Users/a \"b\": c".into();
1021        let v: serde_yaml_ng::Value = serde_yaml_ng::from_str(&render_lima_yaml(&c)).unwrap();
1022        assert_eq!(v["mounts"][0]["location"], "/Users/a \"b\": c");
1023    }
1024
1025    #[test]
1026    fn names_sizes_users() {
1027        assert!(validate_name("isb").is_ok());
1028        assert!(validate_name("dev-2").is_ok());
1029        for bad in ["", "-x", "Isb", "a/b", "a b", &"x".repeat(33)] {
1030            assert!(validate_name(bad).is_err(), "{bad}");
1031        }
1032        for ok in ["4GiB", "512MiB", "10G", "1.5GiB", "100"] {
1033            assert!(check_size("x", ok).is_ok(), "{ok}");
1034        }
1035        for bad in ["", "GiB", "4 GiB", "4gigs", "-1G", "4GiB\n"] {
1036            assert!(check_size("x", bad).is_err(), "{bad}");
1037        }
1038        assert_eq!(guest_user("stephan"), "stephan");
1039        assert_eq!(guest_user("Stephan"), "lima");
1040        assert_eq!(guest_user("a.b"), "lima");
1041        assert_eq!(guest_user(""), "lima");
1042    }
1043
1044    #[test]
1045    fn lima_version() {
1046        assert_eq!(parse_lima_version("limactl version 2.2.0"), Some((2, 2)));
1047        assert_eq!(parse_lima_version("limactl version v1.0.7"), Some((1, 0)));
1048        assert_eq!(
1049            parse_lima_version("limactl version 2.0.0-beta.1"),
1050            Some((2, 0))
1051        );
1052        assert_eq!(parse_lima_version("nonsense"), None);
1053    }
1054
1055    #[test]
1056    fn guest_unit_and_setup() {
1057        let u = render_guest_unit("me");
1058        assert!(u.contains("\nUser=me\n"));
1059        assert!(u.contains("\nEnvironment=ISB_SERVE_SOCKET=/run/isb/serve.sock\n"));
1060        assert!(u.contains("\nEnvironment=ISB_SERVE_LISTEN=127.0.0.1:8092\n"));
1061        assert!(u.contains("\nEnvironment=ISB_BIND_CALLER_OWNED=1\n"));
1062        assert!(u.contains("\nRuntimeDirectory=isb\n"));
1063        let s = guest_setup_script(Path::new("/Users/me/.isb/machine/isb/isb-linux"), &u);
1064        assert!(
1065            s.contains("install -m 0755 '/Users/me/.isb/machine/isb/isb-linux' /usr/local/bin/isb")
1066        );
1067        assert!(
1068            s.contains("\nISB_UNIT_EOF\n"),
1069            "heredoc terminator on its own line"
1070        );
1071        assert_eq!(shell_quote("a'b"), r"'a'\''b'");
1072    }
1073
1074    #[test]
1075    fn launch_agent() {
1076        let p = render_launch_agent(
1077            Path::new("/opt/isb & co/isb"),
1078            "isb",
1079            "/opt/homebrew/bin:/usr/bin",
1080            Path::new("/Users/me/.isb/machine/isb/launchd.log"),
1081        );
1082        assert!(p.contains("<string>dev.isb.machine</string>"));
1083        assert!(p.contains("<string>/opt/isb &amp; co/isb</string>"));
1084        assert!(p.contains(
1085            "<string>machine</string>\n    <string>start</string>\n    <string>isb</string>"
1086        ));
1087        assert!(p.contains("<key>RunAtLoad</key>\n  <true/>"));
1088    }
1089
1090    #[test]
1091    fn asset_names() {
1092        assert_eq!(
1093            release_asset("0.7.0", "aarch64"),
1094            "isb-v0.7.0-aarch64-unknown-linux-musl"
1095        );
1096    }
1097}