From 4ffed87e2a3a92c38367308ced680517fefa2504 Mon Sep 17 00:00:00 2001 From: chris lee Date: Sun, 11 Oct 2026 17:49:10 -0400 Subject: [PATCH] macOS guests 1/6: VZ runtime, machine images and instance lifecycle Experimental, administrator-provisioned macOS guests on Apple silicon through the normal image and instance APIs. Unsupported operations are rejected explicitly. Linux create, start and API paths are unchanged; macOS enters through hooks. Per-operation status: docs/macos-support.md. --- cmd/api/api/builds.go | 3 + cmd/api/api/exec.go | 5 + cmd/api/api/images.go | 2 + cmd/api/api/ingress.go | 6 + cmd/api/api/instances.go | 36 +- cmd/api/api/macos.go | 72 ++ cmd/api/api/macos_test.go | 119 +++ cmd/api/config/config.go | 15 +- cmd/api/main.go | 46 +- cmd/import-macos/main.go | 35 + cmd/vz-shim/macos_arm64.go | 69 ++ cmd/vz-shim/macos_arm64_test.go | 24 + cmd/vz-shim/macos_identity.go | 48 + cmd/vz-shim/macos_identity_test.go | 25 + cmd/vz-shim/macos_other.go | 16 + cmd/vz-shim/macos_vm.go | 67 ++ cmd/vz-shim/main.go | 9 + cmd/vz-shim/vm.go | 4 + docs/macos-experimental.md | 74 ++ docs/macos-support.md | 27 + lib/guestmemory/active_ballooning.go | 2 + lib/guestmemory/controller.go | 4 + lib/guestmemory/controller_test.go | 20 + lib/hypervisor/config.go | 10 + lib/hypervisor/config_validation.go | 10 + lib/hypervisor/hypervisor.go | 3 + lib/hypervisor/qemu/macos_config_test.go | 12 + lib/hypervisor/qemu/profile.go | 3 + lib/hypervisor/vz/client.go | 2 + lib/hypervisor/vz/macos_config_test.go | 38 + lib/hypervisor/vz/shimconfig/config.go | 5 + lib/hypervisor/vz/starter.go | 33 +- lib/images/macos_bundle.go | 108 +++ lib/images/macos_bundle_test.go | 60 ++ lib/images/macos_import_darwin.go | 116 +++ lib/images/macos_import_darwin_test.go | 51 ++ lib/images/manager.go | 4 + lib/images/platform.go | 14 +- lib/images/storage.go | 43 +- lib/images/tag.go | 3 + lib/images/types.go | 32 +- lib/instances/configdisk.go | 3 + lib/instances/create.go | 34 +- lib/instances/create_image.go | 13 + lib/instances/delete.go | 4 +- lib/instances/fork.go | 3 + lib/instances/ingress_resolver.go | 6 + lib/instances/ingress_resolver_macos_test.go | 26 + lib/instances/logs.go | 19 +- lib/instances/logs_macos_source_test.go | 19 + lib/instances/logs_vmm_source_test.go | 18 + lib/instances/macos.go | 291 ++++++ lib/instances/macos_darwin.go | 34 + lib/instances/macos_darwin_test.go | 83 ++ lib/instances/macos_only_gate_test.go | 40 + lib/instances/macos_other.go | 9 + lib/instances/macos_test.go | 83 ++ lib/instances/macos_update_test.go | 73 ++ lib/instances/manager.go | 7 +- lib/instances/query.go | 9 +- lib/instances/restore.go | 3 + lib/instances/snapshot.go | 9 + lib/instances/snapshot_schedule.go | 7 +- lib/instances/snapshot_schedule_test.go | 21 + lib/instances/standby.go | 3 + lib/instances/start.go | 41 +- lib/instances/stop.go | 2 +- lib/instances/types.go | 2 + lib/instances/update.go | 6 + lib/instances/vm_storage_path_test.go | 15 + lib/instances/vsock.go | 4 + lib/oapi/oapi.go | 875 ++++++++++--------- lib/paths/paths.go | 6 + lib/providers/providers.go | 2 + lib/resources/resource.go | 3 + lib/vm_metrics/adapter.go | 1 + lib/vm_metrics/collector_darwin.go | 122 ++- lib/vm_metrics/collector_darwin_test.go | 59 ++ lib/vm_metrics/manager.go | 12 + lib/vm_metrics/related_other.go | 6 + lib/vm_metrics/types.go | 2 + openapi.yaml | 40 +- 82 files changed, 2813 insertions(+), 477 deletions(-) create mode 100644 cmd/api/api/macos.go create mode 100644 cmd/api/api/macos_test.go create mode 100644 cmd/import-macos/main.go create mode 100644 cmd/vz-shim/macos_arm64.go create mode 100644 cmd/vz-shim/macos_arm64_test.go create mode 100644 cmd/vz-shim/macos_identity.go create mode 100644 cmd/vz-shim/macos_identity_test.go create mode 100644 cmd/vz-shim/macos_other.go create mode 100644 cmd/vz-shim/macos_vm.go create mode 100644 docs/macos-experimental.md create mode 100644 docs/macos-support.md create mode 100644 lib/hypervisor/qemu/macos_config_test.go create mode 100644 lib/hypervisor/vz/macos_config_test.go create mode 100644 lib/images/macos_bundle.go create mode 100644 lib/images/macos_bundle_test.go create mode 100644 lib/images/macos_import_darwin.go create mode 100644 lib/images/macos_import_darwin_test.go create mode 100644 lib/instances/ingress_resolver_macos_test.go create mode 100644 lib/instances/logs_macos_source_test.go create mode 100644 lib/instances/logs_vmm_source_test.go create mode 100644 lib/instances/macos.go create mode 100644 lib/instances/macos_darwin.go create mode 100644 lib/instances/macos_darwin_test.go create mode 100644 lib/instances/macos_only_gate_test.go create mode 100644 lib/instances/macos_other.go create mode 100644 lib/instances/macos_test.go create mode 100644 lib/instances/macos_update_test.go create mode 100644 lib/instances/vm_storage_path_test.go create mode 100644 lib/vm_metrics/collector_darwin_test.go create mode 100644 lib/vm_metrics/related_other.go diff --git a/cmd/api/api/builds.go b/cmd/api/api/builds.go index 110422638..ae8e74c27 100644 --- a/cmd/api/api/builds.go +++ b/cmd/api/api/builds.go @@ -43,6 +43,9 @@ 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 + } log := logger.FromContext(ctx) // Parse multipart form fields diff --git a/cmd/api/api/exec.go b/cmd/api/api/exec.go index b6f93102c..9313e5372 100644 --- a/cmd/api/api/exec.go +++ b/cmd/api/api/exec.go @@ -71,6 +71,11 @@ func (s *ApiService) ExecHandler(w http.ResponseWriter, r *http.Request) { return } + if inst.MacOS != nil { + http.Error(w, `{"code":"unsupported","message":"exec is not implemented for experimental macOS instances"}`, http.StatusNotImplemented) + return + } + if inst.State != instances.StateRunning { http.Error(w, fmt.Sprintf(`{"code":"invalid_state","message":"instance must be running (current state: %s)"}`, inst.State), http.StatusConflict) return diff --git a/cmd/api/api/images.go b/cmd/api/api/images.go index 7ae20ebdc..95d3f5335 100644 --- a/cmd/api/api/images.go +++ b/cmd/api/api/images.go @@ -115,6 +115,8 @@ func (s *ApiService) TagImage(ctx context.Context, request oapi.TagImageRequestO func tagImageErrorResponse(ctx context.Context, err error, source, target string) oapi.TagImageResponseObject { switch { + case errors.Is(err, images.ErrInvalidPlatform): + return oapi.TagImage400JSONResponse{Code: "invalid_platform", Message: err.Error()} case errors.Is(err, images.ErrInvalidName): return oapi.TagImage400JSONResponse{Code: "invalid_name", Message: err.Error()} case errors.Is(err, images.ErrNotFound): diff --git a/cmd/api/api/ingress.go b/cmd/api/api/ingress.go index da6678da7..a58854802 100644 --- a/cmd/api/api/ingress.go +++ b/cmd/api/api/ingress.go @@ -36,6 +36,12 @@ func (s *ApiService) ListIngresses(ctx context.Context, request oapi.ListIngress // CreateIngress creates a new ingress resource func (s *ApiService) CreateIngress(ctx context.Context, request oapi.CreateIngressRequestObject) (oapi.CreateIngressResponseObject, error) { + if s.Config != nil && s.Config.MacOSOnly { + return oapi.CreateIngress501JSONResponse{Code: "unsupported", Message: "ingress is not implemented in macOS-only mode"}, nil + } + if reason := s.macOSIngressTargetReason(ctx, request.Body.Rules); reason != "" { + return oapi.CreateIngress501JSONResponse{Code: "unsupported", Message: reason}, nil + } log := logger.FromContext(ctx) // Convert OAPI request to domain request diff --git a/cmd/api/api/instances.go b/cmd/api/api/instances.go index 0869c755f..1934b87cf 100644 --- a/cmd/api/api/instances.go +++ b/cmd/api/api/instances.go @@ -63,6 +63,15 @@ 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) + 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 + } + if macOSCreate { + ignoreLinuxCreateDefaults(request.Body) + } + // Parse size (default: 1GB) size := int64(0) if request.Body.Size != nil && *request.Body.Size != "" { @@ -273,12 +282,17 @@ func (s *ApiService) CreateInstance(ctx context.Context, request oapi.CreateInst } } + // macOS guests take CPU and memory from their image and have no I/O shaping. + if macOSCreate && request.Body.Vcpus == nil { + vcpus = 0 + } + // Calculate default resource limits when not specified (0 = auto) // Uses proportional allocation based on CPU: (vcpus / cpuCapacity) * resourceCapacity - if diskIOBps == 0 { + if !macOSCreate && diskIOBps == 0 { diskIOBps, _ = s.ResourceManager.DefaultDiskIOBandwidth(vcpus) } - if networkBandwidthDownload == 0 || networkBandwidthUpload == 0 { + if !macOSCreate && (networkBandwidthDownload == 0 || networkBandwidthUpload == 0) { defaultDown, defaultUp := s.ResourceManager.DefaultNetworkBandwidth(vcpus) if networkBandwidthDownload == 0 { networkBandwidthDownload = defaultDown @@ -459,6 +473,7 @@ func (s *ApiService) GetInstanceStats(ctx context.Context, request oapi.GetInsta inst.Vcpus, inst.Size+inst.HotplugSize, ) + info.StoragePath = inst.VMStoragePath() // Collect stats using vm_metrics manager vmStats := s.VMMetricsManager.GetInstanceStats(ctx, info) @@ -672,6 +687,11 @@ func (s *ApiService) RestoreInstance(ctx context.Context, request oapi.RestoreIn Code: "not_found", Message: "instance not found", }, nil + case errors.Is(err, instances.ErrInvalidRequest): + return oapi.RestoreInstance400JSONResponse{ + Code: "invalid_request", + Message: err.Error(), + }, nil case errors.Is(err, instances.ErrInvalidState): return oapi.RestoreInstance409JSONResponse{ Code: "invalid_state", @@ -938,6 +958,11 @@ func (s *ApiService) GetInstanceLogs(ctx context.Context, request oapi.GetInstan Code: "log_not_found", Message: "requested log file does not exist yet", }, nil + case errors.Is(err, instances.ErrNotSupported): + return oapi.GetInstanceLogs501JSONResponse{ + Code: "not_supported", + Message: err.Error(), + }, nil default: return oapi.GetInstanceLogs500JSONResponse{ Code: "internal_error", @@ -963,6 +988,13 @@ func (s *ApiService) StatInstancePath(ctx context.Context, request oapi.StatInst }, nil } + if inst.MacOS != nil { + return oapi.StatInstancePath501JSONResponse{ + Code: "unsupported", + Message: "stat is not implemented for experimental macOS instances", + }, nil + } + if inst.State != instances.StateRunning { return oapi.StatInstancePath409JSONResponse{ Code: "invalid_state", diff --git a/cmd/api/api/macos.go b/cmd/api/api/macos.go new file mode 100644 index 000000000..e31346f6b --- /dev/null +++ b/cmd/api/api/macos.go @@ -0,0 +1,72 @@ +package api + +import ( + "context" + "errors" + "runtime" + + "github.com/kernel/hypeman/lib/images" + "github.com/kernel/hypeman/lib/oapi" +) + +// 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 { + return false, nil + } + img, err := s.ImageManager.GetImage(ctx, name) + if errors.Is(err, images.ErrNotFound) { + return false, nil + } + if err != nil { + return false, err + } + return img.MacOS != nil, nil +} + +// macOSIngressTargetReason refuses routes to macOS guests. Their addresses are NAT +// leases, and no ingress path has been proven for them, so mixed-mode servers must +// not route public traffic to them. A target that cannot be looked up is refused. +func (s *ApiService) macOSIngressTargetReason(ctx context.Context, rules []oapi.IngressRule) string { + if !macOSHost || s.InstanceManager == nil { + return "" + } + for _, rule := range rules { + inst, err := s.InstanceManager.GetInstance(ctx, rule.Target.Instance) + if err != nil { + return "cannot verify ingress target is not a macOS guest" + } + if inst.MacOS != nil { + return "ingress cannot target macOS guests" + } + } + return "" +} + +// Linux defaults for CreateInstanceRequest in openapi.yaml. +const ( + linuxDefaultSize = "1GB" + linuxDefaultOverlaySize = "10GB" + linuxDefaultVcpus = 2 +) + +// ignoreLinuxCreateDefaults clears create fields that hold their Linux schema +// default. The request validator writes those defaults into every body, so they +// cannot be told apart from explicit values. macOS defaults come from the image; +// an explicit value equal to a Linux default is still treated as omitted. +func ignoreLinuxCreateDefaults(body *oapi.CreateInstanceJSONRequestBody) { + if body.Size != nil && *body.Size == linuxDefaultSize { + body.Size = nil + } + if body.OverlaySize != nil && *body.OverlaySize == linuxDefaultOverlaySize { + body.OverlaySize = nil + } + if body.Vcpus != nil && *body.Vcpus == linuxDefaultVcpus { + body.Vcpus = nil + } +} diff --git a/cmd/api/api/macos_test.go b/cmd/api/api/macos_test.go new file mode 100644 index 000000000..647ece56b --- /dev/null +++ b/cmd/api/api/macos_test.go @@ -0,0 +1,119 @@ +package api + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/kernel/hypeman/cmd/api/config" + "github.com/kernel/hypeman/lib/images" + "github.com/kernel/hypeman/lib/instances" + mw "github.com/kernel/hypeman/lib/middleware" + "github.com/kernel/hypeman/lib/oapi" + "github.com/stretchr/testify/require" +) + +func TestMacOSOnlyRejectsBuildersAndIngress(t *testing.T) { + s := &ApiService{Config: &config.Config{MacOSOnly: true}} + build, err := s.CreateBuild(context.Background(), oapi.CreateBuildRequestObject{}) + require.NoError(t, err) + require.IsType(t, oapi.CreateBuild501JSONResponse{}, build) + ingress, err := s.CreateIngress(context.Background(), oapi.CreateIngressRequestObject{}) + require.NoError(t, err) + require.IsType(t, oapi.CreateIngress501JSONResponse{}, ingress) +} + +func TestMacOSExecRejectedBeforeWebsocketUpgrade(t *testing.T) { + s := &ApiService{} + inst := &instances.Instance{StoredMetadata: instances.StoredMetadata{MacOS: &images.MacOSImage{}}} + ctx := mw.WithResolvedInstance(context.Background(), "test", inst) + r := httptest.NewRequest(http.MethodGet, "/instances/test/exec", nil).WithContext(ctx) + w := httptest.NewRecorder() + s.ExecHandler(w, r) + require.Equal(t, http.StatusNotImplemented, w.Code) + require.Contains(t, w.Body.String(), "not implemented") +} + +func TestMacOSStatRejectedBeforeGuestDial(t *testing.T) { + s := &ApiService{} + inst := &instances.Instance{StoredMetadata: instances.StoredMetadata{MacOS: &images.MacOSImage{}}, State: instances.StateRunning} + ctx := mw.WithResolvedInstance(context.Background(), "test", inst) + resp, err := s.StatInstancePath(ctx, oapi.StatInstancePathRequestObject{Id: "test"}) + require.NoError(t, err) + unsupported, ok := resp.(oapi.StatInstancePath501JSONResponse) + require.True(t, ok, "macOS stat must be rejected with 501, got %T", resp) + require.Equal(t, "unsupported", unsupported.Code) +} + +func TestLinuxCreateDefaultsMatchSchema(t *testing.T) { + spec, err := oapi.GetSwagger() + require.NoError(t, err) + props := spec.Components.Schemas["CreateInstanceRequest"].Value.Properties + require.Equal(t, linuxDefaultSize, props["size"].Value.Default) + require.Equal(t, linuxDefaultOverlaySize, props["overlay_size"].Value.Default) + require.EqualValues(t, linuxDefaultVcpus, props["vcpus"].Value.Default) +} + +func TestMacOSCreateIgnoresInjectedLinuxDefaults(t *testing.T) { + prev := macOSHost + macOSHost = true + t.Cleanup(func() { macOSHost = prev }) + m := newCaptureCreateManager(nil) + s := &ApiService{InstanceManager: m, ImageManager: macOSImageManager{}, Config: &config.Config{}} + platform := "darwin/arm64" + + // The request validator fills these in when the client omits them. + size, overlay, vcpus := linuxDefaultSize, linuxDefaultOverlaySize, linuxDefaultVcpus + _, err := s.CreateInstance(context.Background(), oapi.CreateInstanceRequestObject{Body: &oapi.CreateInstanceRequest{ + Name: "macos", Image: "localhost/macos:spike", Platform: &platform, Size: &size, OverlaySize: &overlay, Vcpus: &vcpus, + }}) + require.NoError(t, err) + require.NotNil(t, m.lastReq) + require.Zero(t, m.lastReq.Vcpus) + require.Zero(t, m.lastReq.Size) + require.Zero(t, m.lastReq.OverlaySize) + + // Other explicit values still reach admission, which checks them against the image. + size, vcpus = "8GB", 4 + _, err = s.CreateInstance(context.Background(), oapi.CreateInstanceRequestObject{Body: &oapi.CreateInstanceRequest{ + Name: "macos", Image: "localhost/macos:spike", Platform: &platform, Size: &size, Vcpus: &vcpus, + }}) + require.NoError(t, err) + require.Equal(t, 4, m.lastReq.Vcpus) + require.EqualValues(t, 8<<30, m.lastReq.Size) +} + +type macOSImageManager struct{ images.Manager } + +func (macOSImageManager) GetImage(context.Context, string) (*images.Image, error) { + return &images.Image{Platform: "darwin/arm64", MacOS: &images.MacOSImage{}}, nil +} + +type macOSIngressTargetManager struct { + instances.Manager + inst *instances.Instance + err error +} + +func (m macOSIngressTargetManager) GetInstance(context.Context, string) (*instances.Instance, error) { + return m.inst, m.err +} + +func TestIngressRejectsMacOSTargets(t *testing.T) { + prev := macOSHost + macOSHost = true + t.Cleanup(func() { macOSHost = prev }) + rule := oapi.IngressRule{Match: oapi.IngressMatch{Hostname: "app.example.test"}, Target: oapi.IngressTarget{Instance: "mac", Port: 8080}} + macos := &instances.Instance{StoredMetadata: instances.StoredMetadata{MacOS: &images.MacOSImage{}}} + s := &ApiService{InstanceManager: macOSIngressTargetManager{inst: macos}} + require.Contains(t, s.macOSIngressTargetReason(context.Background(), []oapi.IngressRule{rule}), "macOS") + + linux := &instances.Instance{} + s = &ApiService{InstanceManager: macOSIngressTargetManager{inst: linux}} + require.Empty(t, s.macOSIngressTargetReason(context.Background(), []oapi.IngressRule{rule})) + + s = &ApiService{InstanceManager: macOSIngressTargetManager{err: errors.New("lookup failed")}} + require.NotEmpty(t, s.macOSIngressTargetReason(context.Background(), []oapi.IngressRule{rule}), "an unverifiable target is refused") +} diff --git a/cmd/api/config/config.go b/cmd/api/config/config.go index c250a3621..fe1fc74ee 100644 --- a/cmd/api/config/config.go +++ b/cmd/api/config/config.go @@ -275,11 +275,13 @@ type GPUConfig struct { // Config is the top-level Hypeman server configuration. type Config struct { - Port string `koanf:"port"` - DataDir string `koanf:"data_dir"` - JwtSecret string `koanf:"jwt_secret"` - Env string `koanf:"env"` - Version string `koanf:"version"` + Port string `koanf:"port"` + ListenAddress string `koanf:"listen_address"` // Empty preserves listening on all interfaces. + DataDir string `koanf:"data_dir"` + JwtSecret string `koanf:"jwt_secret"` + Env string `koanf:"env"` + Version string `koanf:"version"` + MacOSOnly bool `koanf:"macos_only"` // Experimental: omit Linux boot downloads and reject Linux creates. Network NetworkConfig `koanf:"network"` Caddy CaddyConfig `koanf:"caddy"` @@ -588,6 +590,9 @@ func expandHomePath(path string) string { // Validate checks configuration values for correctness. // Returns an error if any configuration value is invalid. func (c *Config) Validate() error { + if c.MacOSOnly && (runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" || c.Hypervisor.Default != "vz") { + return fmt.Errorf("macos_only requires vz on Apple silicon") + } if strings.TrimSpace(c.Metrics.ListenAddress) == "" { return fmt.Errorf("metrics.listen_address must not be empty") } diff --git a/cmd/api/main.go b/cmd/api/main.go index bc271c168..e9d85f2fe 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -335,9 +335,13 @@ func run() error { // Ensure system files (kernel, initrd) exist before starting server logger.Info("Ensuring system files...") - if err := app.SystemManager.EnsureSystemFiles(app.Ctx); err != nil { - logger.Error("failed to ensure system files", "error", err) - os.Exit(1) + if !cfg.MacOSOnly { + if err := app.SystemManager.EnsureSystemFiles(app.Ctx); err != nil { + logger.Error("failed to ensure system files", "error", err) + os.Exit(1) + } + } else { + logger.Info("macOS-only mode: skipping Linux kernel/initrd downloads") } kernelVer := app.SystemManager.GetDefaultKernelVersion() logger.Info("System files ready", @@ -402,11 +406,17 @@ func run() error { // Initialize ingress manager (starts Caddy daemon and DNS server for dynamic upstreams) logger.Info("Initializing ingress manager...") - if err := app.IngressManager.Initialize(app.Ctx); err != nil { - logger.Error("failed to initialize ingress manager", "error", err) - return fmt.Errorf("initialize ingress manager: %w", err) + if !cfg.MacOSOnly { + if err := app.IngressManager.Initialize(app.Ctx); err != nil { + logger.Error("failed to initialize ingress manager", "error", err) + return fmt.Errorf("initialize ingress manager: %w", err) + } + } else { + logger.Info("macOS-only mode: ingress is unsupported; skipping Caddy and DNS startup") + } + if !cfg.MacOSOnly { + logger.Info("Ingress manager initialized", "listen_addr", cfg.Caddy.ListenAddress, "admin", app.IngressManager.AdminURL()) } - logger.Info("Ingress manager initialized", "listen_addr", cfg.Caddy.ListenAddress, "admin", app.IngressManager.AdminURL()) // Create router r := chi.NewRouter() @@ -572,7 +582,7 @@ func run() error { // Create HTTP server srv := &http.Server{ - Addr: fmt.Sprintf(":%s", app.Config.Port), + Addr: net.JoinHostPort(app.Config.ListenAddress, app.Config.Port), Handler: r, } @@ -616,16 +626,16 @@ func run() error { ) } - // Start builders manager (reconcile builder state, idle reaper) - if err := app.BuilderManager.Start(gctx); err != nil { - logger.Error("failed to start builders manager", "error", err) - return err - } - - // Start build manager background services (vsock handler for builder VMs) - if err := app.BuildManager.Start(gctx); err != nil { - logger.Error("failed to start build manager", "error", err) - return err + if !cfg.MacOSOnly { + // Linux builder VMs and their guest-agent service are not part of macOS-only mode. + if err := app.BuilderManager.Start(gctx); err != nil { + logger.Error("failed to start builders manager", "error", err) + return err + } + if err := app.BuildManager.Start(gctx); err != nil { + logger.Error("failed to start build manager", "error", err) + return err + } } grp.Go(func() error { diff --git a/cmd/import-macos/main.go b/cmd/import-macos/main.go new file mode 100644 index 000000000..2a4a3040d --- /dev/null +++ b/cmd/import-macos/main.go @@ -0,0 +1,35 @@ +//go:build darwin && arm64 + +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/kernel/hypeman/lib/images" + "github.com/kernel/hypeman/lib/paths" +) + +func main() { + data := flag.String("data-dir", "", "Hypeman data directory") + source := flag.String("source", "", "stopped macvm bundle") + name := flag.String("name", "", "local image name (e.g. localhost/macos:spike)") + flag.Parse() + if *data == "" || *source == "" || *name == "" { + fmt.Fprintln(os.Stderr, "--data-dir, --source and --name are required") + os.Exit(2) + } + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + img, err := images.ImportMacOSImage(ctx, paths.New(*data), *name, *source) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + json.NewEncoder(os.Stdout).Encode(img) +} diff --git a/cmd/vz-shim/macos_arm64.go b/cmd/vz-shim/macos_arm64.go new file mode 100644 index 000000000..a8b5c761c --- /dev/null +++ b/cmd/vz-shim/macos_arm64.go @@ -0,0 +1,69 @@ +//go:build darwin && arm64 + +package main + +import ( + "encoding/base64" + "fmt" + + "github.com/Code-Hex/vz/v3" + "github.com/kernel/hypeman/lib/hypervisor/vz/shimconfig" +) + +func newMacBootLoader(c *shimconfig.ShimConfig) (vz.BootLoader, error) { + if c.MacHardwareModelData == "" || c.MacMachineIdentifierData == "" || c.MacAuxStoragePath == "" { + return nil, fmt.Errorf("macOS boot requires hardware model, machine identifier, and auxiliary storage") + } + return vz.NewMacOSBootLoader() +} +func configureMacPlatform(vc *vz.VirtualMachineConfiguration, c *shimconfig.ShimConfig) error { + modelData, e := base64.StdEncoding.DecodeString(c.MacHardwareModelData) + if e != nil { + return fmt.Errorf("decode Mac hardware model: %w", e) + } + model, e := vz.NewMacHardwareModelWithData(modelData) + if e != nil { + return e + } + if !model.Supported() { + return fmt.Errorf("Mac hardware model is unsupported on this host") + } + idData, e := base64.StdEncoding.DecodeString(c.MacMachineIdentifierData) + if e != nil { + return fmt.Errorf("decode Mac machine identifier: %w", e) + } + id, e := vz.NewMacMachineIdentifierWithData(idData) + if e != nil { + return e + } + aux, e := vz.NewMacAuxiliaryStorage(c.MacAuxStoragePath) + if e != nil { + return e + } + platform, e := vz.NewMacPlatformConfiguration(vz.WithMacHardwareModel(model), vz.WithMacMachineIdentifier(id), vz.WithMacAuxiliaryStorage(aux)) + if e != nil { + return e + } + vc.SetPlatformVirtualMachineConfiguration(platform) + graphics, e := vz.NewMacGraphicsDeviceConfiguration() + if e != nil { + return e + } + display, e := vz.NewMacGraphicsDisplayConfiguration(1280, 800, 80) + if e != nil { + return e + } + graphics.SetDisplays(display) + vc.SetGraphicsDevicesVirtualMachineConfiguration([]vz.GraphicsDeviceConfiguration{graphics}) + keyboard, e := vz.NewMacKeyboardConfiguration() + if e != nil { + return e + } + trackpad, e := vz.NewMacTrackpadConfiguration() + if e != nil { + return e + } + vc.SetKeyboardsVirtualMachineConfiguration([]vz.KeyboardConfiguration{keyboard}) + vc.SetPointingDevicesVirtualMachineConfiguration([]vz.PointingDeviceConfiguration{trackpad}) + return nil +} diff --git a/cmd/vz-shim/macos_arm64_test.go b/cmd/vz-shim/macos_arm64_test.go new file mode 100644 index 000000000..073c5387e --- /dev/null +++ b/cmd/vz-shim/macos_arm64_test.go @@ -0,0 +1,24 @@ +//go:build darwin && arm64 + +package main + +import ( + "github.com/kernel/hypeman/lib/hypervisor/vz/shimconfig" + "github.com/stretchr/testify/require" + "testing" +) + +func TestMacBootRequiresCompleteIdentity(t *testing.T) { + for _, c := range []shimconfig.ShimConfig{ + {MacHardwareModelData: "AA=="}, + {MacMachineIdentifierData: "AA=="}, + {MacAuxStoragePath: "/missing"}, + } { + _, _, err := createVM(&c) + require.ErrorContains(t, err, "macOS boot requires") + } +} +func TestMacPlatformRejectsMalformedModel(t *testing.T) { + err := configureMacPlatform(nil, &shimconfig.ShimConfig{MacHardwareModelData: "not base64!"}) + require.ErrorContains(t, err, "decode Mac hardware model") +} diff --git a/cmd/vz-shim/macos_identity.go b/cmd/vz-shim/macos_identity.go new file mode 100644 index 000000000..43fd1dfdf --- /dev/null +++ b/cmd/vz-shim/macos_identity.go @@ -0,0 +1,48 @@ +//go:build darwin + +package main + +import ( + "crypto/sha256" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "syscall" + + "github.com/kernel/hypeman/lib/hypervisor/vz/shimconfig" +) + +// Preserve the identity lock until subprocess exit, including while paused. +// Never unlink a lock file: another waiter may already hold its inode open. +func lockMacOSIdentity(c *shimconfig.ShimConfig) (*os.File, error) { + if c.MacMachineIdentifierData == "" { + return nil, nil + } + data, err := base64.StdEncoding.DecodeString(c.MacMachineIdentifierData) + if err != nil { + return nil, fmt.Errorf("decode Mac identity for admission: %w", err) + } + hash := sha256.Sum256(data) + // Fixed directory, not TMPDIR: servers with different environments must contend on the same lock. + path := filepath.Join("/tmp", fmt.Sprintf("hypeman-mac-identity-%d-%x.lock", os.Getuid(), hash)) + // The shared /tmp name is predictable, so refuse symlinks and foreign-owned files. + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|syscall.O_NOFOLLOW, 0600) + if err != nil { + return nil, err + } + info, err := f.Stat() + if err != nil { + f.Close() + return nil, err + } + if st, ok := info.Sys().(*syscall.Stat_t); !ok || !info.Mode().IsRegular() || int(st.Uid) != os.Getuid() { + f.Close() + return nil, fmt.Errorf("Mac identity lock path is not an owned regular file") + } + if err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + f.Close() + return nil, fmt.Errorf("Mac machine identity already in use: %w", err) + } + return f, nil +} diff --git a/cmd/vz-shim/macos_identity_test.go b/cmd/vz-shim/macos_identity_test.go new file mode 100644 index 000000000..af56eb4c4 --- /dev/null +++ b/cmd/vz-shim/macos_identity_test.go @@ -0,0 +1,25 @@ +//go:build darwin + +package main + +import ( + "github.com/kernel/hypeman/lib/hypervisor/vz/shimconfig" + "github.com/stretchr/testify/require" + "testing" +) + +func TestMacOSIdentityLifetimeLock(t *testing.T) { + c := &shimconfig.ShimConfig{MacMachineIdentifierData: "aWRlbnRpdHk="} + first, err := lockMacOSIdentity(c) + require.NoError(t, err) + defer first.Close() + equivalent := *c + equivalent.MacMachineIdentifierData += "\n" + second, err := lockMacOSIdentity(&equivalent) + require.Error(t, err) + require.Nil(t, second) + require.NoError(t, first.Close()) + second, err = lockMacOSIdentity(c) + require.NoError(t, err) + second.Close() +} diff --git a/cmd/vz-shim/macos_other.go b/cmd/vz-shim/macos_other.go new file mode 100644 index 000000000..733ded320 --- /dev/null +++ b/cmd/vz-shim/macos_other.go @@ -0,0 +1,16 @@ +//go:build darwin && !arm64 + +package main + +import ( + "fmt" + "github.com/Code-Hex/vz/v3" + "github.com/kernel/hypeman/lib/hypervisor/vz/shimconfig" +) + +func newMacBootLoader(*shimconfig.ShimConfig) (vz.BootLoader, error) { + return nil, fmt.Errorf("macOS guests require Apple silicon") +} +func configureMacPlatform(*vz.VirtualMachineConfiguration, *shimconfig.ShimConfig) error { + return fmt.Errorf("macOS guests require Apple silicon") +} diff --git a/cmd/vz-shim/macos_vm.go b/cmd/vz-shim/macos_vm.go new file mode 100644 index 000000000..8132a7a1f --- /dev/null +++ b/cmd/vz-shim/macos_vm.go @@ -0,0 +1,67 @@ +//go:build darwin + +package main + +import ( + "fmt" + "log/slog" + + "github.com/Code-Hex/vz/v3" + "github.com/kernel/hypeman/lib/hypervisor/vz/shimconfig" +) + +func isMacGuest(config *shimconfig.ShimConfig) bool { + return config.MacHardwareModelData != "" || config.MacMachineIdentifierData != "" || config.MacAuxStoragePath != "" +} + +// createMacVM boots an imported macOS disk with its preserved platform identity. +// It has no serial console, entropy device, Rosetta share or memory balloon. +func createMacVM(config *shimconfig.ShimConfig) (*vz.VirtualMachine, *vz.VirtualMachineConfiguration, error) { + if config.EnableRosetta || config.EnableMemoryBalloon || config.RequireMemoryBalloon { + return nil, nil, fmt.Errorf("macOS guests do not support Linux Rosetta or memory ballooning") + } + bootLoader, err := newMacBootLoader(config) + if err != nil { + return nil, nil, fmt.Errorf("create boot loader: %w", err) + } + + vcpus := uint(config.VCPUs) + memoryBytes := uint64(config.MemoryBytes) + slog.Debug("macOS VM config", "vcpus", vcpus, "memory_bytes", memoryBytes) + + vmConfig, err := vz.NewVirtualMachineConfiguration(bootLoader, vcpus, memoryBytes) + if err != nil { + return nil, nil, fmt.Errorf("create vm configuration: %w", err) + } + if err := configureNetwork(vmConfig, config.Networks); err != nil { + return nil, nil, fmt.Errorf("configure network: %w", err) + } + if err := configureStorage(vmConfig, config.Disks); err != nil { + return nil, nil, fmt.Errorf("configure storage: %w", err) + } + if err := configureDirectorySharing(vmConfig, config); err != nil { + return nil, nil, fmt.Errorf("configure directory sharing: %w", err) + } + if err := configureMacPlatform(vmConfig, config); err != nil { + return nil, nil, fmt.Errorf("configure Mac platform: %w", err) + } + + vsockConfig, err := vz.NewVirtioSocketDeviceConfiguration() + if err != nil { + return nil, nil, fmt.Errorf("create vsock device: %w", err) + } + vmConfig.SetSocketDevicesVirtualMachineConfiguration([]vz.SocketDeviceConfiguration{vsockConfig}) + + validated, err := vmConfig.Validate() + if err != nil { + return nil, nil, fmt.Errorf("invalid vm configuration: %w", err) + } + if !validated { + return nil, nil, fmt.Errorf("invalid vm configuration") + } + vm, err := vz.NewVirtualMachine(vmConfig) + if err != nil { + return nil, nil, fmt.Errorf("create virtual machine: %w", err) + } + return vm, vmConfig, nil +} diff --git a/cmd/vz-shim/main.go b/cmd/vz-shim/main.go index 5c9f72807..896e9a71d 100644 --- a/cmd/vz-shim/main.go +++ b/cmd/vz-shim/main.go @@ -47,6 +47,15 @@ func main() { slog.Info("vz-shim starting", "control_socket", config.ControlSocket, "vsock_socket", config.VsockSocket) + identityLock, err := lockMacOSIdentity(&config) + if err != nil { + fmt.Fprintf(os.Stderr, "macOS identity admission: %v\n", err) + os.Exit(1) + } + if identityLock != nil { + defer identityLock.Close() + } + // Create the VM vm, vmConfig, err := createVM(&config) if err != nil { diff --git a/cmd/vz-shim/vm.go b/cmd/vz-shim/vm.go index 53f0cf376..b755da866 100644 --- a/cmd/vz-shim/vm.go +++ b/cmd/vz-shim/vm.go @@ -17,6 +17,10 @@ import ( // createVM creates and configures a vz.VirtualMachine from ShimConfig. func createVM(config *shimconfig.ShimConfig) (*vz.VirtualMachine, *vz.VirtualMachineConfiguration, error) { + if isMacGuest(config) { + return createMacVM(config) + } + // Prepare kernel command line (vz uses hvc0 for serial console) kernelArgs := config.KernelArgs if kernelArgs == "" { diff --git a/docs/macos-experimental.md b/docs/macos-experimental.md new file mode 100644 index 000000000..9fb1fe37b --- /dev/null +++ b/docs/macos-experimental.md @@ -0,0 +1,74 @@ +# Experimental macOS guests (local Apple silicon only) + +This spike supports **offline local image import and the normal instance API's +create/list/get/start/stop/delete path**. It is not production macOS support. +Linux guests retain the existing defaults and boot path. + +## Import + +Use a stopped macOS bundle (`config.json`, `disk.img`, `aux.img`) on +Darwin/arm64. Configure its user, SSH, FileVault and desktop before import. +The importer verifies that storage is not open, clones disk and auxiliary +storage, hashes their contents and identity/configuration into a digest, and +publishes a local ready image. Import is an administrator CLI operation, not +an HTTP endpoint accepting arbitrary host paths. + +```sh +go run -tags containers_image_openpgp ./cmd/import-macos \ + --data-dir /absolute/path/to/data \ + --source /absolute/path/to/stopped-bundle \ + --name localhost/macos:spike +``` + +The data directory needs APFS clonefile support. Images are `darwin/arm64`, not +OCI macOS containers. Registry pulls, manifest resolution and tagging/promotion +of macOS images are unsupported. Do not mutate the imported image files. + +## API + +Use the usual JWT-authenticated API with a VZ-capable, signed build: + +```json +{ + "name": "macos-desktop", + "image": "localhost/macos:spike", + "platform": "darwin/arm64", + "network": {"enabled": true} +} +``` + +CPU and memory default to the imported template (minimum 2 CPUs and 4GB). +Each instance gets its own writable complete boot disk and auxiliary storage; +no Linux kernel, initrd, configuration disk, overlay filesystem or balloon is +attached. NAT uses the preserved MAC. The IP is observed from the host's VZ +DHCP leases, which may retain an old lease before the guest is ready. + +**`Running` means the VMM is running, not that SSH, the desktop or Chrome is +ready.** Readiness is currently tested over SSH. Startup does not launch Chrome. +Guest identity, host keys, user secrets and MAC are preserved. Only one active +instance with a given Mac identifier is allowed. Do not run the source bundle +or an external clone concurrently. Production provisioning needs identity and +credential rekeying; this spike is not a multi-tenant image format. + +`POST /instances/{id}/stop` shuts down the VMM; without a Darwin guest agent, +it does **not** guarantee an orderly guest OS/application shutdown. Use a human +or an authorized guest shutdown workflow before destructive operations when +application consistency matters. Start cold-boots the instance's existing disk. +Delete removes instance storage, not the imported image. Deleting the imported +image does not affect existing instances: each owns independent copies and +starts without the template. + +Unsupported operations are rejected explicitly. The status of each operation is kept in +[macos-support.md](macos-support.md), not repeated here. + +An opt-in `macos_only: true` config requires Darwin/arm64 and VZ, refuses Linux +instance creates/starts and skips Linux kernel/initrd downloads, builders and +Caddy/DNS initialization. Ingress creation and builds are rejected in that mode. +The default is false. `listen_address: 127.0.0.1` can bind the HTTP API locally. +No host sudo, bridge creation or launchd installation is required for the spike. + +For controlled Chrome testing after autologin, use `open -na "Google Chrome"` +with a dedicated `--user-data-dir` and guest-local `--remote-debugging-port=9222`. +macOS can reopen Chrome after a power-off without the original debugging flags; +`open -a` may just activate that existing process and ignore the flags. Tunnel +CDP via SSH rather than exposing it on the guest network. diff --git a/docs/macos-support.md b/docs/macos-support.md new file mode 100644 index 000000000..32c2e62e1 --- /dev/null +++ b/docs/macos-support.md @@ -0,0 +1,27 @@ +# macOS guest support + +Single source for what macOS guests support. Per-PR docs link here instead of repeating +the list. Status is per operation on the stacked branches. "PR n" names the stack PR that +owns the behavior. + +| Area | Status | Notes | Owner | +| --- | --- | --- | --- | +| Create, get, list, start, stop, delete | Supported | Start cold-boots the disk. CPU and memory default to the image; cold-create overrides are supported (at least 2 CPUs and 4 GiB in whole MiB, within VZ host limits). The API's Linux defaults (`size` 1GB, `vcpus` 2, `overlay_size` 10GB) do not apply: the request validator fills them in, so a macOS create ignores any value equal to one of them. | PR 1 | +| Image import (offline bundle) | Supported | Local ready `darwin/arm64` image. | PR 1 | +| Image pull (OCI machine image) | Partial | Repository-scoped. No tag promotion. | PR 2 | +| Exec, file copy, stat | Agent-gated | Needs the shared guest agent declared by the image. | PR 3 | +| Logs | Partial | `hypeman` and `vmm` (VZ shim log) sources. No app log file for VZ guests. | PR 1 | +| Stats | Partial | CPU and memory from the VM process. Network counters are 0 under NAT. | PR 1 | +| Memory reclaim | Unsupported | Reported per guest as `unsupported`. No balloon change. | PR 1 | +| Guest port forwarding | Agent-gated | HTTP and WebSocket to a guest loopback port at `/instances/{id}/ports/{port}/*`. The image starts its own services. See [macos-guest-ports.md](macos-guest-ports.md). | PR 6 | +| Builds | Partial | Opt-in `macos_builds`. Restricted recipe. Linux Dockerfile builds unsupported. | PR 5 | +| Stopped snapshots, same-instance restore | Partial | Graceful stop required. Running restore has an open readiness issue. | PR 4 | +| Standby, memory snapshot, memory fork | Unsupported | Rejected with a stable error. | — | +| Stopped clones, fork from snapshot | Unsupported | Needs identity rebinding and guest rekeying. | PR 4 (later) | +| Update (PATCH), VM restart policy | Partial | Expiration/TTL and `restart_policy: always` or `never` are supported. Manual stop suppresses automatic restart. `on_failure` is rejected: no workload exit status is available. | PR 1 | +| Volumes, env and commands, credentials, health and auto-standby policies, passthrough, shaping | Unsupported | Rejected at create, start, or update. | — | +| Ingress | Unsupported | Refused in macOS-only mode and for macOS targets. | — | +| Snapshot schedules | Unsupported | Refused at admission. | — | + +Limits that apply everywhere: preserved machine identity (one active instance per +identifier, no rekeying), no clean-base sanitation audit, and no external review verdict. diff --git a/lib/guestmemory/active_ballooning.go b/lib/guestmemory/active_ballooning.go index c9d1c8b59..191bde0a1 100644 --- a/lib/guestmemory/active_ballooning.go +++ b/lib/guestmemory/active_ballooning.go @@ -94,6 +94,8 @@ type BalloonVM struct { HypervisorType hypervisor.Type SocketPath string AssignedMemoryBytes int64 + // MacOS guests have no traditional balloon device to query or change. + MacOS bool } // Source lists reclaim-eligible VMs. diff --git a/lib/guestmemory/controller.go b/lib/guestmemory/controller.go index 4be91fd0b..37874f76e 100644 --- a/lib/guestmemory/controller.go +++ b/lib/guestmemory/controller.go @@ -149,6 +149,10 @@ func (c *controller) reconcile(ctx context.Context, req reconcileRequest) (Manua actions := make([]ManualReclaimAction, 0, len(vms)) var currentTotalReclaim int64 for _, vm := range vms { + if vm.MacOS { + actions = append(actions, skippedAction(vm, "unsupported", "macOS guests have no runtime balloon control")) + continue + } hv, err := state.newClient(vm.HypervisorType, vm.SocketPath) if err != nil { actions = append(actions, skippedAction(vm, "error", fmt.Sprintf("create hypervisor client: %v", err))) diff --git a/lib/guestmemory/controller_test.go b/lib/guestmemory/controller_test.go index 4720ec7a6..37e5ba827 100644 --- a/lib/guestmemory/controller_test.go +++ b/lib/guestmemory/controller_test.go @@ -277,3 +277,23 @@ func TestTriggerReclaimDryRunDoesNotReportAppliedReclaim(t *testing.T) { assert.Equal(t, int64(0), resp.Actions[0].AppliedReclaimBytes, "dry-run actions should not report applied reclaim") assert.Equal(t, int64(1024*mib), hv.target, "dry-run should not mutate the hypervisor target") } + +func TestTriggerReclaimSkipsMacOSGuestsAsUnsupported(t *testing.T) { + const mib = int64(1024 * 1024) + src := &stubSource{vms: []BalloonVM{ + {ID: "mac", Name: "mac", HypervisorType: hypervisor.TypeVZ, SocketPath: "mac", AssignedMemoryBytes: 4096 * mib, MacOS: true}, + }} + hv := &stubHypervisor{target: 4096 * mib, capabilities: hypervisor.Capabilities{SupportsBalloonControl: true}} + c := NewController(Policy{Enabled: true, ReclaimEnabled: true}, ActiveBallooningConfig{ + Enabled: true, ProtectedFloorPercent: 50, MinAdjustmentBytes: 1, PerVMMaxStepBytes: 4096 * mib, PerVMCooldown: time.Second, + }, src, slog.New(slog.NewTextHandler(io.Discard, nil))).(*controller) + c.sampler = &stubSampler{sample: HostPressureSample{TotalBytes: 4096 * mib, AvailableBytes: 1024 * mib, AvailablePercent: 25}} + c.reconcileMu.newClient = func(hypervisor.Type, string) (hypervisor.Hypervisor, error) { return hv, nil } + + resp, err := c.TriggerReclaim(context.Background(), ManualReclaimRequest{ReclaimBytes: 512 * mib, DryRun: true}) + require.NoError(t, err) + require.Len(t, resp.Actions, 1) + assert.Equal(t, "unsupported", resp.Actions[0].Status) + assert.Zero(t, resp.PlannedReclaimBytes) + assert.Equal(t, int64(4096*mib), hv.target, "a macOS guest's balloon target must not be read or changed") +} diff --git a/lib/hypervisor/config.go b/lib/hypervisor/config.go index e46aa3446..da8c41daa 100644 --- a/lib/hypervisor/config.go +++ b/lib/hypervisor/config.go @@ -30,6 +30,7 @@ type VMConfig struct { // Boot configuration. Empty BootMode preserves the existing direct-kernel // behavior for Linux callers. BootMode BootMode + MacOS *MacOSPlatform Firmware *FirmwareConfig TPM *TPMConfig @@ -65,6 +66,7 @@ type BootMode string const ( BootModeDirect BootMode = "direct" BootModeUEFI BootMode = "uefi" + BootModeMacOS BootMode = "macos" ) // EffectiveBootMode preserves direct Linux kernel boot for existing callers. @@ -75,6 +77,14 @@ func (c VMConfig) EffectiveBootMode() BootMode { return c.BootMode } +// MacOSPlatform carries the persistent Apple-silicon platform identity. +// The auxiliary storage belongs to the instance, never to the image template. +type MacOSPlatform struct { + HardwareModelData []byte + MachineIdentifierData []byte + AuxStoragePath string +} + // FirmwareConfig describes UEFI firmware files. CodePath is immutable firmware; // VarsPath is per-instance writable variable storage. type FirmwareConfig struct { diff --git a/lib/hypervisor/config_validation.go b/lib/hypervisor/config_validation.go index 4419e2a01..26a23065f 100644 --- a/lib/hypervisor/config_validation.go +++ b/lib/hypervisor/config_validation.go @@ -4,6 +4,9 @@ import "fmt" // ValidateBootConfig validates boot and disk fields shared by hypervisor backends. func ValidateBootConfig(cfg VMConfig) error { + if cfg.MacOS != nil && cfg.EffectiveBootMode() != BootModeMacOS { + return fmt.Errorf("Mac platform requires macos boot mode") + } switch cfg.EffectiveBootMode() { case BootModeDirect: if cfg.Firmware != nil { @@ -12,6 +15,13 @@ func ValidateBootConfig(cfg VMConfig) error { if cfg.TPM != nil { return fmt.Errorf("direct boot cannot specify a TPM") } + case BootModeMacOS: + if cfg.MacOS == nil || len(cfg.MacOS.HardwareModelData) == 0 || len(cfg.MacOS.MachineIdentifierData) == 0 || cfg.MacOS.AuxStoragePath == "" { + return fmt.Errorf("macos boot requires a complete Mac platform identity") + } + if cfg.Firmware != nil || cfg.TPM != nil || cfg.KernelPath != "" || cfg.InitrdPath != "" || cfg.KernelArgs != "" { + return fmt.Errorf("macos boot cannot specify Linux boot files, firmware, or TPM") + } case BootModeUEFI: if cfg.Firmware == nil { return fmt.Errorf("UEFI boot requires firmware") diff --git a/lib/hypervisor/hypervisor.go b/lib/hypervisor/hypervisor.go index da117d8b0..ed0634d9f 100644 --- a/lib/hypervisor/hypervisor.go +++ b/lib/hypervisor/hypervisor.go @@ -351,6 +351,9 @@ type Hypervisor interface { // Capabilities indicates which optional features a hypervisor supports. // Callers should check these before calling optional methods. type Capabilities struct { + // SupportsMacOSBoot indicates the backend can cold-boot an imported macOS disk bundle. + SupportsMacOSBoot bool + // SupportsSnapshot indicates if Snapshot/Restore are available SupportsSnapshot bool diff --git a/lib/hypervisor/qemu/macos_config_test.go b/lib/hypervisor/qemu/macos_config_test.go new file mode 100644 index 000000000..e2c8a7ace --- /dev/null +++ b/lib/hypervisor/qemu/macos_config_test.go @@ -0,0 +1,12 @@ +package qemu + +import ( + "github.com/kernel/hypeman/lib/hypervisor" + "github.com/stretchr/testify/require" + "testing" +) + +func TestMacOSBootRejectedByQEMU(t *testing.T) { + err := validateProfileCapabilities(hypervisor.TypeQEMU, hypervisor.Capabilities{SupportsUEFIBoot: true}, hypervisor.VMConfig{BootMode: hypervisor.BootModeMacOS}) + require.ErrorContains(t, err, "does not support macOS boot") +} diff --git a/lib/hypervisor/qemu/profile.go b/lib/hypervisor/qemu/profile.go index 0652551f9..1029fded2 100644 --- a/lib/hypervisor/qemu/profile.go +++ b/lib/hypervisor/qemu/profile.go @@ -75,6 +75,9 @@ func (MicroVMProfile) requiresStoredMachineType() bool { return true } func (MicroVMProfile) requiresStoredVersion() bool { return true } func validateProfileCapabilities(name hypervisor.Type, caps hypervisor.Capabilities, cfg hypervisor.VMConfig) error { + if cfg.EffectiveBootMode() == hypervisor.BootModeMacOS { + return fmt.Errorf("%s does not support macOS boot", name) + } if cfg.EffectiveBootMode() == hypervisor.BootModeUEFI && !caps.SupportsUEFIBoot { return fmt.Errorf("%s does not support UEFI boot on this host", name) } diff --git a/lib/hypervisor/vz/client.go b/lib/hypervisor/vz/client.go index 3036014e4..1f2bc7ae7 100644 --- a/lib/hypervisor/vz/client.go +++ b/lib/hypervisor/vz/client.go @@ -10,6 +10,7 @@ import ( "io" "net" "net/http" + "runtime" "time" "github.com/kernel/hypeman/lib/hypervisor" @@ -89,6 +90,7 @@ func (c *Client) Capabilities() hypervisor.Capabilities { func capabilities() hypervisor.Capabilities { return hypervisor.Capabilities{ + SupportsMacOSBoot: runtime.GOARCH == "arm64", // Snapshot/standby support is runtime-derived: it requires Apple // Silicon AND macOS 14+. An arm64-only check would overstate // support on macOS 13 hosts. diff --git a/lib/hypervisor/vz/macos_config_test.go b/lib/hypervisor/vz/macos_config_test.go new file mode 100644 index 000000000..995fefabe --- /dev/null +++ b/lib/hypervisor/vz/macos_config_test.go @@ -0,0 +1,38 @@ +//go:build darwin + +package vz + +import ( + "encoding/base64" + "github.com/kernel/hypeman/lib/hypervisor" + "github.com/stretchr/testify/require" + "testing" +) + +func TestMacOSStarterConfig(t *testing.T) { + cfg := hypervisor.VMConfig{BootMode: hypervisor.BootModeMacOS, VCPUs: 2, MemoryBytes: 4 << 30, MacOS: &hypervisor.MacOSPlatform{HardwareModelData: []byte{1}, MachineIdentifierData: []byte{2}, AuxStoragePath: "/vm/aux.img"}, Disks: []hypervisor.DiskConfig{{Path: "/vm/root.raw"}}} + require.NoError(t, NewStarter().ValidateConfig(cfg)) + shim := buildShimConfigFromVMConfig(cfg, "/vm/vz.sock") + require.Equal(t, base64.StdEncoding.EncodeToString([]byte{1}), shim.MacHardwareModelData) + require.Equal(t, "/vm/aux.img", shim.MacAuxStoragePath) + for _, resources := range []struct { + cpus int + memory int64 + }{{1, 4 << 30}, {2, 1 << 30}, {2, (4 << 30) + 1}} { + bad := cfg + bad.VCPUs, bad.MemoryBytes = resources.cpus, resources.memory + require.Error(t, NewStarter().ValidateConfig(bad)) + } + bad := cfg + bad.KernelPath = "/linux" + require.Error(t, NewStarter().ValidateConfig(bad)) + bad = cfg + bad.GuestMemory.EnableBalloon = true + require.Error(t, NewStarter().ValidateConfig(bad)) + bad = cfg + bad.Disks = []hypervisor.DiskConfig{{Readonly: true}} + require.Error(t, NewStarter().ValidateConfig(bad)) + bad = cfg + bad.MacOS = nil + require.Error(t, NewStarter().ValidateConfig(bad)) +} diff --git a/lib/hypervisor/vz/shimconfig/config.go b/lib/hypervisor/vz/shimconfig/config.go index 844d687f8..2cd81671a 100644 --- a/lib/hypervisor/vz/shimconfig/config.go +++ b/lib/hypervisor/vz/shimconfig/config.go @@ -32,6 +32,11 @@ type ShimConfig struct { InitrdPath string `json:"initrd_path"` KernelArgs string `json:"kernel_args"` + // macOS boot mode: all three fields must be supplied together. + MacHardwareModelData string `json:"mac_hardware_model_data,omitempty"` + MacMachineIdentifierData string `json:"mac_machine_identifier_data,omitempty"` + MacAuxStoragePath string `json:"mac_aux_storage_path,omitempty"` + // Guest memory reclaim EnableMemoryBalloon bool `json:"enable_memory_balloon,omitempty"` RequireMemoryBalloon bool `json:"require_memory_balloon,omitempty"` diff --git a/lib/hypervisor/vz/starter.go b/lib/hypervisor/vz/starter.go index ec5132fd4..3ed50f481 100644 --- a/lib/hypervisor/vz/starter.go +++ b/lib/hypervisor/vz/starter.go @@ -7,6 +7,7 @@ package vz import ( "bytes" "context" + "encoding/base64" "encoding/json" "fmt" "os" @@ -16,6 +17,7 @@ import ( "syscall" "time" + "github.com/Code-Hex/vz/v3" "github.com/kernel/hypeman/lib/hypervisor" "github.com/kernel/hypeman/lib/hypervisor/vz/shimconfig" "github.com/kernel/hypeman/lib/logger" @@ -106,7 +108,25 @@ func NewStarter() *Starter { var _ hypervisor.VMStarter = (*Starter)(nil) func (s *Starter) ValidateConfig(config hypervisor.VMConfig) error { - return hypervisor.ValidateDirectRawConfig("vz", config) + if config.EffectiveBootMode() != hypervisor.BootModeMacOS { + return hypervisor.ValidateDirectRawConfig("vz", config) + } + if err := hypervisor.ValidateBootConfig(config); err != nil { + return err + } + if config.HotplugBytes != 0 || config.EnableRosetta || config.GuestMemory.EnableBalloon || config.GuestMemory.RequireBalloon || len(config.PCIDevices) != 0 || config.VGPUDevicePath != "" { + return fmt.Errorf("macOS does not support hotplug, ballooning, Rosetta shares or PCI passthrough") + } + if len(config.Disks) != 1 || config.Disks[0].Readonly || config.Disks[0].EffectiveFormat() != hypervisor.DiskFormatRaw { + return fmt.Errorf("macOS requires one writable raw boot disk") + } + if config.VCPUs < 2 || uint(config.VCPUs) > vz.VirtualMachineConfigurationMaximumAllowedCPUCount() { + return fmt.Errorf("macOS CPU count is outside the host's supported range") + } + if config.MemoryBytes < 4<<30 || uint64(config.MemoryBytes) > vz.VirtualMachineConfigurationMaximumAllowedMemorySize() || config.MemoryBytes%(1<<20) != 0 { + return fmt.Errorf("macOS memory is outside the host's supported range or is not whole MiB") + } + return nil } func (s *Starter) SocketName() string { @@ -165,7 +185,11 @@ func (s *Starter) RestoreVM(ctx context.Context, p *paths.Paths, version string, } shimConfig := manifest.ShimConfig - if shimConfig.KernelPath == "" || shimConfig.InitrdPath == "" { + if shimConfig.MacHardwareModelData != "" || shimConfig.MacMachineIdentifierData != "" || shimConfig.MacAuxStoragePath != "" { + if shimConfig.MacHardwareModelData == "" || shimConfig.MacMachineIdentifierData == "" || shimConfig.MacAuxStoragePath == "" { + return 0, nil, fmt.Errorf("invalid snapshot manifest: incomplete Mac platform") + } + } else if shimConfig.KernelPath == "" || shimConfig.InitrdPath == "" { return 0, nil, fmt.Errorf("invalid snapshot manifest: missing kernel/initrd in shim config") } instanceDir := filepath.Dir(socketPath) @@ -193,6 +217,11 @@ func buildShimConfigFromVMConfig(config hypervisor.VMConfig, socketPath string) VsockSocket: filepath.Join(instanceDir, "vz.vsock"), LogPath: filepath.Join(instanceDir, "logs", "vz-shim.log"), } + if config.MacOS != nil { + cfg.MacHardwareModelData = base64.StdEncoding.EncodeToString(config.MacOS.HardwareModelData) + cfg.MacMachineIdentifierData = base64.StdEncoding.EncodeToString(config.MacOS.MachineIdentifierData) + cfg.MacAuxStoragePath = config.MacOS.AuxStoragePath + } for _, disk := range config.Disks { cfg.Disks = append(cfg.Disks, shimconfig.DiskConfig{ Path: disk.Path, diff --git a/lib/images/macos_bundle.go b/lib/images/macos_bundle.go new file mode 100644 index 000000000..5e41bb8e7 --- /dev/null +++ b/lib/images/macos_bundle.go @@ -0,0 +1,108 @@ +package images + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" +) + +const maxMacOSMetadataBytes = 64 << 10 + +// macOSMachinePayload is a complete cold-boot bundle, not a container rootfs. +// Admission of live storage and export sanitation belong to the caller. +type macOSMachinePayload struct { + Disk string + Aux string + Platform *MacOSImage +} + +// ValidateMacOSBundle validates a fixed-layout export, rejecting unknown metadata +// fields so an accidental credential field cannot become part of an image. +func ValidateMacOSBundle(root string) (*MacOSImage, error) { + bundle, err := readMacOSMachineBundle(root, "disk.img", "aux.img", "config.json", true) + if err != nil { + return nil, err + } + return bundle.Platform, nil +} + +func readMacOSMachineBundle(root, diskName, auxName, configName string, strict bool) (*macOSMachinePayload, error) { + disk, err := machineBundleFile(root, diskName) + if err != nil { + return nil, err + } + aux, err := machineBundleFile(root, auxName) + if err != nil { + return nil, err + } + config, err := machineBundleFile(root, configName) + if err != nil { + return nil, err + } + // Compare inodes: different hardlink names do not establish distinctness. + if sameFile(disk, aux) || sameFile(disk, config) || sameFile(aux, config) { + return nil, fmt.Errorf("machine bundle files must be distinct") + } + file, err := os.Open(config) + if err != nil { + return nil, err + } + defer file.Close() + data, err := io.ReadAll(io.LimitReader(file, maxMacOSMetadataBytes+1)) + if err != nil { + return nil, err + } + if len(data) > maxMacOSMetadataBytes { + return nil, fmt.Errorf("machine platform metadata is too large") + } + decoder := json.NewDecoder(bytes.NewReader(data)) + if strict { + decoder.DisallowUnknownFields() + } + var platform MacOSImage + if err := decoder.Decode(&platform); err != nil { + return nil, err + } + if err := decoder.Decode(new(any)); err != io.EOF { + return nil, fmt.Errorf("machine platform metadata must contain one JSON object") + } + if err := platform.Validate(); err != nil { + return nil, err + } + return &macOSMachinePayload{Disk: disk, Aux: aux, Platform: &platform}, nil +} + +func machineBundleFile(root, relative string) (string, error) { + if relative == "" || !filepath.IsLocal(relative) { + return "", fmt.Errorf("machine payload path must be local and relative") + } + root, err := filepath.EvalSymlinks(root) + if err != nil { + return "", err + } + path, err := filepath.EvalSymlinks(filepath.Join(root, relative)) + if err != nil { + return "", err + } + rel, err := filepath.Rel(root, path) + if err != nil || !filepath.IsLocal(rel) { + return "", fmt.Errorf("machine payload escapes artifact root") + } + info, err := os.Stat(path) + if err != nil { + return "", err + } + if !info.Mode().IsRegular() || info.Size() == 0 { + return "", fmt.Errorf("machine payload must be a nonempty regular file") + } + return path, nil +} + +func sameFile(a, b string) bool { + ai, errA := os.Stat(a) + bi, errB := os.Stat(b) + return errA == nil && errB == nil && os.SameFile(ai, bi) +} diff --git a/lib/images/macos_bundle_test.go b/lib/images/macos_bundle_test.go new file mode 100644 index 000000000..0a5d883a0 --- /dev/null +++ b/lib/images/macos_bundle_test.go @@ -0,0 +1,60 @@ +package images + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMacOSBundleValidation(t *testing.T) { + platform := MacOSImage{HardwareModel: []byte{1}, MachineIdentifier: []byte{2}, MAC: "02:00:00:00:00:01", CPUs: 4, Memory: 8 << 30} + data, err := json.Marshal(platform) + require.NoError(t, err) + for _, name := range []string{"valid", "empty-disk", "hardlink", "escape", "oversized", "truncated", "trailing", "unknown-field", "invalid-mac"} { + t.Run(name, func(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "disk.img"), []byte("disk"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(root, "aux.img"), []byte("aux"), 0600)) + config := append([]byte(nil), data...) + switch name { + case "empty-disk": + require.NoError(t, os.WriteFile(filepath.Join(root, "disk.img"), nil, 0600)) + case "hardlink": + require.NoError(t, os.Remove(filepath.Join(root, "aux.img"))) + require.NoError(t, os.Link(filepath.Join(root, "disk.img"), filepath.Join(root, "aux.img"))) + case "escape": + outside := filepath.Join(t.TempDir(), "disk") + require.NoError(t, os.WriteFile(outside, []byte("outside"), 0600)) + require.NoError(t, os.Remove(filepath.Join(root, "disk.img"))) + require.NoError(t, os.Symlink(outside, filepath.Join(root, "disk.img"))) + case "oversized": + config = []byte(strings.Repeat(" ", maxMacOSMetadataBytes+1)) + case "truncated": + config = config[:len(config)-1] + case "trailing": + config = append(config, []byte(" {}")...) + case "unknown-field": + config = append(config[:len(config)-1], []byte(",\"credential\":\"not-a-secret\"}")...) + case "invalid-mac": + config = []byte(strings.Replace(string(config), platform.MAC, "invalid", 1)) + } + require.NoError(t, os.WriteFile(filepath.Join(root, "config.json"), config, 0600)) + got, err := ValidateMacOSBundle(root) + if name == "valid" { + require.NoError(t, err) + require.Equal(t, &platform, got) + } else { + require.Error(t, err) + } + if name == "unknown-field" { + bundle, err := readMacOSMachineBundle(root, "disk.img", "aux.img", "config.json", false) + require.NoError(t, err, "import/OCI compatibility permits unknown non-export metadata") + require.Equal(t, &platform, bundle.Platform) + } + }) + } +} diff --git a/lib/images/macos_import_darwin.go b/lib/images/macos_import_darwin.go new file mode 100644 index 000000000..245085c96 --- /dev/null +++ b/lib/images/macos_import_darwin.go @@ -0,0 +1,116 @@ +//go:build darwin && arm64 + +package images + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/kernel/hypeman/lib/paths" + "golang.org/x/sys/unix" +) + +// ImportMacOSImage is an offline, administrator-only import of a STOPPED macvm +// bundle. It intentionally is not exposed as an HTTP host-filesystem read API. +// Imports preserve identity: instances from this image may run sequentially, +// not concurrently, until rekeying is validated. +func ImportMacOSImage(ctx context.Context, p *paths.Paths, name, source string) (*Image, error) { + ref, err := ParseNormalizedRef(name) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidName, err) + } + if ref.IsDigest() { + return nil, fmt.Errorf("%w: import requires a tagged name", ErrInvalidName) + } + bundle, err := readMacOSMachineBundle(source, "disk.img", "aux.img", "config.json", false) + if err != nil { + return nil, err + } + mac := bundle.Platform + for _, file := range []string{"disk.img", "aux.img"} { + file = filepath.Join(source, file) + info, e := os.Lstat(file) + if e != nil { + return nil, e + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("bundle file must be regular: %s", file) + } + if e = exec.CommandContext(ctx, "lsof", "-t", file).Run(); e == nil { + return nil, fmt.Errorf("bundle storage is open: stop its VM first") + } + if exit, ok := e.(*exec.ExitError); !ok || exit.ExitCode() != 1 { + return nil, fmt.Errorf("cannot verify storage is closed: %v", e) + } + } + root := p.ImageRepositoryDir(ref.Repository()) + if err = os.MkdirAll(root, 0700); err != nil { + return nil, err + } + stage, err := os.MkdirTemp(root, ".macos-import-") + if err != nil { + return nil, err + } + defer os.RemoveAll(stage) + for src, dst := range map[string]string{bundle.Disk: "rootfs.raw", bundle.Aux: "aux.img"} { + if err = unix.Clonefile(src, filepath.Join(stage, dst), unix.CLONE_NOFOLLOW|unix.CLONE_NOOWNERCOPY); err != nil { + return nil, fmt.Errorf("clone bundle: %w", err) + } + if err = os.Chmod(filepath.Join(stage, dst), 0600); err != nil { + return nil, err + } + } + // Hash cloned content, not the caller's mutable paths. Per-file hashes and + // logical sizes make the digest unambiguous, including sparse zero ranges. + h := sha256.New() + canonical, _ := json.Marshal(mac) + h.Write(canonical) + var logicalSize int64 + for _, name := range []string{"rootfs.raw", "aux.img"} { + f, e := os.Open(filepath.Join(stage, name)) + if e != nil { + return nil, e + } + fh := sha256.New() + n, e := io.Copy(fh, contextReader{ctx: ctx, reader: f}) + f.Close() + if e != nil { + return nil, e + } + fmt.Fprintf(h, "\n%s:%d:%x", name, n, fh.Sum(nil)) + if name == "rootfs.raw" { + logicalSize = n + } + } + digestHex := hex.EncodeToString(h.Sum(nil)) + digest := "sha256:" + digestHex + meta := &imageMetadata{Name: ref.Repository() + "@" + digest, Digest: digest, Platform: "darwin/arm64", MacOS: mac, Status: StatusReady, SizeBytes: logicalSize, CreatedAt: time.Now().UTC()} + if err = writeMetadataFile(filepath.Join(stage, "metadata.json"), meta); err != nil { + return nil, err + } + dst := p.ImageDigestDir(ref.Repository(), digestHex) + if _, err = os.Stat(dst); os.IsNotExist(err) { + if err = os.Rename(stage, dst); err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } else { + old, e := readMetadata(p, ref.Repository(), digestHex) + if e != nil || old.Status != StatusReady || old.MacOS == nil { + return nil, fmt.Errorf("existing import is incomplete") + } + } + if err = createTagSymlink(p, ref.Repository(), ref.Tag(), digestHex); err != nil { + return nil, err + } + return meta.toImageFor(ref.String()), nil +} diff --git a/lib/images/macos_import_darwin_test.go b/lib/images/macos_import_darwin_test.go new file mode 100644 index 000000000..8518fbab2 --- /dev/null +++ b/lib/images/macos_import_darwin_test.go @@ -0,0 +1,51 @@ +//go:build darwin && arm64 + +package images + +import ( + "context" + "encoding/json" + "github.com/kernel/hypeman/lib/paths" + "github.com/stretchr/testify/require" + "os" + "path/filepath" + "testing" +) + +func TestMacOSOfflineImport(t *testing.T) { + source := t.TempDir() + p := paths.New(t.TempDir()) + c := MacOSImage{HardwareModel: []byte{1}, MachineIdentifier: []byte{2}, MAC: "02:00:00:00:00:01", CPUs: 4, Memory: 8 << 30} + b, _ := json.Marshal(c) + require.NoError(t, os.WriteFile(filepath.Join(source, "config.json"), b, 0600)) + require.NoError(t, os.WriteFile(filepath.Join(source, "disk.img"), []byte("boot disk"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(source, "aux.img"), []byte("aux storage"), 0600)) + img, err := ImportMacOSImage(context.Background(), p, "localhost/macos:test", source) + require.NoError(t, err) + require.Equal(t, "darwin/arm64", img.Platform) + require.Equal(t, c.MachineIdentifier, img.MacOS.MachineIdentifier) + m := &manager{paths: p} + loaded, err := m.GetImage(context.Background(), img.Name) + require.NoError(t, err) + require.NotNil(t, loaded.MacOS) + disk, err := GetDiskPath(p, img.Name, img.Digest) + require.NoError(t, err) + got, err := os.ReadFile(filepath.Join(filepath.Dir(disk), "aux.img")) + require.NoError(t, err) + require.Equal(t, "aux storage", string(got)) + _, err = m.TagImage(context.Background(), img.Name, "localhost/alias:tag") + require.ErrorIs(t, err, ErrInvalidPlatform) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err = ImportMacOSImage(ctx, p, "localhost/macos:cancelled", source) + require.Error(t, err) +} +func TestMacOSPlatformLocalOnly(t *testing.T) { + p, err := ParsePlatform("darwin/arm64") + require.NoError(t, err) + require.Equal(t, "darwin", p.OS) + _, err = ParsePlatform("darwin/amd64") + require.Error(t, err) + _, err = resolveManifestPlatform(&containerMetadata{OS: "darwin", Architecture: "arm64"}, "") + require.ErrorIs(t, err, ErrInvalidPlatform) +} diff --git a/lib/images/manager.go b/lib/images/manager.go index d00726d98..5ccd7b7e7 100644 --- a/lib/images/manager.go +++ b/lib/images/manager.go @@ -174,6 +174,10 @@ func (m *manager) CreateImage(ctx context.Context, req CreateImageRequest) (*Ima return nil, err } + if platform.OS == "darwin" { + return nil, fmt.Errorf("%w: macOS images must be imported locally with import-macos, not pulled as Linux containers", ErrInvalidPlatform) + } + // Parse and normalize normalized, err := ParseNormalizedRef(req.Name) if err != nil { diff --git a/lib/images/platform.go b/lib/images/platform.go index f5c40b3d8..28e788b26 100644 --- a/lib/images/platform.go +++ b/lib/images/platform.go @@ -9,9 +9,8 @@ import ( ) // Platform identifies the OS/architecture variant of an image, modeled on -// Docker's --platform (os/arch[/variant]). Hypeman guests are always Linux, so -// OS is currently constrained to "linux", but the field exists for parity with -// Docker references and future non-Linux support. +// Docker's --platform (os/arch[/variant]). Linux supports OCI container pulls; +// darwin/arm64 identifies locally imported macOS disk bundles. type Platform struct { OS string Architecture string @@ -83,8 +82,11 @@ func (p Platform) Normalize() Platform { // guests on amd64 or arm64. Other operating systems and architectures are // rejected with an actionable error. func (p Platform) validate() error { + if p.OS == "darwin" && p.Architecture == "arm64" && p.Variant == "" { + return nil + } if p.OS != "linux" { - return fmt.Errorf("%w: unsupported os %q: only linux guests are supported", ErrInvalidPlatform, p.OS) + return fmt.Errorf("%w: unsupported os %q: use linux or locally imported darwin/arm64", ErrInvalidPlatform, p.OS) } switch p.Architecture { case "amd64", "arm64": @@ -165,6 +167,10 @@ func resolveManifestPlatform(meta *containerMetadata, requested string) (Platfor Variant: meta.Variant, }.Normalize() + if actual.OS == "darwin" { + return Platform{}, fmt.Errorf("%w: macOS must be imported as a local disk bundle", ErrInvalidPlatform) + } + // An explicit request is authoritative for the match check and, when the // manifest omits its own platform, for the recorded value too. if strings.TrimSpace(requested) != "" { diff --git a/lib/images/storage.go b/lib/images/storage.go index 5b1d9e566..a6331fbd3 100644 --- a/lib/images/storage.go +++ b/lib/images/storage.go @@ -17,6 +17,7 @@ type imageMetadata struct { Name string `json:"name"` // Normalized ref (tag or digest) Digest string `json:"digest"` // Always present: sha256:... Platform string `json:"platform,omitempty"` + MacOS *MacOSImage `json:"macos,omitempty"` Status string `json:"status"` Error *string `json:"error,omitempty"` Request *CreateImageRequest `json:"request,omitempty"` @@ -64,6 +65,7 @@ func (m *imageMetadata) toImage() *Image { Name: m.Name, Digest: m.Digest, Platform: platform, + MacOS: m.MacOS, Status: m.Status, Error: m.Error, CreatedAt: m.CreatedAt, @@ -112,14 +114,10 @@ type imageLayout struct { // once content metadata is ready, content becomes canonical even if its disk // is missing so callers report the corruption instead of mixing layouts. func resolveImageLayout(p *paths.Paths, repository, digestHex string) imageLayout { - legacy := imageLayout{ - dir: p.ImageDigestDir(repository, digestHex), - metadata: p.ImageMetadata(repository, digestHex), - disk: p.ImageDigestPath(repository, digestHex), - } + legacy := legacyLayout(p, repository, digestHex) content := contentLayout(p, digestHex) - if legacyImageExists(p, repository, digestHex) { + if legacyImageExists(legacy) { contentStatus, contentOK := metadataStatus(content.metadata) if !contentOK || contentStatus != StatusReady { return legacy @@ -152,9 +150,19 @@ func digestDir(p *paths.Paths, repository, digestHex string) string { return resolveImageLayout(p, repository, digestHex).dir } -func legacyImageExists(p *paths.Paths, repository, digestHex string) bool { - _, metadataErr := os.Stat(p.ImageMetadata(repository, digestHex)) - _, diskErr := os.Stat(p.ImageDigestPath(repository, digestHex)) +func legacyLayout(p *paths.Paths, repository, digestHex string) imageLayout { + layout := imageLayout{dir: p.ImageDigestDir(repository, digestHex), metadata: p.ImageMetadata(repository, digestHex), disk: p.ImageDigestPath(repository, digestHex)} + // Locally imported machine images keep a raw disk beside their metadata. Linux + // legacy layouts never have one, so a stat is enough to pick the file. + if raw := filepath.Join(layout.dir, "rootfs.raw"); pathExists(raw) { + layout.disk = raw + } + return layout +} + +func legacyImageExists(layout imageLayout) bool { + _, metadataErr := os.Stat(layout.metadata) + _, diskErr := os.Stat(layout.disk) return metadataErr == nil && diskErr == nil } @@ -184,6 +192,23 @@ func GetDiskPath(p *paths.Paths, imageName string, digest string) (string, error return resolveImageLayout(p, ref.Repository(), digestHex).disk, nil } +// BootStorage names the rootfs disk and auxiliary storage files of one image digest. +// Both come from the same resolved layout, so callers never infer one from the other. +type BootStorage struct { + Disk string + Aux string +} + +// GetBootStorage returns the disk and auxiliary storage paths for an image digest. +func GetBootStorage(p *paths.Paths, imageName string, digest string) (BootStorage, error) { + ref, err := ParseNormalizedRef(imageName) + if err != nil { + return BootStorage{}, fmt.Errorf("parse image name: %w", err) + } + layout := resolveImageLayout(p, ref.Repository(), strings.TrimPrefix(digest, "sha256:")) + return BootStorage{Disk: layout.disk, Aux: filepath.Join(layout.dir, "aux.img")}, nil +} + func digestPath(p *paths.Paths, repository, digestHex string) string { return resolveImageLayout(p, repository, digestHex).disk } diff --git a/lib/images/tag.go b/lib/images/tag.go index af7582476..3a497a6a9 100644 --- a/lib/images/tag.go +++ b/lib/images/tag.go @@ -28,6 +28,9 @@ func (m *manager) TagImage(ctx context.Context, source, target string) (*Image, if err != nil { return nil, err } + if meta.MacOS != nil { + return nil, fmt.Errorf("%w: tagging macOS bundles is not implemented", ErrInvalidPlatform) + } if err := m.cancelPendingTag(targetRef.Repository(), targetRef.Tag()); err != nil { return nil, fmt.Errorf("cancel pending image tag: %w", err) } diff --git a/lib/images/types.go b/lib/images/types.go index a7c498193..b2c8ac272 100644 --- a/lib/images/types.go +++ b/lib/images/types.go @@ -1,6 +1,8 @@ package images import ( + "fmt" + "net" "time" "github.com/google/go-containerregistry/pkg/authn" @@ -9,9 +11,10 @@ import ( // Image represents a container image converted to bootable disk type Image struct { - Name string // Normalized ref (e.g., docker.io/library/alpine:latest) - Digest string // Resolved manifest digest (sha256:...) - Platform string // Normalized platform (e.g., linux/amd64) + Name string // Normalized ref (e.g., docker.io/library/alpine:latest) + Digest string // Resolved manifest digest (sha256:...) + Platform string // Normalized platform (e.g., linux/amd64) + MacOS *MacOSImage // Non-nil for locally imported macOS disk images Status string QueuePosition *int Error *string @@ -25,6 +28,29 @@ type Image struct { CreatedAt time.Time } +// MacOSImage is a cold-boot template. Identity is preserved; until rekeying +// is validated, only one instance with this identifier may run at a time. +type MacOSImage struct { + HardwareModel []byte `json:"hardware_model"` + MachineIdentifier []byte `json:"machine_identifier"` + MAC string `json:"mac"` + CPUs uint `json:"cpus"` + Memory uint64 `json:"memory"` +} + +// Validate checks the platform fields every macOS bundle must carry, whether it +// arrives as a local import or as an OCI machine image. +func (m MacOSImage) Validate() error { + if len(m.HardwareModel) == 0 || len(m.MachineIdentifier) == 0 || m.CPUs < 2 || m.Memory < 4<<30 { + return fmt.Errorf("invalid macOS platform metadata") + } + // net.ParseMAC also accepts EUI-64 and InfiniBand addresses, which VZ rejects. + if mac, err := net.ParseMAC(m.MAC); err != nil || len(mac) != 6 { + return fmt.Errorf("invalid machine MAC %q: want a 6-byte Ethernet address", m.MAC) + } + return nil +} + // CreateImageRequest represents a request to create an image type CreateImageRequest struct { Name string diff --git a/lib/instances/configdisk.go b/lib/instances/configdisk.go index 6b1d74f0b..ee3bcf6ba 100644 --- a/lib/instances/configdisk.go +++ b/lib/instances/configdisk.go @@ -18,6 +18,9 @@ import ( // createConfigDisk generates an ext4 disk with instance configuration. // The disk contains /config.json read by the guest init binary. func (m *manager) createConfigDisk(ctx context.Context, inst *Instance, imageInfo *images.Image, netConfig *network.NetworkConfig, proxyCfg *egressproxy.GuestConfig) error { + if inst.MacOS != nil { + return nil // macOS boots its imported disk and has no init to read a config disk. + } // Create temporary directory for config files tmpDir, err := os.MkdirTemp("", "hypeman-config-*") if err != nil { diff --git a/lib/instances/create.go b/lib/instances/create.go index 504b58e65..25b9b443d 100644 --- a/lib/instances/create.go +++ b/lib/instances/create.go @@ -102,6 +102,9 @@ func (m *manager) createInstance( if req.GPU != nil && req.GPU.Profile != "" && !devices.Capabilities().SupportsVGPU { return nil, fmt.Errorf("%w: %w", ErrInvalidRequest, devices.ErrVGPUNotSupportedOnMacOS) } + if err := m.requireMacOSImageName(ctx, req.Image); err != nil { + return nil, err + } hvType := req.Hypervisor if hvType == "" { hvType = m.defaultHypervisor @@ -131,6 +134,9 @@ func (m *manager) createInstance( log.ErrorContext(ctx, "image not ready", "image", req.Image, "status", imageInfo.Status) return nil, fmt.Errorf("%w: image status is %s", ErrImageNotReady, imageInfo.Status) } + if err := m.checkMacOSCreate(&req, imageInfo, hvType); err != nil { + return nil, err + } // A guest whose architecture differs from the host kernel can only boot via // emulation. On Apple silicon that is Rosetta, enabled automatically; on any @@ -207,8 +213,8 @@ func (m *manager) createInstance( if overlaySize == 0 { overlaySize = 10 * 1024 * 1024 * 1024 // 10GB default } - // Validate overlay size against max - if overlaySize > m.limits.MaxOverlaySize { + // Validate overlay size against max. A macOS boot disk is sized by its image. + if imageInfo.MacOS == nil && overlaySize > m.limits.MaxOverlaySize { return nil, fmt.Errorf("overlay size %d exceeds maximum allowed size %d", overlaySize, m.limits.MaxOverlaySize) } vcpus := req.Vcpus @@ -252,7 +258,7 @@ func (m *manager) createInstance( // 7. Determine network based on NetworkEnabled flag networkName := "" - if req.NetworkEnabled { + if req.NetworkEnabled && imageInfo.MacOS == nil { networkName = "default" } @@ -338,6 +344,7 @@ func (m *manager) createInstance( Image: req.Image, ResolvedImage: resolvedImageRef, Platform: imageInfo.Platform, + MacOS: macOSPlatformForCreate(imageInfo.MacOS, size, vcpus), Size: size, HotplugSize: hotplugSize, OverlaySize: overlaySize, @@ -412,7 +419,7 @@ func (m *manager) createInstance( // 13. Create overlay disk with specified size log.DebugContext(ctx, "creating overlay disk", "instance_id", id, "size_bytes", stored.OverlaySize) - if err := m.createOverlayDisk(id, stored.OverlaySize); err != nil { + if err := m.createBootDisk(stored, imageInfo); err != nil { log.ErrorContext(ctx, "failed to create overlay disk", "instance_id", id, "error", err) return nil, fmt.Errorf("create overlay disk: %w", err) } @@ -459,6 +466,10 @@ func (m *manager) createInstance( }) } + if stored.MacOS != nil && stored.NetworkEnabled { + netConfig = applyMacOSNetwork(stored) + } + // 15. Validate and attach volumes if len(req.Volumes) > 0 { log.DebugContext(ctx, "validating volumes", "instance_id", id, "count", len(req.Volumes)) @@ -569,7 +580,7 @@ func (m *manager) createInstance( // guest boot markers have not yet been written, so we are in Initializing; // persistBootMarkers will advance us to Running once the markers appear // in the serial log. - stored.Phases.Record(phasetracking.PhaseInitializing, time.Now().UTC()) + stored.Phases.Record(initialBootPhase(stored), time.Now().UTC()) meta = &metadata{StoredMetadata: *stored} if err := m.saveMetadata(meta); err != nil { // VM is running but metadata failed - log but don't fail @@ -797,6 +808,13 @@ func (m *manager) startAndBootVM( refreshHostVersion bool, ) error { log := logger.FromContext(ctx) + if stored.MacOS != nil { + m.macOSBootMu.Lock() + defer m.macOSBootMu.Unlock() + if err := m.checkMacOSIdentityAvailable(ctx, stored); err != nil { + return err + } + } // Get VM starter for this hypervisor type starter, err := m.getVMStarter(stored.HypervisorType) @@ -869,6 +887,9 @@ func resolveRuntimeHypervisorPID(log *slog.Logger, stored *StoredMetadata, fallb // buildHypervisorConfig creates a hypervisor-agnostic VM configuration func (m *manager) buildHypervisorConfig(ctx context.Context, inst *Instance, imageInfo *images.Image, netConfig *network.NetworkConfig) (hypervisor.VMConfig, error) { + if inst.MacOS != nil { + return m.macOSVMConfig(inst), nil + } // Get system file paths kernelPath, _ := m.systemManager.GetKernelPath(system.KernelVersion(inst.KernelVersion)) initrdPath, _ := m.systemManager.GetInitrdPath() @@ -1000,6 +1021,9 @@ func (m *manager) buildHypervisorConfig(ctx context.Context, inst *Instance, ima } func resolveCreateKernelVersion(imageInfo *images.Image, defaultKernel system.KernelVersion) (system.KernelVersion, error) { + if imageInfo != nil && imageInfo.MacOS != nil { + return "", nil + } if imageInfo == nil || len(imageInfo.Labels) == 0 { return defaultKernel, nil } diff --git a/lib/instances/create_image.go b/lib/instances/create_image.go index 238747452..cd605597f 100644 --- a/lib/instances/create_image.go +++ b/lib/instances/create_image.go @@ -18,6 +18,19 @@ type createImageResolver interface { } func resolveImageForCreate(ctx context.Context, imageManager createImageResolver, imageName, platform string, log *slog.Logger) (*images.Image, error) { + if p, err := images.ParsePlatform(platform); err == nil && p.OS == "darwin" { + img, err := imageManager.GetImage(ctx, imageName) + if err != nil { + return nil, fmt.Errorf("get imported macOS image: %w", err) + } + if img.MacOS == nil { + return nil, fmt.Errorf("%w: image is not a macOS disk bundle", images.ErrInvalidPlatform) + } + if err := validateResolvedImagePlatform(img, platform); err != nil { + return nil, err + } + return img, nil + } // An empty platform means the host platform: a no-platform run must boot a // host-native guest and never silently emulate, so we resolve the host variant // explicitly rather than follow the (last-pull-wins) tag, which may point at a diff --git a/lib/instances/delete.go b/lib/instances/delete.go index 7826803c2..92824755c 100644 --- a/lib/instances/delete.go +++ b/lib/instances/delete.go @@ -76,7 +76,7 @@ func (m *manager) deleteInstanceWithOptions( // 2. Get network allocation BEFORE killing VMM (while we can still query it) var networkAlloc *network.Allocation - if inst.NetworkEnabled { + if inst.NetworkEnabled && inst.MacOS == nil { log.DebugContext(ctx, "getting network allocation", "instance_id", id) networkAlloc, err = m.networkManager.GetAllocation(ctx, id) if err != nil { @@ -154,7 +154,7 @@ func (m *manager) deleteInstanceWithOptions( } // 6. Release network allocation - if inst.NetworkEnabled { + if inst.NetworkEnabled && inst.MacOS == nil { m.unregisterEgressProxyInstance(ctx, id) log.DebugContext(ctx, "releasing network", "instance_id", id, "network", "default") releaseNetworkCtx, releaseNetworkSpanEnd := m.startLifecycleStep(ctx, "release_network", diff --git a/lib/instances/fork.go b/lib/instances/fork.go index 4c0046c7e..5a5c57847 100644 --- a/lib/instances/fork.go +++ b/lib/instances/fork.go @@ -42,6 +42,9 @@ func (m *manager) forkInstance(ctx context.Context, id string, req ForkInstanceR if err != nil { return nil, "", false, err } + if err := m.admitGuestOperation(&meta.StoredMetadata, "fork"); err != nil { + return nil, "", false, err + } source := m.toInstance(ctx, meta) targetState, err := resolveForkTargetState(req.TargetState, source.State) if err != nil { diff --git a/lib/instances/ingress_resolver.go b/lib/instances/ingress_resolver.go index 17c2bf7a9..3379329f8 100644 --- a/lib/instances/ingress_resolver.go +++ b/lib/instances/ingress_resolver.go @@ -35,6 +35,12 @@ func (r *IngressResolver) ResolveInstanceIP(ctx context.Context, nameOrID string return "", fmt.Errorf("instance not found: %s", nameOrID) } + // macOS guests sit behind vmnet NAT, and their addresses are observed leases. Ingress + // must not route to them, and the public route must not reach them either. + if inst.MacOS != nil { + return "", fmt.Errorf("instance %s is a macOS guest and is not an ingress target", nameOrID) + } + // Check if instance has network enabled if !inst.NetworkEnabled { return "", fmt.Errorf("instance %s has no network configured", nameOrID) diff --git a/lib/instances/ingress_resolver_macos_test.go b/lib/instances/ingress_resolver_macos_test.go new file mode 100644 index 000000000..e8fb2eb36 --- /dev/null +++ b/lib/instances/ingress_resolver_macos_test.go @@ -0,0 +1,26 @@ +package instances + +import ( + "context" + "testing" + + "github.com/kernel/hypeman/lib/images" + "github.com/stretchr/testify/require" +) + +type fakeMinPrefixManager struct { + Manager + inst *Instance +} + +func (f fakeMinPrefixManager) getInstanceWithMinIDPrefix(context.Context, string, int) (*Instance, error) { + return f.inst, nil +} + +func TestIngressResolverRefusesMacOSGuest(t *testing.T) { + inst := &Instance{StoredMetadata: StoredMetadata{Id: "mac", NetworkEnabled: true, IP: "192.168.64.2", MacOS: &images.MacOSImage{}}} + r := &IngressResolver{manager: fakeMinPrefixManager{inst: inst}} + _, err := r.ResolveInstanceIP(context.Background(), "mac") + require.Error(t, err) + require.Contains(t, err.Error(), "macOS guest") +} diff --git a/lib/instances/logs.go b/lib/instances/logs.go index 54b280f81..c1ee84240 100644 --- a/lib/instances/logs.go +++ b/lib/instances/logs.go @@ -62,9 +62,15 @@ func (m *manager) streamInstanceLogs(ctx context.Context, id string, tail int, f return nil, ErrTailNotFound } - if _, err := m.loadMetadata(id); err != nil { + meta, err := m.loadMetadata(id) + if err != nil { return nil, err } + // macOS guests have no serial console, so the app log is never written. Say so + // instead of implying the file may still appear. + if meta.MacOS != nil && source == LogSourceApp { + return nil, fmt.Errorf("%w: app logs are not available for macOS guests", ErrNotSupported) + } // Determine log path based on source var logPath string @@ -72,7 +78,7 @@ func (m *manager) streamInstanceLogs(ctx context.Context, id string, tail int, f case LogSourceApp: logPath = m.paths.InstanceAppLog(id) case LogSourceVMM: - logPath = m.paths.InstanceVMMLog(id) + logPath = m.vmmLogPath(meta) case LogSourceHypeman: logPath = m.paths.InstanceHypemanLog(id) case LogSourceSWTPM: @@ -210,3 +216,12 @@ func (m *manager) archiveAppLogForBoot(id string) error { } return nil } + +// vmmLogPath maps the VMM source to the file the hypervisor writes. macOS guests +// log through the VZ shim, not Cloud Hypervisor's vmm.log. +func (m *manager) vmmLogPath(meta *metadata) string { + if meta.MacOS != nil { + return m.paths.InstanceVZShimLog(meta.Id) + } + return m.paths.InstanceVMMLog(meta.Id) +} diff --git a/lib/instances/logs_macos_source_test.go b/lib/instances/logs_macos_source_test.go new file mode 100644 index 000000000..3411cca4d --- /dev/null +++ b/lib/instances/logs_macos_source_test.go @@ -0,0 +1,19 @@ +package instances + +import ( + "context" + "testing" + + "github.com/kernel/hypeman/lib/images" + "github.com/kernel/hypeman/lib/paths" + "github.com/stretchr/testify/require" +) + +func TestMacOSAppLogSourceIsNotSupported(t *testing.T) { + p := paths.New(t.TempDir()) + m := &manager{paths: p} + require.NoError(t, m.ensureDirectories("mac-logs")) + require.NoError(t, m.saveMetadata(&metadata{StoredMetadata: StoredMetadata{Id: "mac-logs", DataDir: p.InstanceDir("mac-logs"), MacOS: &images.MacOSImage{}}})) + _, err := m.streamInstanceLogs(context.Background(), "mac-logs", 10, false, LogSourceApp) + require.ErrorIs(t, err, ErrNotSupported) +} diff --git a/lib/instances/logs_vmm_source_test.go b/lib/instances/logs_vmm_source_test.go new file mode 100644 index 000000000..7bd26e289 --- /dev/null +++ b/lib/instances/logs_vmm_source_test.go @@ -0,0 +1,18 @@ +package instances + +import ( + "testing" + + "github.com/kernel/hypeman/lib/hypervisor" + "github.com/kernel/hypeman/lib/images" + "github.com/kernel/hypeman/lib/paths" + "github.com/stretchr/testify/require" +) + +func TestVMMLogSourceFollowsMacOSShimLog(t *testing.T) { + m := &manager{paths: paths.New(t.TempDir())} + mac := &metadata{StoredMetadata: StoredMetadata{Id: "i1", HypervisorType: hypervisor.TypeVZ, MacOS: &images.MacOSImage{}}} + require.Equal(t, m.paths.InstanceVZShimLog("i1"), m.vmmLogPath(mac)) + linux := &metadata{StoredMetadata: StoredMetadata{Id: "i1", HypervisorType: hypervisor.TypeVZ}} + require.Equal(t, m.paths.InstanceVMMLog("i1"), m.vmmLogPath(linux)) +} diff --git a/lib/instances/macos.go b/lib/instances/macos.go new file mode 100644 index 000000000..c8b4741cd --- /dev/null +++ b/lib/instances/macos.go @@ -0,0 +1,291 @@ +package instances + +import ( + "bytes" + "context" + "encoding/hex" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "strings" + + "github.com/kernel/hypeman/lib/hypervisor" + "github.com/kernel/hypeman/lib/images" + "github.com/kernel/hypeman/lib/instances/phasetracking" + "github.com/kernel/hypeman/lib/network" + restartpolicy "github.com/kernel/hypeman/lib/restart-policy" +) + +// macOSAuxStorageFile is the instance's copy of the image's auxiliary storage. +const macOSAuxStorageFile = "mac-aux.img" + +// requireMacOSImageName refuses a create on a macOS-only server unless the image +// is an already imported macOS image, so a Linux image is never pulled there. +func (m *manager) requireMacOSImageName(ctx context.Context, name string) error { + if !m.macOSOnly { + return nil + } + cached, err := m.imageManager.GetImage(ctx, name) + if err != nil && !errors.Is(err, images.ErrNotFound) { + return err + } + if err != nil || cached.MacOS == nil { + return fmt.Errorf("%w: macOS-only server requires an already imported macOS image", ErrInvalidRequest) + } + return nil +} + +// checkMacOSCreate applies macOS machine defaults to a create from a macOS image. +// Linux images pass through unchanged, except on a macOS-only server. +func (m *manager) checkMacOSCreate(req *CreateInstanceRequest, img *images.Image, hvType hypervisor.Type) error { + if img.MacOS == nil { + if m.macOSOnly { + return fmt.Errorf("%w: server is configured for macOS guests only", ErrInvalidRequest) + } + return nil + } + caps, _ := hypervisor.CapabilitiesForType(hvType) + prepared := *req + if err := prepareMacOSCreate(&prepared, img, caps); err != nil { + return err + } + starter, err := m.getVMStarter(hvType) + if err != nil { + return err + } + config := hypervisor.VMConfig{ + BootMode: hypervisor.BootModeMacOS, VCPUs: prepared.Vcpus, MemoryBytes: prepared.Size, + MacOS: &hypervisor.MacOSPlatform{HardwareModelData: img.MacOS.HardwareModel, MachineIdentifierData: img.MacOS.MachineIdentifier, AuxStoragePath: "mac-aux.img"}, + Disks: []hypervisor.DiskConfig{{Path: "disk.img"}}, + } + if err := validatePlannedVMConfig(starter, hvType, config); err != nil { + return err + } + *req = prepared + return nil +} + +// prepareMacOSCreate validates explicit options before applying machine defaults. +// Rejected requests are unchanged; effective CPU and memory are resolved once. +func prepareMacOSCreate(req *CreateInstanceRequest, img *images.Image, caps hypervisor.Capabilities) error { + if img.MacOS == nil { + return fmt.Errorf("%w: image is not a macOS disk bundle", ErrInvalidRequest) + } + if !caps.SupportsMacOSBoot { + return fmt.Errorf("%w: macOS guests require vz on Apple silicon", ErrInvalidRequest) + } + if img.Platform != "darwin/arm64" || img.SizeBytes == nil || *img.SizeBytes <= 0 { + return fmt.Errorf("%w: incomplete macOS image", ErrImageNotReady) + } + if req.HotplugSize != 0 || req.OverlaySize != 0 || len(req.Volumes) != 0 || len(req.Devices) != 0 || req.GPU != nil || len(req.Env) != 0 || len(req.Entrypoint) != 0 || len(req.Cmd) != 0 || req.NetworkEgress != nil || len(req.Credentials) != 0 || req.AutoStandby != nil || req.HealthCheck != nil || req.SnapshotPolicy != nil || req.DiskIOBps != 0 || req.NetworkBandwidthDownload != 0 || req.NetworkBandwidthUpload != 0 { + return fmt.Errorf("%w: experimental macOS supports local disk clone, CPU/RAM, tags, expiration and NAT only; Linux commands/env/volumes, overlays, agents, auto-standby/snapshot policies and I/O shaping are unsupported", ErrInvalidRequest) + } + if err := validateMacOSRestartPolicy(req.RestartPolicy); err != nil { + return err + } + size, vcpus := req.Size, req.Vcpus + if size == 0 { + size = int64(img.MacOS.Memory) + } + if vcpus == 0 { + vcpus = int(img.MacOS.CPUs) + } + if size < 4<<30 || vcpus < 2 || size%(1<<20) != 0 { + return fmt.Errorf("%w: macOS requires at least 4 GiB in whole MiB and 2 vCPUs", ErrInvalidRequest) + } + req.Size, req.Vcpus = size, vcpus + req.OverlaySize = *img.SizeBytes // Reserve the writable boot disk, not a Linux overlay. + req.SkipGuestAgent = true + req.SkipKernelHeaders = true + return nil +} + +func macOSPlatformForCreate(platform *images.MacOSImage, size int64, vcpus int) *images.MacOSImage { + if platform == nil { + return nil + } + configured := *platform + configured.Memory, configured.CPUs = uint64(size), uint(vcpus) + return &configured +} + +func validateMacOSRestartPolicy(policy *restartpolicy.Policy) error { + if policy != nil && policy.Policy == restartpolicy.PolicyOnFailure { + return fmt.Errorf("%w: macOS supports restart_policy always or never; workload exit status is unavailable", ErrInvalidRequest) + } + return nil +} + +func checkMacOSUpdate(stored *StoredMetadata, req UpdateInstanceRequest) error { + if stored.MacOS == nil { + return nil + } + if len(req.Env) != 0 || req.AutoStandby != nil || req.HealthCheck != nil { + return fmt.Errorf("%w: macOS updates support expiration and VM restart policy only", ErrInvalidRequest) + } + return validateMacOSRestartPolicy(req.RestartPolicy) +} + +// checkMacOSStart refuses starts a macOS-only server or a macOS guest cannot honor. +func (m *manager) checkMacOSStart(stored *StoredMetadata, req StartInstanceRequest) error { + if err := m.requireMacOSGuest(stored); err != nil { + return err + } + if stored.MacOS != nil && (len(req.Entrypoint) != 0 || len(req.Cmd) != 0) { + return fmt.Errorf("%w: macOS start does not support command overrides", ErrInvalidRequest) + } + return nil +} + +// createBootDisk creates the Linux overlay, or clones a macOS image's boot disk and +// aux storage into the instance. +func (m *manager) createBootDisk(stored *StoredMetadata, img *images.Image) error { + if stored.MacOS == nil { + return m.createOverlayDisk(stored.Id, stored.OverlaySize) + } + boot, err := images.GetBootStorage(m.paths, img.Name, img.Digest) + if err != nil { + return err + } + return cloneMacOSStorage(boot.Disk, boot.Aux, m.paths.InstanceOverlay(stored.Id), filepath.Join(stored.DataDir, macOSAuxStorageFile)) +} + +// applyMacOSNetwork sets the template MAC on stored. vmnet assigns the address, which is +// observed after boot, so no Linux TAP or static IP is allocated. +func applyMacOSNetwork(stored *StoredMetadata) *network.NetworkConfig { + stored.MAC = stored.MacOS.MAC + stored.IP = "" + return &network.NetworkConfig{MAC: stored.MAC} +} + +func (m *manager) macOSVMConfig(inst *Instance) hypervisor.VMConfig { + c := hypervisor.VMConfig{BootMode: hypervisor.BootModeMacOS, VCPUs: inst.Vcpus, MemoryBytes: inst.Size, VsockCID: inst.VsockCID, VsockSocket: inst.VsockSocket, + MacOS: &hypervisor.MacOSPlatform{HardwareModelData: inst.MacOS.HardwareModel, MachineIdentifierData: inst.MacOS.MachineIdentifier, AuxStoragePath: filepath.Join(inst.DataDir, macOSAuxStorageFile)}, + Disks: []hypervisor.DiskConfig{{Path: m.paths.InstanceOverlay(inst.Id)}}} + if inst.NetworkEnabled { + c.Networks = []hypervisor.NetworkConfig{{MAC: inst.MAC}} + } + return c +} +func initialBootPhase(stored *StoredMetadata) phasetracking.Phase { + if stored.MacOS != nil { + return phasetracking.PhaseRunning + } + return phasetracking.PhaseInitializing +} +func (m *manager) checkMacOSIdentityAvailable(ctx context.Context, stored *StoredMetadata) error { + entries, err := os.ReadDir(m.paths.GuestsDir()) + if err != nil { + return err + } + for _, entry := range entries { + if !entry.IsDir() || entry.Name() == stored.Id { + continue + } + meta, err := m.loadMetadata(entry.Name()) + if errors.Is(err, ErrNotFound) { + // A create in progress or a leftover directory has no instance to admit against. + continue + } + if err != nil { + return fmt.Errorf("check Mac identity admission: %w", err) + } + if meta.MacOS == nil || !bytes.Equal(meta.MacOS.MachineIdentifier, stored.MacOS.MachineIdentifier) { + continue + } + state := m.deriveStateWithoutHydration(ctx, &meta.StoredMetadata).State + if state.RequiresVMM() || state == StateUnknown { + return fmt.Errorf("%w: macOS template identity is already in use by instance %s; stop it first", ErrInsufficientResources, meta.Id) + } + } + return nil +} + +// macOSUnsupportedOperations are the instance operations experimental macOS guests +// do not implement. +var macOSUnsupportedOperations = map[string]bool{ + "fork": true, + "restore": true, + "snapshot": true, + "restore snapshot": true, + "snapshot schedules": true, + "standby": true, +} + +// admitGuestOperation refuses an operation this server cannot run for the guest: +// any Linux operation on a macOS-only server, and operations macOS guests do not +// implement. Callers check it right after loading the record under the lock. +func (m *manager) admitGuestOperation(stored *StoredMetadata, operation string) error { + if err := m.requireMacOSGuest(stored); err != nil { + return err + } + if stored.MacOS != nil && macOSUnsupportedOperations[operation] { + return fmt.Errorf("%w: %s is not implemented for experimental macOS instances", ErrInvalidRequest, operation) + } + return nil +} + +// vmnet stores octets without leading zeroes. A lease is observed addressing, +// not a static-IP assignment or a guest-readiness signal. +func macOSGuestIP(mac string) string { + wanted, err := net.ParseMAC(mac) + if err != nil { + return "" + } + data, err := os.ReadFile("/var/db/dhcpd_leases") + if err != nil { + return "" + } + return macOSLeaseIP(string(data), wanted) +} + +// dhcpd appends newer leases, so the last matching block is the current one. +func macOSLeaseIP(data string, wanted net.HardwareAddr) string { + found := "" + for _, block := range strings.Split(data, "}") { + var addr, ip string + for _, line := range strings.Split(block, "\n") { + k, v, ok := strings.Cut(strings.TrimSpace(line), "=") + if !ok { + continue + } + switch k { + case "hw_address": + _, addr, _ = strings.Cut(v, ",") + case "ip_address": + ip = v + } + } + var padded []string + for _, octet := range strings.Split(addr, ":") { + if len(octet) == 1 { + octet = "0" + octet + } + padded = append(padded, octet) + } + raw, err := hex.DecodeString(strings.Join(padded, "")) + if err == nil && string(raw) == string(wanted) && net.ParseIP(ip) != nil { + found = ip + } + } + return found +} + +// VMStoragePath is the disk a macOS VM process holds open, used to find that process. +// Linux guests return empty: their hypervisor PID is the whole guest. +func (s *StoredMetadata) VMStoragePath() string { + if s.MacOS == nil || s.DataDir == "" { + return "" + } + return filepath.Join(s.DataDir, "overlay.raw") +} + +// requireMacOSGuest refuses Linux guests on a macOS-only server. +func (m *manager) requireMacOSGuest(stored *StoredMetadata) error { + if m.macOSOnly && stored.MacOS == nil { + return fmt.Errorf("%w: server is configured for macOS guests only", ErrInvalidRequest) + } + return nil +} diff --git a/lib/instances/macos_darwin.go b/lib/instances/macos_darwin.go new file mode 100644 index 000000000..884c794d7 --- /dev/null +++ b/lib/instances/macos_darwin.go @@ -0,0 +1,34 @@ +//go:build darwin + +package instances + +import ( + "os" + + "golang.org/x/sys/unix" +) + +func cloneMacOSStorage(rootDisk, rootAux, disk, aux string) (err error) { + var created []string + defer func() { + if err != nil { + // Only remove files this operation created, never pre-existing storage. + for _, path := range created { + os.Remove(path) + } + } + }() + for _, file := range []struct{ src, dst string }{ + {rootDisk, disk}, + {rootAux, aux}, + } { + if err = unix.Clonefile(file.src, file.dst, unix.CLONE_NOFOLLOW|unix.CLONE_NOOWNERCOPY); err != nil { + return err + } + created = append(created, file.dst) + if err = os.Chmod(file.dst, 0600); err != nil { + return err + } + } + return nil +} diff --git a/lib/instances/macos_darwin_test.go b/lib/instances/macos_darwin_test.go new file mode 100644 index 000000000..036f5ec0a --- /dev/null +++ b/lib/instances/macos_darwin_test.go @@ -0,0 +1,83 @@ +//go:build darwin + +package instances + +import ( + "errors" + "net" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" +) + +func TestMacOSStorageClone(t *testing.T) { + root := t.TempDir() + source := filepath.Join(root, "rootfs.raw") + auxSource := filepath.Join(root, "aux.img") + require.NoError(t, os.WriteFile(source, []byte("template disk"), 0644)) + require.NoError(t, os.WriteFile(auxSource, []byte("template aux"), 0644)) + probe := filepath.Join(root, "probe") + err := unix.Clonefile(source, probe, unix.CLONE_NOFOLLOW|unix.CLONE_NOOWNERCOPY) + if errors.Is(err, unix.ENOTSUP) || errors.Is(err, unix.EXDEV) { + t.Skip("test filesystem does not support clonefile") + } + require.NoError(t, err) + require.NoError(t, os.Remove(probe)) + + t.Run("private writable copies", func(t *testing.T) { + dest := t.TempDir() + disk, aux := filepath.Join(dest, "disk"), filepath.Join(dest, "aux") + require.NoError(t, cloneMacOSStorage(source, auxSource, disk, aux)) + for path, expected := range map[string]string{disk: "template disk", aux: "template aux"} { + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, expected, string(data)) + info, err := os.Stat(path) + require.NoError(t, err) + require.Equal(t, os.FileMode(0600), info.Mode().Perm()) + require.NoError(t, os.WriteFile(path, []byte("instance changes"), 0600)) + } + for path, expected := range map[string]string{source: "template disk", auxSource: "template aux"} { + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, expected, string(data)) + } + }) + + t.Run("aux failure rolls back only new disk", func(t *testing.T) { + dest := t.TempDir() + disk, aux := filepath.Join(dest, "disk"), filepath.Join(dest, "aux") + require.NoError(t, os.WriteFile(aux, []byte("existing aux"), 0600)) + require.Error(t, cloneMacOSStorage(source, auxSource, disk, aux)) + _, err := os.Stat(disk) + require.ErrorIs(t, err, os.ErrNotExist) + data, err := os.ReadFile(aux) + require.NoError(t, err) + require.Equal(t, "existing aux", string(data)) + }) + + t.Run("existing disk is preserved", func(t *testing.T) { + dest := t.TempDir() + disk, aux := filepath.Join(dest, "disk"), filepath.Join(dest, "aux") + require.NoError(t, os.WriteFile(disk, []byte("existing disk"), 0600)) + require.Error(t, cloneMacOSStorage(source, auxSource, disk, aux)) + data, err := os.ReadFile(disk) + require.NoError(t, err) + require.Equal(t, "existing disk", string(data)) + _, err = os.Stat(aux) + require.ErrorIs(t, err, os.ErrNotExist) + }) +} + +func TestMacOSLeaseNormalization(t *testing.T) { + mac, err := net.ParseMAC("02:00:01:0a:0b:ff") + require.NoError(t, err) + data := "{\n ip_address=192.168.64.7\n hw_address=1,2:0:1:a:b:ff\n}\n" + require.Equal(t, "192.168.64.7", macOSLeaseIP(data, mac)) + require.Empty(t, macOSLeaseIP("garbage", mac)) + // A later lease for the same MAC supersedes the earlier one. + require.Equal(t, "192.168.64.9", macOSLeaseIP(data+"{\n ip_address=192.168.64.9\n hw_address=1,2:0:1:a:b:ff\n}\n", mac)) +} diff --git a/lib/instances/macos_only_gate_test.go b/lib/instances/macos_only_gate_test.go new file mode 100644 index 000000000..f2f7b12d0 --- /dev/null +++ b/lib/instances/macos_only_gate_test.go @@ -0,0 +1,40 @@ +package instances + +import ( + "testing" + + "github.com/kernel/hypeman/lib/hypervisor" + "github.com/kernel/hypeman/lib/images" + "github.com/stretchr/testify/require" +) + +func TestMacOSOnlyServerRefusesLinuxRestoreAndFork(t *testing.T) { + m := &manager{macOSOnly: true} + require.ErrorIs(t, m.requireMacOSGuest(&StoredMetadata{}), ErrInvalidRequest) + require.NoError(t, m.requireMacOSGuest(&StoredMetadata{MacOS: &images.MacOSImage{}})) + + mixed := &manager{} + require.NoError(t, mixed.requireMacOSGuest(&StoredMetadata{}), "a mixed host keeps Linux guests") +} + +func TestMacOSCreateAcceptsExplicitResources(t *testing.T) { + img := &images.Image{Platform: "darwin/arm64", SizeBytes: testInt64Ptr(1 << 30), MacOS: &images.MacOSImage{Memory: 8 << 30, CPUs: 4}} + req := CreateInstanceRequest{Size: 4 << 30} + require.NoError(t, prepareMacOSCreate(&req, img, testMacOSCaps())) + require.Equal(t, int64(4<<30), req.Size) + + req = CreateInstanceRequest{Vcpus: 8} + require.NoError(t, prepareMacOSCreate(&req, img, testMacOSCaps())) + require.Equal(t, 8, req.Vcpus) + require.Equal(t, uint(4), img.MacOS.CPUs) + require.Equal(t, uint64(8<<30), img.MacOS.Memory) + + req = CreateInstanceRequest{Size: 8 << 30, Vcpus: 4} + require.NoError(t, prepareMacOSCreate(&req, img, testMacOSCaps()), "matching values are accepted") +} + +func testMacOSCaps() hypervisor.Capabilities { + return hypervisor.Capabilities{SupportsMacOSBoot: true} +} + +func testInt64Ptr(v int64) *int64 { return &v } diff --git a/lib/instances/macos_other.go b/lib/instances/macos_other.go new file mode 100644 index 000000000..581e3ba18 --- /dev/null +++ b/lib/instances/macos_other.go @@ -0,0 +1,9 @@ +//go:build !darwin + +package instances + +import "fmt" + +func cloneMacOSStorage(string, string, string, string) error { + return fmt.Errorf("macOS image clones require APFS on macOS") +} diff --git a/lib/instances/macos_test.go b/lib/instances/macos_test.go new file mode 100644 index 000000000..c618e369c --- /dev/null +++ b/lib/instances/macos_test.go @@ -0,0 +1,83 @@ +package instances + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/kernel/hypeman/lib/hypervisor" + "github.com/kernel/hypeman/lib/images" + "github.com/kernel/hypeman/lib/paths" + "github.com/stretchr/testify/require" +) + +func testMacImage() *images.Image { + size := int64(64 << 30) + return &images.Image{Platform: "darwin/arm64", SizeBytes: &size, MacOS: &images.MacOSImage{HardwareModel: []byte{1}, MachineIdentifier: []byte{2}, MAC: "02:00:00:00:00:01", CPUs: 4, Memory: 8 << 30}} +} +func TestMacOSRequestDefaultsAndRejections(t *testing.T) { + caps := hypervisor.Capabilities{SupportsMacOSBoot: true} + req := CreateInstanceRequest{} + require.NoError(t, prepareMacOSCreate(&req, testMacImage(), caps)) + require.Equal(t, int64(8<<30), req.Size) + require.Equal(t, 4, req.Vcpus) + require.Equal(t, int64(64<<30), req.OverlaySize) + require.True(t, req.SkipGuestAgent) + for _, r := range []CreateInstanceRequest{ + {Env: map[string]string{"X": "Y"}}, {Cmd: []string{"sh"}}, {HotplugSize: 1}, {OverlaySize: 1}, {DiskIOBps: 1}, {Size: 1 << 30}, {Size: (4 << 30) + 1}, {Vcpus: 1}, {Volumes: []VolumeAttachment{{VolumeID: "v"}}}, + } { + original := r + require.ErrorIs(t, prepareMacOSCreate(&r, testMacImage(), caps), ErrInvalidRequest) + require.Equal(t, original, r) + } + require.ErrorIs(t, prepareMacOSCreate(&CreateInstanceRequest{}, testMacImage(), hypervisor.Capabilities{}), ErrInvalidRequest) +} +func TestMacOSBootConfigNoLinuxDevices(t *testing.T) { + p := paths.New(t.TempDir()) + m := &manager{paths: p} + inst := &Instance{StoredMetadata: StoredMetadata{Id: "mac", DataDir: p.InstanceDir("mac"), Size: 8 << 30, Vcpus: 4, MacOS: testMacImage().MacOS, NetworkEnabled: true, MAC: "02:00:00:00:00:01"}} + cfg, err := m.buildHypervisorConfig(context.Background(), inst, nil, nil) + require.NoError(t, err) + require.Equal(t, hypervisor.BootModeMacOS, cfg.BootMode) + require.Len(t, cfg.Disks, 1) + require.False(t, cfg.Disks[0].Readonly) + require.Empty(t, cfg.KernelPath) + require.Empty(t, cfg.InitrdPath) + require.Empty(t, cfg.SerialLogPath) + require.False(t, cfg.GuestMemory.EnableBalloon) + require.Equal(t, StateRunning, deriveRunningState(&inst.StoredMetadata)) + require.Nil(t, inst.ProgramStartedAt) +} +func TestMacOSIdentityAdmissionSkipsDirectoriesWithoutMetadata(t *testing.T) { + p := paths.New(t.TempDir()) + m := &manager{paths: p} + stored := StoredMetadata{Id: "new", DataDir: p.InstanceDir("new"), MacOS: testMacImage().MacOS} + require.NoError(t, m.ensureDirectories("orphan")) + require.NoError(t, m.ensureDirectories("new")) + require.NoError(t, m.checkMacOSIdentityAvailable(context.Background(), &stored)) + + require.NoError(t, os.WriteFile(p.InstanceMetadata("orphan"), []byte("{not json"), 0600)) + require.Error(t, m.checkMacOSIdentityAvailable(context.Background(), &stored)) +} + +func TestMacOSPreservedIdentityAdmission(t *testing.T) { + p := paths.New(t.TempDir()) + m := &manager{paths: p} + require.NoError(t, m.ensureDirectories("active")) + socket := filepath.Join(p.InstanceDir("active"), "vz.sock") + require.NoError(t, os.WriteFile(socket, nil, 0600)) + stored := StoredMetadata{Id: "active", DataDir: p.InstanceDir("active"), SocketPath: socket, MacOS: testMacImage().MacOS, HypervisorType: hypervisor.TypeVZ, CreatedAt: time.Now()} + require.NoError(t, m.saveMetadata(&metadata{StoredMetadata: stored})) + m.storeCachedHypervisorState("active", hypervisor.StateRunning) + other := stored + other.Id = "other" + err := m.checkMacOSIdentityAvailable(context.Background(), &other) + require.True(t, errors.Is(err, ErrInsufficientResources)) + require.NoError(t, m.checkMacOSIdentityAvailable(context.Background(), &stored)) + require.NoError(t, os.Remove(socket)) + require.NoError(t, m.checkMacOSIdentityAvailable(context.Background(), &other)) + require.ErrorIs(t, (&manager{}).admitGuestOperation(&stored, "snapshot schedules"), ErrInvalidRequest) +} diff --git a/lib/instances/macos_update_test.go b/lib/instances/macos_update_test.go new file mode 100644 index 000000000..d156b8112 --- /dev/null +++ b/lib/instances/macos_update_test.go @@ -0,0 +1,73 @@ +package instances + +import ( + "context" + "testing" + "time" + + "github.com/kernel/hypeman/lib/autostandby" + "github.com/kernel/hypeman/lib/healthcheck" + "github.com/kernel/hypeman/lib/paths" + restartpolicy "github.com/kernel/hypeman/lib/restart-policy" + "github.com/stretchr/testify/require" +) + +func TestMacOSUpdateExpirationAndRestartPolicy(t *testing.T) { + now := time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC) + p := paths.New(t.TempDir()) + m := &manager{paths: p, now: func() time.Time { return now }, macOSOnly: true, lifecycleEvents: newLifecycleSubscribers()} + id := "mac-update" + require.NoError(t, m.ensureDirectories(id)) + require.NoError(t, m.saveMetadata(&metadata{StoredMetadata: StoredMetadata{Id: id, DataDir: p.InstanceDir(id), MacOS: testMacImage().MacOS}})) + + ttl := time.Hour + updated, err := m.UpdateInstance(context.Background(), id, UpdateInstanceRequest{TTL: &ttl, RestartPolicySet: true, RestartPolicy: &restartpolicy.Policy{Policy: restartpolicy.PolicyAlways}}) + require.NoError(t, err) + require.Equal(t, now.Add(ttl), *updated.ExpiresAt) + require.Equal(t, restartpolicy.PolicyAlways, updated.RestartPolicy.Policy) + + expires := now.Add(2 * time.Hour) + updated, err = m.UpdateInstance(context.Background(), id, UpdateInstanceRequest{ExpiresAt: &expires}) + require.NoError(t, err) + require.Equal(t, expires, *updated.ExpiresAt) + + ttl = 0 + updated, err = m.UpdateInstance(context.Background(), id, UpdateInstanceRequest{TTL: &ttl, RestartPolicySet: true}) + require.NoError(t, err) + require.Nil(t, updated.ExpiresAt) + require.Nil(t, updated.RestartPolicy) + persisted, err := m.loadMetadata(id) + require.NoError(t, err) + require.Nil(t, persisted.ExpiresAt) + require.Nil(t, persisted.RestartPolicy) +} + +func TestMacOSUpdateRejectsUnsupportedFields(t *testing.T) { + stored := StoredMetadata{MacOS: testMacImage().MacOS} + for _, req := range []UpdateInstanceRequest{ + {Env: map[string]string{"TOKEN": "value"}}, + {AutoStandby: &autostandby.Policy{Enabled: true}}, + {HealthCheck: &healthcheck.Policy{Type: healthcheck.TypeTCP, TCP: &healthcheck.TCPCheck{Port: 80}}}, + {RestartPolicySet: true, RestartPolicy: &restartpolicy.Policy{Policy: restartpolicy.PolicyOnFailure}}, + } { + require.ErrorIs(t, checkMacOSUpdate(&stored, req), ErrInvalidRequest) + require.NoError(t, checkMacOSUpdate(&StoredMetadata{}, req), "Linux admission is unchanged") + } +} + +func TestMacOSCreateRestartPolicy(t *testing.T) { + req := CreateInstanceRequest{RestartPolicy: &restartpolicy.Policy{Policy: restartpolicy.PolicyAlways}} + require.NoError(t, prepareMacOSCreate(&req, testMacImage(), testMacOSCaps())) + req = CreateInstanceRequest{RestartPolicy: &restartpolicy.Policy{Policy: restartpolicy.PolicyOnFailure}} + require.ErrorIs(t, prepareMacOSCreate(&req, testMacImage(), testMacOSCaps()), ErrInvalidRequest) +} + +func TestMacOSPlatformForCreateDoesNotMutateImage(t *testing.T) { + platform := testMacImage().MacOS + configured := macOSPlatformForCreate(platform, 4<<30, 3) + require.Equal(t, uint64(4<<30), configured.Memory) + require.Equal(t, uint(3), configured.CPUs) + require.Equal(t, uint64(8<<30), platform.Memory) + require.Equal(t, uint(4), platform.CPUs) + require.Nil(t, macOSPlatformForCreate(nil, 1<<30, 2)) +} diff --git a/lib/instances/manager.go b/lib/instances/manager.go index 2a9bb6dfb..0b39814f0 100644 --- a/lib/instances/manager.go +++ b/lib/instances/manager.go @@ -86,6 +86,7 @@ type ResourceLimits struct { // ManagerConfig holds non-resource manager behavior settings. type ManagerConfig struct { + MacOSOnly bool LifecycleEventBufferSize int FirecrackerSnapshotMemoryBackend string FirecrackerUFFDCacheMaxBytes int64 @@ -174,6 +175,8 @@ type manager struct { resourceValidator ResourceValidator // Optional validator for aggregate resource limits instanceLocks sync.Map // map[string]*sync.RWMutex - per-instance locks forkMetadataMu sync.Mutex + macOSBootMu sync.Mutex // Serialize admission for preserved Mac identities. + macOSOnly bool bootMarkerScans sync.Map // map[string]time.Time next allowed boot-marker rescan hypervisorStateCache sync.Map // map[string]hypervisorStateCacheEntry - last observed hypervisor state per instance hostTopology *HostTopology // Cached host CPU topology @@ -285,6 +288,7 @@ func NewManagerWithConfigE(p *paths.Paths, imageManager images.Manager, systemMa m := &manager{ paths: p, + macOSOnly: managerConfig.MacOSOnly, imageManager: imageManager, systemManager: systemManager, networkManager: networkManager, @@ -922,9 +926,10 @@ func (m *manager) ListRunningInstancesInfo(ctx context.Context) ([]resources.Ins } // Derive TAP device name if networking is enabled - if inst.NetworkEnabled { + if inst.NetworkEnabled && inst.MacOS == nil { info.TAPDevice = network.GenerateTAPName(inst.Id) } + info.StoragePath = inst.VMStoragePath() infos = append(infos, info) } diff --git a/lib/instances/query.go b/lib/instances/query.go index 0621460da..02ec7ed49 100644 --- a/lib/instances/query.go +++ b/lib/instances/query.go @@ -137,7 +137,7 @@ func (m *manager) deriveStateWithOptions(ctx context.Context, stored *StoredMeta return stateResult{State: StateCreated} case hypervisor.StateRunning: hydrated := false - if hydrateBootMarkers { + if hydrateBootMarkers && stored.MacOS == nil { hydrated = m.hydrateBootMarkersFromLogs(ctx, stored) } return stateResult{ @@ -219,6 +219,10 @@ func (m *manager) updateCachedHypervisorStateFromInstance(inst *Instance) { } func deriveRunningState(stored *StoredMetadata) State { + // For unmanaged macOS desktops, Running means VMM running, not app/agent ready. + if stored.MacOS != nil { + return StateRunning + } if stored.ProgramStartedAt == nil { return StateInitializing } @@ -555,6 +559,9 @@ func (m *manager) toInstanceWithStateDerivation(ctx context.Context, meta *metad HealthCheckRuntime: healthcheck.CloneRuntime(meta.HealthCheckRuntime), } refreshHypervisorPID(&inst.StoredMetadata, result.State) + if inst.MacOS != nil && inst.NetworkEnabled && inst.State.RequiresVMM() { + inst.IP = macOSGuestIP(inst.MAC) + } // If VM is stopped and exit info isn't persisted yet, populate in-memory // from the serial console log. This is read-only -- no metadata writes. diff --git a/lib/instances/restore.go b/lib/instances/restore.go index ab27903ba..bdbdb3b2a 100644 --- a/lib/instances/restore.go +++ b/lib/instances/restore.go @@ -42,6 +42,9 @@ func (m *manager) restoreInstance( log.ErrorContext(ctx, "failed to load instance metadata", "instance_id", id, "error", err) return nil, err } + if err := m.admitGuestOperation(&meta.StoredMetadata, "restore"); err != nil { + return nil, err + } inst := m.toInstance(ctx, meta) stored := &meta.StoredMetadata diff --git a/lib/instances/snapshot.go b/lib/instances/snapshot.go index 1058af131..6e783ce7e 100644 --- a/lib/instances/snapshot.go +++ b/lib/instances/snapshot.go @@ -63,6 +63,9 @@ func (m *manager) createSnapshot(ctx context.Context, id string, req CreateSnaps if err != nil { return nil, err } + if err := m.admitGuestOperation(&meta.StoredMetadata, "snapshot"); err != nil { + return nil, err + } inst := m.toInstance(ctx, meta) stored := &meta.StoredMetadata @@ -259,6 +262,9 @@ func (m *manager) restoreSnapshot(ctx context.Context, id string, snapshotID str if err != nil { return nil, err } + if err := m.admitGuestOperation(&sourceMeta.StoredMetadata, "restore snapshot"); err != nil { + return nil, err + } sourceInst := m.toInstance(ctx, sourceMeta) if sourceInst.State == StateRunning { return nil, fmt.Errorf("%w: cannot restore snapshot while source is %s", ErrInvalidState, sourceInst.State) @@ -378,6 +384,9 @@ func (m *manager) forkSnapshot(ctx context.Context, snapshotID string, req ForkS if err != nil { return nil, err } + if err := m.admitGuestOperation(&rec.StoredMetadata, "fork snapshot"); err != nil { + return nil, err + } if err := validateForkVolumeSafety(rec.StoredMetadata.Volumes); err != nil { return nil, fmt.Errorf("%w: snapshot requires readonly volume attachments: %v", ErrNotSupported, err) } diff --git a/lib/instances/snapshot_schedule.go b/lib/instances/snapshot_schedule.go index 541f6a6d1..4eccc4983 100644 --- a/lib/instances/snapshot_schedule.go +++ b/lib/instances/snapshot_schedule.go @@ -36,7 +36,12 @@ func (m *manager) SetSnapshotSchedule(ctx context.Context, instanceID string, re lock.Lock() defer lock.Unlock() - if _, err := m.loadMetadata(instanceID); err != nil { + meta, err := m.loadMetadata(instanceID) + if err != nil { + return nil, err + } + // Schedules run the snapshot path, which macOS guests reject; refuse up front. + if err := m.admitGuestOperation(&meta.StoredMetadata, "snapshot schedules"); err != nil { return nil, err } diff --git a/lib/instances/snapshot_schedule_test.go b/lib/instances/snapshot_schedule_test.go index 06d50e5b2..d208b9abf 100644 --- a/lib/instances/snapshot_schedule_test.go +++ b/lib/instances/snapshot_schedule_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/kernel/hypeman/lib/images" "github.com/kernel/hypeman/lib/scheduledsnapshots" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -656,3 +657,23 @@ func markSnapshotScheduleDue(t *testing.T, mgr *manager, instanceID string) { schedule.UpdatedAt = mgr.now().UTC() require.NoError(t, mgr.saveSnapshotScheduleUnlocked(schedule)) } + +func TestSnapshotScheduleRejectedForMacOSGuests(t *testing.T) { + t.Parallel() + mgr, _ := setupTestManager(t) + ctx := context.Background() + id := "snapshot-schedule-macos" + createStoppedSnapshotSourceFixture(t, mgr, id, id, mgr.defaultHypervisor) + meta, err := mgr.loadMetadata(id) + require.NoError(t, err) + meta.MacOS = &images.MacOSImage{HardwareModel: []byte{1}, MachineIdentifier: []byte{2}, MAC: "02:00:00:00:00:01", CPUs: 4, Memory: 8 << 30} + require.NoError(t, mgr.saveMetadata(meta)) + + _, err = mgr.SetSnapshotSchedule(ctx, id, SetSnapshotScheduleRequest{ + Interval: 2 * time.Hour, + NamePrefix: "nightly", + Retention: SnapshotScheduleRetention{MaxCount: 1}, + }) + require.ErrorIs(t, err, ErrInvalidRequest) + require.Contains(t, err.Error(), "snapshot schedules") +} diff --git a/lib/instances/standby.go b/lib/instances/standby.go index 5c6e0173e..f521b251e 100644 --- a/lib/instances/standby.go +++ b/lib/instances/standby.go @@ -42,6 +42,9 @@ func (m *manager) standbyInstance( log.ErrorContext(ctx, "failed to load instance metadata", "instance_id", id, "error", err) return nil, err } + if err := m.admitGuestOperation(&meta.StoredMetadata, "standby"); err != nil { + return nil, err + } inst := m.toInstance(ctx, meta) stored := &meta.StoredMetadata diff --git a/lib/instances/start.go b/lib/instances/start.go index b162ad90f..763ed44c1 100644 --- a/lib/instances/start.go +++ b/lib/instances/start.go @@ -6,7 +6,7 @@ import ( "time" "github.com/kernel/hypeman/lib/egressproxy" - "github.com/kernel/hypeman/lib/instances/phasetracking" + "github.com/kernel/hypeman/lib/images" "github.com/kernel/hypeman/lib/logger" "github.com/kernel/hypeman/lib/network" "go.opentelemetry.io/otel/attribute" @@ -42,6 +42,10 @@ func (m *manager) startInstance( ctx = enrichInstancesTrace(ctx, attribute.String("hypervisor", string(stored.HypervisorType))) log.DebugContext(ctx, "loaded instance", "instance_id", id, "state", inst.State) + if err := m.checkMacOSStart(stored, req); err != nil { + return nil, err + } + // 2. Validate state (must be Stopped to start) if inst.State != StateStopped { log.ErrorContext(ctx, "invalid state for start", "instance_id", id, "state", inst.State) @@ -94,18 +98,23 @@ func (m *manager) startInstance( // 3. Get image info (needed for buildHypervisorConfig). Resolve by the // digest-pinned boot reference so a moved tag can't drift the rootfs/arch. - bootImage := bootImageRef(stored) - log.DebugContext(ctx, "getting image info", "instance_id", id, "image", bootImage) - imageCtx, imageSpanEnd := m.startLifecycleStep(ctx, "resolve_image", - attribute.String("instance_id", id), - attribute.String("hypervisor", string(stored.HypervisorType)), - attribute.String("operation", "resolve_image"), - ) - imageInfo, err := m.imageManager.GetImage(imageCtx, bootImage) - imageSpanEnd(err) - if err != nil { - log.ErrorContext(ctx, "failed to get image", "instance_id", id, "image", bootImage, "error", err) - return nil, fmt.Errorf("get image: %w", err) + // macOS boots the disk and aux storage cloned at create time, so it does not + // depend on the template image after create. + var imageInfo *images.Image + if stored.MacOS == nil { + bootImage := bootImageRef(stored) + log.DebugContext(ctx, "getting image info", "instance_id", id, "image", bootImage) + imageCtx, imageSpanEnd := m.startLifecycleStep(ctx, "resolve_image", + attribute.String("instance_id", id), + attribute.String("hypervisor", string(stored.HypervisorType)), + attribute.String("operation", "resolve_image"), + ) + imageInfo, err = m.imageManager.GetImage(imageCtx, bootImage) + imageSpanEnd(err) + if err != nil { + log.ErrorContext(ctx, "failed to get image", "instance_id", id, "image", bootImage, "error", err) + return nil, fmt.Errorf("get image: %w", err) + } } // Setup cleanup stack for automatic rollback on errors @@ -114,7 +123,9 @@ func (m *manager) startInstance( // 4. Allocate fresh network if network enabled var netConfig *network.NetworkConfig - if stored.NetworkEnabled { + if stored.MacOS != nil && stored.NetworkEnabled { + netConfig = applyMacOSNetwork(stored) + } else if stored.NetworkEnabled { log.DebugContext(ctx, "allocating network for start", "instance_id", id, "network", "default") networkCtx, networkSpanEnd := m.startLifecycleStep(ctx, "allocate_network", attribute.String("instance_id", id), @@ -231,7 +242,7 @@ func (m *manager) startInstance( // 7. Update metadata (set PID, StartedAt). Boot markers were cleared at // the top of this function, so we are in Initializing until they hydrate. - stored.Phases.Record(phasetracking.PhaseInitializing, time.Now().UTC()) + stored.Phases.Record(initialBootPhase(stored), time.Now().UTC()) meta = &metadata{StoredMetadata: *stored} if err := m.saveMetadata(meta); err != nil { // VM is running but metadata failed - log but don't fail diff --git a/lib/instances/stop.go b/lib/instances/stop.go index ec6bf029e..603b9a75e 100644 --- a/lib/instances/stop.go +++ b/lib/instances/stop.go @@ -146,7 +146,7 @@ func (m *manager) stopInstance( // 3. Get network allocation BEFORE killing VMM (while we can still query it) var networkAlloc *network.Allocation var networkAllocErr error - if inst.NetworkEnabled { + if inst.NetworkEnabled && inst.MacOS == nil { log.DebugContext(ctx, "getting network allocation", "instance_id", id) networkAlloc, networkAllocErr = m.networkManager.GetAllocation(ctx, id) if networkAllocErr != nil { diff --git a/lib/instances/types.go b/lib/instances/types.go index efa057b31..638dbf2b9 100644 --- a/lib/instances/types.go +++ b/lib/instances/types.go @@ -7,6 +7,7 @@ import ( "github.com/kernel/hypeman/lib/devices" "github.com/kernel/hypeman/lib/healthcheck" "github.com/kernel/hypeman/lib/hypervisor" + "github.com/kernel/hypeman/lib/images" "github.com/kernel/hypeman/lib/instances/phasetracking" restartpolicy "github.com/kernel/hypeman/lib/restart-policy" "github.com/kernel/hypeman/lib/snapshot" @@ -92,6 +93,7 @@ type StoredMetadata struct { // "linux/amd64"), captured from the pulled image's metadata at create time. // Read-only; echoed on the instance API. Platform string + MacOS *images.MacOSImage `json:"MacOS,omitempty"` // Non-nil for experimental local macOS disk guests. // Resources (matching Cloud Hypervisor terminology) Size int64 // Base memory in bytes diff --git a/lib/instances/update.go b/lib/instances/update.go index f5a2a420a..55249fa38 100644 --- a/lib/instances/update.go +++ b/lib/instances/update.go @@ -27,6 +27,12 @@ func (m *manager) updateInstance(ctx context.Context, id string, req UpdateInsta log.ErrorContext(ctx, "failed to load instance metadata", "instance_id", id, "error", err) return nil, err } + if err := m.admitGuestOperation(&meta.StoredMetadata, "update"); err != nil { + return nil, err + } + if err := checkMacOSUpdate(&meta.StoredMetadata, req); err != nil { + return nil, err + } inst, err := m.getInstance(ctx, id) if err != nil { diff --git a/lib/instances/vm_storage_path_test.go b/lib/instances/vm_storage_path_test.go new file mode 100644 index 000000000..6b68cdbdb --- /dev/null +++ b/lib/instances/vm_storage_path_test.go @@ -0,0 +1,15 @@ +package instances + +import ( + "testing" + + "github.com/kernel/hypeman/lib/images" + "github.com/stretchr/testify/require" +) + +func TestVMStoragePathOnlyForMacOSGuests(t *testing.T) { + require.Empty(t, (&StoredMetadata{DataDir: "/data/guests/i1"}).VMStoragePath()) + mac := &StoredMetadata{DataDir: "/data/guests/i1", MacOS: &images.MacOSImage{}} + require.Equal(t, "/data/guests/i1/overlay.raw", mac.VMStoragePath()) + require.Empty(t, (&StoredMetadata{MacOS: &images.MacOSImage{}}).VMStoragePath(), "no data dir, no path") +} diff --git a/lib/instances/vsock.go b/lib/instances/vsock.go index 415dcc290..f4060950e 100644 --- a/lib/instances/vsock.go +++ b/lib/instances/vsock.go @@ -2,6 +2,7 @@ package instances import ( "context" + "fmt" "github.com/kernel/hypeman/lib/hypervisor" ) @@ -13,5 +14,8 @@ func (m *manager) GetVsockDialer(ctx context.Context, instanceID string) (hyperv return nil, err } + if inst.MacOS != nil { + return nil, fmt.Errorf("%w: macOS guest-agent exec/stat is not implemented", ErrInvalidRequest) + } return hypervisor.NewVsockDialer(hypervisor.Type(inst.HypervisorType), inst.VsockSocket, inst.VsockCID) } diff --git a/lib/oapi/oapi.go b/lib/oapi/oapi.go index b1d2067b8..ce5626905 100644 --- a/lib/oapi/oapi.go +++ b/lib/oapi/oapi.go @@ -743,16 +743,16 @@ type CreateInstanceRequest struct { Enabled *bool `json:"enabled,omitempty"` } `json:"network,omitempty"` - // OverlaySize Writable overlay disk size (human-readable format like "10GB", "50G") + // OverlaySize Writable overlay disk size (human-readable format like "10GB", "50G"). Omit this field for macOS guests, which clone the image's complete boot disk instead. OverlaySize *string `json:"overlay_size,omitempty"` - // Platform Target platform as os/arch[/variant] (e.g. "linux/amd64"), matching Docker --platform. Omit for the host platform. Not a fixed enum: the os/arch[/variant] grammar is validated server-side and invalid values return 400 invalid_platform. Only os "linux" with arch amd64 or arm64 is accepted today. + // Platform Target platform as os/arch[/variant]. Linux container guests support amd64 and arm64. Experimental darwin/arm64 guests require an offline-imported macOS disk image and vz on Apple silicon. Omit to use the cached native image or host platform. Platform *string `json:"platform,omitempty"` // RestartPolicy Whole-instance restart supervision policy. RestartPolicy *RestartPolicy `json:"restart_policy,omitempty"` - // Size Base memory size (human-readable format like "1GB", "512MB", "2G") + // Size Base memory size (human-readable format like "1GB", "512MB", "2G"). macOS guests default to their image's memory; explicit sizes must be at least 4 GiB in whole MiB and within VZ host limits. Size *string `json:"size,omitempty"` // SkipGuestAgent Skip guest-agent installation during boot. @@ -773,7 +773,7 @@ type CreateInstanceRequest struct { // Ttl Relative lifetime from instance creation, in Go duration format. Use "0s" or omit both expiration fields to disable automatic expiration. Mutually exclusive with expires_at. Ttl *string `json:"ttl,omitempty"` - // Vcpus Number of virtual CPUs + // Vcpus Number of virtual CPUs. macOS guests default to their image's vCPUs; explicit counts must be at least 2 and within VZ host limits. Vcpus *int `json:"vcpus,omitempty"` // Volumes Volumes to attach to the instance at creation time @@ -1438,7 +1438,7 @@ type InstanceStats struct { // MemoryVmsBytes Virtual Memory Size - total virtual memory allocated in bytes MemoryVmsBytes int64 `json:"memory_vms_bytes"` - // NetworkRxBytes Total network bytes received by the VM (from TAP interface) + // NetworkRxBytes Total network bytes received by the VM (from TAP interface). Always 0 for macOS guests, which use NAT with no per-instance interface. NetworkRxBytes int64 `json:"network_rx_bytes"` // NetworkTxBytes Total network bytes transmitted by the VM (from TAP interface) @@ -6608,6 +6608,7 @@ type CreateBuildResponse struct { JSON404 *Error JSON409 *Error JSON500 *Error + JSON501 *Error } // Status returns HTTPResponse.Status @@ -7023,6 +7024,7 @@ type CreateIngressResponse struct { JSON401 *Error JSON409 *Error JSON500 *Error + JSON501 *Error } // Status returns HTTPResponse.Status @@ -7296,6 +7298,7 @@ type GetInstanceLogsResponse struct { HTTPResponse *http.Response JSON404 *Error JSON500 *Error + JSON501 *Error } // Status returns HTTPResponse.Status @@ -7318,6 +7321,7 @@ type RestoreInstanceResponse struct { Body []byte HTTPResponse *http.Response JSON200 *Instance + JSON400 *Error JSON404 *Error JSON409 *Error JSON499 *Error @@ -7524,6 +7528,7 @@ type StatInstancePathResponse struct { JSON404 *Error JSON409 *Error JSON500 *Error + JSON501 *Error } // Status returns HTTPResponse.Status @@ -9059,6 +9064,13 @@ func ParseCreateBuildResponse(rsp *http.Response) (*CreateBuildResponse, error) } response.JSON500 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON501 = &dest + } return response, nil @@ -9788,6 +9800,13 @@ func ParseCreateIngressResponse(rsp *http.Response) (*CreateIngressResponse, err } response.JSON500 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON501 = &dest + } return response, nil @@ -10291,6 +10310,13 @@ func ParseGetInstanceLogsResponse(rsp *http.Response) (*GetInstanceLogsResponse, } response.JSON500 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON501 = &dest + } return response, nil @@ -10317,6 +10343,13 @@ func ParseRestoreInstanceResponse(rsp *http.Response) (*RestoreInstanceResponse, } response.JSON200 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -10735,6 +10768,13 @@ func ParseStatInstancePathResponse(rsp *http.Response) (*StatInstancePathRespons } response.JSON500 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON501 = &dest + } return response, nil @@ -14817,6 +14857,15 @@ func (response CreateBuild500JSONResponse) VisitCreateBuildResponse(w http.Respo return json.NewEncoder(w).Encode(response) } +type CreateBuild501JSONResponse Error + +func (response CreateBuild501JSONResponse) VisitCreateBuildResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(501) + + return json.NewEncoder(w).Encode(response) +} + type CancelBuildRequestObject struct { Id string `json:"id"` } @@ -15517,6 +15566,15 @@ func (response CreateIngress500JSONResponse) VisitCreateIngressResponse(w http.R return json.NewEncoder(w).Encode(response) } +type CreateIngress501JSONResponse Error + +func (response CreateIngress501JSONResponse) VisitCreateIngressResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(501) + + return json.NewEncoder(w).Encode(response) +} + type DeleteIngressRequestObject struct { Id string `json:"id"` } @@ -16022,6 +16080,15 @@ func (response GetInstanceLogs500JSONResponse) VisitGetInstanceLogsResponse(w ht return json.NewEncoder(w).Encode(response) } +type GetInstanceLogs501JSONResponse Error + +func (response GetInstanceLogs501JSONResponse) VisitGetInstanceLogsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(501) + + return json.NewEncoder(w).Encode(response) +} + type RestoreInstanceRequestObject struct { Id string `json:"id"` } @@ -16039,6 +16106,15 @@ func (response RestoreInstance200JSONResponse) VisitRestoreInstanceResponse(w ht return json.NewEncoder(w).Encode(response) } +type RestoreInstance400JSONResponse Error + +func (response RestoreInstance400JSONResponse) VisitRestoreInstanceResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + type RestoreInstance404JSONResponse Error func (response RestoreInstance404JSONResponse) VisitRestoreInstanceResponse(w http.ResponseWriter) error { @@ -16460,6 +16536,15 @@ func (response StatInstancePath500JSONResponse) VisitStatInstancePathResponse(w return json.NewEncoder(w).Encode(response) } +type StatInstancePath501JSONResponse Error + +func (response StatInstancePath501JSONResponse) VisitStatInstancePathResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(501) + + return json.NewEncoder(w).Encode(response) +} + type GetInstanceStatsRequestObject struct { Id string `json:"id"` } @@ -19241,395 +19326,397 @@ func (sh *strictHandler) GetVolume(w http.ResponseWriter, r *http.Request, id st // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "qZIc0fkBAA==", + "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", + "kNocHjx6sN/7+P7j/z8Agm/bWh/+AQA=", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/lib/paths/paths.go b/lib/paths/paths.go index b294f2417..314151ce4 100644 --- a/lib/paths/paths.go +++ b/lib/paths/paths.go @@ -288,6 +288,12 @@ func (p *Paths) InstanceAppLog(id string) string { return filepath.Join(p.InstanceLogs(id), "app.log") } +// InstanceVZShimLog returns the path to the Virtualization.framework shim log, which +// is the VMM log for VZ guests. +func (p *Paths) InstanceVZShimLog(id string) string { + return filepath.Join(p.InstanceLogs(id), "vz-shim.log") +} + // InstanceVMMLog returns the path to instance VMM log (Cloud Hypervisor stdout+stderr). func (p *Paths) InstanceVMMLog(id string) string { return filepath.Join(p.InstanceLogs(id), "vmm.log") diff --git a/lib/providers/providers.go b/lib/providers/providers.go index 8f3e7bbfe..bc028293b 100644 --- a/lib/providers/providers.go +++ b/lib/providers/providers.go @@ -145,6 +145,7 @@ func ProvideInstanceManager(p *paths.Paths, cfg *config.Config, imageManager ima return nil, fmt.Errorf("failed to parse hypervisor.firecracker_uffd_cache_max_bytes %q: %w", cfg.Hypervisor.FirecrackerUFFDCacheMaxBytes, err) } managerConfig := instances.ManagerConfig{ + MacOSOnly: cfg.MacOSOnly, LifecycleEventBufferSize: cfg.Instances.LifecycleEventBufferSize, FirecrackerSnapshotMemoryBackend: cfg.Hypervisor.FirecrackerSnapshotMemoryBackend, FirecrackerUFFDCacheMaxBytes: int64(firecrackerUFFDCacheMaxBytes), @@ -315,6 +316,7 @@ func (s *guestMemoryInstanceSource) ListBalloonVMs(ctx context.Context) ([]guest HypervisorType: inst.HypervisorType, SocketPath: inst.SocketPath, AssignedMemoryBytes: inst.Size + inst.HotplugSize, + MacOS: inst.MacOS != nil, }) } return vms, nil diff --git a/lib/resources/resource.go b/lib/resources/resource.go index f8f624e93..51a92513e 100644 --- a/lib/resources/resource.go +++ b/lib/resources/resource.go @@ -149,6 +149,9 @@ type InstanceUtilizationInfo struct { Name string HypervisorPID *int // PID of the hypervisor process TAPDevice string // Name of the TAP device (e.g., "hype-01234567") + // StoragePath is a file the guest's VM process holds open, used to find processes + // that do the guest's work outside the hypervisor PID (macOS VZ). Empty otherwise. + StoragePath string // Allocated resources (for computing utilization ratios) AllocatedVcpus int // Number of allocated vCPUs diff --git a/lib/vm_metrics/adapter.go b/lib/vm_metrics/adapter.go index 0f18a1872..f0c53d6ca 100644 --- a/lib/vm_metrics/adapter.go +++ b/lib/vm_metrics/adapter.go @@ -37,6 +37,7 @@ func (a *InstanceManagerAdapter) ListRunningInstancesForMetrics() ([]InstanceInf Name: info.Name, HypervisorPID: info.HypervisorPID, TAPDevice: info.TAPDevice, + StoragePath: info.StoragePath, AllocatedVcpus: info.AllocatedVcpus, AllocatedMemoryBytes: info.AllocatedMemoryBytes, } diff --git a/lib/vm_metrics/collector_darwin.go b/lib/vm_metrics/collector_darwin.go index 1b2a1405c..eb374b576 100644 --- a/lib/vm_metrics/collector_darwin.go +++ b/lib/vm_metrics/collector_darwin.go @@ -2,19 +2,127 @@ package vm_metrics -import "fmt" +import ( + "context" + "fmt" + "os/exec" + "strconv" + "strings" + "time" +) -// ReadProcStat is not available on macOS (/proc does not exist). +// macOS has no /proc. ps(1) reports a process's cumulative CPU time as +// [[dd-]hh:]mm:ss[.ff] and its resident and virtual sizes in KiB. +const psTimeout = 2 * time.Second + +// ReadProcStat returns cumulative CPU time (user + system) in microseconds. func ReadProcStat(pid int) (uint64, error) { - return 0, fmt.Errorf("read proc stat: not supported on macOS") + cpu, _, _, err := psSample(pid) + if err != nil { + return 0, err + } + return uint64(cpu.Microseconds()), nil } -// ReadProcStatm is not available on macOS (/proc does not exist). +// ReadProcStatm returns resident and virtual memory sizes in bytes. func ReadProcStatm(pid int) (rssBytes, vmsBytes uint64, err error) { - return 0, 0, fmt.Errorf("read proc statm: not supported on macOS") + _, rssKiB, vszKiB, err := psSample(pid) + if err != nil { + return 0, 0, err + } + return rssKiB * 1024, vszKiB * 1024, nil } -// ReadTAPStats is not available on macOS (/sys does not exist). +// ReadTAPStats has no macOS equivalent: guests use NAT, so there is no per-instance +// host interface to count. Callers keep network counters at zero. func ReadTAPStats(tapName string) (rxBytes, txBytes uint64, err error) { - return 0, 0, fmt.Errorf("read TAP stats: not supported on macOS") + return 0, 0, fmt.Errorf("read TAP stats: no per-instance interface on macOS NAT") +} + +func psSample(pid int) (cpu time.Duration, rssKiB, vszKiB uint64, err error) { + ctx, cancel := context.WithTimeout(context.Background(), psTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, "/bin/ps", "-o", "time=,rss=,vsz=", "-p", strconv.Itoa(pid)).Output() + if err != nil { + return 0, 0, 0, fmt.Errorf("sample pid %d with ps: %w", pid, err) + } + return parsePSSample(string(out)) +} + +func parsePSSample(output string) (time.Duration, uint64, uint64, error) { + fields := strings.Fields(output) + if len(fields) != 3 { + return 0, 0, 0, fmt.Errorf("unexpected ps output %q", output) + } + cpu, err := parsePSCPUTime(fields[0]) + if err != nil { + return 0, 0, 0, err + } + rss, err := strconv.ParseUint(fields[1], 10, 64) + if err != nil { + return 0, 0, 0, fmt.Errorf("parse rss: %w", err) + } + vsz, err := strconv.ParseUint(fields[2], 10, 64) + if err != nil { + return 0, 0, 0, fmt.Errorf("parse vsz: %w", err) + } + return cpu, rss, vsz, nil } + +func parsePSCPUTime(value string) (time.Duration, error) { + days := 0.0 + if i := strings.IndexByte(value, '-'); i >= 0 { + d, err := strconv.Atoi(value[:i]) + if err != nil { + return 0, fmt.Errorf("parse cpu days: %w", err) + } + days = float64(d) + value = value[i+1:] + } + parts := strings.Split(value, ":") + if len(parts) < 2 || len(parts) > 3 { + return 0, fmt.Errorf("parse cpu time %q", value) + } + seconds := 0.0 + for _, part := range parts[:len(parts)-1] { + n, err := strconv.ParseFloat(part, 64) + if err != nil { + return 0, fmt.Errorf("parse cpu time %q: %w", value, err) + } + seconds = seconds*60 + n + } + last, err := strconv.ParseFloat(parts[len(parts)-1], 64) + if err != nil { + return 0, fmt.Errorf("parse cpu time %q: %w", value, err) + } + seconds = seconds*60 + last + return time.Duration((days*86400 + seconds) * float64(time.Second)), nil +} + +// relatedVMPIDs returns Virtualization.framework VM processes that hold storagePath +// open. The XPC process is reparented to launchd, so the shim cannot be its parent. +func relatedVMPIDs(storagePath string) []int { + if storagePath == "" { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), psTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, "/usr/sbin/lsof", "-t", storagePath).Output() + if err != nil { + return nil // no holder (VM stopped) or lsof unavailable + } + var pids []int + for _, field := range strings.Fields(string(out)) { + pid, err := strconv.Atoi(field) + if err != nil { + continue + } + command, err := exec.CommandContext(ctx, "/bin/ps", "-o", "command=", "-p", field).Output() + if err == nil && strings.Contains(string(command), vzVirtualMachineProcess) { + pids = append(pids, pid) + } + } + return pids +} + +const vzVirtualMachineProcess = "com.apple.Virtualization.VirtualMachine" diff --git a/lib/vm_metrics/collector_darwin_test.go b/lib/vm_metrics/collector_darwin_test.go new file mode 100644 index 000000000..94f18671b --- /dev/null +++ b/lib/vm_metrics/collector_darwin_test.go @@ -0,0 +1,59 @@ +//go:build darwin + +package vm_metrics + +import ( + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestParsePSCPUTime(t *testing.T) { + for in, want := range map[string]time.Duration{ + "12:47.29": 12*time.Minute + 47*time.Second + 290*time.Millisecond, + "1:02:03.50": time.Hour + 2*time.Minute + 3500*time.Millisecond, + "2-01:00:00.00": 49 * time.Hour, + "0:00.01": 10 * time.Millisecond, + } { + got, err := parsePSCPUTime(in) + require.NoError(t, err, in) + require.InDelta(t, float64(want), float64(got), float64(time.Millisecond), in) + } + for _, bad := range []string{"", "x:01", "1:2:3:4", "-1:00"} { + _, err := parsePSCPUTime(bad) + require.Error(t, err, bad) + } +} + +func TestParsePSSampleRejectsMalformedOutput(t *testing.T) { + _, _, _, err := parsePSSample("0:01.00 100") + require.Error(t, err) + cpu, rss, vsz, err := parsePSSample(" 0:01.50 2048 4096\n") + require.NoError(t, err) + require.Equal(t, 1500*time.Millisecond, cpu) + require.EqualValues(t, 2048, rss) + require.EqualValues(t, 4096, vsz) +} + +// The live sample uses this test process, so it needs no VM and no privileges. +func TestReadProcStatSamplesLiveProcess(t *testing.T) { + usec, err := ReadProcStat(os.Getpid()) + require.NoError(t, err) + require.Greater(t, usec, uint64(0)) + rss, vms, err := ReadProcStatm(os.Getpid()) + require.NoError(t, err) + require.Greater(t, rss, uint64(0)) + require.GreaterOrEqual(t, vms, rss) +} + +func TestRelatedVMPIDsIgnoresOrdinaryHolders(t *testing.T) { + require.Empty(t, relatedVMPIDs("")) + path := t.TempDir() + "/held.raw" + require.NoError(t, os.WriteFile(path, []byte("x"), 0600)) + f, err := os.Open(path) // held by this test process, which is not a VM process + require.NoError(t, err) + defer f.Close() + require.Empty(t, relatedVMPIDs(path)) +} diff --git a/lib/vm_metrics/manager.go b/lib/vm_metrics/manager.go index 8f545c549..7aba6d590 100644 --- a/lib/vm_metrics/manager.go +++ b/lib/vm_metrics/manager.go @@ -147,6 +147,18 @@ func (m *Manager) GetInstanceStats(ctx context.Context, info InstanceInfo) *VMSt } } + // Virtualization.framework runs guest CPU and memory in a separate XPC process, + // not the shim. Add those processes so the totals describe the whole guest. + for _, pid := range relatedVMPIDs(info.StoragePath) { + if cpuUsec, err := ReadProcStat(pid); err == nil { + stats.CPUUsec += cpuUsec + } + if rssBytes, vmsBytes, err := ReadProcStatm(pid); err == nil { + stats.MemoryRSSBytes += rssBytes + stats.MemoryVMSBytes += vmsBytes + } + } + // Read TAP stats if we have a TAP device if info.TAPDevice != "" { rxBytes, txBytes, err := ReadTAPStats(info.TAPDevice) diff --git a/lib/vm_metrics/related_other.go b/lib/vm_metrics/related_other.go new file mode 100644 index 000000000..dea1311bb --- /dev/null +++ b/lib/vm_metrics/related_other.go @@ -0,0 +1,6 @@ +//go:build !darwin + +package vm_metrics + +// relatedVMPIDs has no extra processes to sample outside macOS: the hypervisor PID is the guest. +func relatedVMPIDs(string) []int { return nil } diff --git a/lib/vm_metrics/types.go b/lib/vm_metrics/types.go index 1e4b197d3..2b6a06d59 100644 --- a/lib/vm_metrics/types.go +++ b/lib/vm_metrics/types.go @@ -47,6 +47,8 @@ type InstanceInfo struct { Name string HypervisorPID *int // PID of the hypervisor process (nil if not running) TAPDevice string // Name of the TAP device (e.g., "hype-01234567") + // StoragePath is a file the guest's VM process holds open; see resources.InstanceUtilizationInfo. + StoragePath string // Allocated resources AllocatedVcpus int // Number of allocated vCPUs diff --git a/openapi.yaml b/openapi.yaml index 3621c3e8c..14850b18f 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -557,11 +557,11 @@ components: platform: type: string pattern: '^[a-z0-9]+/[a-z0-9]+(/[a-z0-9.]+)?$' - description: 'Target platform as os/arch[/variant] (e.g. "linux/amd64"), matching Docker --platform. Omit for the host platform. Not a fixed enum: the os/arch[/variant] grammar is validated server-side and invalid values return 400 invalid_platform. Only os "linux" with arch amd64 or arm64 is accepted today.' + description: 'Target platform as os/arch[/variant]. Linux container guests support amd64 and arm64. Experimental darwin/arm64 guests require an offline-imported macOS disk image and vz on Apple silicon. Omit to use the cached native image or host platform.' example: linux/amd64 size: type: string - description: Base memory size (human-readable format like "1GB", "512MB", "2G") + description: Base memory size (human-readable format like "1GB", "512MB", "2G"). macOS guests default to their image's memory; explicit sizes must be at least 4 GiB in whole MiB and within VZ host limits. default: "1GB" example: "2GB" hotplug_size: @@ -570,7 +570,7 @@ components: example: "2GB" overlay_size: type: string - description: Writable overlay disk size (human-readable format like "10GB", "50G") + description: Writable overlay disk size (human-readable format like "10GB", "50G"). Omit this field for macOS guests, which clone the image's complete boot disk instead. default: "10GB" example: "20GB" disk_io_bps: @@ -579,7 +579,7 @@ components: example: "100MB/s" vcpus: type: integer - description: Number of virtual CPUs + description: Number of virtual CPUs. macOS guests default to their image's vCPUs; explicit counts must be at least 2 and within VZ host limits. default: 2 example: 2 env: @@ -1249,7 +1249,7 @@ components: network_rx_bytes: type: integer format: int64 - description: Total network bytes received by the VM (from TAP interface) + description: Total network bytes received by the VM (from TAP interface). Always 0 for macOS guests, which use NAT with no per-instance interface. example: 12345678 network_tx_bytes: type: integer @@ -3078,6 +3078,12 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + 400: + description: Request not supported for this guest type + content: + application/json: + schema: + $ref: "#/components/schemas/Error" 409: description: Conflict - instance not in standby or no snapshot content: @@ -3702,6 +3708,12 @@ paths: text/event-stream: schema: type: string + 501: + description: Not implemented - this log source does not exist for this guest type + content: + application/json: + schema: + $ref: "#/components/schemas/Error" 404: description: Instance not found content: @@ -3765,6 +3777,12 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + 501: + description: Not implemented for this guest type + content: + application/json: + schema: + $ref: "#/components/schemas/Error" 500: description: Internal server error content: @@ -4514,6 +4532,12 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + 501: + description: Not implemented - unsupported in macOS-only mode + content: + application/json: + schema: + $ref: "#/components/schemas/Error" 409: description: Conflict - ingress with this name already exists or hostname in use content: @@ -4732,6 +4756,12 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + 501: + description: Not implemented - unsupported in macOS-only mode + content: + application/json: + schema: + $ref: "#/components/schemas/Error" 409: description: Builder cannot currently accept a build content: