diff --git a/cmd/api/api/builds.go b/cmd/api/api/builds.go index 110422638..cff961ca2 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.CreateBuild400JSONResponse{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..3371e8ade 100644 --- a/cmd/api/api/ingress.go +++ b/cmd/api/api/ingress.go @@ -36,6 +36,9 @@ 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.CreateIngress400JSONResponse{Code: "unsupported", Message: "ingress is not implemented in macOS-only mode"}, 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..06008e2b3 100644 --- a/cmd/api/api/instances.go +++ b/cmd/api/api/instances.go @@ -119,7 +119,7 @@ func (s *ApiService) CreateInstance(ctx context.Context, request oapi.CreateInst diskIOBps = int64(ioBpsBytes) } - vcpus := 2 + var vcpus int if request.Body.Vcpus != nil { vcpus = *request.Body.Vcpus } @@ -273,21 +273,6 @@ func (s *ApiService) CreateInstance(ctx context.Context, request oapi.CreateInst } } - // Calculate default resource limits when not specified (0 = auto) - // Uses proportional allocation based on CPU: (vcpus / cpuCapacity) * resourceCapacity - if diskIOBps == 0 { - diskIOBps, _ = s.ResourceManager.DefaultDiskIOBandwidth(vcpus) - } - if networkBandwidthDownload == 0 || networkBandwidthUpload == 0 { - defaultDown, defaultUp := s.ResourceManager.DefaultNetworkBandwidth(vcpus) - if networkBandwidthDownload == 0 { - networkBandwidthDownload = defaultDown - } - if networkBandwidthUpload == 0 { - networkBandwidthUpload = defaultUp - } - } - // Parse command overrides (like docker run --entrypoint / docker run ) var entrypoint []string if request.Body.Entrypoint != nil { @@ -672,6 +657,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", @@ -963,6 +953,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_test.go b/cmd/api/api/macos_test.go new file mode 100644 index 000000000..a7de011ac --- /dev/null +++ b/cmd/api/api/macos_test.go @@ -0,0 +1,64 @@ +package api + +import ( + "context" + "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.CreateBuild400JSONResponse{}, build) + ingress, err := s.CreateIngress(context.Background(), oapi.CreateIngressRequestObject{}) + require.NoError(t, err) + require.IsType(t, oapi.CreateIngress400JSONResponse{}, 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 TestMacOSSchemaDefersTemplateDefaults(t *testing.T) { + spec, err := oapi.GetSwagger() + require.NoError(t, err) + for _, name := range []string{"size", "vcpus", "overlay_size"} { + require.Nil(t, spec.Components.Schemas["CreateInstanceRequest"].Value.Properties[name].Value.Default, name) + } + m := newCaptureCreateManager(nil) + s := &ApiService{InstanceManager: m, Config: &config.Config{}} + platform := "darwin/arm64" + _, err = s.CreateInstance(context.Background(), oapi.CreateInstanceRequestObject{Body: &oapi.CreateInstanceRequest{Name: "macos", Image: "localhost/macos:spike", Platform: &platform}}) + 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) +} 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..72bf7f87c 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,9 +406,13 @@ 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") } logger.Info("Ingress manager initialized", "listen_addr", cfg.Caddy.ListenAddress, "admin", app.IngressManager.AdminURL()) @@ -572,7 +580,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 +624,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..5a765d9f6 --- /dev/null +++ b/cmd/vz-shim/macos_identity.go @@ -0,0 +1,38 @@ +//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)) + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return nil, err + } + 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/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..06856054e 100644 --- a/cmd/vz-shim/vm.go +++ b/cmd/vz-shim/vm.go @@ -25,11 +25,14 @@ func createVM(config *shimconfig.ShimConfig) (*vz.VirtualMachine, *vz.VirtualMac kernelArgs = strings.ReplaceAll(kernelArgs, "console=ttyS0", "console=hvc0") } - bootLoader, err := vz.NewLinuxBootLoader( - config.KernelPath, - vz.WithCommandLine(kernelArgs), - vz.WithInitrd(config.InitrdPath), - ) + macGuest := config.MacHardwareModelData != "" || config.MacMachineIdentifierData != "" || config.MacAuxStoragePath != "" + var bootLoader vz.BootLoader + var err error + if macGuest { + bootLoader, err = newMacBootLoader(config) + } else { + bootLoader, err = vz.NewLinuxBootLoader(config.KernelPath, vz.WithCommandLine(kernelArgs), vz.WithInitrd(config.InitrdPath)) + } if err != nil { return nil, nil, fmt.Errorf("create boot loader: %w", err) } @@ -44,19 +47,23 @@ func createVM(config *shimconfig.ShimConfig) (*vz.VirtualMachine, *vz.VirtualMac return nil, nil, fmt.Errorf("create vm configuration: %w", err) } - if err := configureSerialConsole(vmConfig, config.SerialLogPath); err != nil { - return nil, nil, fmt.Errorf("configure serial: %w", err) + if !macGuest { + if err := configureSerialConsole(vmConfig, config.SerialLogPath); err != nil { + return nil, nil, fmt.Errorf("configure serial: %w", err) + } } if err := configureNetwork(vmConfig, config.Networks); err != nil { return nil, nil, fmt.Errorf("configure network: %w", err) } - entropyConfig, err := vz.NewVirtioEntropyDeviceConfiguration() - if err != nil { - return nil, nil, fmt.Errorf("create entropy device: %w", err) + if !macGuest { + entropyConfig, err := vz.NewVirtioEntropyDeviceConfiguration() + if err != nil { + return nil, nil, fmt.Errorf("create entropy device: %w", err) + } + vmConfig.SetEntropyDevicesVirtualMachineConfiguration([]*vz.VirtioEntropyDeviceConfiguration{entropyConfig}) } - vmConfig.SetEntropyDevicesVirtualMachineConfiguration([]*vz.VirtioEntropyDeviceConfiguration{entropyConfig}) if err := configureStorage(vmConfig, config.Disks); err != nil { return nil, nil, fmt.Errorf("configure storage: %w", err) @@ -66,7 +73,14 @@ func createVM(config *shimconfig.ShimConfig) (*vz.VirtualMachine, *vz.VirtualMac return nil, nil, fmt.Errorf("configure directory sharing: %w", err) } - if err := configurePlatform(vmConfig, config); err != nil { + if macGuest { + if config.EnableRosetta || config.EnableMemoryBalloon || config.RequireMemoryBalloon { + return nil, nil, fmt.Errorf("macOS spike does not support Linux Rosetta or memory ballooning") + } + if err := configureMacPlatform(vmConfig, config); err != nil { + return nil, nil, fmt.Errorf("configure Mac platform: %w", err) + } + } else if err := configurePlatform(vmConfig, config); err != nil { return nil, nil, fmt.Errorf("configure platform: %w", err) } diff --git a/docs/macos-experimental.md b/docs/macos-experimental.md new file mode 100644 index 000000000..57e9f396d --- /dev/null +++ b/docs/macos-experimental.md @@ -0,0 +1,77 @@ +# 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 `spike/macvm` 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 instance operations reject requests: snapshot/fork/standby/restore, +updates, volumes, env/commands/credential brokering, Linux guest-agent exec and +vsock operations, health/restart/auto-standby policies, passthrough and shaping. +The shim's standalone save/restore proof does not make API snapshot semantics +safe; consistent disk+aux+state bundle handling remains separate work. + +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/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..e86be5516 --- /dev/null +++ b/lib/hypervisor/vz/macos_config_test.go @@ -0,0 +1,30 @@ +//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, 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) + 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..e81b3e679 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" @@ -106,7 +107,19 @@ 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") + } + return nil } func (s *Starter) SocketName() string { @@ -165,7 +178,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 +210,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/boot_prepare.go b/lib/instances/boot_prepare.go new file mode 100644 index 000000000..ee1a47528 --- /dev/null +++ b/lib/instances/boot_prepare.go @@ -0,0 +1,69 @@ +package instances + +import ( + "context" + "fmt" + + "github.com/kernel/hypeman/lib/egressproxy" + "github.com/kernel/hypeman/lib/images" + "github.com/kernel/hypeman/lib/network" + "go.opentelemetry.io/otel/attribute" +) + +// prepareBootNetwork owns network allocation and its rollback for create/start. +// VZ machine networking preserves the template MAC and never allocates a Linux TAP. +func (m *manager) prepareBootNetwork(ctx context.Context, stored *StoredMetadata, req network.AllocateRequest) (*network.NetworkConfig, func(), error) { + if !stored.NetworkEnabled { + return nil, nil, nil + } + if stored.MacOS != nil { + stored.MAC = stored.MacOS.MAC + stored.IP = "" // vmnet addressing is observed after boot, never a static assignment. + return &network.NetworkConfig{MAC: stored.MAC}, nil, nil + } + networkCtx, end := m.startLifecycleStep(ctx, "allocate_network", + attribute.String("instance_id", stored.Id), attribute.String("hypervisor", string(stored.HypervisorType)), attribute.String("operation", "allocate_network"), attribute.Bool("network_enabled", true)) + config, err := m.networkManager.CreateAllocation(networkCtx, req) + end(err) + if err != nil { + return nil, nil, fmt.Errorf("allocate network: %w", err) + } + stored.IP, stored.MAC = config.IP, config.MAC + release := func() { + allocation, err := m.networkManager.GetAllocation(ctx, stored.Id) + if err == nil && allocation != nil { + m.networkManager.ReleaseAllocation(ctx, allocation) + } else { + m.networkManager.ReleaseByInstanceID(ctx, stored.Id) + } + } + return config, release, nil +} + +// prepareBootConfig owns Linux proxy/config artifacts; imported machines have +// neither. The caller adds the returned proxy rollback to its cleanup stack. +func (m *manager) prepareBootConfig(ctx context.Context, stored *StoredMetadata, image *images.Image, config *network.NetworkConfig) (func(), error) { + if stored.MacOS != nil { + return nil, nil + } + var proxy *egressproxy.GuestConfig + var err error + proxy, err = m.maybeRegisterEgressProxy(ctx, stored, config) + if err != nil { + return nil, fmt.Errorf("configure egress proxy: %w", err) + } + var release func() + if proxy != nil { + release = func() { m.unregisterEgressProxyInstance(ctx, stored.Id) } + } + diskCtx, end := m.startLifecycleStep(ctx, "create_config_disk", attribute.String("instance_id", stored.Id), attribute.String("hypervisor", string(stored.HypervisorType)), attribute.String("operation", "create_config_disk")) + err = m.createConfigDisk(diskCtx, &Instance{StoredMetadata: *stored}, image, config, proxy) + end(err) + if err != nil { + if release != nil { + release() + } + return nil, fmt.Errorf("create config disk: %w", err) + } + return release, nil +} diff --git a/lib/instances/boot_prepare_test.go b/lib/instances/boot_prepare_test.go new file mode 100644 index 000000000..04a13dc54 --- /dev/null +++ b/lib/instances/boot_prepare_test.go @@ -0,0 +1,86 @@ +package instances + +import ( + "context" + "errors" + "testing" + + "github.com/kernel/hypeman/lib/network" + "github.com/stretchr/testify/require" +) + +type bootNetworkFixture struct { + network.Manager + requested network.AllocateRequest + config *network.NetworkConfig + createErr error + lookupErr error + released bool + fallback bool +} + +func (f *bootNetworkFixture) CreateAllocation(_ context.Context, req network.AllocateRequest) (*network.NetworkConfig, error) { + f.requested = req + return f.config, f.createErr +} +func (f *bootNetworkFixture) GetAllocation(context.Context, string) (*network.Allocation, error) { + return &network.Allocation{InstanceID: f.requested.InstanceID, TAPDevice: f.config.TAPDevice}, f.lookupErr +} +func (f *bootNetworkFixture) ReleaseAllocation(_ context.Context, a *network.Allocation) error { + f.released = a.InstanceID == f.requested.InstanceID && a.TAPDevice == f.config.TAPDevice + return nil +} +func (f *bootNetworkFixture) ReleaseByInstanceID(_ context.Context, id string) error { + f.fallback = id == f.requested.InstanceID + return nil +} + +func TestPrepareBootMachineSkipsLinuxDependencies(t *testing.T) { + for _, enabled := range []bool{false, true} { + m := &manager{} + stored := &StoredMetadata{Id: "machine", MacOS: testMacImage().MacOS, NetworkEnabled: enabled, IP: "old-lease"} + config, release, err := m.prepareBootNetwork(context.Background(), stored, network.AllocateRequest{}) + require.NoError(t, err) + require.Nil(t, release) + if enabled { + require.Equal(t, stored.MacOS.MAC, config.MAC) + require.Empty(t, stored.IP) + } else { + require.Nil(t, config) + } + release, err = m.prepareBootConfig(context.Background(), stored, nil, config) + require.NoError(t, err) + require.Nil(t, release) + } +} +func TestPrepareBootNetworkPreservesRequestAndRollback(t *testing.T) { + for _, lookupFails := range []bool{false, true} { + f := &bootNetworkFixture{config: &network.NetworkConfig{IP: "192.0.2.1", MAC: "02:00:00:00:00:01", TAPDevice: "synthetic"}} + if lookupFails { + f.lookupErr = errors.New("lookup failed") + } + m := &manager{networkManager: f} + stored := &StoredMetadata{Id: "linux", NetworkEnabled: true} + request := network.AllocateRequest{InstanceID: stored.Id, InstanceName: "test", DownloadBps: 100, UploadBps: 200, UploadCeilBps: 400} + config, release, err := m.prepareBootNetwork(context.Background(), stored, request) + require.NoError(t, err) + require.Equal(t, request, f.requested) + require.Same(t, f.config, config) + require.Equal(t, config.IP, stored.IP) + require.Equal(t, config.MAC, stored.MAC) + release() + require.Equal(t, !lookupFails, f.released) + require.Equal(t, lookupFails, f.fallback) + } +} +func TestPrepareBootNetworkFailureDoesNotMutateMetadata(t *testing.T) { + cause := errors.New("allocation failed") + f := &bootNetworkFixture{createErr: cause} + m := &manager{networkManager: f} + stored := &StoredMetadata{Id: "linux", NetworkEnabled: true, IP: "before", MAC: "before"} + _, release, err := m.prepareBootNetwork(context.Background(), stored, network.AllocateRequest{InstanceID: stored.Id}) + require.ErrorIs(t, err, cause) + require.Nil(t, release) + require.Equal(t, "before", stored.IP) + require.Equal(t, "before", stored.MAC) +} diff --git a/lib/instances/configdisk.go b/lib/instances/configdisk.go index 6b1d74f0b..622abaed5 100644 --- a/lib/instances/configdisk.go +++ b/lib/instances/configdisk.go @@ -16,7 +16,8 @@ import ( ) // createConfigDisk generates an ext4 disk with instance configuration. -// The disk contains /config.json read by the guest init binary. +// The disk contains /config.json read by the guest init binary. Linux guests +// only: macOS boots the imported disk and callers skip this step for it. func (m *manager) createConfigDisk(ctx context.Context, inst *Instance, imageInfo *images.Image, netConfig *network.NetworkConfig, proxyCfg *egressproxy.GuestConfig) error { // Create temporary directory for config files tmpDir, err := os.MkdirTemp("", "hypeman-config-*") diff --git a/lib/instances/create.go b/lib/instances/create.go index 504b58e65..51fdbb4aa 100644 --- a/lib/instances/create.go +++ b/lib/instances/create.go @@ -10,7 +10,6 @@ import ( "time" "github.com/kernel/hypeman/lib/devices" - "github.com/kernel/hypeman/lib/egressproxy" "github.com/kernel/hypeman/lib/guestmemory" "github.com/kernel/hypeman/lib/hypervisor" "github.com/kernel/hypeman/lib/images" @@ -76,6 +75,26 @@ func generateVsockCID(instanceID string) int64 { return (sum % 4294967292) + 3 } +// applyLinuxShapingDefaults fills unspecified disk and network limits with the +// proportional defaults for vcpus. Without a resource validator they stay at auto. +func (m *manager) applyLinuxShapingDefaults(req *CreateInstanceRequest, vcpus int) { + if m.resourceValidator == nil { + return + } + if req.DiskIOBps == 0 { + req.DiskIOBps, _ = m.resourceValidator.DefaultDiskIOBandwidth(vcpus) + } + if req.NetworkBandwidthDownload == 0 || req.NetworkBandwidthUpload == 0 { + defaultDown, defaultUp := m.resourceValidator.DefaultNetworkBandwidth(vcpus) + if req.NetworkBandwidthDownload == 0 { + req.NetworkBandwidthDownload = defaultDown + } + if req.NetworkBandwidthUpload == 0 { + req.NetworkBandwidthUpload = defaultUp + } + } +} + // createInstance creates and starts a new instance // Multi-hop orchestration: Stopped → Created → Running func (m *manager) createInstance( @@ -102,6 +121,15 @@ func (m *manager) createInstance( if req.GPU != nil && req.GPU.Profile != "" && !devices.Capabilities().SupportsVGPU { return nil, fmt.Errorf("%w: %w", ErrInvalidRequest, devices.ErrVGPUNotSupportedOnMacOS) } + if m.macOSOnly { + cached, err := m.imageManager.GetImage(ctx, req.Image) + if err != nil && !errors.Is(err, images.ErrNotFound) { + return nil, err + } + if err != nil || cached.MacOS == nil { + return nil, fmt.Errorf("%w: macOS-only server requires an already imported macOS image", ErrInvalidRequest) + } + } hvType := req.Hypervisor if hvType == "" { hvType = m.defaultHypervisor @@ -132,6 +160,16 @@ func (m *manager) createInstance( return nil, fmt.Errorf("%w: image status is %s", ErrImageNotReady, imageInfo.Status) } + if m.macOSOnly && imageInfo.MacOS == nil { + return nil, fmt.Errorf("%w: server is configured for macOS guests only", ErrInvalidRequest) + } + if imageInfo.MacOS != nil { + caps, _ := hypervisor.CapabilitiesForType(hvType) + if err := prepareMacOSCreate(&req, imageInfo, caps); 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 // other host we reject the create up front rather than launching an @@ -207,14 +245,20 @@ 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. The macOS boot disk reuses the overlay field + // for accounting, but its size comes from the imported image, not the Linux limit. + 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 if vcpus == 0 { vcpus = 2 } + // Linux defaults come from the resolved image, not a separate lookup at the API. + // macOS guests take their CPU and memory from the image and reject shaping. + if imageInfo.MacOS == nil { + m.applyLinuxShapingDefaults(&req, vcpus) + } // Validate per-instance resource limits if m.limits.MaxVcpusPerInstance > 0 && vcpus > m.limits.MaxVcpusPerInstance { @@ -250,12 +294,6 @@ func (m *manager) createInstance( req.Tags = make(map[string]string) } - // 7. Determine network based on NetworkEnabled flag - networkName := "" - if req.NetworkEnabled { - networkName = "default" - } - // Enrich logger and trace span with hypervisor type log = log.With("hypervisor", string(hvType)) ctx = logger.AddToContext(ctx, log) @@ -338,6 +376,7 @@ func (m *manager) createInstance( Image: req.Image, ResolvedImage: resolvedImageRef, Platform: imageInfo.Platform, + MacOS: imageInfo.MacOS, Size: size, HotplugSize: hotplugSize, OverlaySize: overlaySize, @@ -412,51 +451,24 @@ 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.prepareBootStorage(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) } // 14. Allocate network (if network enabled) - var netConfig *network.NetworkConfig - if networkName != "" { - log.DebugContext(ctx, "allocating network", "instance_id", id, "network", networkName, - "download_bps", stored.NetworkBandwidthDownload, "upload_bps", stored.NetworkBandwidthUpload) - networkCtx, networkSpanEnd := m.startLifecycleStep(ctx, "allocate_network", - attribute.String("instance_id", id), - attribute.String("hypervisor", string(stored.HypervisorType)), - attribute.String("operation", "allocate_network"), - attribute.Bool("network_enabled", true), - ) - netConfig, err = m.networkManager.CreateAllocation(networkCtx, network.AllocateRequest{ - InstanceID: id, - InstanceName: req.Name, - DownloadBps: stored.NetworkBandwidthDownload, - UploadBps: stored.NetworkBandwidthUpload, - UploadCeilBps: stored.NetworkBandwidthUpload * int64(m.networkManager.GetUploadBurstMultiplier()), - }) - networkSpanEnd(err) - if err != nil { - log.ErrorContext(ctx, "failed to allocate network", "instance_id", id, "network", networkName, "error", err) - return nil, fmt.Errorf("allocate network: %w", err) - } - // Store IP/MAC in metadata (persisted with instance) - stored.IP = netConfig.IP - stored.MAC = netConfig.MAC - // Add network cleanup to stack - cu.Add(func() { - // Network cleanup: TAP devices are removed when ReleaseAllocation is called. - // In case of unexpected scenarios (like power loss), TAP devices persist until host reboot. - // CreateAllocation just succeeded so the TAP exists on the host. If - // GetAllocation can't derive a full allocation here, fall back to ID-based - // release rather than silently leaking the TAP. - netAlloc, err := m.networkManager.GetAllocation(ctx, id) - if err == nil && netAlloc != nil { - m.networkManager.ReleaseAllocation(ctx, netAlloc) - return - } - m.networkManager.ReleaseByInstanceID(ctx, id) - }) + networkRequest := network.AllocateRequest{InstanceID: id, InstanceName: req.Name} + if stored.NetworkEnabled && stored.MacOS == nil { + networkRequest.DownloadBps = stored.NetworkBandwidthDownload + networkRequest.UploadBps = stored.NetworkBandwidthUpload + networkRequest.UploadCeilBps = stored.NetworkBandwidthUpload * int64(m.networkManager.GetUploadBurstMultiplier()) + } + netConfig, releaseNetwork, err := m.prepareBootNetwork(ctx, stored, networkRequest) + if err != nil { + return nil, err + } + if releaseNetwork != nil { + cu.Add(releaseNetwork) } // 15. Validate and attach volumes @@ -499,31 +511,14 @@ func (m *manager) createInstance( stored.Volumes = req.Volumes } - // 16. Create config disk (needs Instance for buildVMConfig) - inst := &Instance{StoredMetadata: *stored} - var proxyGuestConfig *egressproxy.GuestConfig - proxyGuestConfig, err = m.maybeRegisterEgressProxy(ctx, stored, netConfig) + // 16. Prepare guest-specific boot configuration. + releaseConfig, err := m.prepareBootConfig(ctx, stored, imageInfo, netConfig) if err != nil { - log.ErrorContext(ctx, "failed to configure egress proxy", "instance_id", id, "error", err) - return nil, fmt.Errorf("configure egress proxy: %w", err) - } - if proxyGuestConfig != nil { - cu.Add(func() { - m.unregisterEgressProxyInstance(ctx, id) - }) + return nil, err } - log.DebugContext(ctx, "creating config disk", "instance_id", id) - configDiskCtx, configDiskSpanEnd := m.startLifecycleStep(ctx, "create_config_disk", - attribute.String("instance_id", id), - attribute.String("hypervisor", string(stored.HypervisorType)), - attribute.String("operation", "create_config_disk"), - ) - if err := m.createConfigDisk(configDiskCtx, inst, imageInfo, netConfig, proxyGuestConfig); err != nil { - configDiskSpanEnd(err) - log.ErrorContext(ctx, "failed to create config disk", "instance_id", id, "error", err) - return nil, fmt.Errorf("create config disk: %w", err) + if releaseConfig != nil { + cu.Add(releaseConfig) } - configDiskSpanEnd(nil) // 17. Record boot start time before launching the VM so marker hydration // can safely ignore stale sentinels from prior runs. @@ -569,7 +564,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 +792,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 +871,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 +1005,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_defaults_test.go b/lib/instances/create_defaults_test.go new file mode 100644 index 000000000..5743c41ce --- /dev/null +++ b/lib/instances/create_defaults_test.go @@ -0,0 +1,45 @@ +package instances + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +type proportionalShapingValidator struct{ recordingResourceValidator } + +func (*proportionalShapingValidator) DefaultNetworkBandwidth(vcpus int) (int64, int64) { + return int64(vcpus) * 10, int64(vcpus) * 20 +} + +func (*proportionalShapingValidator) DefaultDiskIOBandwidth(vcpus int) (int64, int64) { + return int64(vcpus) * 100, 0 +} + +func TestApplyLinuxShapingDefaultsFillsOnlyUnspecifiedLimits(t *testing.T) { + t.Parallel() + m := &manager{resourceValidator: &proportionalShapingValidator{}} + + req := CreateInstanceRequest{NetworkBandwidthUpload: 7} + m.applyLinuxShapingDefaults(&req, 2) + require.Equal(t, int64(200), req.DiskIOBps) + require.Equal(t, int64(20), req.NetworkBandwidthDownload) + require.Equal(t, int64(7), req.NetworkBandwidthUpload) + + req = CreateInstanceRequest{DiskIOBps: 5} + m.applyLinuxShapingDefaults(&req, 4) + require.Equal(t, int64(5), req.DiskIOBps) + require.Equal(t, int64(40), req.NetworkBandwidthDownload) + require.Equal(t, int64(80), req.NetworkBandwidthUpload) +} + +func TestApplyLinuxShapingDefaultsWithoutValidatorLeavesAuto(t *testing.T) { + t.Parallel() + m := &manager{} + + req := CreateInstanceRequest{} + m.applyLinuxShapingDefaults(&req, 2) + require.Zero(t, req.DiskIOBps) + require.Zero(t, req.NetworkBandwidthDownload) + require.Zero(t, req.NetworkBandwidthUpload) +} 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/create_mdev_test.go b/lib/instances/create_mdev_test.go index 05b3e9d8c..b97dfec36 100644 --- a/lib/instances/create_mdev_test.go +++ b/lib/instances/create_mdev_test.go @@ -24,6 +24,10 @@ func (v *recordingResourceValidator) ReserveAllocation(context.Context, string, func (v *recordingResourceValidator) FinishAllocation(string) {} +func (v *recordingResourceValidator) DefaultNetworkBandwidth(int) (int64, int64) { return 0, 0 } + +func (v *recordingResourceValidator) DefaultDiskIOBandwidth(int) (int64, int64) { return 0, 0 } + func TestCreateInstanceRejectsUnsupportedVGPUBeforeResourceReservation(t *testing.T) { t.Parallel() 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..a4ff597c8 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 := meta.rejectMacOS("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/macos.go b/lib/instances/macos.go new file mode 100644 index 000000000..c02eb32d0 --- /dev/null +++ b/lib/instances/macos.go @@ -0,0 +1,153 @@ +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" +) + +// 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.RestartPolicy != 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, policies and I/O shaping are unsupported", ErrInvalidRequest) + } + 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 { + return fmt.Errorf("%w: macOS requires at least 4 GiB 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 (m *manager) prepareBootStorage(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, "mac-aux.img")) +} +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, "mac-aux.img")}, + 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 +} + +// rejectMacOS refuses operations the experimental macOS guest does not implement. +// Callers check it right after loading the record they already hold under the lock. +func (s *StoredMetadata) rejectMacOS(operation string) error { + if s.MacOS != nil { + 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) +} +func macOSLeaseIP(data string, wanted net.HardwareAddr) string { + 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 { + return ip + } + } + return "" +} 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..6d9c51be6 --- /dev/null +++ b/lib/instances/macos_darwin_test.go @@ -0,0 +1,81 @@ +//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)) +} 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..ba44e5b0b --- /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}, {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, stored.rejectMacOS("snapshot"), ErrInvalidRequest) +} diff --git a/lib/instances/manager.go b/lib/instances/manager.go index 2a9bb6dfb..1316b04cd 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 @@ -161,6 +162,10 @@ type ResourceValidator interface { ReserveAllocation(ctx context.Context, instanceID string, vcpus int, memoryBytes int64, networkDownloadBps int64, networkUploadBps int64, diskIOBps int64, diskBytes int64, needsGPU bool) error // FinishAllocation removes any pending reservation for the given instance ID. FinishAllocation(instanceID string) + // DefaultNetworkBandwidth and DefaultDiskIOBandwidth return the proportional + // limits applied to unspecified shaping for a guest with vcpus. + DefaultNetworkBandwidth(vcpus int) (downloadBps, uploadBps int64) + DefaultDiskIOBandwidth(vcpus int) (ioBps, burstBps int64) } type manager struct { @@ -174,6 +179,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 +292,7 @@ func NewManagerWithConfigE(p *paths.Paths, imageManager images.Manager, systemMa m := &manager{ paths: p, + macOSOnly: managerConfig.MacOSOnly, imageManager: imageManager, systemManager: systemManager, networkManager: networkManager, 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..685f11093 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 := meta.rejectMacOS("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..5623cfb33 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 := meta.rejectMacOS("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 := sourceMeta.rejectMacOS("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) diff --git a/lib/instances/standby.go b/lib/instances/standby.go index 5c6e0173e..6d3da1536 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 := meta.rejectMacOS("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..b103615ac 100644 --- a/lib/instances/start.go +++ b/lib/instances/start.go @@ -5,8 +5,7 @@ import ( "fmt" "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 +41,13 @@ 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 m.macOSOnly && stored.MacOS == nil { + return nil, fmt.Errorf("%w: server is configured for macOS guests only", ErrInvalidRequest) + } + if stored.MacOS != nil && (len(req.Entrypoint) != 0 || len(req.Cmd) != 0) { + return nil, fmt.Errorf("%w: macOS start does not support command overrides", ErrInvalidRequest) + } + // 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 +100,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 disk and aux storage cloned at create time, with its identity + // in metadata, so it does not depend on the template 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 @@ -113,48 +124,12 @@ func (m *manager) startInstance( defer cu.Clean() // 4. Allocate fresh network if network enabled - var netConfig *network.NetworkConfig - 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), - attribute.String("hypervisor", string(stored.HypervisorType)), - attribute.String("operation", "allocate_network"), - attribute.Bool("network_enabled", true), - ) - netConfig, err = m.networkManager.CreateAllocation(networkCtx, network.AllocateRequest{ - InstanceID: id, - InstanceName: stored.Name, - }) - networkSpanEnd(err) - if err != nil { - log.ErrorContext(ctx, "failed to allocate network", "instance_id", id, "error", err) - return nil, fmt.Errorf("allocate network: %w", err) - } - // Update stored metadata with new IP/MAC - stored.IP = netConfig.IP - stored.MAC = netConfig.MAC - // Add network cleanup to stack - cu.Add(func() { - m.networkManager.ReleaseAllocation(ctx, &network.Allocation{ - InstanceID: id, - TAPDevice: netConfig.TAPDevice, - }) - }) + netConfig, releaseNetwork, err := m.prepareBootNetwork(ctx, stored, network.AllocateRequest{InstanceID: id, InstanceName: stored.Name}) + if err != nil { + return nil, err } - - var proxyGuestConfig *egressproxy.GuestConfig - if stored.NetworkEnabled { - proxyGuestConfig, err = m.maybeRegisterEgressProxy(ctx, stored, netConfig) - if err != nil { - log.ErrorContext(ctx, "failed to configure egress proxy", "instance_id", id, "error", err) - return nil, fmt.Errorf("configure egress proxy: %w", err) - } - if proxyGuestConfig != nil { - cu.Add(func() { - m.unregisterEgressProxyInstance(ctx, id) - }) - } + if releaseNetwork != nil { + cu.Add(releaseNetwork) } // 4b. Recreate the vGPU if this instance had a GPU profile @@ -181,20 +156,14 @@ func (m *manager) startInstance( } } - // 5. Regenerate config disk with new network configuration - instForConfig := &Instance{StoredMetadata: *stored} - log.DebugContext(ctx, "regenerating config disk", "instance_id", id) - configDiskCtx, configDiskSpanEnd := m.startLifecycleStep(ctx, "create_config_disk", - attribute.String("instance_id", id), - attribute.String("hypervisor", string(stored.HypervisorType)), - attribute.String("operation", "create_config_disk"), - ) - if err := m.createConfigDisk(configDiskCtx, instForConfig, imageInfo, netConfig, proxyGuestConfig); err != nil { - configDiskSpanEnd(err) - log.ErrorContext(ctx, "failed to create config disk", "instance_id", id, "error", err) - return nil, fmt.Errorf("create config disk: %w", err) + // 5. Regenerate guest-specific boot configuration. + releaseConfig, err := m.prepareBootConfig(ctx, stored, imageInfo, netConfig) + if err != nil { + return nil, err + } + if releaseConfig != nil { + cu.Add(releaseConfig) } - configDiskSpanEnd(nil) if err := m.archiveAppLogForBoot(id); err != nil { log.WarnContext(ctx, "failed to archive app log before start", "instance_id", id, "error", err) @@ -231,7 +200,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..9f39ec443 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 // 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..8431f7cfc 100644 --- a/lib/instances/update.go +++ b/lib/instances/update.go @@ -27,6 +27,9 @@ 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 := meta.rejectMacOS("update"); err != nil { + return nil, err + } inst, err := m.getInstance(ctx, id) if err != nil { 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..908752181 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 Linux overlay disk size (human-readable format like "10GB", "50G"), default 10GB. Omit for macOS, which clones the complete boot disk. 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"). Defaults to 1GB for Linux or the imported template memory for macOS. 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. Defaults to 2 for Linux or the imported template CPU count for macOS. Vcpus *int `json:"vcpus,omitempty"` // Volumes Volumes to attach to the instance at creation time @@ -7318,6 +7318,7 @@ type RestoreInstanceResponse struct { Body []byte HTTPResponse *http.Response JSON200 *Instance + JSON400 *Error JSON404 *Error JSON409 *Error JSON499 *Error @@ -7524,6 +7525,7 @@ type StatInstancePathResponse struct { JSON404 *Error JSON409 *Error JSON500 *Error + JSON501 *Error } // Status returns HTTPResponse.Status @@ -10317,6 +10319,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 +10744,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 @@ -16039,6 +16055,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 +16485,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 +19275,395 @@ 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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", + "0GD7AQA=", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/lib/providers/providers.go b/lib/providers/providers.go index 8f3e7bbfe..d8aa41d4e 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), diff --git a/openapi.yaml b/openapi.yaml index 3621c3e8c..b23af2c15 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -557,12 +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") - default: "1GB" + description: Base memory size (human-readable format like "1GB", "512MB", "2G"). Defaults to 1GB for Linux or the imported template memory for macOS. example: "2GB" hotplug_size: type: string @@ -570,8 +569,7 @@ components: example: "2GB" overlay_size: type: string - description: Writable overlay disk size (human-readable format like "10GB", "50G") - default: "10GB" + description: Writable Linux overlay disk size (human-readable format like "10GB", "50G"), default 10GB. Omit for macOS, which clones the complete boot disk. example: "20GB" disk_io_bps: type: string @@ -579,8 +577,7 @@ components: example: "100MB/s" vcpus: type: integer - description: Number of virtual CPUs - default: 2 + description: Number of virtual CPUs. Defaults to 2 for Linux or the imported template CPU count for macOS. example: 2 env: type: object @@ -3078,6 +3075,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: @@ -3765,6 +3768,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: diff --git a/spike/macvm/main.go b/spike/macvm/main.go new file mode 100644 index 000000000..6a0678c60 --- /dev/null +++ b/spike/macvm/main.go @@ -0,0 +1,531 @@ +//go:build darwin && arm64 + +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net" + "net/http" + "os" + "os/signal" + "path/filepath" + "runtime" + "strings" + "syscall" + "time" + + "github.com/Code-Hex/vz/v3" + "golang.org/x/sys/unix" +) + +func init() { runtime.LockOSThread() } + +type config struct { + HardwareModel []byte `json:"hardware_model"` + MachineIdentifier []byte `json:"machine_identifier"` + MAC string `json:"mac"` + CPUs uint `json:"cpus"` + Memory uint64 `json:"memory"` +} + +func main() { + if err := execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} +func readConfig(dir string) (config, error) { + var c config + b, err := os.ReadFile(filepath.Join(dir, "config.json")) + if err != nil { + return c, err + } + err = json.Unmarshal(b, &c) + return c, err +} +func writeConfig(dir string, c config) error { + b, e := json.MarshalIndent(c, "", " ") + if e != nil { + return e + } + return os.WriteFile(filepath.Join(dir, "config.json"), b, 0600) +} +func execute() error { + if len(os.Args) < 2 { + return fmt.Errorf("commands: fetch-ipsw, install, run, ctl, validate, clone") + } + cmd := os.Args[1] + fs := flag.NewFlagSet(cmd, flag.ContinueOnError) + dir := fs.String("dir", "", "VM directory") + out := fs.String("out", "", "output IPSW") + ipsw := fs.String("ipsw", "", "IPSW path") + diskGB := fs.Int64("disk-gb", 64, "disk GiB") + cpus := fs.Uint("cpus", 0, "CPU count") + mem := fs.Uint64("mem-gb", 0, "RAM GiB") + gui := fs.Bool("gui", false, "open GUI") + profile := fs.String("profile", "suspendable", "suspendable or full") + devices := fs.String("devices", "", "exact device list (validate only)") + restore := fs.String("restore", "", "saved state") + paused := fs.Bool("stay-paused", false, "don't resume restored VM") + src := fs.String("src", "", "clone source") + dst := fs.String("dst", "", "clone destination") + rekey := fs.Bool("rekey", false, "new identifier") + newMAC := fs.Bool("new-mac", false, "new MAC") + state := fs.String("with-state", "", "clone saved state") + if e := fs.Parse(os.Args[2:]); e != nil { + return e + } + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + switch cmd { + case "fetch-ipsw": + if *out == "" { + return fmt.Errorf("--out required") + } + r, e := vz.FetchLatestSupportedMacOSRestoreImage(ctx, *out) + if e != nil { + return e + } + tick := time.NewTicker(5 * time.Second) + defer tick.Stop() + for { + select { + case <-r.Finished(): + if e := r.Err(); e != nil { + return e + } + image, e := vz.LoadMacOSRestoreImageFromPath(*out) + if e != nil { + return e + } + fmt.Printf("version=%s build=%s bytes=%d\n", image.OperatingSystemVersion(), image.BuildVersion(), r.Current()) + return nil + case <-tick.C: + fmt.Printf("download %.1f%% bytes=%d\n", 100*r.FractionCompleted(), r.Current()) + case <-ctx.Done(): + return ctx.Err() + } + } + case "install": + if *dir == "" || *ipsw == "" { + return fmt.Errorf("--dir and --ipsw required") + } + if *diskGB < 32 { + return fmt.Errorf("disk must be at least 32 GiB") + } + if _, e := os.Stat(*ipsw); e != nil { + return e + } + if _, e := os.Stat(filepath.Join(*dir, "config.json")); !os.IsNotExist(e) { + return fmt.Errorf("refusing to overwrite existing VM") + } + if e := os.MkdirAll(*dir, 0700); e != nil { + return e + } + image, e := vz.LoadMacOSRestoreImageFromPath(*ipsw) + if e != nil { + return e + } + req := image.MostFeaturefulSupportedConfiguration() + model := req.HardwareModel() + if !model.Supported() { + return fmt.Errorf("unsupported hardware model") + } + id, e := vz.NewMacMachineIdentifier() + if e != nil { + return e + } + mac, e := vz.NewRandomLocallyAdministeredMACAddress() + if e != nil { + return e + } + ncpu := *cpus + if ncpu == 0 { + ncpu = 4 + } + memory := *mem + if memory == 0 { + memory = 8 + } + c := config{model.DataRepresentation(), id.DataRepresentation(), mac.String(), ncpu, memory << 30} + if uint64(c.CPUs) < req.MinimumSupportedCPUCount() || c.Memory < req.MinimumSupportedMemorySize() { + return fmt.Errorf("resources below IPSW requirements") + } + if _, e := vz.NewMacAuxiliaryStorage(filepath.Join(*dir, "aux.img"), vz.WithCreatingMacAuxiliaryStorage(model)); e != nil { + return e + } + f, e := os.OpenFile(filepath.Join(*dir, "disk.img"), os.O_CREATE|os.O_EXCL|os.O_RDWR, 0600) + if e != nil { + return e + } + e = f.Truncate(*diskGB << 30) + ce := f.Close() + if e != nil { + return e + } + if ce != nil { + return ce + } + if e := writeConfig(*dir, c); e != nil { + return e + } + vc, e := build(*dir, c, "mac-graphics,mac-input,vsock,nat-net,blk") + if e != nil { + return e + } + if ok, err := vc.Validate(); !ok || err != nil { + return fmt.Errorf("invalid installer VM configuration: %v", err) + } + vm, e := vz.NewVirtualMachine(vc) + if e != nil { + return e + } + installer, e := vz.NewMacOSInstaller(vm, *ipsw) + if e != nil { + return e + } + done := make(chan error, 1) + go func() { done <- installer.Install(ctx) }() + tick := time.NewTicker(5 * time.Second) + defer tick.Stop() + start := time.Now() + for { + select { + case e := <-done: + if e != nil { + return e + } + fmt.Printf("install elapsed=%s version=%s build=%s\n", time.Since(start), image.OperatingSystemVersion(), image.BuildVersion()) + return nil + case <-tick.C: + fmt.Printf("install %.1f%%\n", 100*installer.FractionCompleted()) + } + } + case "clone": + if *src == "" || *dst == "" { + return fmt.Errorf("--src and --dst required") + } + c, e := readConfig(*src) + if e != nil { + return e + } + if *state != "" && (*rekey || *newMAC) { + // Saved state is only restorable into the configuration it was saved from. + return fmt.Errorf("--with-state keeps the saved configuration; it cannot be combined with --rekey or --new-mac") + } + if e := os.Mkdir(*dst, 0700); e != nil { + return e + } + // Remove only the destination this run created, so a retry is not blocked by a partial bundle. + if e := cloneBundle(*src, *dst, c, *state, *rekey, *newMAC); e != nil { + return errors.Join(e, os.RemoveAll(*dst)) + } + return nil + case "ctl": + if *dir == "" || len(fs.Args()) == 0 { + return fmt.Errorf("--dir and action required") + } + transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", filepath.Join(*dir, "ctl.sock")) + }} + defer transport.CloseIdleConnections() + client := &http.Client{Transport: transport, Timeout: 5 * time.Minute} + body := strings.Join(fs.Args()[1:], " ") + r, e := client.Post("http://vm/"+fs.Args()[0], "text/plain", strings.NewReader(body)) + if e != nil { + return e + } + defer r.Body.Close() + var result map[string]any + if e := json.NewDecoder(r.Body).Decode(&result); e != nil { + return e + } + b, _ := json.Marshal(result) + fmt.Println(string(b)) + if r.StatusCode != 200 { + return fmt.Errorf("control failed: %s", r.Status) + } + return nil + case "validate", "run": + if *dir == "" { + return fmt.Errorf("--dir required") + } + c, e := readConfig(*dir) + if e != nil { + return e + } + if *cpus != 0 { + c.CPUs = *cpus + } + if *mem != 0 { + c.Memory = *mem << 30 + } + list := "mac-graphics,mac-input,vsock,nat-net,blk" + if *profile == "full" { + list = "mac-graphics,usb-input,entropy,vsock,nat-net,blk" + } else if *profile != "suspendable" { + return fmt.Errorf("unknown profile") + } + if cmd == "validate" && *devices != "" { + list = *devices + } + vc, e := build(*dir, c, list) + if e != nil { + return e + } + if cmd == "validate" { + ok, ve := vc.Validate() + save, se := vc.ValidateSaveRestoreSupport() + return json.NewEncoder(os.Stdout).Encode(map[string]any{"devices": list, "valid": ok, "validation_error": errText(ve), "save_restore": save, "save_restore_error": errText(se)}) + } + return run(ctx, *dir, vc, *gui, *restore, *paused) + default: + return fmt.Errorf("unknown command %q", cmd) + } +} +func cloneBundle(src, dst string, c config, state string, rekey, newMAC bool) error { + for _, name := range []string{"disk.img", "aux.img"} { + if e := unix.Clonefile(filepath.Join(src, name), filepath.Join(dst, name), 0); e != nil { + return e + } + } + if rekey { + id, e := vz.NewMacMachineIdentifier() + if e != nil { + return e + } + c.MachineIdentifier = id.DataRepresentation() + } + if newMAC { + mac, e := vz.NewRandomLocallyAdministeredMACAddress() + if e != nil { + return e + } + c.MAC = mac.String() + } + if state != "" { + if e := unix.Clonefile(state, filepath.Join(dst, "machine-state.vzm"), 0); e != nil { + return e + } + } + return writeConfig(dst, c) +} + +func errText(e error) string { + if e != nil { + return e.Error() + } + return "" +} +func build(dir string, c config, list string) (*vz.VirtualMachineConfiguration, error) { + boot, e := vz.NewMacOSBootLoader() + if e != nil { + return nil, e + } + vc, e := vz.NewVirtualMachineConfiguration(boot, c.CPUs, c.Memory) + if e != nil { + return nil, e + } + model, e := vz.NewMacHardwareModelWithData(c.HardwareModel) + if e != nil { + return nil, e + } + id, e := vz.NewMacMachineIdentifierWithData(c.MachineIdentifier) + if e != nil { + return nil, e + } + aux, e := vz.NewMacAuxiliaryStorage(filepath.Join(dir, "aux.img")) + if e != nil { + return nil, e + } + platform, e := vz.NewMacPlatformConfiguration(vz.WithMacHardwareModel(model), vz.WithMacMachineIdentifier(id), vz.WithMacAuxiliaryStorage(aux)) + if e != nil { + return nil, e + } + vc.SetPlatformVirtualMachineConfiguration(platform) + for _, device := range strings.Split(list, ",") { + switch device { + case "mac-graphics": + g, e := vz.NewMacGraphicsDeviceConfiguration() + if e != nil { + return nil, e + } + d, e := vz.NewMacGraphicsDisplayConfiguration(1280, 800, 80) + if e != nil { + return nil, e + } + g.SetDisplays(d) + vc.SetGraphicsDevicesVirtualMachineConfiguration([]vz.GraphicsDeviceConfiguration{g}) + case "mac-input": + k, e := vz.NewMacKeyboardConfiguration() + if e != nil { + return nil, e + } + p, e := vz.NewMacTrackpadConfiguration() + if e != nil { + return nil, e + } + vc.SetKeyboardsVirtualMachineConfiguration([]vz.KeyboardConfiguration{k}) + vc.SetPointingDevicesVirtualMachineConfiguration([]vz.PointingDeviceConfiguration{p}) + case "usb-input": + k, e := vz.NewUSBKeyboardConfiguration() + if e != nil { + return nil, e + } + p, e := vz.NewUSBScreenCoordinatePointingDeviceConfiguration() + if e != nil { + return nil, e + } + vc.SetKeyboardsVirtualMachineConfiguration([]vz.KeyboardConfiguration{k}) + vc.SetPointingDevicesVirtualMachineConfiguration([]vz.PointingDeviceConfiguration{p}) + case "blk": + a, e := vz.NewDiskImageStorageDeviceAttachment(filepath.Join(dir, "disk.img"), false) + if e != nil { + return nil, e + } + d, e := vz.NewVirtioBlockDeviceConfiguration(a) + if e != nil { + return nil, e + } + vc.SetStorageDevicesVirtualMachineConfiguration([]vz.StorageDeviceConfiguration{d}) + case "nat-net": + a, e := vz.NewNATNetworkDeviceAttachment() + if e != nil { + return nil, e + } + n, e := vz.NewVirtioNetworkDeviceConfiguration(a) + if e != nil { + return nil, e + } + hw, e := net.ParseMAC(c.MAC) + if e != nil { + return nil, e + } + m, e := vz.NewMACAddress(hw) + if e != nil { + return nil, e + } + n.SetMACAddress(m) + vc.SetNetworkDevicesVirtualMachineConfiguration([]*vz.VirtioNetworkDeviceConfiguration{n}) + case "vsock": + s, e := vz.NewVirtioSocketDeviceConfiguration() + if e != nil { + return nil, e + } + vc.SetSocketDevicesVirtualMachineConfiguration([]vz.SocketDeviceConfiguration{s}) + case "entropy": + d, e := vz.NewVirtioEntropyDeviceConfiguration() + if e != nil { + return nil, e + } + vc.SetEntropyDevicesVirtualMachineConfiguration([]*vz.VirtioEntropyDeviceConfiguration{d}) + case "balloon": + d, e := vz.NewVirtioTraditionalMemoryBalloonDeviceConfiguration() + if e != nil { + return nil, e + } + vc.SetMemoryBalloonDevicesVirtualMachineConfiguration([]vz.MemoryBalloonDeviceConfiguration{d}) + default: + return nil, fmt.Errorf("device %q not implemented", device) + } + } + return vc, nil +} +func run(ctx context.Context, dir string, vc *vz.VirtualMachineConfiguration, gui bool, restore string, paused bool) error { + if ok, e := vc.Validate(); !ok || e != nil { + return fmt.Errorf("invalid VM: %v", e) + } + vm, e := vz.NewVirtualMachine(vc) + if e != nil { + return e + } + log, e := os.OpenFile(filepath.Join(dir, "events.jsonl"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + if e != nil { + return e + } + defer log.Close() + done := make(chan struct{}) + go func() { + defer close(done) + enc := json.NewEncoder(log) + for s := range vm.StateChangedNotify() { + enc.Encode(map[string]any{"timestamp_ns": time.Now().UnixNano(), "state": s.String()}) + fmt.Println("state", s) + if s == vz.VirtualMachineStateStopped || s == vz.VirtualMachineStateError { + return + } + } + }() + socket := filepath.Join(dir, "ctl.sock") + ln, e := net.Listen("unix", socket) + if e != nil { + return e + } + defer ln.Close() + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + var e error + switch r.URL.Path { + case "/status": + case "/pause": + e = vm.Pause() + case "/resume": + e = vm.Resume() + case "/stop": + e = vm.Stop() + case "/request-stop": + _, e = vm.RequestStop() + case "/save": + var b []byte + b, e = io.ReadAll(r.Body) + if e == nil { + e = vm.SaveMachineStateToPath(string(b)) + } + default: + e = fmt.Errorf("unknown action") + } + if e != nil { + w.WriteHeader(500) + } + json.NewEncoder(w).Encode(map[string]any{"state": vm.State().String(), "error": errText(e)}) + }) + server := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second} + go server.Serve(ln) + defer server.Close() + if restore != "" { + e = vm.RestoreMachineStateFromURL(restore) + if e == nil && !paused { + e = vm.Resume() + } + } else { + e = vm.Start() + } + if e != nil { + return e + } + go func() { + select { + case <-ctx.Done(): + vm.Stop() + case <-done: + } + }() + if gui { + return vm.StartGraphicApplication(1280, 800, vz.WithWindowTitle("Hypeman macOS spike")) + } + select { + case <-done: + if vm.State() == vz.VirtualMachineStateError { + return fmt.Errorf("VM entered error state") + } + return nil + case <-ctx.Done(): + <-done + return ctx.Err() + } +}