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#[doc(hidden)]
33pub use crate::self_update::hex;
34pub(crate) use crate::self_update::{download_asset, fetch};
35
36pub const DEFAULT_NAME: &str = "isb";
39pub const SERVE_LISTEN: &str = "127.0.0.1:8092";
41pub 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";
46const 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
56pub 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
73pub 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#[derive(Debug, Clone)]
89pub struct LimaConfig {
90 pub name: String,
91 pub cpus: u32,
92 pub memory: String,
93 pub disk: String,
94 pub home: PathBuf,
96 pub user: String,
98 pub uid: u32,
100}
101
102fn q(s: &str) -> String {
104 Value::String(s.to_string()).to_string()
105}
106
107pub 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
123fn 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
143fn 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
213const BRIDGE_ADDRESS: &str = "10.177.0.1/24";
215
216pub 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
310pub 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
340fn 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
363fn 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
383fn 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
395fn 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
412fn 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
434fn 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#[derive(Debug, Clone)]
470pub struct InitOptions {
471 pub name: String,
472 pub cpus: u32,
473 pub memory: String,
474 pub disk: String,
475 pub isb_binary: Option<PathBuf>,
477 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
494pub 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
576fn 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
624pub fn release_asset(version: &str, arch: &str) -> String {
626 format!("isb-v{version}-{arch}-unknown-linux-musl")
627}
628
629#[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
641fn 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
684pub 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
711pub 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#[derive(Debug, Clone, Serialize)]
727pub struct Status {
728 pub name: String,
729 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 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 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
789pub 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
798fn 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('&', "&")
809 .replace('<', "<")
810 .replace('>', ">")
811 .replace('"', """)
812}
813
814pub 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
855fn 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 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
889pub 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 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
945pub 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 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 & 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}