Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ jobs:
# cross-compiled from the Linux release job — it builds on a macOS arm64 runner.
release-darwin:
needs: release
runs-on: macos-14
# vz-shim needs the macOS 27 SDK.
runs-on: xcode-27
steps:
- name: Checkout
uses: actions/checkout@v4
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,8 @@ jobs:
with:
go-version-file: go.mod
cache: false
- name: Show macOS and SDK versions
run: sw_vers && xcrun --sdk macosx --show-sdk-version
- name: Ensure Docker is running
run: |
if docker info >/dev/null 2>&1; then
Expand Down
31 changes: 10 additions & 21 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ test-linux: ensure-ch-binaries ensure-firecracker-binaries ensure-caddy-binaries
# macOS tests (no sudo needed, adds e2fsprogs to PATH)
# Uses 'go list' to discover compilable packages, then filters out packages
# whose test files reference Linux-only symbols (devices, system/init).
DARWIN_EXCLUDE_PKGS := /lib/devices|/lib/system/init|/cmd/vz-shim
DARWIN_EXCLUDE_PKGS := /lib/devices|/lib/system/init
test-darwin: build-embedded sign-vz-shim
@VERBOSE_FLAG=""; \
if [ -n "$(VERBOSE)" ]; then VERBOSE_FLAG="-v"; fi; \
Expand All @@ -349,23 +349,6 @@ test-darwin: build-embedded sign-vz-shim
PATH="/opt/homebrew/opt/e2fsprogs/sbin:$(PATH)" \
go test -tags containers_image_openpgp $$VERBOSE_FLAG -timeout=$(TEST_TIMEOUT) $$PKGS; \
fi
$(MAKE) test-vz-shim-signed VERBOSE="$(VERBOSE)" TEST="$(TEST)" TEST_TIMEOUT="$(TEST_TIMEOUT)"

# cmd/vz-shim's tests call Virtualization.framework in-process, which requires the
# com.apple.security.virtualization entitlement. Plain `go test` binaries are
# unsigned, so compile the test binary, ad-hoc-sign it with the entitlement (as
# sign-vz-shim does for the shim), then run it. HYPEMAN_VZ_SIGNED=1 tells the
# tests not to skip for a missing entitlement.
.PHONY: test-vz-shim-signed
test-vz-shim-signed:
@set -e; \
RUN_FLAG=""; \
if [ -n "$(TEST)" ]; then RUN_FLAG="-test.run=$(TEST)"; fi; \
BIN="$$(mktemp -d)/vz-shim.test"; \
go test -tags containers_image_openpgp -c -o "$$BIN" ./cmd/vz-shim; \
if [ ! -f "$$BIN" ]; then echo "no cmd/vz-shim test binary for this platform; skipping"; exit 0; fi; \
codesign --sign - --entitlements $(ENTITLEMENTS_FILE) --force "$$BIN"; \
HYPEMAN_VZ_SIGNED=1 "$$BIN" $$RUN_FLAG -test.v -test.timeout=$(TEST_TIMEOUT)

# Manual-only guest memory policy integration tests (Linux hypervisors).
test-guestmemory-linux: ensure-ch-binaries ensure-firecracker-binaries ensure-caddy-binaries build-embedded
Expand Down Expand Up @@ -424,8 +407,13 @@ release-prep: download-ch-binaries download-firecracker-binaries build-caddy-bin
# Entitlements file for macOS codesigning
ENTITLEMENTS_FILE ?= vz.entitlements

# Build vz-shim (subprocess that hosts vz VMs)
# Also copies to embed directory so it gets embedded in the hypeman binary
# Build vz-shim (subprocess that hosts vz VMs) from the Swift sources in
# cmd/vz-shim. Also copies to embed directory so it gets embedded in the hypeman
# binary. It links DiskImageKit and the vmnet network API, so it needs the macOS 27
# SDK (Xcode 27); the binary runs on macOS 26, and stacked disks need macOS 27.
VZ_SHIM_SOURCES := $(wildcard cmd/vz-shim/*.swift)
VZ_SHIM_TARGET ?= $(shell uname -m)-apple-macos26.0

# DARWIN_LDFLAGS silences the macOS (Xcode 15+) linker's "ignoring duplicate
# libraries: '-lobjc'" warning: cgo deps that link Virtualization.framework
# specify -lobjc more than once, which the new linker flags. Harmless; suppress.
Expand All @@ -434,7 +422,8 @@ DARWIN_LDFLAGS := -ldflags=-extldflags=-Wl,-no_warn_duplicate_libraries
.PHONY: build-vz-shim
build-vz-shim: | $(BIN_DIR)
@echo "Building vz-shim for macOS..."
go build $(DARWIN_LDFLAGS) -o $(BIN_DIR)/vz-shim ./cmd/vz-shim
@sdk=$$(xcrun --sdk macosx --show-sdk-version); case $$sdk in 2[7-9]*|[3-9][0-9]*) ;; *) echo "vz-shim needs the macOS 27 SDK (Xcode 27); found $$sdk" >&2; exit 1;; esac
swiftc -O -target $(VZ_SHIM_TARGET) -o $(BIN_DIR)/vz-shim $(VZ_SHIM_SOURCES)
mkdir -p lib/hypervisor/vz/vz-shim
cp $(BIN_DIR)/vz-shim lib/hypervisor/vz/vz-shim/vz-shim
@echo "Build complete: $(BIN_DIR)/vz-shim"
Expand Down
17 changes: 15 additions & 2 deletions cmd/api/api/builds.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,15 @@ func (s *ApiService) ListBuilds(ctx context.Context, request oapi.ListBuildsRequ
// CreateBuild creates a new build job
func (s *ApiService) CreateBuild(ctx context.Context, request oapi.CreateBuildRequestObject) (oapi.CreateBuildResponseObject, error) {
if s.Config != nil && s.Config.MacOSOnly {
return oapi.CreateBuild501JSONResponse{Code: "unsupported", Message: "Linux builder VMs are unavailable in macOS-only mode"}, nil
if !s.Config.MacOSBuilds {
return oapi.CreateBuild501JSONResponse{Code: "unsupported", Message: "Linux builder VMs are unavailable in macOS-only mode"}, nil
}
req, source, err := parseMachineBuildMultipart(request.Body)
if err != nil {
return oapi.CreateBuild400JSONResponse{Code: "invalid_request", Message: err.Error()}, nil
}
return s.submitBuild(ctx, req, source)
}
log := logger.FromContext(ctx)

// Parse multipart form fields
var sourceData []byte
Expand All @@ -69,6 +75,8 @@ func (s *ApiService) CreateBuild(ctx context.Context, request oapi.CreateBuildRe
}

switch part.FormName() {
case "machine_base_image", "machine_network_mode":
return oapi.CreateBuild400JSONResponse{Code: "unsupported", Message: "machine builds require macos_only and macos_builds"}, nil
case "source":
sourceData, err = io.ReadAll(part)
if err != nil {
Expand Down Expand Up @@ -256,6 +264,11 @@ func (s *ApiService) CreateBuild(ctx context.Context, request oapi.CreateBuildRe
}
}

return s.submitBuild(ctx, domainReq, sourceData)
}

func (s *ApiService) submitBuild(ctx context.Context, domainReq builds.CreateBuildRequest, sourceData []byte) (oapi.CreateBuildResponseObject, error) {
log := logger.FromContext(ctx)
build, err := s.BuildManager.CreateBuild(ctx, domainReq, sourceData)
if err != nil {
switch {
Expand Down
36 changes: 31 additions & 5 deletions cmd/api/api/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"sync"

"github.com/kernel/hypeman/lib/hypervisor"
"github.com/kernel/hypeman/lib/images"
"github.com/kernel/hypeman/lib/logger"
"github.com/kernel/hypeman/lib/network"
"github.com/kernel/hypeman/lib/oapi"
Expand All @@ -26,6 +25,8 @@ const (
featureLogs = "logs"
featureDevices = "devices"
featureRosettaEmulation = "rosetta-emulation"
featureMacOSImages = "macos-machine-images"
featureMacOSBuilds = "macos-machine-builds-v1"
)

// apiVersion is the API contract version from the embedded OpenAPI document.
Expand Down Expand Up @@ -133,13 +134,11 @@ func (s *ApiService) GetCapabilities(ctx context.Context, _ oapi.GetCapabilities
},
Runtimes: runtimes,
Network: *networkCaps,
Images: oapi.CapabilitiesImages{
Platforms: imagePlatforms(runtime.GOARCH, emulation),
DefaultPlatform: images.HostPlatformString(),
},
Images: hostImageCapabilities(runtime.GOOS, runtime.GOARCH, emulation, s.Config.MacOSOnly),
Features: serverFeatures(runtime.GOOS, emulation),
}

resp.Features = append(resp.Features, machineImageFeatures(runtime.GOOS, runtime.GOARCH, s.Config.MacOSBuilds)...)
return oapi.GetCapabilities200JSONResponse(resp), nil
}

Expand Down Expand Up @@ -210,6 +209,33 @@ func imagePlatforms(goarch string, emulation bool) []string {
return platforms
}

func hostImageCapabilities(goos, goarch string, emulation, macOSOnly bool) oapi.CapabilitiesImages {
caps := oapi.CapabilitiesImages{
Platforms: imagePlatforms(goarch, emulation),
DefaultPlatform: "linux/" + goarch,
}
if goos == "darwin" && goarch == "arm64" {
if macOSOnly {
caps.Platforms = []string{"darwin/arm64"}
caps.DefaultPlatform = "darwin/arm64"
} else {
caps.Platforms = append(caps.Platforms, "darwin/arm64")
}
}
return caps
}

func machineImageFeatures(goos, goarch string, builds bool) []string {
if goos != "darwin" || goarch != "arm64" {
return nil
}
features := []string{featureMacOSImages}
if builds {
features = append(features, featureMacOSBuilds)
}
return features
}

// serverFeatures builds the server-level feature ID list: API surfaces that
// are always present plus host-platform conditionals.
func serverFeatures(goos string, emulation bool) []string {
Expand Down
34 changes: 34 additions & 0 deletions cmd/api/api/capabilities_machine_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package api

import (
"testing"

"github.com/stretchr/testify/require"
)

func TestHostMachineImageCapabilities(t *testing.T) {
for _, tc := range []struct {
name, os, arch, defaultPlatform string
emulation, macOSOnly bool
platforms []string
}{
{"Linux unchanged", "linux", "amd64", "linux/amd64", false, false, []string{"linux/amd64"}},
{"Linux arm unchanged", "linux", "arm64", "linux/arm64", false, false, []string{"linux/arm64"}},
{"Mac mixed", "darwin", "arm64", "linux/arm64", false, false, []string{"linux/arm64", "darwin/arm64"}},
{"Mac mixed Rosetta", "darwin", "arm64", "linux/arm64", true, false, []string{"linux/arm64", "linux/amd64", "darwin/arm64"}},
{"Mac only", "darwin", "arm64", "darwin/arm64", true, true, []string{"darwin/arm64"}},
} {
t.Run(tc.name, func(t *testing.T) {
caps := hostImageCapabilities(tc.os, tc.arch, tc.emulation, tc.macOSOnly)
require.Equal(t, tc.defaultPlatform, caps.DefaultPlatform)
require.Equal(t, tc.platforms, caps.Platforms)
})
}
}

func TestMachineImageFeatureActivation(t *testing.T) {
require.Empty(t, machineImageFeatures("linux", "arm64", true))
require.Empty(t, machineImageFeatures("darwin", "amd64", true))
require.Equal(t, []string{"macos-machine-images"}, machineImageFeatures("darwin", "arm64", false))
require.Equal(t, []string{"macos-machine-images", "macos-machine-builds-v1"}, machineImageFeatures("darwin", "arm64", true))
}
8 changes: 6 additions & 2 deletions cmd/api/api/instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@ func (s *ApiService) ListInstances(ctx context.Context, request oapi.ListInstanc
func (s *ApiService) CreateInstance(ctx context.Context, request oapi.CreateInstanceRequestObject) (oapi.CreateInstanceResponseObject, error) {
log := logger.FromContext(ctx)

macOSCreate, err := s.isMacOSImage(ctx, request.Body.Image)
platform := derefString(request.Body.Platform)
if s.Config != nil && s.Config.MacOSOnly && platform == "" {
platform = "darwin/arm64"
}
macOSCreate, err := s.isMacOSImage(ctx, request.Body.Image, platform)
if err != nil {
log.ErrorContext(ctx, "failed to look up image for create", "error", err, "image", request.Body.Image)
return oapi.CreateInstance500JSONResponse{Code: "internal_error", Message: "failed to look up image"}, nil
Expand Down Expand Up @@ -337,7 +341,7 @@ func (s *ApiService) CreateInstance(ctx context.Context, request oapi.CreateInst
Name: request.Body.Name,
Image: request.Body.Image,
Size: size,
Platform: derefString(request.Body.Platform),
Platform: platform,
HotplugSize: hotplugSize,
OverlaySize: overlaySize,
Vcpus: vcpus,
Expand Down
18 changes: 13 additions & 5 deletions cmd/api/api/macos.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,19 @@ import (
// macOSHost is true where macOS guests can exist. Tests override it.
var macOSHost = runtime.GOOS == "darwin"

// isMacOSImage reports whether a create names an imported macOS image. Other
// hosts skip the lookup, so Linux creates are unchanged. An image that is not
// cached yet is not a macOS image; any other lookup error is returned.
func (s *ApiService) isMacOSImage(ctx context.Context, name string) (bool, error) {
if !macOSHost || s.ImageManager == nil {
// isMacOSImage identifies macOS creates before Linux defaults are applied. An
// explicit Darwin platform and macOS-only servers can resolve uncached images.
func (s *ApiService) isMacOSImage(ctx context.Context, name, platform string) (bool, error) {
if !macOSHost {
return false, nil
}
if s.Config != nil && s.Config.MacOSOnly {
return true, nil
}
if p, err := images.ParsePlatform(platform); err == nil && p.OS == "darwin" {
return true, nil
}
if s.ImageManager == nil {
return false, nil
}
img, err := s.ImageManager.GetImage(ctx, name)
Expand Down
122 changes: 122 additions & 0 deletions cmd/api/api/macos_builds.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package api

import (
"fmt"
"io"
"mime/multipart"
"net/http"
"strconv"

"github.com/kernel/hypeman/lib/builds"
"github.com/kernel/hypeman/lib/images"
mw "github.com/kernel/hypeman/lib/middleware"
"github.com/kernel/hypeman/lib/scopes"
)

// maxConcurrentMachineBuildUploads bounds how many machine build bodies, each up
// to 65 MiB, are read into memory at once.
const maxConcurrentMachineBuildUploads = 4

// LimitMachineBuildBody must wrap the validator, which also consumes request bodies.
// A build upload is authenticated before it takes an upload slot, so unauthenticated
// requests cannot hold the slots.
func LimitMachineBuildBody(jwtSecret string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
uploads := make(chan struct{}, maxConcurrentMachineBuildUploads)
upload := mw.JwtAuth(jwtSecret)(scopes.RequireScope(scopes.BuildWrite)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case uploads <- struct{}{}:
defer func() { <-uploads }()
default:
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(`{"code":"too_many_requests","message":"too many machine build uploads in progress"}`))
return
}
r.Body = http.MaxBytesReader(w, r.Body, 65<<20)
next.ServeHTTP(w, r)
})))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && r.URL.Path == "/builds" {
upload.ServeHTTP(w, r)
return
}
next.ServeHTTP(w, r)
})
}
}

// Machine-only HTTP admission is bounded regardless of multipart field order.
// Linux retains its existing parser and source contract.
func parseMachineBuildMultipart(body *multipart.Reader) (builds.CreateBuildRequest, []byte, error) {
req := builds.CreateBuildRequest{BuildPolicy: &builds.BuildPolicy{}}
policy := req.BuildPolicy
numbers := map[string]*int{"timeout_seconds": &policy.TimeoutSeconds, "memory_mb": &policy.MemoryMB, "cpus": &policy.CPUs}
var source []byte
seen := map[string]bool{}
if body == nil {
return req, nil, fmt.Errorf("multipart source is required")
}
for {
part, err := body.NextPart()
if err == io.EOF {
break
}
if err != nil {
return req, nil, fmt.Errorf("invalid multipart form")
}
name := part.FormName()
if seen[name] {
return req, nil, fmt.Errorf("duplicate multipart field")
}
seen[name] = true
limit := int64(64 << 10)
switch name {
case "source":
limit = 64 << 20
case "machine_base_image", "machine_network_mode", "timeout_seconds", "memory_mb", "cpus", "tags":
default:
return req, nil, fmt.Errorf("unsupported machine build field: %s", name)
}
data, err := io.ReadAll(io.LimitReader(part, limit+1))
if err != nil || int64(len(data)) > limit {
return req, nil, fmt.Errorf("invalid or oversized %s field", name)
}
switch name {
case "source":
source = data
case "machine_base_image":
req.MachineBaseImage = string(data)
case "machine_network_mode":
if string(data) != "isolated" && string(data) != "egress" {
return req, nil, fmt.Errorf("machine_network_mode must be isolated or egress")
}
req.BuildPolicy.NetworkMode = string(data)
case "tags":
req.Tags, err = parseTagsJSON(string(data))
if err != nil {
return req, nil, fmt.Errorf("tags must be a JSON object of strings")
}
default:
value, err := strconv.Atoi(string(data))
if err != nil || value < 0 {
return req, nil, fmt.Errorf("%s must be a nonnegative integer", name)
}
*numbers[name] = value
}
}
if len(source) == 0 {
return req, nil, fmt.Errorf("source is required")
}
ref, err := images.ParseNormalizedRef(req.MachineBaseImage)
if err != nil || !ref.IsDigest() || !builds.ValidMachineDigest(ref.Digest()) {
return req, nil, fmt.Errorf("machine_base_image must be pinned by sha256 digest")
}
if req.BuildPolicy.TimeoutSeconds > 86400 {
return req, nil, fmt.Errorf("timeout_seconds exceeds 86400")
}
if err = req.BuildPolicy.Validate(); err != nil {
return req, nil, err
}
return req, source, nil
}
Loading
Loading