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