Skip to content

fix(microvm): enable local dev workflow on macOS/Apple Silicon - #1629

Open
jhrozek wants to merge 4 commits into
acc/microvm-execution-environmentsfrom
jhrozek/microvm-darwin-fixes
Open

jhrozek wants to merge 4 commits into
acc/microvm-execution-environmentsfrom
jhrozek/microvm-darwin-fixes

Conversation

@jhrozek

@jhrozek jhrozek commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Enables the microVM execution-environment feature (#580) to run its host-side
readiness/preflight path on macOS/Apple Silicon, in addition to the currently
supported Linux amd64/arm64. Stacked on #580's own branch.

A striking amount of macOS support already existed in the code (darwin
preflight logic, process-identity handling, darwin release artifacts) but was
gated behind a single hardcoded linux/amd64-only check, leaving it dead and
never exercised. Flipping that gate and actually running the feature end to
end on real Apple Silicon hardware surfaced four real, previously-undetected
bugs — none of which were findable by code review alone, since the darwin
code paths had never executed. Each commit fixes one bug found this way:

  • feat(microvm): allow darwin/arm64 and linux/arm64 hosts — the platform
    gate itself (supportedPlatform() and a separate microvm_dev dev-release
    gate), plus a macOS-portable fix to the dev-release packaging script's
    tar/find invocation.
  • fix(microvm): darwin manager-lock EINTR retry and symlink-ancestor overreach — a raw syscall.Flock with no EINTR retry, causing an
    intermittent EnsureReady lock-convergence hang under concurrent load
    (Go's own SIGURG async-preemption signal can interrupt a blocking
    LOCK_EX); plus a first attempt at fixing an overly-broad symlink-ancestor
    safety check.
  • fix(microvm): macOS-portable dev-release packaging — GNU-tar-only flags
    (--sort, --mtime, --owner, --group, --numeric-owner) silently
    produced empty archives on macOS's bsdtar (no pipefail, so the failure
    was swallowed); plus a macOS AppleDouble/xattr tar quirk and two more
    platform-hardcoded test assumptions this unblocked for the first time.
  • fix(microvm): bound refuseSymlinkAncestors to the manager's own root
    the real fix for the symlink-ancestor check: macOS's /var and /tmp are
    themselves symlinks to /private/var//private/tmp (normal OS layout,
    not an attack), and the check had no notion of its own security boundary.
    Threaded an explicit root (the manager's own state/data/runtime/config
    directory boundary) through the check and every call site, so ambient host
    layout above that root is never inspected, while a symlink planted inside
    the manager's own subtree is still refused. Independently reviewed for
    architectural soundness before landing.

Verified on real macOS/Apple Silicon hardware, not just unit tests: after
these fixes, mecated microvm doctor reports host preflight: passed (the
darwin preflight code that had been dead until now), and the full readiness
pipeline (prepare → preflight → download → verify → install → daemon → socket → reconcile → health → ready) completes successfully end to end.
POST /v1/sessions against a real mecated serve --default-placement microvm-local returns a real session with "placement":{"kind":"microvm",...}.

Development stage

  • Spike / Routine — acceptance-plan spine exempt; Spike evidence does not ship as-is

Contract linkage

  • Work classification: Routine
  • Classification rationale: bug fixes and a platform-gate flip for
    already-designed, already-partially-implemented darwin support (the darwin
    preflight/process-identity code predates this PR and was already reviewed
    as part of feat: add microVM execution environments #580); no new architecture, no public/guarded API change, no
    persistence or trust-boundary change. Discovered and fixed via hands-on
    execution of the feature on real hardware, directed interactively by a repo
    maintainer (this account) rather than a pre-planned acceptance plan.
  • Decision record: None — implements target platform support already
    anticipated by docs/adr/0342-microvm-execution-environments.md (currently
    states macOS is "not yet available"; follow-up needed once this merges).
  • Human waiver of spine: N/A — Routine classification is spine-exempt by
    definition, not a waiver of a required spine.
  • Acceptance plan: N/A (Routine)
  • Human decisions resolved and recorded: N/A (Routine)
  • Plan / Interface PR: N/A
  • Approved commit baseline: N/A
  • Combined/exemption rationale: N/A (using Spike/Routine, not Combined)

Interface conformance

No public/guarded engine API changed. The only interface touched
(microvmmanager.Operations.Download/Install, gaining an explicit root
parameter) is an internal adapter interface in internal/adapter/microvmmanager,
not the engine/api guarded surface — task api:check is unaffected.

Issue relationship

Relates to #580

Type of change

  • Bug fix
  • New feature (macOS/Apple Silicon host support)

Test plan

Baseline checks

  • Linting (task lint) — 0 issues in every file this PR touches. 4
    pre-existing issues remain in environment/microvm (gosec G115 in
    process_identity_darwin.go, 3 staticcheck SA4023 in
    cmd/mecatl-guest-agent/main.go) — confirmed via git diff origin/acc/microvm-execution-environments to predate this PR entirely
    (last touched by feat: add microVM execution environments #580's own original commit); out of scope here.
  • Offline test suite (task test) — 2 pre-existing failures unrelated to
    this PR, both in internal/app (last touched by unrelated commits
    d14494785/147aa0d5f, confirmed outside this PR's diff):
    TestCanonicalConfiguredDir_AllowsMissingDefaultParents (a /var
    vs /private/var path-canonicalization mismatch — the same bug class
    this PR fixes in microvmmanager, but in a different, untouched
    package) and TestCommandRunnerEnvironment_Scenario2_DefaultScrub.
    Every test in internal/adapter/microvmmanager (this PR's actual
    scope) passes, including with -race and both with/without
    -tags=microvm_dev.
  • Offline demo (go run ./cmd/mecademo) — unaffected by this change;
    not re-verified here since this PR only touches microvm-specific code.
  • Markdown changed: N/A, no Markdown touched
  • User docs/user-facing behavior changed: not yet — docs/adr/0342 and
    user-docs/building/deployment/microvm-environments.md still say
    macOS is unsupported; intentionally left as a follow-up until this is
    merged and the release pipeline is revisited (out of scope for this
    local-dev-focused PR).
  • Guarded engine API affected: N/A, see Interface conformance above
  • Intentional engine API change: N/A
  • Landed plan: N/A (Routine)
  • Final implementation review: /panel-review not run in this session;
    the symlink-boundary fix (the highest-risk change) was independently
    reviewed by a separate architecture-focused pass before landing.

Changes

File Change
internal/adapter/microvmmanager/default_operations.go platform gate, root-bounded path safety threading
internal/adapter/microvmmanager/manager.go platform gate, Flock EINTR retry, root-bounded refuseSymlinkAncestors
internal/adapter/microvmmanager/development_release_microvm_dev.go darwin/arm64-aware dev-release gate
internal/adapter/microvmmanager/*_test.go updated/added tests for all of the above
Taskfile.yml platform-aware microvm:dev:prepare verification path
.github/scripts/prepare-microvm-development-release.sh darwin-arm64 support, portable find/tar

User-facing change

Developers on macOS/Apple Silicon can now use microvm-local execution
environments (previously hard-refused with "microvm-local supports Linux
amd64 with KVM only"). This PR covers the local development workflow
(task microvm:dev:*); the release/CI pipeline for shipping signed
darwin-arm64 binaries to end users is a separate, larger follow-up.

Special notes for reviewers

  • The symlink-ancestor fix (refuseSymlinkAncestors) is the highest-risk
    change here — it's a security-relevant path-safety check that's now been
    fixed twice before landing correctly the third time (each prior attempt
    was a heuristic, not a real boundary). It was independently reviewed for
    architectural soundness (root-exclusive/path-inclusive climb, complete
    threading across every call site, fail-closed empty-root/out-of-root
    handling, test coverage of both sides of the boundary condition) before
    this PR was opened.
  • Two real, pre-existing UX rough edges were surfaced (not fixed here, flagging
    for a maintainer decision): (1) POST /v1/sessions blocks synchronously for
    the full first-boot readiness duration (~3+ minutes cold-cache on this
    hardware) with no progress visible over the plain HTTP API, so any client
    with a shorter default timeout will see an apparent hang/failure even
    though the server-side operation would succeed; (2) a canceled/interrupted
    first attempt (e.g. from exactly that client timeout) can leave wedged
    daemon/socket state that doctor/status don't self-heal, requiring
    manual cleanup — a live reproduction of a risk feat: add microVM execution environments #580's own prior review
    already flagged in the abstract.
  • This PR is local-dev-workflow-scoped only. The release pipeline already
    builds/publishes darwin-arm64 artifacts (per feat: add microVM execution environments #580), but the code-signing/
    entitlements story for a released binary was not re-verified here beyond
    confirming that upstream go-microvm already ships a pre-signed
    darwin-arm64 runtime — worth a maintainer sanity check before relying on it
    for a real release.

jhrozek and others added 4 commits September 16, 2026 15:40
supportedPlatform() previously accepted linux/amd64 only, leaving the
already-implemented darwin preflight (macOS 15+, Hypervisor.framework)
dead. Widen it, and reuse the same predicate in the microvm_dev
development-release gate instead of duplicating the linux-amd64 check.
Also fix the dev-release prep script's host-platform case (was
Linux-x86_64 only) and two portability bugs it exposed: GNU-only
`find -printf`, and a hardcoded "linux-amd64" platform literal in the
release descriptor writer.

Local-only enablement for testing on Apple Silicon; stacked on #580.

Co-Authored-By: mecatl <noreply@stacklok.com>
…erreach

Two real bugs surfaced by actually running the darwin/arm64-enabled
preflight path on real macOS hardware, both pre-existing (reproducible
independent of the platform-gate commit):

- lockManager called syscall.Flock directly; unlike os/net file
  descriptors it isn't wrapped with automatic EINTR retry, so a
  blocking LOCK_EX could spuriously fail if the thread received a
  signal (notably Go's SIGURG async-preemption) while parked in the
  kernel wait. Manifested as an EnsureReady hang/failure under
  concurrent load on darwin/arm64. Added flockRetryEINTR.

- refuseSymlinkAncestors walked every ancestor up to the filesystem
  root refusing any symlink, including OS-level layout the manager
  never creates paths within. On macOS /var is itself a symlink to
  /private/var, and TMPDIR-derived paths (t.TempDir()) land under
  /var/folders/..., so the walk tripped on ordinary system layout.
  Now stops at the first pre-existing ancestor, which is exactly the
  boundary MkdirAll actually creates past — preserving the real
  symlink-swap protection for paths this manager creates while no
  longer flagging pre-existing host layout.

Verified: go test -race, -tags=microvm_dev, and 400 concurrent stress
iterations of the previously-hanging test, all clean. Stacked on #580.

Co-Authored-By: mecatl <noreply@stacklok.com>
Three more bugs surfaced by actually running task microvm:dev:prepare
end to end on macOS/arm64:

- package-microvm-release.sh's write_artifact used GNU-tar-only
  --sort/--mtime/--owner/--group/--numeric-owner. bsdtar (macOS)
  rejects all five and exits nonzero, but the tar|gzip pipe has no
  pipefail, so the archive silently came out empty while the pipeline
  reported success. Added a portable reproducible_tar helper (sorted
  -T file list + portable touch -t mtime stamping); byte parity with
  GNU tar's own output isn't required since the signed provenance
  binds artifact_tree_digest, computed independently over the raw
  tree, not the archive bytes. Applied the same fix to the identical
  copy-pasted pattern in microvm-ci-release_test.sh.

- Taskfile.yml's microvm:dev:prepare verification step hardcoded a
  linux-amd64 descriptor path regardless of host platform; the
  underlying script (already darwin-aware since a7ab5df77) writes to
  a platform-specific dir. Mirrors the script's own uname-based
  platform resolution inline.

- Fixing the above let TestPreparedDevelopmentReleaseBundleIsImportable
  actually run on darwin-arm64 for the first time, surfacing two more
  linux-amd64-hardcoded assumptions inside the test itself (expected
  manifest filename, DefaultOperations{GOOS,GOARCH} fixture) — made
  both platform-derived from the descriptor instead.

- macOS bsdtar also embeds AppleDouble "._*" sidecar entries for any
  file carrying an xattr (macOS auto-stamps com.apple.provenance on
  most files) — invisible to tar's own listing but visible to Go's
  archive/tar, breaking the bundle-membership check. Fixed with
  COPYFILE_DISABLE=1 (no-op on Linux).

Verified with a full green `task microvm:dev:prepare` run on real
macOS/Apple Silicon hardware. Stacked on #580.

Co-Authored-By: mecatl <noreply@stacklok.com>
The prior fix ("stop at the first ancestor that already exists") wasn't
a real boundary, just a heuristic that happened to dodge the /var case.
It broke again live: creating a session against a real running daemon
failed with "refusing symlink path /tmp", because the manager's
RuntimeDir fallback is created directly inside /tmp (also a symlink to
/private/tmp on macOS), making /tmp itself "the first existing
ancestor" and tripping the same false refusal.

The check had no notion of whose subtree it was protecting. Threaded
an explicit root boundary through Paths (StateRoot/RuntimeRoot/
DataRoot/ConfigRoot: the ambient XDG bases, or the /tmp fallback) and
every call site of secureMkdirAll/atomicWrite/atomicWriteMode/
Operations.Download/Operations.Install. refuseSymlinkAncestors now
climbs from the target up to, but never including, that root — root
and anything above it is ambient host OS layout the manager never
created and has no authority over, however the OS lays it out (macOS
/var and /tmp being symlinks included) — while a symlink planted
strictly between root and target is still refused, preserving the
real TOCTOU protection this function exists for.

Verified end to end on real macOS/Apple Silicon hardware: the
readiness pipeline now runs cleanly through prepare/preflight/
download/verify/install/daemon/socket/reconcile (all previously
blocked by this bug) and reaches a genuinely different, expected next
gap at the health stage (dev-build code-signing/entitlements, not a
symlink refusal).

Independently reviewed for architectural soundness (root-exclusive/
path-inclusive climb, complete threading across ~12 call sites, all
Operations implementers and Paths constructors updated, fail-closed
empty-root/out-of-root handling) before landing.

Co-Authored-By: mecatl <noreply@stacklok.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant