1use 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
33pub const DEFAULT_NAME: &str = "isb";
36pub const SERVE_LISTEN: &str = "127.0.0.1:8092";
38pub 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";
43const 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
54pub 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
71pub 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#[derive(Debug, Clone)]
87pub struct LimaConfig {
88 pub name: String,
89 pub cpus: u32,
90 pub memory: String,
91 pub disk: String,
92 pub home: PathBuf,
94 pub user: String,
96 pub uid: u32,
98}
99
100fn q(s: &str) -> String {
102 Value::String(s.to_string()).to_string()
103}
104
105pub 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
121fn 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
141fn 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
211const BRIDGE_ADDRESS: &str = "10.177.0.1/24";
213
214pub 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
308pub 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
338fn 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
361fn 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
381fn 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
393fn 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
410fn 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
432fn 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#[derive(Debug, Clone)]
468pub struct InitOptions {
469 pub name: String,
470 pub cpus: u32,
471 pub memory: String,
472 pub disk: String,
473 pub isb_binary: Option<PathBuf>,
475 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
492pub 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
574fn 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
622pub 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#[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
700fn 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
743pub 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
770pub 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#[derive(Debug, Clone, Serialize)]
786pub struct Status {
787 pub name: String,
788 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 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 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
848pub 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
857fn 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('&', "&")
868 .replace('<', "<")
869 .replace('>', ">")
870 .replace('"', """)
871}
872
873pub 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
914fn 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 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
948pub 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 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
1004pub 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 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 & 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}