From 45f1fe3efb01fea28f17b96a3dcc64ae4c5f9d34 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Wed, 14 Oct 2026 15:37:52 +0200 Subject: [PATCH 1/3] feat(colima): Upgrade to colima 0.10.3 and lima 2.2.1 colima 0.8.1 creates VMs with Linux 6.8.0-50, which misreports SVE2 on Apple M4 and newer (see the kernel upgrade in `devenv colima start`). 0.10.3's image ships 6.8.0-117, which doesn't, so VMs created from now on are right from the start. It also gets new VMs a separate data disk for container data (colima 0.9), so recreating a VM no longer has to lose containers and volumes. colima 0.10 configures rosetta the way lima 2 expects (vmOpts.vz), so lima 1.0.3 would silently drop it. lima 2 only looks for its guest agents relative to limactl, in ../share/lima, so install upstream's release tarball as a prefix instead of plucking files out of a homebrew bottle; upstream builds support macOS 15, which devenv already requires. Also: - --cpu was renamed --cpus - pass --mount-inotify, which colima 0.10.2 turned off by default - the DNS fixer restarts dnsmasq on VMs created since colima 0.9, which replaced systemd-resolved with it Co-Authored-By: Claude Opus 5.5 --- devenv/checks/limaDns.py | 24 +++++++- devenv/lib/colima.py | 15 +++-- devenv/lib/limactl.py | 56 ++++++++--------- tests/checks/test_limaDns.py | 40 +++++++++++++ tests/lib/test_limactl.py | 113 +++++++++++++++++++++++++++++++++++ 5 files changed, 211 insertions(+), 37 deletions(-) create mode 100644 tests/checks/test_limaDns.py create mode 100644 tests/lib/test_limactl.py diff --git a/devenv/checks/limaDns.py b/devenv/checks/limaDns.py index 3e83ead3..914110bf 100644 --- a/devenv/checks/limaDns.py +++ b/devenv/checks/limaDns.py @@ -47,6 +47,26 @@ def check() -> tuple[bool, str]: return True, "" +def _resolver_service() -> str: + # VMs created since colima 0.9 use dnsmasq instead of systemd-resolved + try: + proc.run( + ( + "colima", + "exec", + "--", + "systemctl", + "is-active", + "--quiet", + "dnsmasq.service", + ), + stdout=True, + ) + except RuntimeError: + return "systemd-resolved.service" + return "dnsmasq.service" + + @fixer def fix() -> tuple[bool, str]: status = colima.start() @@ -62,13 +82,13 @@ def fix() -> tuple[bool, str]: "sudo", "systemctl", "restart", - "systemd-resolved.service", + _resolver_service(), ), stdout=True, ) except RuntimeError as e: print(f""" -failed to restart the vm's resolved: +failed to restart the vm's dns resolver: {e} we're going to try restarting colima diff --git a/devenv/lib/colima.py b/devenv/lib/colima.py index d94e68d7..f1aaba74 100644 --- a/devenv/lib/colima.py +++ b/devenv/lib/colima.py @@ -56,12 +56,12 @@ def install_shim(binroot: str) -> None: def install_global() -> None: - version = "v0.8.1" + version = "v0.10.3" cfg = { "darwin_x86_64": f"https://github.com/abiosoft/colima/releases/download/{version}/colima-Darwin-x86_64", - "darwin_x86_64_sha256": "791330c62c60389f70e5e1c33a56c35502a9e36e544a418daea0273e539acbf4", + "darwin_x86_64_sha256": "3082737fe8a98afda11cba7d9a20b6e56fe80c6153464beda04bec630758770b", "darwin_arm64": f"https://github.com/abiosoft/colima/releases/download/{version}/colima-Darwin-arm64", - "darwin_arm64_sha256": "c266fcb272b39221ef6152d2093bb02a1ebadc26042233ad359e1ae52d5d5922", + "darwin_arm64_sha256": "980ad8bf61a4ca370243f4cb41401a61276dcd2c2502bee7b9b86f9250169f34", } binroot = f"{root}/bin" @@ -178,7 +178,7 @@ def start(restart: bool = False) -> ColimaStatus: memsize_bytes = os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") args = [ - "--cpu", + "--cpus", f"{cpus // 2}", "--memory", f"{memsize_bytes // (2 * 1024**3)}", @@ -194,10 +194,17 @@ def start(restart: bool = False) -> ColimaStatus: "--verbose", # we're overprovisioning a 512 GiB disk here which is fine because # the disk doesn't reserve space and is copy-on-write + # (VMs created since colima 0.9 keep container data on a separate + # disk of this size, which survives `colima delete` without --data) "--disk", "512", + # colima 0.10.2 stopped propagating file events by default; + # keep VMs created now behaving like the ones created before + "--mount-inotify", # this effectively makes the vm's resolvectl status use: # DNS Servers: 8.8.8.8 1.1.1.1 192.168.5.2 + # (VMs created since colima 0.9 use dnsmasq instead, with only + # 8.8.8.8 and 1.1.1.1 as upstreams) # https://lima-vm.io/docs/config/network/user/ # 192.168.5.2 is the host, accessible from the vm # sometimes using only the host will result in dns breaking diff --git a/devenv/lib/limactl.py b/devenv/lib/limactl.py index 1db55196..b1379463 100644 --- a/devenv/lib/limactl.py +++ b/devenv/lib/limactl.py @@ -10,9 +10,19 @@ from devenv.lib import archive from devenv.lib import proc +# lima >= 2 looks for its guest agents and drivers relative to limactl, +# in ../share/lima and ../libexec/lima +_DATA_DIRS = ("share/lima", "libexec/lima") + def uninstall(binroot: str) -> None: - for d in (f"{home}/.lima",): + prefix = os.path.dirname(binroot) + for d in ( + f"{home}/.lima", + *(f"{prefix}/{d}" for d in _DATA_DIRS), + # lima < 2 kept its templates and guest agents next to limactl + f"{binroot}/templates", + ): shutil.rmtree(d, ignore_errors=True) for fp in ( @@ -20,7 +30,6 @@ def uninstall(binroot: str) -> None: f"{binroot}/limactl", f"{binroot}/lima-guestagent.Linux-aarch64", f"{binroot}/lima-guestagent.Linux-x86_64", - f"{binroot}/templates/default.yaml", ): try: os.remove(fp) @@ -32,46 +41,28 @@ def uninstall(binroot: str) -> None: def _install(url: str, sha256: str, into: str) -> None: + prefix = os.path.dirname(into) os.makedirs(into, exist_ok=True) with tempfile.TemporaryDirectory(dir=into) as tmpd: archive_file = archive.download(url, sha256, dest=f"{tmpd}/download") - - # the archive from homebrew has a lima/version prefix - archive.unpack_strip_n(archive_file, tmpd, n=2) + archive.unpack(archive_file, tmpd) # the archive was atomically placed into tmpd so # these are on the same fs and can be atomically moved too + for d in _DATA_DIRS: + os.makedirs(os.path.dirname(f"{prefix}/{d}"), exist_ok=True) + os.replace(f"{tmpd}/{d}", f"{prefix}/{d}") os.replace(f"{tmpd}/bin/lima", f"{into}/lima") os.replace(f"{tmpd}/bin/limactl", f"{into}/limactl") - os.replace( - f"{tmpd}/share/lima/lima-guestagent.Linux-aarch64", - f"{into}/lima-guestagent.Linux-aarch64", - ) - os.replace( - f"{tmpd}/share/lima/lima-guestagent.Linux-x86_64", - f"{into}/lima-guestagent.Linux-x86_64", - ) - os.makedirs(f"{into}/templates", exist_ok=True) - os.replace( - f"{tmpd}/share/lima/templates/default.yaml", - f"{into}/templates/default.yaml", - ) def install_global() -> None: - version = "1.0.3" + version = "2.2.1" cfg = { - # upstream github releases aren't built for macOS 14, so for now - # we use homebrew binaries from https://formulae.brew.sh/api/formula/lima.json - # unfortunately there's no way to view previous versions of a formula - # so if we ever need to upgrade to a specific non-latest version we'll - # have to build it ourselves - # sonoma - "darwin_x86_64": "https://ghcr.io/v2/homebrew/core/lima/blobs/sha256:f675abee28d0f10f335f7a04dc6ad3de12434c83c2f0f32c913061204c137a94", - "darwin_x86_64_sha256": "f675abee28d0f10f335f7a04dc6ad3de12434c83c2f0f32c913061204c137a94", - # arm64_sonoma - "darwin_arm64": "https://ghcr.io/v2/homebrew/core/lima/blobs/sha256:8aeb0a3b7295f0c3e0c2a7a92a798a44397936e5bb732db825aee6da5e762d7a", - "darwin_arm64_sha256": "8aeb0a3b7295f0c3e0c2a7a92a798a44397936e5bb732db825aee6da5e762d7a", + "darwin_x86_64": f"https://github.com/lima-vm/lima/releases/download/v{version}/lima-{version}-Darwin-x86_64.tar.gz", + "darwin_x86_64_sha256": "6b776e4bc41f358e6ee01fefcc78e2eb26f11635a0fcff632234af77a566c6c4", + "darwin_arm64": f"https://github.com/lima-vm/lima/releases/download/v{version}/lima-{version}-Darwin-arm64.tar.gz", + "darwin_arm64_sha256": "9e9eacce88f37e185c346bad73aa6136f738d8cdf8c3bb23cd42b071824bc66e", } binroot = f"{root}/bin" @@ -82,7 +73,10 @@ def install_global() -> None: ): stdout = proc.run((f"{binroot}/limactl", "--version"), stdout=True) installed_version = stdout.strip().split()[-1] - if version == installed_version: + # an interrupted install can leave limactl without its guest agents + if version == installed_version and all( + os.path.isdir(f"{root}/{d}") for d in _DATA_DIRS + ): return print(f"installed limactl {installed_version} is unexpected!") diff --git a/tests/checks/test_limaDns.py b/tests/checks/test_limaDns.py new file mode 100644 index 00000000..9faab345 --- /dev/null +++ b/tests/checks/test_limaDns.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from unittest import mock + +import pytest + +from devenv.checks import limaDns +from devenv.lib import colima + + +@pytest.mark.parametrize( + ("dnsmasq_active", "service"), + ((True, "dnsmasq.service"), (False, "systemd-resolved.service")), +) +def test_fix_restarts_the_vms_resolver( + dnsmasq_active: bool, service: str +) -> None: + cmds: list[tuple[str, ...]] = [] + + def run(cmd: tuple[str, ...], stdout: bool = False) -> str: + cmds.append(cmd) + if "is-active" in cmd and not dnsmasq_active: + raise RuntimeError("Command failed! (code 3)") + return "" + + with ( + mock.patch.object(colima, "start", return_value=colima.ColimaStatus.UP), + mock.patch("devenv.checks.limaDns.proc.run", side_effect=run), + ): + assert limaDns.fix() == (True, "") + + assert cmds[-1] == ( + "colima", + "exec", + "--", + "sudo", + "systemctl", + "restart", + service, + ) diff --git a/tests/lib/test_limactl.py b/tests/lib/test_limactl.py new file mode 100644 index 00000000..2d964c4d --- /dev/null +++ b/tests/lib/test_limactl.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import os +import pathlib +import shutil +import tarfile +from unittest.mock import patch + +from devenv.lib import limactl + +# the layout of upstream's lima-2.x-Darwin-*.tar.gz, abridged +RELEASE_FILES = ( + "./bin/lima", + "./bin/limactl", + "./bin/docker.lima", + "./libexec/lima/lima-driver-krunkit", + "./share/lima/lima-guestagent.Linux-aarch64.gz", + "./share/lima/templates/default.yaml", + "./share/doc/lima/README.md", +) + + +def _release(tmp_path: pathlib.Path) -> str: + src = tmp_path / "release" + for name in RELEASE_FILES: + (src / name).parent.mkdir(parents=True, exist_ok=True) + (src / name).write_text(name) + path = f"{tmp_path}/lima.tar.gz" + with tarfile.open(path, "w:gz") as tarf: + tarf.add(src, arcname=".") + return path + + +def _install(tmp_path: pathlib.Path, binroot: str) -> None: + release = _release(tmp_path) + + def download(url: str, sha256: str, dest: str) -> str: + shutil.copy(release, dest) + return dest + + with patch("devenv.lib.archive.download", side_effect=download): + limactl._install("url", "sha256", binroot) + + +def test_install_keeps_data_dirs_next_to_bin(tmp_path: pathlib.Path) -> None: + prefix = f"{tmp_path}/sentry-devenv" + binroot = f"{prefix}/bin" + + _install(tmp_path, binroot) + + # lima >= 2 finds these relative to limactl + assert os.path.exists(f"{binroot}/lima") + assert os.path.exists(f"{binroot}/limactl") + assert os.path.exists( + f"{prefix}/share/lima/lima-guestagent.Linux-aarch64.gz" + ) + assert os.path.exists(f"{prefix}/libexec/lima/lima-driver-krunkit") + # only lima and limactl go on PATH + assert sorted(os.listdir(binroot)) == ["lima", "limactl"] + + +def test_install_replaces_a_previous_install(tmp_path: pathlib.Path) -> None: + prefix = f"{tmp_path}/sentry-devenv" + binroot = f"{prefix}/bin" + _install(tmp_path, binroot) + stale = f"{prefix}/share/lima/stale" + open(stale, "w").close() + + limactl.uninstall(binroot) + _install(tmp_path, binroot) + + assert not os.path.exists(stale) + assert os.path.exists(f"{binroot}/limactl") + + +def test_uninstall_removes_the_lima_1_layout(tmp_path: pathlib.Path) -> None: + binroot = f"{tmp_path}/sentry-devenv/bin" + os.makedirs(f"{binroot}/templates") + for name in ( + "lima", + "limactl", + "lima-guestagent.Linux-aarch64", + "lima-guestagent.Linux-x86_64", + "templates/default.yaml", + "colima", + ): + open(f"{binroot}/{name}", "w").close() + + limactl.uninstall(binroot) + + assert os.listdir(binroot) == ["colima"] + + +def test_install_global_reinstalls_without_guest_agents( + tmp_path: pathlib.Path, +) -> None: + root = f"{tmp_path}/sentry-devenv" + binroot = f"{root}/bin" + _install(tmp_path, binroot) + # e.g. an interrupted install + shutil.rmtree(f"{root}/share/lima") + + with ( + patch.object(limactl, "root", root), + patch.object(limactl, "SYSTEM_MACHINE", "darwin_arm64"), + patch( + "devenv.lib.limactl.proc.run", return_value="limactl version 2.2.1" + ), + patch.object(limactl, "_install") as install, + ): + limactl.install_global() + + install.assert_called_once() From 5eee1cbf88927c5f67b930e9368b2d395768d840 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Tue, 20 Oct 2026 17:08:48 +0200 Subject: [PATCH 2/3] fix(limaDns): Restart dnsmasq even when it's the one that died The fixer picked dnsmasq only if it was running. But a crashed dnsmasq breaks DNS on colima >= 0.9 VMs, since colima points their resolv.conf at it. In that case the fixer restarted systemd-resolved instead, which leaves DNS broken but still reports success. Pick by whether dnsmasq is installed instead. That's what colima itself goes by when it decides to configure dnsmasq for a VM. Co-Authored-By: Claude Opus 5.5 --- devenv/checks/limaDns.py | 14 ++++---------- tests/checks/test_limaDns.py | 18 ++++++++++++++---- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/devenv/checks/limaDns.py b/devenv/checks/limaDns.py index 914110bf..19360232 100644 --- a/devenv/checks/limaDns.py +++ b/devenv/checks/limaDns.py @@ -48,18 +48,12 @@ def check() -> tuple[bool, str]: def _resolver_service() -> str: - # VMs created since colima 0.9 use dnsmasq instead of systemd-resolved + # VMs created since colima 0.9 come with dnsmasq, and colima makes it + # their resolver instead of systemd-resolved. Check that it's installed + # rather than running: if dnsmasq died, it's the one to restart. try: proc.run( - ( - "colima", - "exec", - "--", - "systemctl", - "is-active", - "--quiet", - "dnsmasq.service", - ), + ("colima", "exec", "--", "systemctl", "cat", "dnsmasq.service"), stdout=True, ) except RuntimeError: diff --git a/tests/checks/test_limaDns.py b/tests/checks/test_limaDns.py index 9faab345..3df3d666 100644 --- a/tests/checks/test_limaDns.py +++ b/tests/checks/test_limaDns.py @@ -9,17 +9,27 @@ @pytest.mark.parametrize( - ("dnsmasq_active", "service"), - ((True, "dnsmasq.service"), (False, "systemd-resolved.service")), + ("dnsmasq_installed", "dnsmasq_active", "service"), + ( + (True, True, "dnsmasq.service"), + # e.g. dnsmasq crashed, which is why DNS is broken + (True, False, "dnsmasq.service"), + # VMs created before colima 0.9 + (False, False, "systemd-resolved.service"), + ), ) def test_fix_restarts_the_vms_resolver( - dnsmasq_active: bool, service: str + dnsmasq_installed: bool, dnsmasq_active: bool, service: str ) -> None: cmds: list[tuple[str, ...]] = [] def run(cmd: tuple[str, ...], stdout: bool = False) -> str: cmds.append(cmd) - if "is-active" in cmd and not dnsmasq_active: + vm_cmd = cmd[3:] + if vm_cmd == ("systemctl", "cat", "dnsmasq.service"): + if not dnsmasq_installed: + raise RuntimeError("Command failed! (code 1)") + elif vm_cmd[:2] == ("systemctl", "is-active") and not dnsmasq_active: raise RuntimeError("Command failed! (code 3)") return "" From c6b38f4faf39c2994000612f349a0dd57752d1ca Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Tue, 20 Oct 2026 17:08:48 +0200 Subject: [PATCH 3/3] fix(limactl): Make the guest agent check testable and say what's missing The fake release in test_limactl wrote non-executable binaries, so shutil.which never found them. install_global() then reinstalled whatever the guest agents' state, and the test passed even with the check removed. Mark the binaries executable, and also cover a complete install, which must not be reinstalled. When only the guest agents are missing, install_global() printed "installed limactl 2.2.1 is unexpected!" for the version it expects. Name the missing directories instead. Co-Authored-By: Claude Opus 5.5 --- devenv/lib/limactl.py | 14 ++++++++++---- tests/lib/test_limactl.py | 24 +++++++++++++++++++----- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/devenv/lib/limactl.py b/devenv/lib/limactl.py index b1379463..aa89ea84 100644 --- a/devenv/lib/limactl.py +++ b/devenv/lib/limactl.py @@ -74,11 +74,17 @@ def install_global() -> None: stdout = proc.run((f"{binroot}/limactl", "--version"), stdout=True) installed_version = stdout.strip().split()[-1] # an interrupted install can leave limactl without its guest agents - if version == installed_version and all( - os.path.isdir(f"{root}/{d}") for d in _DATA_DIRS - ): + missing = [ + f"{root}/{d}" + for d in _DATA_DIRS + if not os.path.isdir(f"{root}/{d}") + ] + if version != installed_version: + print(f"installed limactl {installed_version} is unexpected!") + elif missing: + print(f"limactl {version} is missing {', '.join(missing)}...") + else: return - print(f"installed limactl {installed_version} is unexpected!") uninstall(binroot) _install(cfg[SYSTEM_MACHINE], cfg[f"{SYSTEM_MACHINE}_sha256"], binroot) diff --git a/tests/lib/test_limactl.py b/tests/lib/test_limactl.py index 2d964c4d..87aa05b4 100644 --- a/tests/lib/test_limactl.py +++ b/tests/lib/test_limactl.py @@ -6,6 +6,8 @@ import tarfile from unittest.mock import patch +import pytest + from devenv.lib import limactl # the layout of upstream's lima-2.x-Darwin-*.tar.gz, abridged @@ -25,6 +27,9 @@ def _release(tmp_path: pathlib.Path) -> str: for name in RELEASE_FILES: (src / name).parent.mkdir(parents=True, exist_ok=True) (src / name).write_text(name) + if name.startswith("./bin/"): + # install_global() only trusts what shutil.which finds + (src / name).chmod(0o755) path = f"{tmp_path}/lima.tar.gz" with tarfile.open(path, "w:gz") as tarf: tarf.add(src, arcname=".") @@ -91,14 +96,23 @@ def test_uninstall_removes_the_lima_1_layout(tmp_path: pathlib.Path) -> None: assert os.listdir(binroot) == ["colima"] -def test_install_global_reinstalls_without_guest_agents( - tmp_path: pathlib.Path, +@pytest.mark.parametrize( + ("missing", "reinstalls"), + ( + (None, False), + # e.g. an interrupted install + ("share/lima", True), + ("libexec/lima", True), + ), +) +def test_install_global_reinstalls_an_incomplete_install( + tmp_path: pathlib.Path, missing: str | None, reinstalls: bool ) -> None: root = f"{tmp_path}/sentry-devenv" binroot = f"{root}/bin" _install(tmp_path, binroot) - # e.g. an interrupted install - shutil.rmtree(f"{root}/share/lima") + if missing: + shutil.rmtree(f"{root}/{missing}") with ( patch.object(limactl, "root", root), @@ -110,4 +124,4 @@ def test_install_global_reinstalls_without_guest_agents( ): limactl.install_global() - install.assert_called_once() + assert install.called == reinstalls