From 4849059682db6f3726a42b07324defeb1dff7818 Mon Sep 17 00:00:00 2001 From: chris lee Date: Wed, 7 Oct 2026 15:26:07 -0400 Subject: [PATCH 01/16] Add experimental local macOS guests through VZ and instance API --- cmd/api/api/builds.go | 3 + cmd/api/api/exec.go | 5 + cmd/api/api/images.go | 2 + cmd/api/api/ingress.go | 3 + cmd/api/api/instances.go | 14 +- cmd/api/api/macos_test.go | 59 ++ cmd/api/config/config.go | 15 +- cmd/api/main.go | 42 +- cmd/import-macos/main.go | 35 + cmd/vz-shim/macos_arm64.go | 69 ++ cmd/vz-shim/macos_arm64_test.go | 24 + cmd/vz-shim/macos_identity.go | 37 ++ cmd/vz-shim/macos_identity_test.go | 26 + cmd/vz-shim/macos_other.go | 16 + cmd/vz-shim/main.go | 9 + cmd/vz-shim/vm.go | 38 +- docs/macos-experimental.md | 75 +++ lib/hypervisor/config.go | 10 + lib/hypervisor/config_validation.go | 10 + lib/hypervisor/qemu/macos_config_test.go | 12 + lib/hypervisor/qemu/profile.go | 3 + lib/hypervisor/vz/macos_config_test.go | 30 + lib/hypervisor/vz/shimconfig/config.go | 5 + lib/hypervisor/vz/starter.go | 26 +- lib/images/macos_import_darwin.go | 126 ++++ lib/images/macos_import_darwin_test.go | 51 ++ lib/images/manager.go | 4 + lib/images/platform.go | 14 +- lib/images/storage.go | 24 +- lib/images/tag.go | 3 + lib/images/types.go | 17 +- lib/instances/configdisk.go | 3 + lib/instances/create.go | 39 +- lib/instances/create_image.go | 13 + lib/instances/delete.go | 4 +- lib/instances/macos.go | 97 +++ lib/instances/macos_darwin.go | 68 ++ lib/instances/macos_darwin_test.go | 17 + lib/instances/macos_other.go | 10 + lib/instances/macos_test.go | 72 +++ lib/instances/manager.go | 28 + lib/instances/query.go | 9 +- lib/instances/start.go | 17 +- lib/instances/stop.go | 6 +- lib/instances/types.go | 2 + lib/instances/vsock.go | 4 + lib/oapi/oapi.go | 785 +++++++++++------------ lib/providers/providers.go | 1 + openapi.yaml | 11 +- spike/macvm/main.go | 518 +++++++++++++++ 50 files changed, 2047 insertions(+), 464 deletions(-) create mode 100644 cmd/api/api/macos_test.go create mode 100644 cmd/import-macos/main.go create mode 100644 cmd/vz-shim/macos_arm64.go create mode 100644 cmd/vz-shim/macos_arm64_test.go create mode 100644 cmd/vz-shim/macos_identity.go create mode 100644 cmd/vz-shim/macos_identity_test.go create mode 100644 cmd/vz-shim/macos_other.go create mode 100644 docs/macos-experimental.md create mode 100644 lib/hypervisor/qemu/macos_config_test.go create mode 100644 lib/hypervisor/vz/macos_config_test.go create mode 100644 lib/images/macos_import_darwin.go create mode 100644 lib/images/macos_import_darwin_test.go create mode 100644 lib/instances/macos.go create mode 100644 lib/instances/macos_darwin.go create mode 100644 lib/instances/macos_darwin_test.go create mode 100644 lib/instances/macos_other.go create mode 100644 lib/instances/macos_test.go create mode 100644 spike/macvm/main.go 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..da66f597f 100644 --- a/cmd/api/api/instances.go +++ b/cmd/api/api/instances.go @@ -273,12 +273,22 @@ func (s *ApiService) CreateInstance(ctx context.Context, request oapi.CreateInst } } + // Imported desktop guests do not implement Linux disk/TAP shaping. Do not + // silently advertise proportional limits that the backend would ignore. + macOSImage := false + if img, err := s.ImageManager.GetImage(ctx, request.Body.Image); err == nil { + macOSImage = img.MacOS != nil + } + if macOSImage && request.Body.Vcpus == nil { + vcpus = 0 + } + // Calculate default resource limits when not specified (0 = auto) // Uses proportional allocation based on CPU: (vcpus / cpuCapacity) * resourceCapacity - if diskIOBps == 0 { + if diskIOBps == 0 && !macOSImage { diskIOBps, _ = s.ResourceManager.DefaultDiskIOBandwidth(vcpus) } - if networkBandwidthDownload == 0 || networkBandwidthUpload == 0 { + if !macOSImage && (networkBandwidthDownload == 0 || networkBandwidthUpload == 0) { defaultDown, defaultUp := s.ResourceManager.DefaultNetworkBandwidth(vcpus) if networkBandwidthDownload == 0 { networkBandwidthDownload = defaultDown diff --git a/cmd/api/api/macos_test.go b/cmd/api/api/macos_test.go new file mode 100644 index 000000000..bc4797b8b --- /dev/null +++ b/cmd/api/api/macos_test.go @@ -0,0 +1,59 @@ +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") +} + +type macOSFixtureImages struct{ images.Manager } + +func (macOSFixtureImages) GetImage(context.Context, string) (*images.Image, error) { + return &images.Image{MacOS: &images.MacOSImage{}}, nil +} + +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, ImageManager: macOSFixtureImages{}, 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..ee1860a3d --- /dev/null +++ b/cmd/vz-shim/macos_identity.go @@ -0,0 +1,37 @@ +//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) + path := filepath.Join(os.TempDir(), 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..718ffe1e3 --- /dev/null +++ b/cmd/vz-shim/macos_identity_test.go @@ -0,0 +1,26 @@ +//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) { + t.Setenv("TMPDIR", t.TempDir()) + 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..0decda585 --- /dev/null +++ b/docs/macos-experimental.md @@ -0,0 +1,75 @@ +# 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. + +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/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/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_import_darwin.go b/lib/images/macos_import_darwin.go new file mode 100644 index 000000000..761ae6628 --- /dev/null +++ b/lib/images/macos_import_darwin.go @@ -0,0 +1,126 @@ +//go:build darwin && arm64 + +package images + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net" + "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) + } + b, err := os.ReadFile(filepath.Join(source, "config.json")) + if err != nil { + return nil, err + } + var mac MacOSImage + if err = json.Unmarshal(b, &mac); err != nil { + return nil, err + } + if len(mac.HardwareModel) == 0 || len(mac.MachineIdentifier) == 0 || mac.CPUs < 2 || mac.Memory < 4<<30 { + return nil, fmt.Errorf("invalid macOS bundle configuration") + } + if _, err = net.ParseMAC(mac.MAC); err != nil { + return nil, fmt.Errorf("invalid MAC: %w", err) + } + 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{"disk.img": "rootfs.raw", "aux.img": "aux.img"} { + if err = unix.Clonefile(filepath.Join(source, 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..eefc56f94 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,11 +114,7 @@ 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) { @@ -152,9 +150,21 @@ func digestDir(p *paths.Paths, repository, digestHex string) string { return resolveImageLayout(p, repository, digestHex).dir } +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)} + if b, err := os.ReadFile(layout.metadata); err == nil { + var meta imageMetadata + if json.Unmarshal(b, &meta) == nil && meta.MacOS != nil { + layout.disk = filepath.Join(layout.dir, "rootfs.raw") + } + } + return layout +} + func legacyImageExists(p *paths.Paths, repository, digestHex string) bool { - _, metadataErr := os.Stat(p.ImageMetadata(repository, digestHex)) - _, diskErr := os.Stat(p.ImageDigestPath(repository, digestHex)) + layout := legacyLayout(p, repository, digestHex) + _, metadataErr := os.Stat(layout.metadata) + _, diskErr := os.Stat(layout.disk) return metadataErr == nil && diskErr == nil } 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..959d7e26e 100644 --- a/lib/images/types.go +++ b/lib/images/types.go @@ -9,9 +9,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 +26,16 @@ 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"` +} + // CreateImageRequest represents a request to create an image type CreateImageRequest struct { Name string diff --git a/lib/instances/configdisk.go b/lib/instances/configdisk.go index 6b1d74f0b..28d99eece 100644 --- a/lib/instances/configdisk.go +++ b/lib/instances/configdisk.go @@ -18,6 +18,9 @@ import ( // createConfigDisk generates an ext4 disk with instance configuration. // The disk contains /config.json read by the guest init binary. func (m *manager) createConfigDisk(ctx context.Context, inst *Instance, imageInfo *images.Image, netConfig *network.NetworkConfig, proxyCfg *egressproxy.GuestConfig) error { + if inst.MacOS != nil { + return nil + } // macOS boots the imported disk; no Linux config disk. // Create temporary directory for config files tmpDir, err := os.MkdirTemp("", "hypeman-config-*") if err != nil { diff --git a/lib/instances/create.go b/lib/instances/create.go index 504b58e65..1aa624d34 100644 --- a/lib/instances/create.go +++ b/lib/instances/create.go @@ -102,6 +102,12 @@ 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 || 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 +138,15 @@ 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 { + if err := prepareMacOSRequest(&req, imageInfo, hvType); err != nil { + return nil, err + } + } + // A guest whose architecture differs from the host kernel can only boot via // emulation. On Apple silicon that is Rosetta, enabled automatically; on any // other host we reject the create up front rather than launching an @@ -338,6 +353,7 @@ func (m *manager) createInstance( Image: req.Image, ResolvedImage: resolvedImageRef, Platform: imageInfo.Platform, + MacOS: imageInfo.MacOS, Size: size, HotplugSize: hotplugSize, OverlaySize: overlaySize, @@ -412,14 +428,18 @@ 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 != "" { + if stored.MacOS != nil && stored.NetworkEnabled { + netConfig = &network.NetworkConfig{MAC: stored.MacOS.MAC} + stored.MAC = stored.MacOS.MAC + } + if networkName != "" && stored.MacOS == nil { 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", @@ -569,7 +589,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 +817,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 +896,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 +1030,9 @@ func (m *manager) buildHypervisorConfig(ctx context.Context, inst *Instance, ima } func resolveCreateKernelVersion(imageInfo *images.Image, defaultKernel system.KernelVersion) (system.KernelVersion, error) { + if imageInfo != nil && imageInfo.MacOS != nil { + return "", nil + } if imageInfo == nil || len(imageInfo.Labels) == 0 { return defaultKernel, nil } diff --git a/lib/instances/create_image.go b/lib/instances/create_image.go index 238747452..cd605597f 100644 --- a/lib/instances/create_image.go +++ b/lib/instances/create_image.go @@ -18,6 +18,19 @@ type createImageResolver interface { } func resolveImageForCreate(ctx context.Context, imageManager createImageResolver, imageName, platform string, log *slog.Logger) (*images.Image, error) { + if p, err := images.ParsePlatform(platform); err == nil && p.OS == "darwin" { + img, err := imageManager.GetImage(ctx, imageName) + if err != nil { + return nil, fmt.Errorf("get imported macOS image: %w", err) + } + if img.MacOS == nil { + return nil, fmt.Errorf("%w: image is not a macOS disk bundle", images.ErrInvalidPlatform) + } + if err := validateResolvedImagePlatform(img, platform); err != nil { + return nil, err + } + return img, nil + } // An empty platform means the host platform: a no-platform run must boot a // host-native guest and never silently emulate, so we resolve the host variant // explicitly rather than follow the (last-pull-wins) tag, which may point at a diff --git a/lib/instances/delete.go b/lib/instances/delete.go index 7826803c2..92824755c 100644 --- a/lib/instances/delete.go +++ b/lib/instances/delete.go @@ -76,7 +76,7 @@ func (m *manager) deleteInstanceWithOptions( // 2. Get network allocation BEFORE killing VMM (while we can still query it) var networkAlloc *network.Allocation - if inst.NetworkEnabled { + if inst.NetworkEnabled && inst.MacOS == nil { log.DebugContext(ctx, "getting network allocation", "instance_id", id) networkAlloc, err = m.networkManager.GetAllocation(ctx, id) if err != nil { @@ -154,7 +154,7 @@ func (m *manager) deleteInstanceWithOptions( } // 6. Release network allocation - if inst.NetworkEnabled { + if inst.NetworkEnabled && inst.MacOS == nil { m.unregisterEgressProxyInstance(ctx, id) log.DebugContext(ctx, "releasing network", "instance_id", id, "network", "default") releaseNetworkCtx, releaseNetworkSpanEnd := m.startLifecycleStep(ctx, "release_network", diff --git a/lib/instances/macos.go b/lib/instances/macos.go new file mode 100644 index 000000000..9d0c1fa29 --- /dev/null +++ b/lib/instances/macos.go @@ -0,0 +1,97 @@ +package instances + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "runtime" + + "github.com/kernel/hypeman/lib/hypervisor" + "github.com/kernel/hypeman/lib/images" + "github.com/kernel/hypeman/lib/instances/phasetracking" +) + +func prepareMacOSRequest(req *CreateInstanceRequest, img *images.Image, hv hypervisor.Type) error { + if runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" || hv != hypervisor.TypeVZ { + 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) + } + if req.Size == 0 { + req.Size = int64(img.MacOS.Memory) + } + if req.Vcpus == 0 { + req.Vcpus = int(img.MacOS.CPUs) + } + if req.Size < 4<<30 || req.Vcpus < 2 { + return fmt.Errorf("%w: macOS requires at least 4 GiB and 2 vCPUs", ErrInvalidRequest) + } + 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) + } + root, err := images.GetDiskPath(m.paths, img.Name, img.Digest) + if err != nil { + return err + } + return cloneMacOSStorage(root, 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 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 +} +func (m *manager) rejectMacOSOperation(id, operation string) error { + meta, err := m.loadMetadata(id) + if err != nil { + return err + } + if meta.MacOS != nil { + return fmt.Errorf("%w: %s is not implemented for experimental macOS instances", ErrInvalidRequest, operation) + } + return nil +} diff --git a/lib/instances/macos_darwin.go b/lib/instances/macos_darwin.go new file mode 100644 index 000000000..9b472ecc8 --- /dev/null +++ b/lib/instances/macos_darwin.go @@ -0,0 +1,68 @@ +//go:build darwin + +package instances + +import ( + "encoding/hex" + "net" + "os" + "path/filepath" + "strings" + + "golang.org/x/sys/unix" +) + +func cloneMacOSStorage(root, disk, aux string) error { + for src, dst := range map[string]string{root: disk, filepath.Join(filepath.Dir(root), "aux.img"): aux} { + if err := unix.Clonefile(src, dst, unix.CLONE_NOFOLLOW|unix.CLONE_NOOWNERCOPY); err != nil { + return err + } + if err := os.Chmod(dst, 0600); err != nil { + return err + } + } + 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_test.go b/lib/instances/macos_darwin_test.go new file mode 100644 index 000000000..38aea2ffb --- /dev/null +++ b/lib/instances/macos_darwin_test.go @@ -0,0 +1,17 @@ +//go:build darwin + +package instances + +import ( + "github.com/stretchr/testify/require" + "net" + "testing" +) + +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..0815bb4c2 --- /dev/null +++ b/lib/instances/macos_other.go @@ -0,0 +1,10 @@ +//go:build !darwin + +package instances + +import "fmt" + +func cloneMacOSStorage(string, string, string) error { + return fmt.Errorf("macOS image clones require APFS on macOS") +} +func macOSGuestIP(string) string { return "" } diff --git a/lib/instances/macos_test.go b/lib/instances/macos_test.go new file mode 100644 index 000000000..a8bf3d129 --- /dev/null +++ b/lib/instances/macos_test.go @@ -0,0 +1,72 @@ +package instances + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "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) { + if runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" { + t.Skip("Mac request acceptance requires Apple silicon") + } + req := CreateInstanceRequest{} + require.NoError(t, prepareMacOSRequest(&req, testMacImage(), hypervisor.TypeVZ)) + 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"}}}, + } { + require.ErrorIs(t, prepareMacOSRequest(&r, testMacImage(), hypervisor.TypeVZ), ErrInvalidRequest) + } + require.ErrorIs(t, prepareMacOSRequest(&CreateInstanceRequest{}, testMacImage(), hypervisor.TypeQEMU), 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 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, m.rejectMacOSOperation("active", "snapshot"), ErrInvalidRequest) +} diff --git a/lib/instances/manager.go b/lib/instances/manager.go index 2a9bb6dfb..cc5624fdf 100644 --- a/lib/instances/manager.go +++ b/lib/instances/manager.go @@ -86,6 +86,7 @@ type ResourceLimits struct { // ManagerConfig holds non-resource manager behavior settings. type ManagerConfig struct { + MacOSOnly bool LifecycleEventBufferSize int FirecrackerSnapshotMemoryBackend string FirecrackerUFFDCacheMaxBytes int64 @@ -174,6 +175,8 @@ type manager struct { resourceValidator ResourceValidator // Optional validator for aggregate resource limits instanceLocks sync.Map // map[string]*sync.RWMutex - per-instance locks forkMetadataMu sync.Mutex + macOSBootMu sync.Mutex // Serialize admission for preserved Mac identities. + macOSOnly bool bootMarkerScans sync.Map // map[string]time.Time next allowed boot-marker rescan hypervisorStateCache sync.Map // map[string]hypervisorStateCacheEntry - last observed hypervisor state per instance hostTopology *HostTopology // Cached host CPU topology @@ -285,6 +288,7 @@ func NewManagerWithConfigE(p *paths.Paths, imageManager images.Manager, systemMa m := &manager{ paths: p, + macOSOnly: managerConfig.MacOSOnly, imageManager: imageManager, systemManager: systemManager, networkManager: networkManager, @@ -502,6 +506,9 @@ func (m *manager) GetSnapshot(ctx context.Context, snapshotID string) (*Snapshot } func (m *manager) CreateSnapshot(ctx context.Context, id string, req CreateSnapshotRequest) (*Snapshot, error) { + if err := m.rejectMacOSOperation(id, "snapshot"); err != nil { + return nil, err + } lock := m.getInstanceLock(id) lock.Lock() defer lock.Unlock() @@ -514,6 +521,9 @@ func (m *manager) DeleteSnapshot(ctx context.Context, snapshotID string) error { // ForkInstance creates a forked copy of an instance. func (m *manager) ForkInstance(ctx context.Context, id string, req ForkInstanceRequest) (*Instance, error) { + if err := m.rejectMacOSOperation(id, "fork"); err != nil { + return nil, err + } lock := m.getInstanceLock(id) useReadLock := false var sourceState State @@ -603,6 +613,9 @@ func (m *manager) ForkSnapshot(ctx context.Context, snapshotID string, req ForkS // StandbyInstance puts an instance in standby (pause, snapshot, delete VMM) func (m *manager) StandbyInstance(ctx context.Context, id string, req StandbyInstanceRequest) (*Instance, error) { + if err := m.rejectMacOSOperation(id, "standby"); err != nil { + return nil, err + } lock := m.getInstanceLock(id) lock.Lock() defer lock.Unlock() @@ -624,6 +637,9 @@ func (m *manager) StandbyInstance(ctx context.Context, id string, req StandbyIns // RestoreInstance restores an instance from standby func (m *manager) RestoreInstance(ctx context.Context, id string) (*Instance, error) { + if err := m.rejectMacOSOperation(id, "restore"); err != nil { + return nil, err + } lock := m.getInstanceLock(id) lock.Lock() defer lock.Unlock() @@ -659,6 +675,9 @@ func (m *manager) RestoreInstance(ctx context.Context, id string) (*Instance, er } func (m *manager) RestoreSnapshot(ctx context.Context, id string, snapshotID string, req RestoreSnapshotRequest) (*Instance, error) { + if err := m.rejectMacOSOperation(id, "restore snapshot"); err != nil { + return nil, err + } lock := m.getInstanceLock(id) lock.Lock() defer lock.Unlock() @@ -741,6 +760,9 @@ func standbyRequestHasOptions(req StandbyInstanceRequest) bool { // UpdateInstance updates mutable properties of a running instance func (m *manager) UpdateInstance(ctx context.Context, id string, req UpdateInstanceRequest) (*Instance, error) { + if err := m.rejectMacOSOperation(id, "update"); err != nil { + return nil, err + } lock := m.getInstanceLock(id) lock.Lock() defer lock.Unlock() @@ -888,11 +910,17 @@ func (m *manager) RotateLogs(ctx context.Context, maxBytes int64, maxFiles int) // AttachVolume attaches a volume to an instance (not yet implemented) func (m *manager) AttachVolume(ctx context.Context, id string, volumeId string, req AttachVolumeRequest) (*Instance, error) { + if err := m.rejectMacOSOperation(id, "attach volume"); err != nil { + return nil, err + } return nil, fmt.Errorf("attach volume not yet implemented") } // DetachVolume detaches a volume from an instance (not yet implemented) func (m *manager) DetachVolume(ctx context.Context, id string, volumeId string) (*Instance, error) { + if err := m.rejectMacOSOperation(id, "detach volume"); err != nil { + return nil, err + } return nil, fmt.Errorf("detach volume not yet implemented") } 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/start.go b/lib/instances/start.go index b162ad90f..12610920f 100644 --- a/lib/instances/start.go +++ b/lib/instances/start.go @@ -6,7 +6,6 @@ import ( "time" "github.com/kernel/hypeman/lib/egressproxy" - "github.com/kernel/hypeman/lib/instances/phasetracking" "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) @@ -114,7 +120,12 @@ func (m *manager) startInstance( // 4. Allocate fresh network if network enabled var netConfig *network.NetworkConfig - if stored.NetworkEnabled { + if stored.MacOS != nil && stored.NetworkEnabled { + netConfig = &network.NetworkConfig{MAC: stored.MacOS.MAC} + stored.MAC = stored.MacOS.MAC + stored.IP = "" + } + if stored.NetworkEnabled && stored.MacOS == nil { log.DebugContext(ctx, "allocating network for start", "instance_id", id, "network", "default") networkCtx, networkSpanEnd := m.startLifecycleStep(ctx, "allocate_network", attribute.String("instance_id", id), @@ -231,7 +242,7 @@ func (m *manager) startInstance( // 7. Update metadata (set PID, StartedAt). Boot markers were cleared at // the top of this function, so we are in Initializing until they hydrate. - stored.Phases.Record(phasetracking.PhaseInitializing, time.Now().UTC()) + stored.Phases.Record(initialBootPhase(stored), time.Now().UTC()) meta = &metadata{StoredMetadata: *stored} if err := m.saveMetadata(meta); err != nil { // VM is running but metadata failed - log but don't fail diff --git a/lib/instances/stop.go b/lib/instances/stop.go index ec6bf029e..4c46be901 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 { @@ -205,7 +205,7 @@ func (m *manager) stopInstance( if inst.NetworkEnabled { m.unregisterEgressProxyInstance(ctx, id) } - if inst.NetworkEnabled && networkAlloc != nil { + if inst.NetworkEnabled && inst.MacOS == nil && networkAlloc != nil { log.DebugContext(ctx, "releasing network", "instance_id", id, "network", "default") releaseNetworkCtx, releaseNetworkSpanEnd := m.startLifecycleStep(ctx, "release_network", attribute.String("instance_id", id), @@ -219,7 +219,7 @@ func (m *manager) stopInstance( } else { releaseNetworkSpanEnd(nil) } - } else if inst.NetworkEnabled && networkAllocErr != nil { + } else if inst.NetworkEnabled && inst.MacOS == nil && networkAllocErr != nil { // GetAllocation failed earlier, so we don't have a full Allocation. Fall back // to deleting the TAP by deterministic name to avoid leaking it on the host. log.DebugContext(ctx, "releasing network by instance id (fallback)", "instance_id", id) 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/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..8d33f7f85 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 @@ -19241,395 +19241,394 @@ func (sh *strictHandler) GetVolume(w http.ResponseWriter, r *http.Request, id st // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/+y9+3LbOLY3+ioonf1V23skWb7k5q6u77jtdNq7444ndjLf3q0cGSIhCWMSYAOgbKUr", - "VeeveYCp84TzJKewAJAgBUq0E9vxdGqqph2RxHVhYV1/649OxNOMM8KU7Oz/0ZHRjKQY/jxQCkez9zzJ", - "U/KW/J4TqfTPmeAZEYoSeCnlOVOjDKuZ/ldMZCRopihnnf3OKVYzdDUjgqA5tILkjOdJjMYEwXck7nQ7", - "5BqnWUI6+52tlKmtGCvc6XbUItM/SSUom3Y+dTuC4JizZGG6meA8UZ39CU4k6da6PdFNIyyR/qQH3xTt", - "jTlPCGadT9Di7zkVJO7s/+ZP40PxMh//nURKd36QK36mMIvHi1Oe0GixPNnXlOXX0BvCueIpVjRC0nyD", - "MvgIjbEkMeIM4UjROUGUjXnOYnR+eIoizhiJdGNyyPhYEjEnMZoIniI1I2jGpYJ3lMDRJVJ4nJD+kHW6", - "tf0gTD+J16/S32ZEzYgIDJZKZFtBEy6QmlGJKNNPI9L3N0yJnCyvbLdD44SMFE0Jz9XyQv3Mr1DC2RSm", - "5dpFaS4VmuE5QR+J4Oj3HCd0sqBs2rxIYzLhgqCfFxlJMUNZgiMiEVWIMsXdbMwalTT2JA0RF50yLsgo", - "JlJRhnX7o4wLcyKqo38Df+AEee/C0OB9pGZYOSpnXKFLQrLqRPEVvqwu4287O90Xg8HgQ7dDFUnNscLX", - "NM3Tzv7TJ092n3Q7KWXm39vF6ClTZEqEHr79BQuBF950JM9FREYRjcWqmUQJJUyhw+Ojt7ecQGd70If/", - "bT3vdDvbL3b620+fw7+3n3b8aS0tfHXkn1YfvTOFVS6XeZA5TSNLKCOPSJZn/WuejolAfIKiXAjCVLJA", - "cKRI3ILoKtMehLYi4mxCp7lwRzB05CrLOcMSYWaYRq/GL8rGWp27SDOxmF+xkSAppkyv8dIg3rpHSJ9Q", - "ZA+RHlLEmRI8STRTUIqkmZLuFHU1G2cIZ1lCI2A9lUO1lw5kp9theZLoh7URlrtNEjql8EKrpaHS2yT3", - "LVIcEaaIKE54m6WpsMWmjsvlDu5GyRdbdTnjSTzKmaLJcq/v9M92Tf2eqEQzksSITyZdRCcII92K/tnQ", - "eHXZdwY7T3uDvd7g6fn2s/3Bi/3Bk//pdDsTLlKsOvudGCvS07vcZm+AaUvKovDusDqJpPpCEiQyu1Nc", - "WJUNHJOIpwTppleOfO/zR9543RyWm6pfRPbF8mYNbHW/dmW0o+4ES1UwIdgvqhYjHBjTOU2JVDjNNB/S", - "Y/AWs4kLuQbr++BWfuUCb3/WAjNyrUZ2hYLzCdEHuc5IpG9E7rhJIWDo9ix5FyzrXmhcECz1gLWYpC/T", - "3zo5k3mmr24Sj7IEK92ulqmADEYplVJ/WvwQU2n4SLfjiHzEuBqJnDHzIiPqiotL/03byohmnW5nhuVo", - "Ps3yTnfVtVUlauiCJDiT0J7dcTEiQnDRMaLxYjThwm2SvnPLJVzR1NIKyeKKDaxQp9upLEDBzt1c3LiL", - "XQ0ODnoBWhJGqzBqAExmeeB+W8vDLYa2mrGbW8QI0W6bkf1YVjlATPGUcaloJFuxeRAe9PamPA6wzqOi", - "OURjwhSdUCKsXE2Q0PdDSpBrBOlGEGUol7VzUIj+IzLXutpovjdSUba8KDXFxt88TzYpb0TvVi62vzgp", - "a4i0Oveg4jTHFM7kEZlTc7VUZTe7NaNY0DkRAfZdCACGFZr30IY+65qFMM7IZmWl2JzGFLdhBzGMaUQD", - "1HN6eIzMY3R8hDZm5Lrayc6z8fNOc5MMpwFa+DlPMevpA6GH5dqHd/22X+8FVRSepvloKnieLbd8/Obk", - "5B2Ch4iBhOu3+HwnJKlmER3hOBZEyvD83UN/bIPBYLCPd/YHg/4gNMo5YTEXjUtqHoeXdHsQkxVNtlpS", - "2/7Skv76/vjo+AAdcpFxATrb2oPjL48/L59sqrsSov8fc5rEy1Q/1j+ThnUiQlKpNL/60byGBDGqHLqa", - "cUlQhKMZQWOjtIB6Du21IfmiY3d7hUZwRGXGgc+j9yelNAeqIbkmUa4q/X6PYqp14cgKVd7kWowo4nqP", - "9P0bkixgBZB9R6vayolOtxYCIkHwmu70G606Wz7/uSGvUSqbWnevaDaf0iShkkScxdLvgzL1dK95Mt4p", - "NtfmUlcv9c8oJVLiKUEbYJYCdcNweC1tTTBNSLzZTsJumszf+di71ypnDsigh8fR9s5ukKGleEpGMZ1a", - "u2KdBvXvWj7W7SgEb4cnAhJGu3lAl4JMlvv7Ce4T6ESQCRFEU/xndpcJPicMW5XqP6Dfzv+1VRpct6y1", - "dQsW87R8/VO383tOcjLKuKRmhEtswj7RZARLjeCL8Jjh0aq99ihKKixWnw944wucxFLYXLs21vSj5S08", - "XfvJuX6nztCBHRUCjscFGvn2Sy1pBUQWzpR9UDMB8ylKKDNqkObfZi9A2Ftk5IeETzc7X2wdiuVfPvx6", - "3LdgXuaHhtb0s26hFSR86q/mjGChxqSymA33qm2oHF3j8p9Wjk/tAsWSjFZzkFPKGInB5m4PtnlTy9ZB", - "3QdO0SVVo7m+gUNnDob1C1XIvtHYVMKjywlNyGiG5cwaKeOYGoPraWUmARGy4szAYCRwDYJoA0r12c8H", - "O0+eIttBYA2t9Ve/sDwT72vdvBUvFBZjnCRB2mgmt5vf0csUEqaA0uDbdPcUFOgI03C6jt1Nq7zncmb+", - "At6tRwV3n2YDmrwS/fcS4XY71z3dam+OBay6bt4b1l9dT95vP5ader+eFv17P761Q/F++smNyvvtsByg", - "WxWjJgVN4DDpsFTJKVM9ymALtABuLU/m6ijNrNaSgfQ2FhIe3OyfJVgdLolUaOPtT4e7u7svNtsLV1Re", - "jiT9SEbT8Uq52Q7aCsr6M6Q/01fllE7xeKGI7KNDzBhXaExQNMNsSmKEJ0p/ZcdaUcCfDNY5YRqFJCKa", - "RCT1cQ+/eH59jdWLp/RKvviYjsX077tB/ydYFzW/abbAYbub+lUkMAts4u0u6hRfa83fUEnI+Xli3FWo", - "eMnugawPoY9KhX5Cr/WaK7RdWeng4oa1v8KFxTjr5Yz+nsNuZwleLOuAiuC0BxQRWl7DN8wBkuuPj9Tn", - "x3zjZnqFqdInp/CZ2il3EU9ife9MqJCq094bdiPBgIgvJSJVTllYYlqmiPoCNl7qxTgbz0pCJyRaRAlZ", - "Zu6OfWcit+bWmOg7B/4sDIht2bgbSoUTux9Piy4qPx+V/VV+f2k6/9TtHOIMj2lCHWeu8mnrkx9Zw9+6", - "XfIbOzKfvrVffup2JgSrXJDAWp5BfAAyduJeQuYkQfZtTbz76OD0GMlcTMBlDtRq3kXkOuOSyCETZIpF", - "nBAJpH41o9GsMFeOcXQpEWZlWAA6SK7wQqJMEEmY2h+yYWEhl8NOFw2N8mX/NlRi/jahIe4lNhVE2n+Q", - "axINO13dVsKnctjpo5+5VD2tLltZah8NrT1GDjtow5rUMiylmgmeT2dDlmKGpyQlTHURBGmAGVhzpmSx", - "iTCL0bAjuCRK4R5J8wR4v27sIMsSMmRnNKERZyjF0Zsz++0VVTP01nzkXZ4wY31Zd1FmTc8Sp2TIsDGD", - "6p1coEzwMUEJzlk0IxIRNuEiIpt9dEpEzy2x21uEBdGbYYy4KGf6iBBcbob87QMiTAkb6dCeueiZ3IT+", - "9NIXSrS8yZfH5otPhXfkJh//aj/RrMrOOKijiEVpTffo2dyIhanfXUcVT0B3yGBFYVepfuuKocqWTRI8", - "BVLxDpEhRjlkEU9TUPRsL0iSORE4KXZI33Ec7An6brA30pBFCc/j3myRETGnkouuviFIBMEIoot+J2ne", - "hU71X72URoLPU8TZkCWajLdwGj/d2+yjA5RouScu5k8NcVsSA04A9gCqRzvsYGeTH3bMxKhE+uLvo5dM", - "CWpoDklDcGNzk9aIq+3mecxq6W6D/blJY2fmiyUPh/nZEnR3icV6dFMSYEHGHhcN3VkrGPCyDO7WdbUz", - "yo6v2K0IM7tTFercR1QhnGUEC4koG7KSmlgMVGo/ygSB5ZBU2a1LiUIbKVbAXtQMqyEDDvGdRJXd3+wj", - "kBtNfFjJkcDQK0iyMAMiQ+bGfEWTBAyHCAIbDIczBw0sY14EBRwmzArel5TeLuv8sn7RygQqR8T0/deX", - "J++QXEhFUjSmDIvFJnIRaZrXGtJcdsyFRUa7e6C5g6hWnXXMIepJf4s4qwqQ9fO61oVgRdCSLtZR2M88", - "FGKJRTQLhdFJhQ5P3yH9mCoSaSKujBeL9GnQlcRlQ3O6VwxCrFnvSnPAdNbOmesjBSNeN9nj4h4Ji0iF", - "O37Z2wXWnNKPSxISaV5l4mmM/kaQdS4jngJLr22mZaFNS+Qal+t6l2iDyy094U3vZtFnWuSsj94wBGIE", - "qkgRQ2aDKqMkj4lEHj9HpfnWyReVMKiC7IfsX//v/2dkiRjNKS6kDfSeCpXjhH40WuxE4JRople90qIZ", - "iS6HrC6GIN2q5Ai7S6VYZir9C0XQ6Uwhxq/6Q3ZsxlQMeEPyibrCguSZVjNRr2dH3bNy1qYJ4oKGqKqF", - "aP5W2xtvcW4YS1hx5xUb2l2msHWk+msptlRpdYoVucIBhfiVJr3enEoIWQOasO+i49M+gsfgSohm5qnm", - "olqKRRuGKiibDhk8sSKxJi+QaA2ZWddkH71JqSqIn/HierE33ZDNsERjQhh4EZO5CUAuSXVB6hvQ2R70", - "tweD/qC/HToaUz32keKjaR60u7q7LtbCjELvT3qK996fICXwZEIjTUcZEXbU5oKpDzrIz1Mek6Rhpd2H", - "LoQi6WslQ9B4qoUcvVxW9De/mcspI0KP6/zgVF9wsPrfo2GHYWW+KTl9LxN8TmMSo18PzvXVGr0527Sr", - "ZlVT07CWMIJG8G5H5mNGVNP4zVOIwQ1vBYTSrmO+ZomWtmgdeX+eRKMvftPAdytkkpKDaelEC7EFCfbR", - "64r8auVPF7iiRdBlib5owR3i71Ex2h9AoEEpwUxCCKWW9X0hg8ohc9JHReDQkjayK6o/FTkzXMrchz2P", - "+4JYYuQRkMiKIP25/v/eXPLo0oZ4GC2z4LQMp3C6/aVbkqyoaiXkrDUBeApLYEkLicwXPLWOIBnO5Iwr", - "0KrdP7YEkYoLsqmVcxsvpZ9nOJcE/QWlJOViMWTu/a45ZwlWJoah+HaiGVMHbUQJZ8S3I5gAAjxkUvEs", - "I7F1THyvJTU43LgIIuSisFWbl7SWDF3ILSMBSFTMAjag9MAki3KTvZlsgqkBZlNMS885T82wZ1xlST7t", - "2Xl20EZC58ROW0+QfiSmjTFOEs5Zz8ZD6ldL2wk8QgqLKVHW9CyhfaCZYWfINuDcIkNDWr3MGY3gNof3", - "plne800cHbTx6vTdVhmzNGTeY/gkpvKyR3kvoSlVYCmh8hIdb71BQt/Q8DNlUzN6eNlMp5gkvG+n2Efv", - "cZLbcwrUrtdXK8JG+oeR9pTI1WySJ/to/tEKYdX9KHaykIq2d7vW0pSAhalRknl/giSeE0eQ8JVWqaXS", - "xwjHc83DpLXGXqINS0496+cCupOIERIjxofMb0sfZCBTPcS6yI+MIUai2iINWSm21SLJ3dXmXIaFKcIJ", - "Pu0tNqs1mgYfw+drLTdQkc8Kpb52kWS02at6cHpsAodxVC6Tp78S9CYjTL8V8yhPCdMy0YS7V6tRZIP+", - "TlNkWkP3Z/61UnQvZ1woNKVaEdZiHGeWmw07vZgKpY+/zCcTeg1aZA5WICPTGONmV3OoYSdnl4xfsWGn", - "OsxnT/HOJB7vrt0NN+xuZQ2D+wBMz1qlG/P1VvvQDlc6zLSoqSV3ze09zd9S+Bdwl5UpSfrSEz19VhIK", - "kfuOtrsmWndKmFZV9aMJaOxOQmtw+fSCouyde5Vu5Ilp2FETOdu4oWvmME34GC48N5Ey6LSPjqtr10XY", - "WD6orK9ykS3hBYaiDdKf9vXtkkW0NxgMBj280xsMeoM6tSd7PRPynmGliNAD/H9+w72PB73/GfRefCj/", - "HPV7H/7yH0GFvGW0qvNr23luuLPURW6wfghrfaDrwltv7Vnzh998eMG00LjTkSBwDHCydgynuZwdeq83", - "0/rh8XLUm1mpmEeXRPQp30roWGCx2GJTyq73tTgnVXXdVr+7ysASSI4xglFhd8ASWQvLb1tzLChm6oMZ", - "Ixr6xgEtvyG4NfS9fwQjQr2ea8eyLxd5X3EA9NGvXCFsPdJapduHl5b7nQqcphisnXOc0BgOh3WzSRoT", - "Y5xl8Ey/oaUkQVQuGNobDNyTkTcoLTdw6aYy7FjDqYhmyBqEBAJTiMmQiUgG0bc8xrU0Jd9OsnTS9DH7", - "y1bx14b7s//hL5v/+z++KKkDoa2gcWPOuCE/q8V5A5faSPgVERGWBCVEz1Z2UUynVEkjvMUQGgUGte9R", - "ZII9TOii1viYM1HDe1VyThc9nNGetbwYd/drwqZq1tl/uhtc3A37R+/Df7qfGhZW5ElIX3vLc7C8wmM/", - "8dqNoZXzxa1unoDTJaXs2Hy2vSxOft4Ou4ms2mmj1DVutb5jiqygNQNZzsD/1O1EaUiImBMh9FE0fO3w", - "5EirMJfEMjStNaJhPhjsRvAC/EnsL1qI06oM/LZZlXhsDGFA2PmtQ6IZhzDIJOE3MVJ260y9KUpwpY8s", - "tNol91+OKQQnunGLQ7C7fRGNBb8kAlwyeokpkeiSLIy5YFoxaBI2R3MsnGvwHNISjNJqH4E3iIB6rJVY", - "SM6fcQjwMGzR6mwmDYrgpOSWKabM2D57MuKZlvGYfc1Y9C8Im1+gFGdGrRfEGM1TrIigWl0sDYspianm", - "0UNG4GCgDOszH0VcxJADzY0zvVyF7yS6MGriBTR/QZkm6wtzMGum0j86b96d//jm3a9HozenL389OB79", - "8vK/9c/mo87+b390DJhGEXj1I8GCCPQff8B8P5ngWYjp6xzkasaF1Xadix7iVXBG+zwjDNN+xNNOt/Of", - "/j8/fPrgwj1N5t5cH4PAwD4FxUxr+wzE4LsEKIlsUo1L59RLplmUMzt45oaaKd9Ifjc6EqCiUD4aZzKY", - "m1K3W5Ry6PZgcPLjlo0eeeL+sdlH1nULw9c8iAsrHsuZJp8ClwP8aUnCI5uhMWlKwXddhRg8+FozTkMh", - "4jXmVL66zKN6vfLpDVjR1piyLam3oRfdbN2Bbm4dqPySzangDNRzEJfGCZHVs/Lrm6OXo5e/vu/s64sg", - "ziObiHX65u15Z7+zOxgMOiECJdcZFUQGAx8PxpInuSIIXipjTPvoJJcQ3GmZxyQHOyjIZxKlucpBLSLX", - "UZJLzadAGlAqCeUfP+/tbJ9vP98fDPYHg/9pHbCqSX8N8351+s5kqMN5JzhRsxG45tZ9+DO8ewivAqsA", - "2ySo9oFVKvbUWSv16bXfoI1ZVboyk0NAkMPO7qsfzYHafgVnyRGiTQUuWjEN11bv1Y+hdfGsUcuSXmls", - "gww3FjuqXwajMfb6XFozke8J7zEMEDsgXmARozHHIv6+GksDX/715cm77yQCawVOrJ/IiN7wjZElOZGg", - "K7sYn9LYKru1JQDLT2pMiZghAp7SOdUsp5emlLvvqmzJGlMqNtS+52AK2PG8cKFOt6NnZv/jJtjpduYf", - "qznV682BNjSmla64RgnESUYZWaEFfiXC/hUXlwnHMViJvqSs78W71WBozIPqdhfKqSPwJXSnMWbxFY3V", - "bBTzK6aHHLgh7RNUvFxck9d6Jjj51z/++f6kNIhsvxpn9s7c3nnymXdm7ZbUTQdzbYqJ5Fl4Gu+y8CTe", - "n/zrH/90M3nYSRiR8lbiud3/l6aFOkCNxe0y0fgNftdCDivzc3mDL30NhkHoyuVzIhK88K4TO6bO9gB4", - "em1Ughp3o/3OMyGvvlx0a05ce1W3xu0MwtfHN9vRV247EgRY7ygrsPJWHZC35u1SrQ8QXYDmfsSy8L22", - "obSC0LZ3TuyfO8skF6Y4eUkzE0wxwtMi93MVvN7ZJc2s0gxfOFe+YfRxDmr2mHPVHzIDn6PPJlAOuSaR", - "c5AqdHB6LE10gL7S4NJYFoS0Cu7hLsHrxhcqctZF41xpvZorgqyFw+bx67HAy2OC8jJSs6bl2gkuRx/A", - "slwSwUgyMlqsbLky5iNkP2pcHJjqBEsL9yVUnlXX6+iXkzO0cbRgOKUR+sW0esLjPCHozIhqm9XV6w5Z", - "JgBDRXeiyZHafvkE8Vz1+KSnBCFuiCk0Vjg7LJLE/NXpO4tFIjf7Q/aW6IUlLLZRpU6iKMJK2XfK+Jsr", - "zfr91xa9CW/G+dBbHq4z+3p5utqb/bodpZIQglxiZOuETohJVhA8LanP5c91ter1ipeAB+ZA9tE7qQ/k", - "QA47mkFxzVrHXM18FW5CSRJLX80ocSrL17SSF9bjSpWxppDszYLO4SjLq6S7020EDpybqAQtM1QU3CDK", - "is1uWV5Fg6vqG1Ts/V0CLaoqEkVbG7BpGVBQ14ZFWlOukfhbm3I94+KSUddZvv5omS2wuv1j5gay0pRd", - "Gr8+o68z08hSfoH5uetmdotVOi7WpGYA/zLLcyA9Y2ErwFIjwZhrXqKNC5zRvqXjfsTTiy66+M/KD5rH", - "OcVfy8lXyKwG8E2mf/Lbl0shvWsMmLcO68Xy9vtxIBvhptB8GymBmTQB5jOckT76GS4rpEiqRTDA5/Nj", - "6hi/+h5xI5y7T4dMD01WgwydGVvSKaNsugnR4mOCcBwbW7c1VbnAExnC+XUGqCUJ2IzOovUAqq4NM0cX", - "zuZ8UeWJyxbpZYONNVEvaepmSUBDBxuG2kpzpbvXEy4Eap4rg95lp15FVqvZvddFxNixFDa4W+z/WcEu", - "6uDJ84CqDrkgk9JR4XlMmhwTViALO00uycIkCFgHCV5ykfi+kbAHw8ZyW/HC966MAR6a2/jOwrFiXCTW", - "KwJRt7Wg75C/YN1W6PVqvfxVlTeYgNJzky0pxmqxDpjTcSE9OdMfWNskgcUHFXrf5DNddI3OT0DDQkwT", - "S+KC0uvNQzwuRO9d2F/6trWLIjvuS4Btl2kP/tYib2edeGtCmK14pEjcrcoGl4Rkcv2kTFzmrMHfJ8iV", - "oI6ROWTHlmKoTRdJrTL0eQaQl15jKyKh2jaxnB5q1tcbs8P0xTbADPbe7Ac4fipQv3XE+7imnRrIlGqX", - "FzhJLtCGfWkTCfJ3gEO1e8U4K4n9/PDUkUARZ/X+pKspUnOBi5lS2Uj/nxzpU3xRb8x+6054iUb+fAB6", - "5N7ebjVZwQy41mzVThyEgWnemtNczu4ocKnBGu1g341F2t1AWS5naEPhqT46Fu9H3xObn2uppkySKBdk", - "/Vk/APHIYs7AmBJMWe/n8/NTJMiUSsixDUbzm5jwBnAyq8CXcVpuuopXzZU7u3tPnj57/qIfX4o+iUQ/", - "lz2Cpept93GKP3KGr6QWu7bSBc6y/fn2Wj5vtqAYXzPLd9pmMynwVLNkF4nbRnM9LD8pPWWXlMVtG/hF", - "v7s+2tMp1nftb8gE6eXZVGCAu/yS3oZbB/TAajbv6poSIKsjeXOpeOpj326sDuCtLtacJ70YK3yT+F0z", - "3OVQ3XRhmjIqedDM1xQXfVYPhq4A1gQLAXxudJUbS2hbmsB0jSGBxCPFA3mrTnY4PgLsbftuK9RQwP5V", - "fDSfUL4uux1iRio5q0YqseYN3UQvi6i1nrmEDxBWzSKA7vD+xA8q6Q9ZD6SwfXRUdFA0WzSJQcXAsXFv", - "b3BRSZwFJ/J4sYkwen/SR+fFaL+TyHqLLbpwkTKZgycNhKKeEcn8AeSQYUJV/XNrKjRAxJsQO8Pts35R", - "rgSMkoVRC2xYY1qbj0loho2yd4mPudLSSLsKluutuY9WQXNVQiGe9Abbve0n59uDm4ZC3AHWcqitgypv", - "sUHvPvc5fHd8tGNNkZu3RuP64mjMYVZ2VEbro41cEi8TlYtgjL4XCt8Qg3/r0Po7ipQvcSdXvWtW4ly/", - "eRcw0yEgLItUeXMg6DrDXIs26k1u2aCzyMD8UJ4ST5C3WRURDaYcH1F5+aMg+BLg85fvbQBgGJn7LBxo", - "BygZ4wWgQoGRS3CuJhIV4Cml8Xt779ne892ne88HgwCQ8TLB84iOIG+m1QDeHB6jBC8KTL8N8N/HaJzw", - "cZXQn+w+ff5s8GJ7p+04jMe63ToU+rf7Cm3YFfmLK3HmnlQGtbPz7Onu7u7g6dOdvVajsm6DVoNyLoaK", - "SPJs99ne9vOdvVarENLrXjpg6boAH6pycGAKA0Hep8xIRCc0QgDIhvQHkDI/o4wUztnqmRzj2NWCCN8d", - "CtNErgxrM53ZN429Nc0TRbOEmGewIa1cEjDzI2gpWFyLsaL2xs1asnDcawOe3FyKV0Ccick4n04t1l2x", - "dCc2jb0UnihJ4n1UqGwr+RzsZjmwD010YOfQkhpea9XJos55RGCuLgPRIAgq6MRsWmVWLtaBsiwPkkTj", - "Uv6UC4M+AY0iPOa5Ml51Wyyl7MQAuTDwMucsbmfu+ImLy7WJDfomLmrCtDQYuAx3kwFfy233hb7C+21i", - "BOxzid6aL4yhsPw5y6sF8brQkzUoMpeWHxd2Y9tMW+kyLLeAzdyFy5j+St55T6F8vYmJfvqyGraYEqiF", - "pNZKLJpSTDzSGbzeOk9Kf7jWkNJi3Rm5uo9Fh0y/nibbnmQ4u5sVXxUxXNgavDx9buPs+whOFwT5uQT9", - "2kk7c1ATdsn7Q2YTjTy8AnCkAZaACROfESoQF3RKqx1X7az3GLN7E8p0xHVr6vQ/bIrBk4UbMMQDDNLy", - "xITAmgIgPtCr3ZNOt3NWFI2yjKm6NG+LwltLK1JG1i9j7py+u2nkbSb4hIaAcCASyD61ipqLSX29Nzjr", - "bf/VRM1r8gOJjTITPZTyuFbjyb7f7iJ6dfrutGlMRdUl5I9uaU4r4H2Wo10mOTN1nzybCrb1GC3imsHj", - "MZ310Y9Eqh6ZTLhQ+zaQBRxSXs1XvECCxLmmBx+ajDMk6TiBM1r0qgV+/YueEACBjPPJhIgq6sCLkCzt", - "vT1KA8a9n/RzZF4oYA9OfqzK01pub6u1n1aoAdT2CY4om2623u6AQbA2jXW4gq9O3721ZYOaEJf1Uhal", - "hQzYch/9WhTW0kstS1SlfsBSWKMnE0CtXxjJhIeiUX4ShCB4Vhb01GcPPNgGo7eIGsAxYDRx5soKog2I", - "8oqJRL/nWGCmKCMxev+T/B4NrMv8/U/IZNFYBkSlH9hY1RSfhba0MRvudLaQNMKJWRZT2oMy30oJR7q1", - "mnFafmjtuQFlI1xUzrEPtDGfZjks4Nnb3vGb91tpTObdypggGnHGE6LHvenx2LmDZChT9yqsdd5kLnJb", - "VdQ4HJV1FesHAdCgrR2zTh16bwZoTCLsAEXqmxcVZYjHEHsBVq8rQZXSkiu2RmeInikbMaB5Ek3yJOlb", - "BuiGbMzME+DDHoKpuao0+cDzDIMmVYhR1wo6JUjmUURIXAtfqY/ao7d9lLMUCznDSX1y+0WodzTDAkf6", - "Pvzu+rtOuI6QHpxsy+g96ixumtZk6d0rAXr0zl3TEX//U/V4mqveJAdbAzllVAHYGSBcbxSDNJjcfXSC", - "F9aOksGdaeml3DE8mRjIz+IuESQhWEL9b4ne/9RfG5ipuMJJ0xzO9UPLpzb0hPQp0sPsoqzCBoB3eVhb", - "frdPg1dGOZ+Amwk6rPss+uiYhdmeif4BtDwDfCUlnTKt20kbABRhVqzk0t5V786gWaiOLtipLlxlOt3A", - "BRCimNBtZbIZl9Wc5Xqk/LLKpvjl+hqYppHmfg9dwmXNr2V1SXduIS/TRSIh86nlGMY6KkmGBVYkWdRC", - "oqs1DchyUAm5JtENMj5f6tc/mTo2uSAjNRNEznhSDUXa7S7XQpYQ9j8ntvybmZPne1McpVhcwilzujTK", - "mVmBatbI7jqIp5lS2Q0m9fP5+akxsCki5jip5x3JpSCbI5LgBRoTdUUIc1PBEmE/7Lyety0bSnAJNcqI", - "oLy6hp3dQL9nJhUBTQWOCDJfuUrqBVvTR6/tUtpeAqChUUSkbNjf7VX7az+d5Em7PQ4Na3vdDqvoJht8", - "fnjqSkkVpbrdMu8sr/IpET1z5FzN7tVbuyNXFzVzXTGDBb0ks48JVDmzyV5+LrYLMYQqbvrzSv6zxxyk", - "n7Fr+4FTYJaqa875h1YKXv24h2JpUsziUMlzk+Nj0DGmAJkHiQYiB+2LxkZSMneydz/bVCUtaVFGpKxB", - "C0S5SDrdTm9iZ7W/taX5fQKooXu728+3VkdyrwzhtxGLo5iuMvG4uEYT+eYS2Q3kIky6ShJbOMtaGMHN", - "Oq65H4A9LYcMQ5lzfbd5SpbLIRkMllASrnGkXGFGsIpXoh6wf2wB27sqyOgGU1NrSWu7L/zzOQiGoWRY", - "zarkv7VE+xCaBmHFmkaMxbG2jobIPwalUy5C1fe4UDbpdkxc3HFxH7qoXodiXfGPD577s3z65Mnuk3V8", - "CJhN7ZjbcxeYqnm7CitAvOW259c2ABGSVZnDHenVBYT1uqyhKc0Rl0hq9YLyjLAbreeTvd2dm61n24kc", - "u8jMGl8KoTIdnhwZmUjr8pgyIlBKFI6xwlUmA+ZkzWWgvBMmKWTmTb5fzVoaQph8mKXb1qb7UgEwDWUq", - "3zpU9hQzOgElybzp9yxneOfJ031TTDcmk70nT/v9/k3BZ16WaDOttmLLxMl6ODR9Ofu8fbgDjJk2c/mj", - "c3pw/rNmZLkU5tLakmPK9r1/F/8sH8Af5p9jysLYNG3qL9PJUt3lakhobsG/SbyPyhL7Tu5pE6LX4A+C", - "BAEAxAoiPVYCpe8O0rGgcVqt3HGD9PwV6epaXHnDkkXj2ty6lDIrbKHKK6Hs2xValFOmH1eHuDiLM7xj", - "+zSWjqLS9HJwy61qhcuV5VSXqu1lhBUFVJPE/BVxBtjaoWqqlSvSPWtRjA+uEVt1r+jS/7Ho3fvx0B+I", - "97sr5uf9ZMuqfrhhVNpKgfRvy3Loei7kxNE1hzls/i9uhbYlrC0UZDAd44HvwttEblZ7fzP9r9//jzx9", - "9vft31+/f//f81f/dfQr/e/3yembz4IZWo0C+qBQnl8MvRPCFSsQnm1J6QSrKGCj0+pfwwrbJ8bioKIZ", - "FN1FY7I/ZD30mioiTAnHWv7xsIM2CGhK8JUWd6E+lUn93NQfn5qgAv3xH04M/lRvI7a4EMJuSAH3I/Nx", - "zFNM2eaQDZltC7mJSNAL9F8xinBm6jBShrT+u0BjAUUzrZe37LyL/sBZ9mlzyMAqS64NoH2Goe7gpPBN", - "MBejYUdlItHt66RAfjFJwUNW3NYFDKZx9ffLmhmUJPW0vYZFWa2/Wc3p+SAEGAopZXojoS4UqCAFZWsy", - "KnLd0PPB5rI+t0bHKGhoBfnZWBiTc3yQh8zFTXnKRySmEfAVl6o7s8ncRZa0oTRrxMsEv17A3rw1+aMx", - "wrmaaV4UWWyLiPNLSrqwpV3wSEPwFXxpQmpmPOuNF70ZzwqcEyxMwBk2QSlVJfv/9OxEe++JoBPbUxCu", - "QpNIQOiEI2NnZrJ+C+vC0sTOTUkdpkWfObGvm9pM0pTjMdkUKhfMVYwhUDcWAHUK6iMhmfx7FCUUrE5y", - "Bj68GWBeKt1MCLayMyiy1vA4ismk/u9qVNHOk6egwbp/7+60Tho3S7eKyvIkoNOmjvW14NiGTcIAjHgw", - "cobwNXGA+ga0oRRgp1Ac/nuGXEPliSsYifH0mTRWaQuQJNJLYN0MZhraY2BRe0bYnqY299HSKawkLrZo", - "wQQIwWdJC7yglyZh+vz1GVJEpA7CYiPSuwOnxIDD9KiUua2Fd3B48nKz3wlinVVcWrBVKxMbq4MOwJ3Y", - "gKGmOKjSRoNT0kXHR5Cwbq+VUheDDKOfuECJuRXLy2gf8HKq5h5sqmgeH1kBNFmUUUdGbBl2Nl2LWf16", - "20dvCxUQF0MpUo9L2nJNlpcJNGtjUE3601LrtUx18I9Z9c/ex5DsBCVGDS8GvNbG+6u9zdGhv+mLqmYh", - "u/GF5AeCNdq/vL3/0sjmX15G372ZjG690KNshmWIume+VxNeWtp335FdZfeiOWyv0u9I0uDZ+psrtORd", - "Q4roe67yeQjK90lve/t8e+/m5rubglJX0eg8pMoCl7o9oPRdADMHYJapGjXmdyD92GZzOLvI+xM0w5J9", - "p+BhzTqyvfusjVECem2bGeHnRPCJGVLBpRy0XRHRb0D+LmmSGAFG0inDCXqBNs6OX/1y/Pr1JuqhN29O", - "6lux6ovg/twCnxpuAVhHExwVQDeroHKgIn33/Pw1HK6EQAaUkcMvb49avda02ALF2g3u1ek7cPxjOXKx", - "083pwrhMuSfXVCq5DGzYKgXhc1CzzaelYazNJE0bNqhyLfT2zxVs6CBS5eYdYGa7/JGl5XwAOOmHzNP9", - "+qCsV4JPfy6CtLUz3BGAdOOVFgJfroGSPGm63W4PBX0nw6lgOoXYli/hOBCFW2Mvdzs0kEB+YOP40PFp", - "WWytdEa45mtzerHT3376HOoFbw/aMPYURyv6Pjk4bN/5YMfcMvt4vB/F+6Cw39ZnZQnbqCA4ucILqLdp", - "lnbYMRemp916x9Yqkq3ia5Yhrm+HaF0X4xowq0GcdYFLcpSuLPfTIkO4jluY5haVNaVJQiWJOItlVUae", - "YYlkZsCITdmbQoIfMhhgFxXVx0FKQTiKRF6aHq10beX9PLN0D6V3M860DgC1N34hC4lSCk7QonsIfZSo", - "SESLh2xDuKTFIjsRqu7G+gdIAera5JK4C1HDUN5HfzBkcpYrzcQ2++iQM5mnRFirLBpT8BhtIpkblRbG", - "C6ux0AxT0piIIdOvBeCO/yjUk/2ng8Fg0O0Umtyu/vcgRE136vzsWzhvk3YPgJvMAntDtLvIGcpZTERR", - "gp8YcqiHyN3QcfqZON7u83bilf28lKvCB3Md7Hc7PO/PBVGGoTbo5xAdegvl/MntRfRWaX9OfrUJf/ar", - "0U0iGGxKhtb4xvq2MwY5ElszpCQ216PIvXlnyt9Wp25DjxVHv+dELND7k5NK2IPN0Gg3ceASDfvAsxtt", - "w84aG8na0dzEvexBTt8HzHRdUvEkxC8OKu17GB0OgaHQimGrojivsq9prTKYA0WZ2SdNNCsmWKuAbFBb", - "ysBIv1W5kBMLsWpFaZdTYSHfCwSW0qkvF3JrnMutLKJbNntsC+BxngM8zl4QvyAm81Geh1Qj/cjhIb17", - "d3yENuAXgHc2CTKV7jF+uv188PxF7/l4+2lvLx5s9/D27tPezhM8mOxGz3a3d3ZXJBW1SGi9fY5qUGMO", - "xDEXUesjFz0fCmpuyl2oySY2HvuKsphfVa6/YICs37sNvl3X/XJofeshBFOCEiyVMV80cLITuORJpNs2", - "Aek2abqodhY2dD49H2x/rvUHBtdwR5yLnBm3qoH5KFwIqTdgf7Oq47wdy4cBucSXdavld95+0Qb7T17s", - "P/ncRXPJG+vGWCene9zcpogwB4Neyw5xScKeHckZKDtWJjJWfZtM0ul2inwX+BuEgVosdfG4VRJX04Ht", - "htnIqmulAb/guKKvQKSKgcGM97Wk4vQRKKpSoGRoEegw4XmMPFucQQUEP9yxp7voZsAtZk10JnHZJGNA", - "ZiSVNp+PMs2Iwf+oG7FgB/voFbwLj3Bq1Do7CFNCyHe94Xhh4mX0+XJdGyVr9ZDPrH4F32hlC+l/wbT1", - "MliT7eomjHS2j37l8E2h7TFet/2a10HNWn69bifesIj5DrwGOrOi5j76qRAvCwHVCqQbktg/R5ZhlZhR", - "mxXkDrvjHU0t5c55KBTdjlnRTrfjFgrQKpZxK96VVL90/nxSDAWSEZzAWS7T9HNFE4uUDzOhUtFI2uQR", - "vblNYo/NzSTxyChPTTGpJvXVKljFR06qen+CNgAM9S/IGrb1vzaL+NXKXbfzYu/F02c7L562gjwrB7he", - "ND4EZILlwa2Vk6MsH1nbSNPUD0/fGdtHZKwKRezL+xMfYSYTXLMePXPXoN/5i/4LH+kt5vk48RyLFhbS", - "AEvDhgXBDAte1BAH+TtN5nQyYb9/jC53/i5oun39VO6MtxsQrE1HYbPbsR9csGSjJuOeqVQWBuMCghKy", - "Ea/uLZEwA3RGFAL66SEcgXpT5FNbknOodnbFg4S1t7u7+/zZk51WdGVH5x2cERjhApeyHYF3xOBNtPH2", - "7AxteQRn2nSwLlkA5qB2zpAtMz6oCqT97cFuiEoaLu6Samzb87Rxyd9b9dFOyi46JG8XquXSKQ+u9u7u", - "4Nnek+dP2h1jax4eievVHMalLJnlsbUw/J3fAGny/OAUQULwBEdV246LELvRqNSNRgV1XEz9hRsM7Pmz", - "p0/2dne22wEvhoJOLKRo5cBWeVfg0AWIIrAbgaVYZr3dptsiJE4ZAntLogTT9CByKRa128fUWRgJ81q5", - "CW0uBquBL11cLb5tZdwqTFYmQceIBlygnBXVffrrXbJfxLPazLXN9bCeq4fScphePYsQZqoY3mIpM0Hm", - "lOfyCzTElcmZnSScixt926SwvCUyT5Sx2VCJ3p98BzxF0xqSimRVHcpS4woctVtO7kbnuUIiYSJvWqxW", - "u9Fm61dNuNtwarurADUq3KARvTDWnCtn64M/D3ES5VDPChf7qWcFMHwACZBlycLE9icJ5wxFM8zASSI8", - "0DE040ncD0bC6iejSTCqgl+hhBvc9UtCMlvqyQxCf6ZFGDonaMMvcmhIqVZ6+ElqmIwt5lOlxidpuIZq", - "GFOpSIXX64kV9yDBzScVS2jCpxKUQgVZC/16JYoMC5OMgJkpXTZPjS4ZCLgODLHGzEM3qrlJ+cQquFbk", - "gERzs5I4Elx6cGDvT2r5yyty3oos5vUBndXBtiBd49AMXGUGkK51hcPQ/RjI5/mcGxJoGHIGV4RKOuNk", - "ilkOxZ88QraG+H7rcMgZl2pUwFzdcLBSjaCiSy5ICVhZZN0X9iD3TvBedKztNstl445v9fUSVYWbahpg", - "M08Nrmh4tboFDYbIeBlXbiWUXQnwVwezugleZFkChEpolXrIgWgDcl48tuShQG62CZIJq6y6nyVt1Rbo", - "fb03OGuLrLgaSPEUq9kxm/AA9scNPKfOEm2jVTMiHLhhTBglsdMlCxeqNXVBwngiCYpzYlfOyKc+/B2E", - "OGA1A3skfEjZtMbr6x22MQ+bMawu+AL92hfbhDvJcELtuchhrUy8okS4TK1tFQRK5SjszlpuWJBpnmCB", - "LCZqmyHLRZpQdtmmdblIxzyhEdIf1P3iE54k/GqkH8kfYC6brWanPxg1VQc7M4OzeYFmQ2r9llP4Qc9y", - "s5aVDJaYLfP9FjhG20SPBSPFf6IJsdiU7xi99gi9WhFhb2fQlC3f0GglT34ZnPWmnNuSbPDE5zKQW7hS", - "ynF1zUhsy1QYsSfLpSmx1OJWcjjIzgV4O49ONXHk86BJDg2/rgGToDGBvB83tWWu0YIttplKsLpLLmfo", - "73xcNYi2DfsN1AzcYCVEhiCTYHw/7OhKg7R5Y2lNvN29CQYFsFU9UfjohtAO66orlvFVTfzk7VKhwRmx", - "S0bdHE3RwRZFdFz8RwFfYHttj2NQLwkZiFcGlBqpFlBcGSpaLbw6pxKNuRAAAq8lHM7cbAB2Rcs8eq0d", - "7hU6n5EFEiTFlA0ZZYWRFMDUCGJkToSXJcuFVrKmJO6jv3kqHsDmp5la2HoMYDz/TiJ+xYoxDpk/SN14", - "LnU7B8xYFkWeqUrFVt0saH2aUCBrGZxgSkAJU6pmaCKInPlzD5Wt1TLeFRdxYz2wBXKvQJkp8LEixS8J", - "81lZ0UxQNTQNjcxXy1F8puY0PLX6J6qUgUb1Ms+r+8slEWEhsZhS8Uqr0BXvqHjKiQGBAUQUKO9p/zIs", - "vkBBaYF5Ujb/V9dk+dNp0Xj1t9prHq6JQ/o+MGbboAk2Mmk8tWCfqidtbagKpMGtQrNZ9iWgDRdC7Wok", - "VSUBr1ZRq3uyXSZePVnAjWZLkqja+97zJ8+etiwW9VnOOoPe9aVdc/N0hUuuYadO2vh9nj95/uLF7t6T", - "Fzs38rC4vJKG/WnKLfH3B22Qa6UPa/Kvf/zz/UnN6/MEYrAHNxqUySwJD6khu6Q6oPcn//rHP92obj2g", - "EKNZBulv8Ns3Rukk/k66QIGqC6+dk2yFfn9QMRLggs2gDQJQ3HRORmbdeuVgajAg7aRgnOGIqkWAkeMr", - "E+1evFLD2m7jDqoONiTymrYtKqrmXDIfl0mnG65z9J/GN1yjheeta87JfNzkh35T79V4oUuvhR/j0CLE", - "wFBE2MBdzOcKy0pAt/47grwLl2G2nG1j3liNultPhYAoFlta0QsFDNUaqMmT9iN/+2vb6fktK2ad+op/", - "WHEOm4/gjay+gRs5YPSN1qfW1viDvQBv99Vo7FeDXFlus1I6srx1b95vi+zh5VIlxQ128/68hMmbfFjH", - "BAZ6tGOwS1623a2QRAM1ebkwAQMaT0jPK15gSm3J3HgE9Zm3MPOBDM7okk8mVazbJ83Y6AD7A8lerhes", - "lNZMuohcO5tFHVjbYPwMO0/ksKNVgGFnOx12am6rYPpkiq9HtoMqtstgFVh5mf5eG6R0MxgnPLo0hQ6h", - "fn4fDVBKMJMoZ3D4a1617cFq71C3k3l7U0CDExPitMS2YExjMsNzCvVUrE9lWgnEJNdUSQgYhXb2UcwN", - "2lOlyrOdoX7NJDful5OGSwezhW1YN6jf48xFtJbvgoFvArWl2UcieNeCFWiO/ebNSdcEMEDooRlYJb7R", - "TdSMQDPIootaeYXy93D88DghIxh3Ha4/XV5HPycdPKuCSKKkxe8uyaFGBCjiOVN1HP+0nSJXTStbvpJy", - "BsF+NvwDcNls74ZAUEwiOJFy+SxWCf0WxF3LG7ArHUoc2A2RMByKFfV33lqHcH0AxtjgFWg37fhx3cZL", - "OJKK24p+xakekeuIkLgO+Bl+pW2svP0yGCv/GluMoKJ2un0b4p2XZ9e/uwQvGGvTavsx/YyzHqCTuC21", - "SCIGGtBi1VQJrQI97kFajELwqqEX2mRck+vVa/0ruVaAjx7niQG9C5OuZVX2Mlq34rfObGw60FyQtRUy", - "76BypIk3v1XtSBuq/hDlI+1bd1Iycml3zohy755ZMmrcoWqhl4pLywX8u1eqMTaGlLrIXvBoO92skeDe", - "LGwVsaC8LXM0GU7JKBNkQq9XEI95wSjGVViT8iAVGQwGX3Qjxddo7xmU/pK1sTM6nalkUQ3A2QtgKX1W", - "XVVBFGHOUNhm58vddB8uR7vZ7fRbDwnHZx400FJJEyuSjlbhZh+W3jZrnc/wAqw4jU7CZ7t7g8HuzuBW", - "wNluWDdYrsPyE1uFtNpOU0qd95119FeiVP0WiiTr5dLWplyd55SUShCc7kPiTYYjghIyAZC8IqF1vWex", - "3vXqwVuBymbRFvTvNsrum/PBV0vmFF1ZzHE3jY5zLlYxiPznaxyiDWwmWoLUC+Tc7fYGT8+3d/efPN3f", - "3r4LsOtikZqyPZ593L56luzgyV7yfPHs9+3Zs+lOuhvUwy6pqQzUhlZ/0e82RtmUl2QVy6jC0tCGnUNG", - "RL1meb3WvyQJZaQniwyp9WmKK3iB8b+vPf83s/ObGayUHc6qk/RFCKzKxalQ1sPgb9nJrPRd1GdzfLR6", - "FrfKQKoPJExv9aEAebUbDFSo2O58JjJDzlpeQ++8F1tfRCuz4tZdRSEPO5z04C43rHiIvGvADN6sV13g", - "y5dcwHY65YKqWbr6tiheK2DEIW76o1RxFe+pj46njGvtyf+5CJPzlSj9cafbST7uVc+M/b098pdFIC4I", - "0G61LxW0CCNLyJwkq1cBXikVD2Ei2bWursf8w3Zv+wXEISQf934Y9F5UIw66ZrX85dt2b1d+HbRZQ78E", - "oCsdtf3iRhHXbj1XUdAvNFTArryXLTaxpfGyPLy7OlzCbWWDy8dLe1xD8mkUQD9X0rOX28gXmmKS4EUI", - "m94z1Mqa9ugTGRqTKWWyjd12d1AYbp+kw04fHViAcNBlFS/68ZvXtOLTCU1TElMtYxrVvzmDYaelLa6u", - "S9ysNon7KiCt9cPi2ov1EAnrEq7WXZP9z8jH/Sztt53Guwq9A+xqTkUFDDF4sYvoBGFWK1DqilUbpQMS", - "IyFebd8BtZUka3mALOVAZyfpoilXqEyhb2lvy1mzXbAYP7kGe+sKzAxDEDtfBBClABCjq9jX8RHKBI/z", - "qMwfTWDQJeKHyGsQbSuE/PUhuXdp34DE7AkXaL19o8mg0c4+2bTfNdukJtjmrd4erN/qOzGKdDt5Fq/n", - "YealdhzsRsjta1IQAyaa6rLXJEFvMh9acPS3/gou67zGlhxpkSjPnINF09QyJQXcLeBiCMX1HpGE6Gtq", - "uRHEk7jMkqCy5KLrWer20+ezJhcneKSWB/ILIZnWVQD/CPpLMVsEB+bKjhZ3ycbAoX1L4/DqmXJFdrWq", - "g3u2VhJr3CrfhNtUQsFw+ZrN2+ClXHrm7wLj2xfNlhFQHMOvCGlvm0sA2C9d2Fuj/fguzHIPKaS9sa6H", - "GmyrAxUu0NFd/2UssBbrqsS7F3LPh8jiHE+hVt4aP0ojqYTqSoKTAyOFbXErKLCp/4mgiqh0gC1OQIDP", - "XUavLTaIqEJ4iuuVI5rKUhpP9PoYouZCL+fWRNCEyVtPh/XN7we9/zHmdjTq72/98Jf/u/fhP4Nm95oB", - "QRLRi8kEIq4uyaJnqjApPK1eoX+YEhBaq5jaM0NwCsY0QHu3XMkf75NBwT0Xv+J0aQoQquaVUNpeO6G/", - "/EdzoJe3jO/gwlh7dj+7QspdVJJV3N3LGykRUxdU7zLqNvtDBkl6l2QhkVeYzcp27sR+J4tPvFB8dGHI", - "vU/Y/AKNKVS6lEOm1XscRSTTapWt9UNNuXYObFgQnPjt2AJx7rxYz6wJrCDo/ckSnPGbd+c/vnn369Ho", - "zenLXw+OR7+8/G+IdbnqmR7inqa9vSdPbZF2fyW3g4VCbl7voo9ObL6CjXmY5KDZA2CZRGmucoiOIddR", - "kks6d55Sldy+ssVy1vLtK0V8JhSyUkkoPMNCdid0QiDAAe5VG11EpSNGKqG6vbXyUIaWRRdDOMMOXCmK", - "O0kiWFdEb0V4tcuNrS7601m7u8SAxAYOO6T+6sMWsKK+phKAO1wQjPcy2oAUGleC12UQb94MtPagaDAY", - "gvmFKy0NXnyJaqjvVpY/nfOkp/W8hpIRQbO6WYtgCgE0ZVIzOk3el+k4oMxYG/eUTnHA4RJyrHyRqqVu", - "QGtTx5b2v7F8Wzih46heT8McS7NUtfoPNWuJVL3mfI9Ui/cNCMQAsWySeKkXZFjN2E2Z2rLVhUPAITEH", - "VPdVadvlKXMwkT34aH028koF05uZN5LmvTlxelRN01uxQKd6aa5mRBBvI+CDsk7BDZfMJii1gKMx1Rkz", - "IsrgXZfdpMVz8LtLtFGYwNwSFGnXy36B1XUoTvB10QP4lLBccsTCPMo6WNuvfoSaAW9d7U86cU3AMGpa", - "bhghv0pFq9bEUdXyZvhUtTxv837w4FletYL7NZ2tGnGWfVRIM0SPf8NU/cQF6MXN4C93DrQPl39MBIDh", - "1WH0W2HQ05TEI56r1edfv6alR3PlF/Vhy/rCzgaAgYijSl5zEy9w8CTlGJZXWi8HiXJB1eJMr5eNaod8", - "UFfUFxYSOoKfy46hkOqnT2A9nwQyZ14RRgSNoEytPo8pZqAxofcnXrVCU7hyCbgWRKA3h8fW7uKwj0GP", - "pgpIzwWgHpwed7qdORHG9tAZ9Hf7AzjMGWE4o539zm5/uz/ogFY1gylujXOaxDaR3GrUhQZ/HFtJ6Ef3", - "kv5S4JQo+OK3ACQCBGDa10EFwVNPicwwFVaLzBKAajAEQ/XXUHfBXaj75lbummVvbTyGfGtIAyLZG7u5", - "H0BQhrMD09wZDCzCu7LXLyQxmcyJrb/bMNqy31ZSnV2iQBmCJTXPyZbF0n/qdvYG2zca06qhwNkNdfyO", - "YZvNTEA7f3LDhbhVp8fM5CfabHMbF+afOCAk/6z99kHvmczTFIuFWzB/tTIumwRjIhF27xo9TkkUaVYB", - "xZL66A0j5jnCCmETwi1yBjWm3YeaQqunwLTtNrlAa/qRx4svtoSVPpyN4lOVnenj8mmJnr8c7RRkvLyR", - "9pGDGjdUew8E9CMuCqQ/2EnZG7y4+04POZskNFKoVxCwDcymEmKfEgBOdyBMXKDfc64wKvIaHtGRtjLr", - "uCC3bnkVbf1B40/meCck5A84JSLFzGSJmHfWHPql42x8M+VxXnmrOcI/PurYm8qhEZmLCgS56hH1r626", - "MLh8He0FoChsn2Z68QMS/t49nHA72aJG7kMeOahMinJJHtNxsr7GcSmEBGW5V0R9LTQ/uM8ry1ZT+BOe", - "osdCwK9IIeGVu7V0KWxlImdGAQ5KgG/LzE373XdV4e+8fOKFC4FfQzcNdT2UcTDjeNFHbk2N0q8WgDUl", - "CMwzXr5WTvXwvpYTtnMfJwxmXHiKvl1T366pVafcUIubAhxM75S3sEHcyALx57M/3Nj68M320N720Mry", - "wMiVtS78nY/7yIbmRjwmSM54nsRoTJABfnJBOAqL/vQjwiKa0TkBdD+oVpcnimZYQIhNimKssPGhNxom", - "Vpoliua2dHM9F5BZLnAd0EOSEYTAjJqAOMtQTMoYiZH+xEbNlLiKS3XVzdkPGtiLBsurEV3NuCQFsCFT", - "3m0Oed7SaMfQbH/Izi3irV5AiCp3vEaSBHB7V9h/OEN4yOwH3zsW4iLiJE5LzoUFgCdSA9FptmU5x0+P", - "dCQjHgIdOicMM9WTGYnohEZ2WpdkYQNbgw22KkClB+zG+f6kyFxBO5th4DoIXQqjFB8Vz5ClpKr/hkE0", - "eJTkcenkclhKWIxxkgQrlEwTPsbJyKzPJQn4BF/BG3ZRSodL6U1iPCamln62UDPOzN/5OGcqN3+PBb+S", - "RAw7m/0hg4wUu9Yk7pYCIrqCinZpxvU5Ezw1fW6ZIW79cUkWn/pDdhCnlDmKgE9wIjki1/AdxI0BeIjh", - "Xg30YE5T2A9+mEvFUx8C1tGdGSbPVZYrm1ojieqG4E+HTHH0hwO5/LT1R9njJ3AWExxrOvFeMVMC2bpp", - "1HKE9exH8GrA3U5gAYYdfZGaMI+pwEwZ/NICpRNN/S3dKMpEQOnY+gpHmKGMZ6bEBhDVDGuSq7QBoBU4", - "SZCCo+S+1YI77GTDfCwGYTpuBCA0iHG1Y0QZOvnRO0yDvefh8yRJJEgoouS/zt78iuBW1ntgXivDtUxu", - "C9MCA4pzcJ06nvYSRzNkHFVQVXHYofGwU7hz400Yay5tuEyvBz7FH/TQfjDddGn8Q7+vmzLuyn302x+m", - "lX19lrLUAKIOO5+6yHswpWqWj4tnH8IL2oTjdlZhBGjDXHObwEkwBcgd78Y3VyRmMeL2FkgWCKOSA/mB", - "K2PKsFisyqgMLL1dQT4xkYzeYvwxhMjFYWd/6GIXh53usEPYHH6zAY7DzqfwClivZXMJP7jPCudmQURP", - "B4PN9ZDgdn0DPssWjoEvrAM2akVF/VG9gxaP9s/lH/i31j8L1w9muvMSo8ko/s74/ggdEJ7E7muiARdE", - "TezGLCKJE7vXG3ru33mgNysiSXLfBPpQ5Fm4x4qSBY+KHGGzymO00nz/wBQ3uK9LpWK2fxj6fXT284D1", - "3NrOydyFOocLtgAYj1WlkXkZYYnOYEy9M618v4Rf+/a/TvcDcMmLhE8v9o3qjhI+RQllNh/AC1TW4oFd", - "S/jI4PEU31l4Hlctb8NIEv/6xz9hUJRN//WPf1qQ+3/9459w3LcMzhwU276YESzUmGB1sY9+ISTr4YTO", - "iZsMlMMlcyIWaHdgbf7wCHk1/62UJodsyN4SlQvm5U2YwnXSNmhdBXo+lOVEWjwjSBOa2Ko6JrYxYLdx", - "Z9ks5b2e6G4AFxJm4E1A34qOBgBUj5qK41YT7YRNpmbOFaNpPUxzKVhvPX9R5FoZ6u2ZAd6QwcASh84d", - "PLCTRhtnZy83+wi0LUMVUDkJdIeyGatG9L/xpPU8yXCUKkOBVTa8KcIZHtOEOpNjQ9kXcwRTHM0oI2V8", - "cQG67prYdyPVPObg9BjZQMguvDpkb862wMSqSKRyQbqWEwgLtVrWheM2zwV6AP5FFUSH9ey7QzYhGPKE", - "jo8ME/DQyIvEyKJhBogmEONKVaUEXXfIDKSuhXDWBy/lMUngI+h/ihW5wosuKor+ujIxCVZaIZZd/fKQ", - "mVRDuwY9wGxB3jD7wM/MkHouktfmbAkySbRqDBH4pv459L0x4QLZCOdumVfqujPZpmZYetFSHL050/Ob", - "gibIjT0QWnpz5nZjs4skR1FCgRoizIZsCoFADsWYs8quFgllMyziXsT1JeCjWl0yfpWQeNrEYw99IrtD", - "SabST+A4/Vwn18cmXMyWJ6APsUHqW+25O7LvtHPd2Rb/TL47WxHzBs47Y8Elht+Y1f3myGvhyAuvm3Pq", - "hTxrRw6K8u4ifk0XDxTw62hvec3NE2/JHsKihzYcxg94RbhAp4fHCMexIFJu/nvb+/RMDZWW8p++HzUr", - "fojQEzsWLiz6obW3VAnksbCDt3bUCLt51QsN+/fbVqUKUeNNVxQkKq+8u789ap3e5Bophd6S1r7dJGuD", - "bamMONRbLKmlB6JRQgrxpTinPhWtsyqbMN7iylkpLln2fHzkDuT92Zdt1zmr3w33wBSPagzxARlhNdXa", - "Lx/+mKj5XbGLDnZ7hfn56yLNwf1JQfdtig6R+WNSF+PasmkuaIBOGi/QV0QZeJO71NNtD4GJnxHhTrUZ", - "6MLMupiW+RQZnBaYEFhiVuu+x+aVdqqvae/PpPnC8txEYrFL/k1EaaHslmu1SsE9trWw706/rSC53XPY", - "iiWwwCKDFXXs3E5gWd3AcsGizW+RK1+cok1cY6nECjdvEheWbIOmVOhZ9yXXHTC/8LqW6axeSxmaJHQ6", - "s06AmE4gVk/5hcxhlDv3MMqiYLjAitgQxceY93uqF9l6gedEeEiN/pW69QcEra5XlRzzWnm7vnv7ukdY", - "xOPCedIsk9onX1hhMvRfyeW9/1P3CPNZqRMPmgTGz9h/E0yOCujO/7XzkwXv/F87P+Eko4z8r92DBCsi", - "1eadEcvgvm66+1ZgHjHxaf2FVhdtiTVtKTz181CrlOnga29CmRXYWWfsLhJpvhj1fXkhr47V20rCuze6", - "15rUfSOWHLuyBGZLuXDoVTVI4n9voe+sQtD3bq+rdE8ljAAEu0cYpcyFQ13FFraaOuK2vIlNAdd7jTGi", - "eKulPcK9/6cySZhJ38goUazrN7tEG7uEv1wrTRN2K+7UOGH6eCDve0FsodWGR9/gtu7BoWMp0oPbqni4", - "S8CtGZcKHj0+7AV7ldCC4vxro6VnsjyQK68PR7rHR11YSCj7D2WHbGrzPfkp3TjuXfG2/d6/0HOQjuk0", - "57n0s6ZTrKIZkRZRICFVBvzYTALl9dxoFPiKqXRwn1fHvev83+j+jqwR9Q01zNsEG6yT+d1bbWV++76W", - "+Q3askVdsLWRuq5u3mZDEojDW25LxhVY6uXklNC4QroIeqcVlVJdQKBB7A/Z/9b6x2+K4PTDDy69Ox8M", - "dp7C74TNP/zgMrzZiSMVwpSgtszpwa9HEOExhSB+qIRagknUx4HSXCpDeq7kyb+dglQGubTXkBwVftOQ", - "WmlI3nKt1pDsXtytilQtm3TvOpKjt9CC23oHf04t6U/uuq1ocDKfTGhECYPiU2D8k0uxykaT++a1vSVY", - "ArOxEl6gY0USaa1GFlxrjYReFv7/kpGE3cYaFBxhpUiaKTQVOCKTPDFVW5Cc5SrmV8yVpIAJuupmtJxP", - "6Hp3TY1cI+EE2XCJ9raablGW8b5VXdvxI81Q5ZmtMG6Vy1K0adYuH5Z471anbHHV3r9W+ZhJzKhvy0uX", - "aQ0hUGLNuHnS3KTzFl+W6Ix9dH7+2qXuavVEuIJ9irsqfa6S85D5Vfr66GVZ/tC84FrQ6gOJbao/JDTb", - "uncxwXFCGYFcByJDWbbV2poPeiy+vAQcLhx63y7uNsfS1sR+OAn4wVjBvciahU9fWYdscTS9mqLFaXHy", - "JpyaR8WvLAMKMJ6QrLeFc8V7Fgxga8YNQmQYJPc0wRFg5OrXDHyjxV8xeK1+UwCqIniSEGFgObNcOXFr", - "yIrBUaZ4UXjaSmYXuvlRzhRNLrom1BCwlSTCbGGx6Yas0pmV+QAjAfA/YISCZGbEtSq6etCU5xLeAjgD", - "v0uEkyu8kENmURXM51B6XZDIINgmSR/9zAHQxlST9hivKeX6nRyyCxonZGTxaC4QlUjOuFCEkRilfE5k", - "tV+CRUKJgEkcYr1yEqV4AcCQBiPXrA/PiAFfrKDecP1vzGIKRUF1z8WU94cMo53BAKUEM2kxLCSewIVj", - "20AwiMqAvkcY7Q1e2K9q+wbg5W75N/RpEoLMeYTHyQIRTcWm2PYmbGBqi/Saqu96+yZUSLNfhX3TVl+s", - "bCyVruZs3EU5K1E6wNafswJUQ2+XygWDeVovIKGiuAYtMNGYRFivJ+PVfgASlkdRLkIXpN5qr1r0v6Pg", - "6E3vDJYqjIGRgMkgIjHsOeNqBmeaw1Ha/L6Bqkqi+nNcNMFDwgXCyKPr0qJBohxY4wZAqF6UpU+ZK2V+", - "sfm9Ozv6+FpG4I6/ATF9LPcTEBGfTCoHcP3VZA7wqtyzZRL+s57TQ1fz2mdxMcVTxqWikWOGDqHaV5q/", - "KYStFMLVKxuk5gkXl80Bxz9xcdlWA3OBkY9LEfNn+BU6IvTwAAT/4f0RYA03yoommntX0ur0VZxSELqo", - "kkVIMEo4m+pTVFrl791t4Gt1GwbQUl+mwji7C/gxrYSM7I+mbLYX1g0uhsi2+tC8SPd+D86oX7lCNM0S", - "khIoq90zxKY3u4SqGy8slF4B2HYzXqlPlY+rYHRBaeIPuk4cArpyG7YB0vvydgWZasKn6wFRi84d+mcA", - "EXXI3klTquDCuJ4uUMGDtUBryo+gqxmNZoCOCnqrbt+Ap+IsuyiA4Tf30Ss4yD4+PnS+YYqOaFqTPCEG", - "9HSephf7y4Wj35+cwEcGGNWUiL7YR65YdHF/SP2Wj3aqZ5FgqdCvFsN1o1DGYUcvFNb6ZjG/TYuDWgL3", - "D1kIE5WRK9sgnaALDx71ogG7z/Hb13wqvxpXUVluxcxFcWRVR6BNwuJOU5AHTcKOn+3BIFQFoCVKqxnG", - "HYO0Lg3mNZ8WpV4qpIyzrC352mECFc/TdAUNow0P8lGqmOfqL1LFRAj42FJ3E3GjDRzZMn/4UhOqBfh0", - "B3sTyC8YymRqLwSXSjPVTrdDWJ529n+z/5qnaafbsePR312pLPVqN9xAyF+DeltvcDn0Ru+QB237TTy/", - "CWhtlel7qLW1G8Sq1c2S+Vvzwp/ea+hsdw9IhiAn1Iy5X5Mo6o23avhhvEDfhZG9uI+RAUQvihIuScXR", - "83gA/qzBqyY7NhuM3Br39PDi3FVEaxPJcmY/PXNffgU6+LqYETdm5KZ778EjyyN4zGAFcmk2Ey7qqHDr", - "okq+ekL6cluyNNU2FPKNNm9ubWxFmFpfWGYR9oPYVMjEueIpVjSC6mzRjHPpkX0B4W7qKFojckGZYGIx", - "2q7NJLjQpHphzdEXVp3Yt6YzhP1Hto8+fG7zD8JfuEflFz951oGC43edCgAVTCTCaCwomaAM55JoqS5P", - "CYoWkeaKphwfwdEMRThTuSBQaZSglDKa5qmPza93bI4BR+hiO73oonGuUILFFLQz89AF3UQ8TQmLCdjp", - "hmxG8Jxq1VKgBCvCokVPEqhQPifoiovLhOMYTA1ZjMHjAxVOBdEUCIUOUqJwjBUGQedCn/iRSWa6KIqW", - "G/WekeuSGuIhEzn73lRd0c1euIFeIAJlBaicFcVtIxwTFgXh9s++bjb25W3SZ0TVJ/pAEUK34qUPGTLk", - "217dcL6OaKJHCw3Rgs2vEHplswpbzQJxZPTveaTNXN0cH8jRVCzxqlP8dXiYCqL7arxMD+9G4gLFuenO", - "O5VA5n9W31DBUPygK8gwNdt4WwdRUcWzWOYb8bytP9yfx7ew5X0lnLDbqNg31YsrJ/01sFy7qrfiuQ9k", - "xLS2JN8m93As2EV2PZj4xIXH5R6LsbWC0FbwbZ87KYFB++LsG9uus20b+HBbtu1ss0uufY+RU9aDWNEw", - "B7dm3EZWbU0H/6ZZKbXZeSzzwVlk6bm4d7hFxxozvEg4jv8MwcIr/EcRF8LAYACwxmOCiPashn6aANjm", - "ykKUXZe1+f7kZLOJSwi1kkcI9Yg5hJeaoz9L42UD7ps5EYLGDgbz8OTIhu1SiUTO+uhNShVSHF0SkpWZ", - "LZBd2Nfzc4AgtWHXkT+6HcKUWGScMrV2FOWrdzOY8gducVG+QlHS1hz45g5v7Q4Hy/7jY2fAZSB3w0xg", - "tWaqsFpbC5myCRepkcvwmOe6dc2D9DLp/TSIBROaELmQiqQmOnGSJ3DcoH6NrVFuvzO73IXYXH1yTNpc", - "RkRKpaScySGzOSMZEbpv/blu3wu0CjoEFC7466lhkl9HEJ8ejIlbw6pp1QC6CWofd/Y7WzjLtmKscEOg", - "mB3eZwzpJ4jKQ3KRjnlCI5RQdinRRkIvjXqC5hIl+o/NlWF9I/juS1dgv/3J0it9zCY8WN/S0GxBzH+q", - "7C7L1pxj8tGxtVfEPyyO/8BGh9na+hrvguCkBzXTHYAPyhVN6EfD6nQjVCoamdQjXKzd+5OCqfaH7IQo", - "od/BkOKWJAbZALTLrUzwaGuYDwa7UUYBBW6XwOCA4TU/TqHHw9N3Jh2VpFwsukOm/wENnx+cGu/uBFtr", - "gjdQW9wdHW+9WRPofAbL9G8cIWgmuBLFILjh31yCN8caaTxDsuGI8myVqsSzP30Iq5XgvtkVHqddAcCe", - "itlsFABfDpUrbEOY8yRP9T/MH8fr8M0Ujmbv4dWvRto1w1nbjZvgoziUdk4xMfV3H8TpYRbsscas6oVz", - "UwAhphINGLwFDtSfkbq/vPneX8ev0N1pV9TVtv5qztZ933x2DA5pw1+Px3LMDaW5mSi+2vp0hWmz9enH", - "hEeX0kKy+GZDrbcBzrr+scTFti5CEBMgQxRZKCMDmEVkd8hqBkiD/CMRRoqIlDKcbMGcTSOA8O2sWHjO", - "KSRqR5Cn0pM0BuykBGC8AQZPzwYMVa4Bz6MrbfU//x3fGak4GpOIp8Shnm+GVLe/Yap+4qIKYf618MVz", - "b/0BGhBTsLevQW1v7vGzUNxP8DWESse5dSi7EW284uWPxhTURbA3w87uQA47XTTs7KTDjt6BQwwmVKzQ", - "E5RSlisi++jI2LcgFffpAEkScRZLB77uLHi7A9mUmGvIsiHL8yl8d59ij6UqWMq3tpMQe9DvIf09JO2g", - "Df/A2TMZd+HQxYjnypj77bmyb8VEgXlk8959td4Z+abbt+Hkf7PHt8KjYJc1u/S23nD2LJcz0mxye20K", - "GuVqDKDergCynKG/87HsIkaujDVcSNVf4nv661PTwX0UHNBd3aTYgJ37t0oDLSoNlGsVBm00AZb6SnbU", - "YZAbyXXGhQI0R5tzb2gINAlAkIAyhW8Oj4cs0qzIQAwKknLgThYX3dzCB387Qy8P33bRERTjRT/n480+", - "esOSha1hb300Q2YkMcO8IszQ2FAtiUPXsxk7UM9dBovrDh6our05GQHPitsrFyTe7cwIjkEi+aPzmpvO", - "AujDb1/rAwQAwObLYts7K4WPzluixKJ3MFFELDd7YvOkWIGdYS9pB0VnBTcDgKk7lA6BrezTyAYGImN3", - "pxNAzPj0rfjD3Rdxvh8vmYkTMWX3xrl6tPVb4SAWzDHEAv3ruiif0JQlbHnZSgUDumyK/P6KTO4reVcF", - "Y/7f9XTBTB+toymr7JMm4qLsylpPr0sOnhlYZOuoinCGI6oWXYSTxN5R9iYoIlJ6hfg7FgRfxvyK9Yfs", - "bVHwxSb0osPTd13nqEUxlZemBeuL7aM3cyJkPi4Gh+CgGa8xrDmJh0xxFOEkyhMtbpDJhESQiwt1XGSD", - "L7cYSucOz07ZSbDojBfVnj+6WndhmoDdK8miTnFbZqu3BIkSTNNmEHIrqEHAIYQajHWjnCHKJokNqYoE", - "lxLZpnokoVM6TmyAkOyj8xlBEqdkyLIEM0YEyqWJitdD72WCSJmbBG/dAID1GorqohJgMBNc2dCEhHMh", - "TTSBpvD3J0gqkq0gs7em5ROY8x3JtqZx29MDGalrY2g2hdhXkN4QQylmwTUd5YkLYLzXUHQzoIeWEh/L", - "wT8XdDolQp8KbJisCcczx9otpzn0lYzlxrqXZ8Vb7epeFq16WYlext5KgLhRibkdd24W9Rfo/JI2Ygja", - "RzfLIv5Ff9Sy72q2angQ9tFnzjJUwvPfsVrmmZck2NaAVVL4YzMneSOvHNVKou16WK3WmbV3menaGj/r", - "wWCzHjNaFq6kzzYpvF8fIQzuF+XhvoutPW7aqqBdVXTThpT/9aj6XwUF3g2c/gOjnNwCTv+ryrsHvPOH", - "wz8JHtSHyqOv+J5d0d0/PSL+XaXPG1h8gGNrSp83XM8Gr65UlN7bd9qpSbbFP5MEb+MdbyC/u2X/pvW3", - "UBm8xVrngtYET9JMLVxAm/VVlkFnkn4k/QZHcBG3eneu4FuEdH458nB02hjQ+eeskf8gMaO2hCCV6Pgo", - "UHz+kWEM+meucrFs6Vunh0U0o3PSbHSvnmC7RJkgvYxn4FyJzYLZ9XB3mcKiP/2IbPMWc9X+C2pQAlQ/", - "iVFMBYlUsjD1QDVHMH18J5HgWhOA51wsmqNEzBH5SfD0wM5mzX1oz5Q1hpVxhumiF2OFe3PHbVaY0D4j", - "utPFU2qGhyhDr35EG+RaCVPpAk205oPopFhSch0REkugyU1/wNuDBssm/UhG03GbUa6oWfLG1oRBUS4V", - "T93eHx+hDaiBNiVM74UW9ScgyWaCz2lM4soYO3OemFXdbljQm9pdtVBRFLBzyoUZ3IPIMG0upOlHmlXZ", - "QhESM6YMw+DWVgWpnimTxK/7w5S5ABy7R24U364wq/ltOGVHUyLU47SLqDg3EM+b3665x3zN+clQ7k6r", - "3HYuPGe18bpdflTLtKW7KPxQ5M7dr9n6/deT0kPlo8zmsabzeaGQNpnNvy4SHNzf/XDf5vL3jzgF9BVx", - "yrdnKocGdIshgnkNMd0xmZOEZynURYd3O91OLpLOfmemVLa/tQWx3zMu1f7ei2e7nU8fPv3/AQAA//8A", - "qZIc0fkBAA==", + "H4sIAAAAAAAC/+y9+3IbN7Y3+ioonv1VpBmSoi6+KZX6jmM7jnaiWNuyPd/eoQ8FdoMkRt1AB0BTZlKu", + "On/NA0ydJ5wnObUWgL4RTbZkS7YnrqmayGzcsbCwsC6/9UcvkmkmBRNG947/6OlowVKKfz42hkaLNzLJ", + "U/aS/ZYzbeDnTMmMKcMZFkplLswko2YB/4qZjhTPDJeid9w7o2ZBrhZMMbLEVoheyDyJyZQRrMfiXr/H", + "3tE0S1jvuLeXCrMXU0N7/Z5ZZfCTNoqLee99v6cYjaVIVrabGc0T0zue0USzfqPbU2iaUE2gygDrFO1N", + "pUwYFb332OJvOVcs7h3/Wp3G26KwnP6dRQY6f5wbeW6oiKerM5nwaLU+2Z+5yN9hb4TmRqbU8IhoW4dk", + "WIlMqWYxkYLQyPAlI1xMZS5i8urJGYmkECyCxvRYyKlmasliMlMyJWbByEJqg2WMotElMXSasOFY9PqN", + "/WACvsTbV+lvC2YWTAUGyzVxrZCZVMQsuCZcwNeIDasbZlTO1le23+NxwiaGp0zmZn2hfpRXJJFijtPy", + "7ZI014Ys6JKR35mS5LecJny24mLevkhTNpOKkR9XGUupIFlCI6YJN4QLI/1s7BqVNHYvDREXnwup2CRm", + "2nBBof1JJpXR66N/gX/QhFTK4tCwPDELajyVC2nIJWNZfaL0il7Wl/HXg4P+o9Fo9Lbf44al9ljRdzzN", + "097x/Xv3Du/1eykX9t/7xei5MGzOVO998QtViq4q09EyVxGbRDxWm2YSJZwJQ56cPH15wwn09kdD/N/e", + "w16/t//oYLh//yH+e/9+rzqttYWvj/z95qN3bqjJ9ToPsqdp4ghlUiGS9Vn/kqdTpoickShXigmTrAge", + "KRZ3ILratEehrYikmPF5rli83rc/crXlXFBNqLBMY9DgF2Vjnc5dBEwslldiolhKuYA1XhvES/+JwAkl", + "7hDBkCIpjJJJAkzBGJZmRvtT1Ac2LgjNsoRHyHpqh+ooHelevyfyJKHTtRGWu80SPudYoNPScF3ZJF+X", + "GEmYMEwVJ7zL0tTYYlvH5XIHd6Pki526XMgknuTC8GS919fws1vTak9ckwVLYiJnsz7hM0IJtAI/Wxqv", + "L/vB6OD+YHQ0GN1/tf/gePToeHTvf3r93kyqlJrecS+mhg1gl7vsDTJtzUUU3h3RJJEULiTFIrs7xYVV", + "28Api2TKCDS9ceRHHz7y1uvmSbmpUJC4guXNGtjqYePK6EbdCdWmYEK4X9ysJjQwplc8ZdrQNAM+BGOo", + "LGYbF/INNvfBr/zGBd7/oAUW7J2ZuBUKzidEH+xdxiK4EaXnJoWAAe058i5Y1p3QuGJUS2HFJLhMf+3l", + "QucZXN0snmQJNdBur9+zZDBJudZQtfgh5trykX7PE/lESDNRuRC2oGDmSqrLaknXyoRnvX5vQfVkOc/y", + "Xn/TtVUnauyCJTTT2J7bcTVhSknVs6LxajKTym9S7211CTc0tbZCurhiAyvU6/dqC1Cwcz8XP+5iV4OD", + "6/fcXxNlXxXYnJ3M+sCrba0PtxjaZsZubxErRPttJq6yrnOAmNO5kNrwSHdi8yg8wPamMg6wzqdFc4TH", + "TBg+40w5uZoRBfdDyohvhEAjhAuS68Y5KET/CVsyYfRkeTQxUba+KI2HTXXzKrJJeSNWbuVi+4uTsoVI", + "63MPPpyWlOOZfMqWPGLrspvbmkms+JKpAPsuBADLCm05sgNnHViIkILt1lZKLHnMaRd2EOOYJjxAPWdP", + "Toj9TE6ekp0Fe1fv5ODB9GGvvUlB0wAt/JinVAzgQMCwfPtYttr2z0ehlrlM03wyVzLP1ls+eXF6+prg", + "RyJQwq22+PAgJKlmEZ/QOFZM6/D8/cfq2Eaj0eiYHhyPRsNRaJRLJmKpWpfUfg4v6f4oZhua7LSkrv21", + "Jf3lzcnTk8fkiVSZVPhm23pwqstTnVeVbOq7EqL/73OexOtUP4WfWcs6MaW5NkwY8r0tRhSzTzlytZCa", + "kYhGC0am9tGCz3NsrwvJFx372ys0gqdcZxL5PHlzWkpz+DRk71iUm1q/35KYa8NF5ISqyuQ6jAjUTwmD", + "+zckWeAKEFeGS0GMF51uLAREitEt3UGJTp2tn//cktck1W2t+yKEC5LyJOGaRVLEutoHF+b+UftkKqfY", + "XptrXT2Dn0nKtKZzRnZQLYXPDcvhCddkRnnC4t1uEnbbZP4up5V7rXbmkAwGdBrtHxwGGVpK52wS87nT", + "KzZpEH4H+RjaMYSnrRNBCaPbPLBLxWbr/f2A9wl2otiMKSaiD+4uU3LJBHVPqv/Afnv/116pcN1z2tY9", + "XMyzsvj7fu+3nOVskknN7QjX2IT7AmSES02wRnjM+Cne7URR2lC1+XxgiY9wEkthc+vaONUPyFt0vrXK", + "KyjTZOjIjgoBp8IFWvn2M5C0AiKLFIaJwOr8LOck4cI+g5gwbi9Q2Ftl7LtEznd7H20diuVfP/ww7hsw", + "L/tDS2vwrV+8ChI5r67mglFlpqy2mC33qmuoHF3r8p/Vjk/jAqWaTTZzkDMuBItR5+4Oti0JsnXw7YOn", + "6JKbyRJu4NCZw2H9xA1xJVqbSmR0OeMJmyyoXtgR0zjmVuF6VptJQISsGTMoKgl8gyja4KP6/MfHB/fu", + "E9dBYA2d9hcKrM+kUhuat2WJoWpKkyRIG+3kdv07ep1CwhRQKnzb7p6CAj1hWk7Xc7vpHu+5Xti/kHfD", + "qPDu6/V7EZBXAn+vEW6/924ArQ6WVOGqQ/OVYf2X76ny2/dlp5Vfz4r+Kz++dEOp/PSDH1XltyflAP2q", + "MLV+HtwzDScdliolF2bABW4BCOBO8zS1m1e8spwmg0hRkfCY6sLONwlWT9ZEKrLz8ocnh4eHj3a7C1dc", + "X040/51N5tONcrMbtBOUoRqBaoQLMudzOl0ZpofkCRVCGjBGRgsq5iwmdGaglhtr7QF+b7TNCNMqJDHV", + "JiKZ34/oo4fv3lHz6D6/0o9+T6dq/vdD2mvTLua6ZX1RA0fdbkJRoqgIbOLNLuqUvoOXv6WSkPHz1Jqr", + "SFHI7YFuDmFIygf9jL+DNTdkv7bSwcUNv/4KE5aQYpAL/luOu50ldLX+BjSMpgOkiNDyWr5hD5Defnw0", + "nB9bx8/0inIDJ6ewmbop94lMYrh3Zlxp0+tuDbuWYMDUxxKRaqcsLDGtU0RzAVsv9WKcrWcl4TMWraKE", + "rTN3z74zlTt1a8zgzsE/CwViVzbOVIAT+x/Pii5qPz8t+6v9/sx2/r7fe0IzOuUJ95y5zqedTX7iFH/b", + "dqna2FNb9aWr+b7fmzFqcsUCa3mO/gHE6okHCVuyhLjSQLzH5PHZCdG5mqHJHKnVlgW1vdRMj4Vic6ri", + "hGkk9asFjxaFuhJUD5pQUboFkMfJFV1pkimmmTDHYzEuNOR63OuTsX18ub8tldi/rWuILyTmimn3D9A1", + "jHt9aCuRcz3uDcmPUpsBPJedLHVMxk4fo8c9smP/JBnV2iyUzOeLsUipoHOWMmH6BJ00UA0MnClZ7RIq", + "YjLuKamZMXTA0jxB3g+NPc6yhI3FOU94JAVJafTi3NW94mZBXtpKlcsTZwyXdZ9kTvWsacrGglo1KOzk", + "imRKThlJaC6iBdOEiZlUEdsdkjOmBn6J/d4SqhhshlXiklzAEWG03Az961vChFHO06E7c4GZXIf+YOmL", + "R7S+Ts0TW+N9YR25TuVfXBVgVW7GwTeKWpXa9Ao92xuxUPX766hmCeiPBa4o7iqHUleC1LZsltA5kkrl", + "EFli1GMRyTTFh57rhWi2ZIomxQ7BHSdRnwB3g7uRxiJKZB4PFquMqSXXUvXhhmAROiOoPvmNpXkfO4W/", + "BimPlFymRIqxSICM92ga3z/aHZLHJOEaiMPPn1vidiSGnAD1ARxGO+5Rr5Mf9+zEuCZw8Q/JM2EUtzRH", + "tCW4qb1JG8TVdfMqzGrtbsP9uU5j57bGmoXD/uwIur/GYit0UxJgQcYVLhq6szYw4HUZ3K/rZmOUG1+x", + "WxEVbqdq1HlMuCE0yxhVmnAxFiU1iRip1FXKFMPl0Ny4rUuZITspNcheQGs7FsghvtGktvu7Q4Jyo/UP", + "KzkSVCGKJSs7IDYWfsxXPElQcUhy9GBADmcPGmrGKh4UeJioKHhfUlq7nPHL2UVrE6gdEdv3fz07fU30", + "ShuWkikXVK12Cdclr7WkuW6YC4uMbvfw5Y6iWn3WsUSvJ6hLpKgLkM3zutWE4ETQki62UdiPMuRiSVW0", + "CLnRaUOenL0m8JkbFgER18ZLVXo/aEqSuqU56JWiEGvXu9YcMp2tc5ZwpHDE2yZ7UtwjYRGpMMevW7ug", + "ZsWOyxIWAa+y/jT2/caIMy4TmSJLF2x9NnutS+Qb19t612RH6j2Y8G7lZoEzrXIxJC8EQTGC1KSIscCi", + "XERJHjNNKvyclOpbL1/U3KAKsh+Lf/2//5+VJWKy5LSQNsgbrgz4kPxuX7EzRVMGTK9+pUULFl2ORVMM", + "IdCqloT6S6VYZq6rF4ri84UhQl4Nx+LEjqkY8I6WM3NFFcszeGaSwcCNeuDkrF3rxIUNcdNw0fy1sTeV", + "xbmmL2HNnFdsaH+dwraR6i+l2FKn1Tk17IoGHsTPgfQGS67RZQ1pwpUlJ2dDgp/RlBAt7FfgoiDFkh1L", + "FVzMxwK/OJF4lziJ1pKZM00OyYuUm4L4hSyuF3fTjQU4GE4ZE2hFTJbWAbkk1RVrbgB4c+6DgXe4Hzoa", + "cxj7xMjJPA/qXf1dF3PFIkPenA6MHLw5JUbR2YxHQEcZU27U9oJpDjrIz8HHIGlZaV/Ru1AkQ3hkKB7P", + "QciB5XKiv/3NXk4ZUzCuV4/P4ILD1f+WjHuCGlun5PQDsOTwmMXkl8ev4GqNXpzvulVzT1PbcA+unZAS", + "vN/T+VQw0zZ++xV9cMNbga6025ivXaK1LdpG3h8m0cDFbxv4ZoNMUnIwkE5AiC1IcEh+rsmvtkLhuAIi", + "6LpEX7TgD/G3pBjtdyjQkJRRoQl1sn5lRITrsfDSR03gAEmbuBWFqioXlkvZ+3BQ4b4ollh5BCWywkl/", + "Cf8/WGoZXToXD/vKLDitoCme7urSrUlW3HQScraqACoPlsCS+t5rgie8EbSgmV5Ig69q/489xbSRiu3C", + "49z5S8H3jOaakb+SlKVSrcbCl+/bc5ZQY30YirozYEw9shMlUrCqHsE6ENCx0EZmGYudYeJbMnOHmxZO", + "hFIVumpbaCxcF3rPSgCaFLPADSgtMMmq3OTKTHZR1YCzKaYFc85TO+yFNFmSzwdunj2yk/Alc9OGCfLf", + "mW0DrChSioHzh4Sipe4EPxFD1ZwZp3rW2D7SzLg3Fjt4bgn+GwwraS54hLc5lptn+aCq4uiRnednr/dK", + "n6WxqHzGKqDUG3A5SHjKgb3twA/kZO8FUXBD489czO3osbCdTjFJLO+mOCRvaJK7c4rUDus7Fl76x5EO", + "jMrNYpYnx2T5uxPC6vtR7GQhFe0f9p2mKUENU6sk8+aUaLpkniCxFjyptYFjROMlU4Zrp429JDuOnAbO", + "zoV0p4lgLCZCjkW1LTjISKYwxKbIT6wiRpPGIo1FKbY1PMn91eZNhoUqwgs+3TU2m180LTaGD3+1XOOJ", + "fF486hsXScbbraqgi8SDQqNymSrvV0ZeZExAqVhGecqEIVzMpC9a9yIbDQ/aPNNauj+vXitF93ohlSFz", + "bohiIMZJ4bjZuDeIuTJw/HU+m/F3+IrMUQtkZRpsSfeBQ417ubgU8kqMe/VhPrhPD2bx9HDrbvhh92tr", + "GNwHZHpOK90ar7fZhvZko8EMRE1iJLydqy9/R+EfwVxWhiTBpacGcFYSzuIKbfett+6cCQacK7Y+oIZ4", + "Ca3F5DMIirK3blW6liWmZUet52zrhm6ZwzyRU7zw/ERKp9MhOamvXZ9Q/IACc32Vi2iJimMo2WHD+RBu", + "lyziA/AMHdCDwWg0GDWpPTkaWJf3jBrDFAzw//mVDn5/PPif0eDR2/LPyXDw9q//EXyQd/RW9XZtN88d", + "f5b6xA+26sLaHOg299YbW9aqw28/vKhaaN3pSDE8BjTZOgbwOHhSKd5O609O1r3e7ErFEvTQQy73Ej5V", + "VK32xJyLd8cgzmlTX7fNZTcpWALBMVYw8gUI1cRpWH7dW1LFqTBv7RjJuKocAPmN4K0B9/5THBEZDHw7", + "jn15z/uaAWBIfpGGUGeRhifdMRZa73euaJpS1HYuacJjPBzOzKZ5zKxyVuA3srRSkmImV4IcjUb+y6Qy", + "KJAbpPZTGfec4lRFC+IUQoqgKsRGyEQsgy6NjGkjTKmyFOsnDY7ZX/eKv3b8n8O3f9393//xUUkdCW0D", + "jVt1xjX5WcPPGwqRnUReMRVRzUjCYLa6T2I+50Zb4S1G1yhUqH1LIuvsYV0XpSJMeBU1lquTc7oa0IwP", + "nObFmrt/ZmJuFr3j+4fBxd1xfwze/sX/1LKwKk9C77WXMkfNK36uBl77MXQyvvjVzRM0uqRcnNhq++vi", + "5IftsJ/Ipp22j7rWrYY7pogK2jKQ9Qh88DhKQ0LEkikFR9HytSenT+EJc8kcQ4NXIxnno9FhhAXwT+Z+", + "ASEOnjL4225d4rHNhYSdX3ssWkh0g0wSeR0lZb/J1Nu8BDfayEKrXXL/dZ9CNKJbszg6u7uCZKrkJVNo", + "koEl5kyTS7ay6oJ5TaHJxJIsqfKmwVcYloBF/Ce0BjF8HsMjFoPzIZaVKccW3ZvNhkExmpTcMqVg7sJ3", + "pY5kxoChumI4NXLBxPKCpDSzz3rFrNI8pYYpDs/FUrGYspgDjx4LhgeDZBTOfBRJBSpW2FvUwZar8I0m", + "F/aZeIHNX3ABZH1hD2ZDVfpH78XrV9+/eP3L08mLs2e/PD6Z/PTsv+FnW6l3/OsfPQumUThefc+oYor8", + "xx843/fWeRZ9+iDUfSGVe+16Ez36q9CMD2XGBOXDSKa9fu8v1X++ff/Wu3vayL1l7zg4sPdBMdPpPgM+", + "+D4AShOpSs9TirggyKK82qGibmio8q3kd60jgU8ULifTTAdjU5p6i1IO3R+NTr/fc94j9/w/dofEmW5x", + "+MCDpHLisV4A+RS4HGhPSxIZuQiNWVsIvu8qxODR1ppJLsxW5lQWXedRg0H59RqsaG/KxZ6GbRhE11t3", + "pJsbOyo/E0uupMDnOYpL04Tp+ln55cXTZ5Nnv7zpHcNFEOcYR9jr985evHzVO+4djkajXohA2buMK6aD", + "jo+Pp1omuWEEC5U+pkNymmt07nTMY5ajHhTlM03SHBRLyYqwd1GSa+BTKA0Yk4Tijx8ODvZf7T88Ho2O", + "R6P/6eywCqS/hXk/P3ttI9TxvDOamMUETXPbKv6IZZ9g0fd9r5vEp31glYo99dpKOL2uDtlZ1KUrOzmC", + "BDnuHT7/3h6o/efjXoUQXShw0YptuLF6z78PrUtFG7Uu6RXfbISbiD3Vr4PRWH19rp2aqGoJHwiKEDtQ", + "MqYqJlNJVfxt3ZcGa4Ii/xtNUFtBE2cnsqI31rGypGQa38rex6dUtup+YwlQ85NaVSIVhKGldMmB5QzS", + "lEtfr86WnDKlpkMdVgxMAT1exV2o1+/BzNx//AR7/d7y93pM9XZ1oHON6fRW3PIIpEnGBdvwCvxMhH3Q", + "KSeSxoP9jyzrV/zdGjA09kN9u4vHqSfwNXSnKRXxFY/NYgIR8TDkwA3pvpCicHFNvoOZ0ORf//jnm9NS", + "IbL/fJq5O3P/4N4H3pmNWxKaDsbaFBPJs/A0XmfhSbw5/dc//uln8mknYUXKG4nnbv+f2RaaADUOt8t6", + "47fYXQs5rIzPlS229C0YBqErVy6ZSuiq5Tr5m+LWumiZpStc0RtvvlH2R/5KuTd6jmobP2b4UlHUoFnI", + "m4Sc0caaV2zMMJlKabDb5qUdvnc+TOk0dNONpDCUC6bsi6ewq7pLw75J0vtHQ/LsXcYUB5GIJiSm6ooL", + "69bia7pXNaECIH8SLtiAp85Gi5O3a2q5LjQMpjTvUqStS9G6Xh7V0TFxN6CtLFVD5XWbuiPFkPVOsgIr", + "b9MBeWlLl8/6MNF9T3Vha+1CZAWN7R+cuj8Pnq8xhf3n30NFT8mO//o9MCyFBWNVqQn3pZOYoy95Zl0w", + "JnReRIxuAuU7v+SZe2pjDe8AYK+HOIeWkeiHY2FBd+BE45jBWd6bVQ3492vrUwAXIV416+ITPNzLy8YW", + "txZUlYs+meYGXuPSMOL0Ii76H8aChaeM5KV/Z+Nt7Ca47rOAy3LJlGDJxL59dceVsZWIq9S6ODjVGdUO", + "JEyZPKuv19OfTs/JztOVoCmPyE+21VMZ5+CoZw/zbn31+mORKURegU6AiLnrV86IzM1AzgZGMeaHmGJj", + "hYnE4U8sn5+9dggmGvyHXjJYWCZi54vq5ZDCGVV8Y6yVutZstf/Goreh1HjLe8cjee6Kl2eyu7Kw3zMm", + "CeHOJZYfQXyPDXEAm1JBfT7qrk+4IM9lCZNgj/WQvNZwrEfgkSIVOhSQqTSL6sNvxlkS6+rjpES3LIvB", + "0zD8+isfmo3zfbQImpSjLN8IMbi0/gsgXTSk/YMuTAeEEoQ/auE7QVgXF06zPiwL5FrV4DiBodgCaurQ", + "F12VzrZlhF3d6ofpdMf2idFZd1zRZq5pkb2q7Y+O4Qmb2z8RfiAbdeeltu0D+jq3jTSXyLXd9zO7wSqd", + "FGvS0Lh/nOV5rCvayU4IqVb2sHKFJjsXoNB0dAwqzIs+ufhL7Qdgj16uAcH8itjVQJYr4Kdq+3rNh3iL", + "xvTGfsRU33w/HutWfCuy3CdGUaGtR/uCZmxIfsR7zvMDuHh41YlPyKtvibSvAV91LGBouu7V6PXmms8F", + "OHmhe/qUgcncKtedbsx7uugQsLDXeK1Jzp5b4ZWFML7Or51ceCX3RZ2drqvA1zVETie+phrA361KAJUm", + "Zi/NDXRvWaSz/srcWLgwN/U6lFtD0b7NBceNpVD63WD/zwt20URrXgbuD5icnJWWkYqJps0S4mS5sJXm", + "kq1wy71Fhq7ZZKrGmLDJxDmPO8mkUhW1dPYq4bpiybE2GWeGQTff+hENGii2bQWsV+flr7+xgxEvAz/Z", + "kmLcs9kjgXouBJOz/aF6TzNcfHyzH9sAqou+VTIw1OQRAcSSeC/4ZvPoAIzughful6Fr7aIIx/sY6N5l", + "nEV1a0llZ71kjM14ycqwuF+XDQAHWm+flHUEXbQYGBW7UtwzMg8l2VGCdfEpqXtHfZjG5VmlsQ2uV12b", + "WI9HtetbGbOjJwuh7MM17H6gpamGLdyE2I9Zbcd7FqOl3uUFTZILsuMK7RLF/o74q26vwJet2H7Ak3Uk", + "UDh2vTntj4XlAhcLY7IJ/J+ewCm+aDbm6voTXsKfPxzhE/To6LAeHWEH3Gi2rpgO4s60bw14ON2Sp1SL", + "+tvjzOPn4gYCbBmyY+gcjo4DGIJ7YvdDVeNcaBblim0/649RPHIgNzimhHIx+PHVqzOi2JxrDOoNhg9Y", + "J/QWNDT39i8dw/x0jazrRw8Oj+7df/Dw0TC+VEMWqWGuB4xqM9gf0pT+LgW90iB27aUrmmXHy/2tfN5u", + "QTG+dpbvH6rtpCBTYMne9bfLo/dJWaU0zV1yEXdt4Ccou9291L/Jb9vAkSk2yLO5ojH7yOaNG3sQ4Wq2", + "7+qWnCObXYdzbUCvUILt7mz2GK4v1lImg5gaeh2HYTvcdd/gdGWbsk/yUHutjtjnTe/rGkJOMPPAh7pz", + "+bGEtqUNvdcqElg8MTIQKOtlh5OnsOa+bCeYUgQbNnKynHG5LZwehlYPksXqXr0BTQyyiDvFmzcn8EL7", + "bnVvb06rXizDsRigFHZMnhYdFM0WTVJ8YtDYaoZ3pKpF6qJCfgogI+TN6ZC8Kkb7jfbKeTsmUsRo5mi6", + "Q6FoYEWy6gByDGnhplndaRkt8vEuOutI921Y5EdBfWahD0P115Q35mMjqHGj3F1SBXnpqN/dhAP20t5H", + "m7DAar4X9waj/cH+vVf7o+v6XtwCuHOorcd13uK87Kvc58nrk6cHTou5e2P4r48O/xxmZU/L8ACyk2tW", + "CX2VKhgUUPG9b3H6v7Ev/y255pdAl5vK2pV4BSVvA9c6hLyFRfo3QJ5uMsyt8KaVya0rdFYZqh/KU1IR", + "5O2OZhEPxjiDc973itFLxOtfv7cR8WFi77OwZx/CckxXCEOFSi4lpZlpUqC1lMrv/aMHRw8P7x89HI0C", + "yMnrBC8jPkHLaKcBgLNLQlcFiOAOOgzEZJrIaZ3Q7x3ef/hg9Gj/oOs4rLW82zoU729fi+y4Ffmrz6nm", + "v9QGdXDw4P7h4eHo/v2Do06jso11G5QrWxdJHhw+ONp/eHDUaRVC77pnHsm6KcCH0io8tpmI4F8DnbGI", + "z3hEEAGOQAWM0V+AOd1bh+tnckpjn3wifHcYyhO90Y/OduZKWn1rmieGZwmz33BDOpkkcOZPsaVgNi8h", + "imQf12vJ4X9v9bDycymKoDgTs2k+nztwvWLpTl3cfCk8cZbEx6R4sm3kc7ib5cDettGBm0NHavhZXhUw", + "dxUisFeXxYRQjBR0YjetNisfmMNFlgdJonUpf8iVhbvARgmdytxYg7zLzlJ2YpFjBBqocxF3U3f8INXl", + "1kgKuImLJDQdFQY+pN6G3DeC6atCX2E4t+4F7rsmL20Nqygsf87yega+PvbkFIrC4wDEhd7YNdNVugzL", + "Lagz9+5ztr+Sd96R7+BgZt2tPu4LW80ZJl8yWyUWoBTrx3SOxTsHZkHFrYqUDusu2NVdLDqGFg6AbAeg", + "QrmdFd/kolzoGspCRDrHfkDChJnHFSCnxkk799gWbsmHY+Eim4qftDWkQUW7DmbBuCJS8Tmvd1zXs96h", + "k/B1KNMT142ps1qxzXdPF2bAEA+w0M4z63NrvQeryLJuT3r93nmRpcoxpvrS+B8DK1K68q+D/KAnx7Vc", + "fTMlZzyEvINORO6re6h5J9ifj0bng/3/sm76QH4osXFhHY/AjjBsZBrC8t0uoudnr8/axlSkeSLV0a3N", + "aQOe0LrTzCwXNtFURadCXQJIB/FmAYBsZ0PyPdg+2WwmlTm2JaxBqiSClK6IYnEO9FDFQpOCgMUUz2jR", + "Kwj88AtMCJFHpvlsxlQd5uBRSJaulJ6kAeXeD/Cd2AIFzsLp93V5+uAo1HSYC5/VqAGf7TMagam/83YH", + "FIKNaWwDMnx+9vqly1PUBvEMS1nkMrLozkPyS5HJC5ZalzBOw4CmsEFP1mMbCkx0IkPeKD8oxgh+KzOI", + "wtlDC7YFBS68BmiMoFBS+DyG4DLvIPp+y6miwnDBYvLmB/0tGTmT+ZsfiA3bcQyI66pPZP2l+CC0pa3h", + "d2eLlQZFnV0Wm0uEi6qWEo9052fGWVnR6XMDj41wFjvPPsgO5C7EBTx/OTh58WYvjdmyXxsTfLxayITB", + "uHcrPHbpMSCKsnXWumxTF/mtKpIqTspEjs2DgPDTTo/ZpA7YmxGZsoh6T+nm5kVF3uMp+l6g1gts1oYJ", + "oqhTOlNRa8Si9Gkyy5Nk6BigHzJBNfMM+XAFMtVeVUA++D2j+JIqxKh3BjtlROdRxFjccF9pjrpCb8ck", + "FylVekGT5uSOC1yCaEEVjQxT5Jt33/TCiYtgcLoro69QZ3HTdCbLyr0SoMfKuWs74m9+qB9Pe9XbaGSn", + "IOeCG0RXQ0jtnWKQFgR8SE7pyulRMrwzHb2UO0ZnM4sxWtwliiWMakw4rsmbH7Y7ZhppaNI2h1fw0fGp", + "HZgQnCIYZp9kNTYAv1fBvard3g9eGeV8AmYm7LBpsxiSExFme9b7B+H5LNKW1nwu4G2nnQNQREWxkmt7", + "V787g2qhJpxhr75wten0AxdAiGJCt5UNn1x/5qwnQJWXdTYlL7cn3bSNtPf7xEd4Nuxa7i3pzy0GgnpP", + "JGKrOo5htaOaZVRRw5KVfVwU1FlPosDWnUrAk/8aIabPoPh7mzgnV2xiFoppcACraRsO++vJlzVGDCyZ", + "yzdn51SxvRlJUqou8ZT5tzTJhV2Bejjp4TZMqYUx2TUmBR4SVsFmmFrSpDaX3v5IrznZPGUQ5zRl5oox", + "4adCNaFVj/VmoLhuyfkFATJMcVlfw95hoN9zG8VA5opGjNhaPnV7wdbg6HVdyt5hy7DgwtG6ZX/3N+2v", + "qzrLk257HBrW/rYdNtF1NvjVkzOfu6rIDe6X+WB9lSH/gj1yrsaWrT3Qm7Oo+a6EBZ9ek9mnDNOquQCu", + "avC3dzHEtHFQvRZwXWEOuhoi7PrBU2CXqm/P+dtOD7zmcQ/50kAUUCjHOn5woW9zxOjDQAOV4+uLx1ZS", + "sndy5X52UU4gaXEBcMZ1J+4oV0mv3xvM3KyO9/aA3ycIU3p0uP9wb7Mn90YXfuexOIn5JhWPK+U833wM", + "mAtChEnXSWKPZlkHJTjW3HY/IHta2wSbVx3utsojy1EamqAanojvaGR8JkjUite8Hmj12CKYeF2QgQZT", + "m9wJXruPqudzFHRDAZ/SOvnvrdE+zMy6FQONWI1jYx0tkf8elE6lCqX7k8q4KN8p837HxX3ovXo9bHbN", + "Pj56WJ3l/Xv3Du9t40PIbBrH3J27wFRt6TqOAasstzu/rgH4j67LHO7LZrLCddlCU6+eBEhq84LKjIlr", + "ree9o8OD661n14mceM/MBl8KwUAB7hPKRGW4bsoMjamhdSaD6mTgMphPirIUg/pm325mLS0uTFVcp5sm", + "w/tYDjAteTFfehj4lAo+w0eSLVntWS/owb37xzZ7b8xmR/fuD4fD66LdPCu+dduKPesnWwG+GerFh+3D", + "LYDadJnLH72zx69+BEaWa2UvrT095eK48u/in+UH/MP+c8pFGAynS8JnPltL9Fzb3ix3aOMsPiZlTn8v", + "93Rx0WuxB2GAACJwBaEla47St4chWdA4r6cKuQaW5Ib4eBBXQL3eujY3zt0sCl2oqeRsruoVOuRvBj/O", + "TW4cXuOMZVyfVtNRpLZed265UXJyvTF/61p6v4yJImNrkti/IikQzDuUvrV2RfpvHbL/4TXi0vwVXVZ/", + "LHqv/PikOpDK7z57YOUnl8f17TW90jYKpH9bl0O3cyEvjm45zGH1f3ErdM2Z7bAng+EYn/guvInnZr33", + "F/P//O3/6LMHf9//7ec3b/57+fw/n/7C//tNcvbig3CNNsOOflLs0I8GF4ruijXM0K6kdAqWmXV6gudf", + "ywq7L1bjYKIFZvklU3YM/tQ/c8OUzRnZiD+GNAcMX0pYi0iByf0c0sQuVD6zTgVQ+Q8vBr9vthE7SAnl", + "NqTAF9L5NJYp5WJ3LMbCtUX8RDS+C+CvmEQ0s4kfuQDFLHhpK8zS6ay8Zed98gfNsve7kEmEGsLeWQT9", + "jGKiw1lhmxDeR8ONynqiu+KsgCm2QcFjUdzWBe6mNfUPyyQdnCXNsL2WRdn8fnMvp4ejEEIphpTBRmIi", + "KnyCFJQNZFTEupGHo93199yWN0ZBQxvIz/nC2JhjiBkOkGJLnPJTFvMI+YoP1bUlK1HSltKcEi9T8t0K", + "9+aljR+NCc3NggnjvBz7JJLykrM+bmkfLdLofIU1rUvNQmaD6WqwkFkBkUKVdTij1iml/sj+PwM30cEb", + "pvjM9RREugASCQideGTczLBMqV1Ym9grm8NHaI7qQlvcJoPSNv+PjaYwuRI+RQ3DRLWI6FNQHwvJ5N+S", + "KOGoddILtOEtEGTTQDMhnMzeqIhao9MoZrPmv+teRQf37uML1v/78KBz0Lhduk1UlieBN23qWV8Hjm3Z", + "JA7AigcTrwjf4gcIN6BzpUA9hZH433PiGypPXMFIrKXPhrFql/Ek0ZUA1t1gpKE7Bg7wZ0Jz03V266ew", + "FrjYoQXrIITVkg5QQ89wHuTVz+fEMJV6CIudCHYHT4nFlRlwrXOXfO/xk9Nnu8NeEFytZtLCrdoY2Fgf", + "dADuxJ6DVj+oUkdDU9YnJ08xYN1dK+VbDCOMfpCKJPZWLC+jY4Taqat7qE3befLUCaDJqvQ6smLLuLfr", + "W8ya19sxeem7JbQYShF6XNKWb7K8TLBZ54Nqw5/WWm9EqqN9zD3/3H2MwU6Y09TyYgSIbb2/uusc3YrD", + "x6aG7NoXUqViu/6rsvcfG0r948voh9eT0Z0VepItqA5R96Jq1cRCa/teNWTX2b1qd9ur9TvRPHi2/uYz", + "O1WuIcPgnqtVD2EH3xvs77/aP7q++u66KNh1OLwKNGYBhN0dwfo2kKADuM7cTFrjOwh8dtEcXi/y5hRC", + "JQEVDT42tCP7hw+6KCWw166REZWPIFLjkAou5VHxCo9+izJ4yZPECjCazwVNyCOyc37y/KeTn3/eJQPy", + "4sVpcys21Qjuzw0AsfEWwHW0zlEBYLQaKgcpwndfvfoZvkQJwwgoK4df3hwme6tqsQNsth/c87PXUGNB", + "9cT7TreHC9My5J6949rodUzETiEIHwLTbauWirEuk7RtOKfKrVjfP9bAqINQmbu3ANLt40fWlvMT4Fd/", + "yjjdzw87eyPa9YdCVjs9wy0hVrdeaSG05wYoyb222+3m2NO3MpwaplOIbVUlHA+icGOw536PBwLIHzs/", + "PnJyVmZ3K40RvvnGnB4dDPfvP8QExfujLow9pdGGvk8fP+ne+ejA3jLHdHocxcf4YL+pzcoRtn2C0OQK", + "wmzH/pE47tkLs/K6rRxbW6ZbAEVHTO2taNpNMa4F6xrFWe+4pCfpxvxCHSKEm7iFae4AXVOeJFyzSIpY", + "12XkBdVEZxbH2ObZKST4scAB9kmR7hylFEKjSOWl6tFJ107ezzNH95jrN5NCM5fs4ye20iTlaAQtukfX", + "R02KQLR4LHaUD1osohMxzW8MP2AIUN8Fl8R99BrGfEJQYSz0IjfAxHaHBPze8pQpp5UlU44Wo12ic/uk", + "xfHiaqyAYWoeMzUWUCyAlPxH8Tw5vg8wB/1e8ZKDnCijUYiabtX4OXRY5tiCRsBN4bLQobe7ygXJRcxU", + "kfOfWXJoushd03D6gcDhvno38cpVL+Wqm+GOdxKmPhh/GYfa8j5H79AbPM7v3VxE7xT25+VXF/Dnak2u", + "48HgQjLgxTeF284q5Fjs1JCauViPIvbmtc23W5+6cz02EozqakXenJ7W3B5chEa3iSOXaNkHmV1rGw62", + "6Ei2juY65uVroVV/OMx0U1KpSIgfHVS6amH0OASWQmuKrdrDeZN+DV6VwRgoLuw+AdFsmGAj5bJFbSkd", + "I6ut6pWeOYhVJ0r7mAqHFl8gsJRGfb3Se9Nc72UR37Pf9R7C4zxEeJyjIH5BzJaTPA89jeCT64e8fg0Y", + "NvgLwjvbAJla95Te3384evho8HC6f39wFI/2B3T/8P7g4B4dzQ6jB4f7B4cbgoo6BLTePEY1+GIO+DEX", + "XusT7z0fcmpui11oyCbOH/uKi1he1a6/oINstXfnfLut+3XX+s5DCIYEJVQbq75o4WSneMmzCNrGcj5o", + "ukivFlZ03n812v9Q7Q8OruWOeKVyYc2qWKA0IaSVAVc3qz7Om7F8HJAPfNm2WtXOuy/a6Pjeo+N7H7po", + "Pnhj2xib5HSHm9vmEeZh0BvRIT5IuKJH8grKnpOJrFbfFl/1+r1cVP9GYaDhS1187hTE1XZg+2E2sula", + "acEvOKm9V9BTxcJgxscgqfj3COZjKVAyQAR6ArowUtHFWVRAtMOdVN4u0AyaxZyKzgYu22AMjIzk2sXz", + "cQGMGO2P0IgDOzgmz7EsfqKpfda5Qdh811XTG41X1l8Gzpfv2j6yNg/53L2vsA48tgj8C6cNy+BUtpub", + "sNLZMflFYp3itSdkU/dri+Mza714oyzZcYj5HrwGO3Oi5jH5oRAvCwHVCaQ7mrk/J45hlZhRuzXkDrfj", + "4EhY2bkKCkW/Z1e01+/5hYI/A7gVr0uqXzt/VVIMOZIxmuBZLsP0c8MTh5SPM+Ha8Ei74BHY3Daxx8Vm", + "snhiH09tPqk29NWWIUUlL1W9OSU7CIb6V+IU2/Cv3cJ/tXbXHTw6enT/wcGj+50gz8oBbheNnyAywfrg", + "tsrJUZZPnG6kbeqQ5gXXPLJahcL3BaxdJZlnSgLrgZn7BqudPxo+qiK9xTIHPlwMycFCWmBp3LAgmGHB", + "i1r8IH/jyZLPZuK336PLg78rnu6/u68PpvstCNa2o7Da7aTqXLCmo2bTgc2IGQbjQoJSuhWv7iXTOANy", + "zgxB+hkQGuHzpoindiSX69qKBwnr6PDw8OGDewed6MqNrnJwJqiEC1zKbgRlSYIlyc7L83OyVyE426aH", + "dckCMAeNc0ZcXvNRXSAd7o8OQ1TScnGXVOPaXqatS/7GPR/dpNyiY/B28bRcO+XB1T48HD04uvfwXrdj", + "7NTDE/VuM4dx5Wx/PhdGded3UJp89fiMQOtqRqO6bsd7iF1rVOZao8I8Ljb/wjUG9vDBfYi52u8GvBhy", + "OnGQorUDW+ddgUMXIIrAbgSWYp319ttui5A4ZQnsJYsSytPHkQ+xaNw+Ns/CRNli5SZ0uRjcC3zt4upQ", + "t5Nyq1BZ2QAdrEOkIrkosvsMt5tkP4pltZ1r2+thO1cPheUIWD2HEGYTIN5gKTPFllzm+iM0JI2NmZ0l", + "Uqpr1W17sLxkGuxuqLPhmrw5/UYTR2tEG5bV31COGjfgqN1wctc6zzUSCRN522J12o0uW79pwv2WU9vf", + "BKhR4wat6IUxcK5cbHf+fEKTKMd8VrTYT5gVwvAhJECWJSvr258kUgoSLahAI4mqgI4RwE4YBj1h4ctk", + "FvSqkFckkRZ3HfLtuFRPdhBQDUQY0AftVPMjWlJqpDW9l1om45L51KnxXhpO2hrGVCpC4WE9qZEVSHBb", + "paYJTeRc46PQYNTCsJmJIqPKBiNQYVOXLVP7lgw4XAeG2GDmoRsVP8LBtA9cJ3JgoLldSRopqStwYG9O", + "9bBrzFsRxbzdobM+2A6kaw2agavMAtJ1znAYuh8D8TwfckMiDWPM4AZXSa+cTKnIMflThZCdIn7Y2R0S", + "tNGTAubqmoPVZoIZXUCBV1iuiqj7Qh/kywTvRc/abrJczu/4RrXXqCrcVNsA23lqcEXDq9UvaDBExuu4", + "chuh7EqAvyaY1XXwIssUIFxjq7yCHEh2MOalwpYqKJC7XZxkwk9W6Gfttepy+4Kdoiuy4mYgxTNqFidi", + "Jtc5wXUsp14T7bxVM6Y8uGHMBGexf0sWJlSn6uLapYKLc+ZWDrutwd+hiwPYrITXkUGIT53XNzvsoh62", + "Y9ic8AX7dQU77CTX4YDaVyrHtUICAuIpQ2s7OYFyPQmbs9YbVmyeJ1Qh4F/HIetVmnBx2aV1vUqnMuER", + "gQpNu/hMQuDXBD7p73Auu51mBxUmbdnBzu3gXFyg3ZBGv+UUvoNZ7jaiklETs2fr70H9Tt5jQU/xH3jC", + "HDbla8HfVQi9nhHh6GDUFi3f0mgtTn4dnPW6nNuRbPDE5zoQW7hRyvF5zVjs0lRgaczPhketw63kcZC9", + "CfBmFp164MiHQZM8sfy6AUxCpgzjfvzU1prrwha7TCWY3QUS3v1dTusK0a5uv4GcgTuihMhQbBb078cd", + "3aiQtiXW1qSyu9fBoEC2ChPFSteEdtiWXbH0r2rjJy/XEg0umFsy7udokw52SKLj/T8K+ALXa3ccg2ZK", + "yIC/MqLUaLPC5MqY0WpVyXMKmcKUQhB4kHCk8LNB2BWp7Fp73CuIxl4RxVLKxVhwUShJEUyNEcGWTFWi", + "ZKWCR9YcQjT+Vnniwe8szczK5WNA5fk3moAJzY9xLKqDhMZB+T0kj4XVLKo8M7WMrSx2rz4gFIxaRiOY", + "UZjClJsFmSmmF9W5h9LWgox3JVXcmg9sRXwRmAZFGysx8pKJKisrmgk+DW1DE1tr3YvP5pzGr+79SWpp", + "oEkzzfPm/nLNVFhILKZUFOnkulI5KpXHCR5IGIBL7+n+siy+QEHpgHlSNv9fvsnyp7Oi8fpvjWIVXBOP", + "9P3Yqm2DKtgoy9dXp2FJ2+qqgmFwm9Bs1m0JZMe7UPscSXq3JVdRp3uyWyReM1jAj2ZPs6je+9HDew/u", + "d0wW9UHGOove9bFNc8t0g0muZadOu9h9Ht57+OjR4dG9RwfXsrD4uJKW/WmLLanuD4B/GDisyb/+8c83", + "pw2rzz30wR5da1B51j6k11mHAb05/dc//ulHdeMBhRjNOkh/i92+1Usnqe6kdxSom/C6Gck2vO8f15QE", + "tGAzZIchFDc43dh1G5SDacCAdJOCaUYjblYBRk6vrLd7UaSBtd2h9cZgQyKvbduhogLn0vm0KAGeJ67A", + "X6xtuEELDzvnnNP5tM0O/aLZK5YjpdWi6uPQwcXAUkRYwV3M54rqmkN3zKzdoV9EmK1H29gSm1F3m6EQ", + "MBQE0q21FYVyDTTkSVepuv2N7azYLWtqneaKv91wDtuP4LW0voEbOaD0jbK8a0Ol3A4X4M1qTabVbJAb", + "023WUkeWt+71++0QPbyeqqS4wa7fXyVg8joVG7Rm6dGNwS152Xa/RhIt1FSJhQko0GTCBpXkBVia6Nxa", + "BOHMO5j5QARndClnszrW7b12bHSE/cFgL98LNQZeJn3Q+judRRNY22L8jHv39LhHpIJAynTca5itguGT", + "KX03cR3UsV1Gm8DKy/D3xiC1n8E0kdGlTXSI+fOHZERSRoUmucDD37Cq7Y82W4f6vayyN34d8XG3tpRu", + "M8mULeiSYz4VZ1OZ1xwx2TtuNDqMYjvHJJYW7amW5dnNEIrZ4MbjctJ46VCxcg1Dg1BOCu/RWpZFBd8M", + "c0uL35mSfQdWABz7xYvTvnVgQNdDO7Caf6OfqB1Br98ru2ikVyh/D/sPg7ECx92E60/X17Eak46WVcU0", + "M9rhd5fk0CACCDoSponjn3Z7yNXDytavpFygs59z/4AF9b1bAiExi/BE6vWzWCf0GxB3I27ArXQocOAw", + "RMJ4KDbk33npDMLNAVhlQyVBu22n6tdtrYQTbWTWq5/qCXsXMRY3AT/DRbr6yruaQV/5n6nDCCpyp7vS", + "6O+8Prvh7QV44VjbVrvq0y+kGMCpK7bUHk6LN+exauqEVl39KqTFJASvGirQYfyCvdu81r+wdwbx0eM8", + "YfH62hZH0rIqdxltW/EbRza2HWip2NYMmbeQOdL6m98od6Styj5F+khX6lZSRq7tzjkzvuy5I6PWHaon", + "eqmZtFwTxBep+9hYUuoTd8GT/XS3QYJHi7BWxIHydozRBH0LGN9n/N0G4rEF7MO4DmtSHiRPDcTii+6k", + "9B05eoCpv3Rj7ILPFyZZ1R1wjgJYSh+UV1Uxw4RXFHbZ+XI3fcV1bze3ndXWQ8LxeQUaaC2liRNJJ5tw", + "s58Uxbx2PqMr1OK0GgkfHB6NRocHoxsBZ/thXWO5npRVXBbSejttIXWVeoRVLWaWA1VbKIKs11Nb23R1", + "5WoSbRSj6TEG3mQ0YiRhMwTJKwJat1sWm11vHrwTqFwUbUH/fqPcvnkbfD1lTtGVmFf+ZVUfONc6BlH1", + "+xaDaAubidYg9QIxd4eD0f1X+4fH9+4f7+/fBth1sUht0R4Pft+/epAc0NlR8nD14Lf9xYP5QXoYfIdd", + "chF3pdWfoGyrl015SdaxjGosjey4OWRMNXOWN3P9a5ZwwQa6iJDaHqa4gRdY+/vW8389Pb+dwUbZ4bw+", + "yaoIQQ3RQcr6NPhbbjIbbRfN2Zw83TyLG0UgNQcSprfmUKBUx8Fghor93gciM+Si4zX0ulKw80W0MSpu", + "21UUsrDjSQ/ucsuKh8i7AcxQmfWmC3z9kgvoTudScbNIN98WRbECRhz9pn/XJq7jPQ3JyVxIxeo/F25y", + "1UcUVO71e8nvR/Uz437vjvzlEIgLAnRbXZUKOriRJWzJks2rgEXKh4eynuxUMVyI7/YH+4/QDyH5/ei7", + "0eBR3eOgb1erunz7vnTt11GXNaymAPSpo/YfXcvj2q/nJgr6iYcS2JX3ssMmdjRepof3V4cPuK1tcPl5", + "bY8bSD6tAuiHSnrucptUhaYYNLIhbPqKolY3Xo9VIiNTNudCd9HbHo4Kxe09UNySxw4gHN+yRhb9VJsH", + "WqnSCU9TFnNqcFTKbIhgOOioi2u+Ja6Xm8TXCkhrw7C49mg7RMK2gKtt1+TwA+JxP+j12+3Fuwm9A/Vq", + "/omKGGJYsE/4DHTQ9fZ9smr76MDASPRXO/ZAbSXJOh6gSznQ60n6ZC4NKUPoO+rbctGuFyzGD5k1842Y", + "GZYgDj4KIEoBIMY3sa+TpyRTMs6jMn40wUGXiB8qF8OuQv7Wod2qfgMDs0HJsV2/0abQ6KafbNvvhm4S", + "CLZ9q/dH27f6VpQi/V6exdt5mC3UjYNdC7l9SwhiQEVTX/aGJFiZzNsOHP1ldQXX37xWlxyBSJRn3sAC", + "NLVOSQFzC5oYQn69T1nCDAs1QmQSl1ESXJdcdDtL3b//cNFm4kSL1PpAfmIsI9RY/CPsLwVrXmhgPu1o", + "cZfsjDzat7YGr4FNV+RWqz64B1slsdatqqpw21IoWC7f0HlbvJTLivrbz6Ymmq0joHiGXxPSXranAHA1", + "vdtbq/74NtRyn1JIe+FMDw3YVg8qXKCj+/5LX2AQ6+rEexQyz4fI4hWdY668LXaUVlIJ5ZVEIwclhrrk", + "VphgE/5JMIuo9oAtXkDA6j6i1yUbJNwQOqfNzBFtaSmtJXq7D1F7opdXdL4Rk7cZDltVvz8e/I9Vt5PJ", + "8Hjvu7/+34O3fwmq3RsKBM3UIGYz9Li6ZKuBzcIEyophHZEWU0D0tKFzd2YYTVGZhmjvjitVx3tvVHDP", + "1S80XZsCuqpVUijtb53QX/+j3dGrsoyv8cLYenY/OEPKbWSSNdLfyzspU3PvVO8j6naHY4FBepdspUkl", + "Mdt0VTux3+iiSsUVn1xYch8ysbwgU46ZLvVYwPOeRhHL4Fnlcv1wHIuSyIYVo0m1HZcgzp8XZ5m1jhUQ", + "Pr0GZ/zi9avvX7z+5enkxdmzXx6fTH569t+9455gVwPbQzwA2ju6d98laa+u5H4wUcj1810MyamLV3A+", + "D7McX/YIWAY2UghBS1aEvYuSXIPbBC69McnNM1usRy3fPFPEB0IhG5OE3DMcZDfAcMOY7b3qvIu49sTI", + "NWa3d1oeLsi66GIJZ9zDK8VIL0kE84rAVoRXu9zY+qLfX3S7SyxIbOCwY+gvHLaAFvVnrhG4wzvBVAqT", + "HQyh8Sl47RfrhnoN0NrHRYNBF8yPnGlp9OhjZEN9vTH96VImA3jntaSMCKrV7VoEQwiwKRua0Wuzvsyn", + "gceM03HP+ZwGDC4hw8pHyVrqB7Q1dGxt/1vTt4UDOp4282nYY2mXqpH/oaEt0WbQHu+RgnjfgkCMEMs2", + "iJdXnAxrze+lwuy57MIh4JBYIqr7prDt8pR5mMgBVtoejbzxgVmZWWUk7XtzKvPQtmxaIIjHBx6pWGUj", + "sAKLb7hkLkCpAxwN0hvJmCqdd11lFM/R7q7JTqEC80tQhF2v2wU256E4pe+KHqAEvCaahlicR5kHa//5", + "95gz4KXbJWCfrgkcRuOVG0bIr1PRpjXxVLW+GVWqWp+3LR88eI5XbeB+bWerQZxlHzXSDNHj3yg3P0iF", + "7+J28JdbB9rHyz9mCsHwmjD6nTDoecriiczN5vMPxUB6tFd+kR+2zC/sdQAUiTiqxTW38QIPT1KOYX2l", + "YTlYlCtuVqA0ci+TKcaD+qS+uJDYEf5cdoyJVN+/R+35LBA585wJpniEaWrhPKZU4IsJYPvKbIU2ceUa", + "cC2KQC+enDi9i8c+xnc0N0h63gH18dkJGOOZsrqH3mh4OBzhYc6YoBnvHfcOh/vDUQ9fVQuc4t4U0uy7", + "QHL3oi5e8Cexk4S+94WgpqIpM1jj1wAkAjpguuL4BKHzyiMyo1y5V2SWyLggGA61Me+Cv1CP7a3ct8ve", + "WXmM8dYYBsSyF25z36KgjGcHp3kwGjmEd+OuXwxispETe393brRlv52kOrdEgTQEa888L1sWS/++3zsa", + "7V9rTJuGgmc31PFrQV00M8PX+b3R6PY7PRE2PtFFmzu/sOqJQ0KqnrVf38Ke6TxNqVr5BauuViZ1m2DM", + "QK/lytp3nNEkAlaByZIA0IbZ76ASpdaFW+UCc0z7ikCh9VNg2/abXKA1fS/j1UdbwlofXkfxvs7O4Li8", + "X6Pnj0c7BRmvb6T75KHGLdXeAQF9T4sE6Z/spByNHt1+p6D7TXgE0aqegJ1jNtfo+5QgcLoHYZKK/JZL", + "Q0kR1/AFHWkns04LcuuXV9HeHzx+b493wkL2gDOmUipslIgts+XQrx1na5spj/PGW80T/snTnrupPBqR", + "vahQkKsf0eq11RQG16+jo95xW592evEnJPyjOzjhbrJFjtxPeeQwMynYlb+k4+RsjdNSCAnKcs+Z+Vxo", + "fnSXV5bLpvAnPEVfCgE/Z4WEV+7W2qWwl6lc2AdwUAJ8WUZuunrf1IW/V+WXirsQ2jWgaczrYayBmcar", + "IfFrah/9oPTVFlhfLa27Wf18ncHwPpcTdnAXJwxnXFiKvl5TX6+pTafcUoufAh7MyinvoIO4lgbiz6d/", + "uLb24avuobvuoZPmQbArWxrwIIfEueZGMmZELyBLKRibLfCTd8IxVA3nvxOqogVfMkT3w2x1eWJ4RhW6", + "2KQErATWht6qmNioliia24PmBt4hs1zgJqCHZhN0gZm0AXGWrphcCFDWUu29ZkpcxbW86vbsBxXsRYPl", + "1UiuFlKzAthQmMptjnHe2r6OsdnhWLxyiLewgOhV7nmNZgni9m7Q/0hB6Fi4Ct96FuI94jRNS85FFYIn", + "cgvRabdlbao40omOZAh06BUTVJiBzljEZ+B9iNO6ZCvn2BpssFMCKhiwHyfkhfGOWge7YeA6dF0KoxQ/", + "Lb4RR0l1+42QxqXmLo1cHkuJKsh9EMxQMk/klCYTuz6XLGATfI4l3KKUBpfSmiRkzGwu/WxlFlLYv/Np", + "Lkxu/54qeaWZApvTWGBEiltr8FYoBERyhRnt0kwqY/0csM89O8S9Py7Z6v1wLB7HKRe2hrZVaKIlmCmg", + "npEePMRyrxZ6sKcpbAd/kmsj0yoErKc7O0yZmyw3LrRGM9MPwZ+OhZHkDw9y+X7vj7LH92gsZjQGOqkU", + "sVNC2bpt1HpCYfYTLBowtzNcgHEPLlLr5jFXVBgcU4nSSebVLd0p0kRg6tjmCkdUkExmNsUGEtWCAsnV", + "2kDQCpokxOBR8nVBcMedbJmPwyBMp60AhBYxrnGMuCCn31cO0+joYfg8aRYpFvIo+c/zF78QvJVhD2yx", + "0l0L14gLEBjAiwZzhzie9gxgkqyhCrMqjns8HvcKc268i2PNtXOXGQzQpvgdDO07202fx98Nh9CUNVce", + "k1//sK0cw1nKUguIOu6975PKhzk3i3xafHsbXtA2HLfzGiMgO/aa20VOQjlC7lRufHtFgupOulsAAARJ", + "yYGqjitTLqhabYqoDCy9W0E5s56MlcX4Y4yei+Pe8dj7Lo57/XGPiSX+5hwcx7334RVwVsv2FH54nxXG", + "zYKI7o9Gu9shwd36BmyWHQwDH/kN2PoqKvKPwg46PNo/l33g3/r9+X3xYhPQeYnRZB/+Xvn+BRogKhJ7", + "9SUaMEE0xG4qIpZ4sXu7oufujQewWRFLkrsm0E9FnoV5rEhZ8EWRI25WeYw2qu8/McWN7upSqantPw39", + "fnH684D23OnO2dK7OocTtiAYj3tKE1sYFBTnOKbBOROGPMNfh+6//u2H4JIXiZxfHNunO+QeIAkXLh6g", + "4qgM4oFbS6xk8XiKevafRba8HStJ/Osf/8RBcTH/1z/+6UDu//WPf+Jx37M4c5hs+2LBqDJTRs3FMYGo", + "twFNwI3cTQbT4bIlUytyOHI6f/xEKjn/nZSmx2IsXjKTK1GJm7CJ67Rr0JkKYD5c5Ew7PCMoyGcuq471", + "bQzobfxZtkt5pye6H8CFxBlUJgC3oqcBBNXjNuO4e4n2wipTO+ea0rTpprnmrLedvxj2zljqHdgBXpPB", + "4BKHzh1+cJMmO+fnz3aHBF9bliowcxK+Hcpm3DNi+JUnbedJlqPUGQqusuVNEc3olCfcqxxb0r7YI5jS", + "aAH4TIV/cQG67ps49iMFHgOuls4Rso9Fx+LF+R6qWA2LTK5Y33EC5aBWy7xw0sW5YA/Iv7hB77CBKzsW", + "M0ahDXLy1DKBChp5ERhZNCwQ0QR9XLmppaDrj4WF1HUQznDwUhmzBCth/3Nq2BVd9UmR9NeniUmogQex", + "7kPhsbChhm4NBojZQirDHCI/s0MaeE9eF7Ol2AyUozgRl/8c+94BvYLzcO6XcaW+OxttaocFi5bS6MU5", + "zG+OL0Fp9YHY0otzvxu7faIliRKO1BBRMRZzdATyKMZS1Ha1CChbUBUPIgmXQBXVCjyQExbP23jsk0pb", + "vVuUZGr9BI7Tj01y/dKEi8X6BOAQW6S+zZa7p65MN9Oda/HPZLuzC3Qd453V4DLLb+zqfjXkdTDkhdfN", + "G/VClrWnHory9jx+bRefyOHX0976mtsvlSX7FBo9suMxftAqIhU5e3JCaBwrpvXuv7e+D2ZqqbSU/+B+", + "BFb8KVxP3Fiksnvh9S11AvlS2MFLN2pC/byaiYar99teLQtR601XJCQqr7zbvz0anV7nGimF3pLWvt4k", + "W51tuY4gdLFKLQMUjRJWiC/FOa1S0TatsnXjLa6cjeKSLQU+Eu5A3p1+2XWdi+bdcAdM8WmDIX5CRlgP", + "ta6mD/+SqPl1sYtuXpvUz58XaY7uTgq6a1V0iMy/pOdi3Fg24IIW6KT1An3OjIU3uc13uushMHHQc7tT", + "bQe6srMupmWrEovTghNCTczmt++JLdLt6Wvb+zO9fHF5riOxuCX/KqJ0eOyWa7XpgXvicmHf3vu2huR2", + "x24rjsACiwwfnG4aYZEgvSTVKxHtfvVc+egUjYtdecQqP28WF5ps/F6+s+5KrnssqonXuS7etVyQWQIo", + "sdYIEPMZ+uqZaiJzHOXBHYyySBiuqGHORfFLjPs9yxNr3YikWDJVQWqsXql7f6DT6vankmdeG2/X1y9/", + "HjARybgwnrTLpO7LR34wWfqvxfLe/an7AuNZuRcP2gTGD9h/60xOCujO/3XwgwPv/F8HP9Ak44L9r8PH", + "CTVMm91bI5bRXd10d/2A+YKJD94vvL5oa6xpz9B5NQ61TpkevvY6lFmDnfXK7iKQ5qNR38cX8ppYvZ0k", + "vDuje3hJ3TViyYlPS2C3VCqPXtWAJP73FvrOawR95/q6Wvdc4whQsPsCvZSl8qir1MFWc0/cjjeJuWJa", + "b1NGFKU66iN8+T+VSsJO+lpKiWJdv+oluuglqsu1UTVhC96ucsL28Yms7wWxhVYbP32F27oDg46jyArc", + "Vs3CXQJuLaQ2+OnLw15wVwkvKK56bXS0TJYHcuP14Un35GkfFxLT/p88LUOb78hO6cdx5w9v1+/dCz2P", + "0ymf5zLX1ajplJpowbRDFEhYnQF/aSqB8npuVQp8xlQ6usur487f/F/p/pa0Ec0NtczbOhtsk/l9qa4y", + "vysPMr9FW3aoCy43Ut/nzdttCQLxeMtdybgGS/2+32VcobcI5JrQpHwuEHxBHI/F/4b3x6+G0fTtdz68", + "Ox+NDu7j70ws337nI7zFqScVJoziLs3p41+eoofHHJ34MRNqCSbRHAdJc20s6fmUJ/92D6TSyaX7C8lT", + "4dcXUqcXUmW5Nr+QbMFbfiLV0ybd+RvJ01towe23P+kr6U9uuq294HQ+m/GIM4HJp1D5p9d8le1L7qvV", + "9oZgCcL5SlQcHWuSSOdnZMG1tkjoZeL/j+lJ2G/NQSEJNYalmQEQo4hBLlwboacXuYnllfApKXCCPrsZ", + "L+cTut59UxPfSDhANpyivetLt0jLeNdPXdfxFxqhKjOXYdw9LkvRpv11+WmJ93bflB2u2rt/VX7JJGaf", + "b+tLl8ELIZBizZp50tyG8xY1S3TGIXn16mcfugvPE+UT9hnps/T5TM5jUc3SNyTPyvSHtoBvAZ4PLHah", + "/lDf572LGY0TLhjGOjAdirKt59b8pMfi40vA4cShd23i7nIs7YZ+Qgn4k7GCO5E1C5u+cQbZ4mhWcooW", + "p8XLm3hqvih+5RhQgPGEZL09mhs5cGAAewtpESLDILlnCY0QIxeKWfhGh79i8VqrTSGoipJJwpSF5cxy", + "48UtQMh1g+PCyCLxtJPMLqD5SS4MTy761tUQyiaaQNJziygzFrXOnMyHGAmI/4EjVCyzI25k0YVBc1A1", + "QimEM6h2SWhyRVd6LByqgq2OqdcViyyCbZIMyY8SAW1sNukK47WpXL/RY3HB44RNHB7NBeGa6IVUhgkW", + "k1Quma73y6hKOFM4iScUVk6TlK4QGNJi5Nr1kRmz4Is11BsJ/6Yi5pgUFHoupnw8FpQcjEYkZVRoh2Gh", + "6QwvHNcGwUHUBvQtoeRo9MjVauwbgpf75d+B06QUW8qITiH/K1CxTba9ixuYuiS9Nus7bN+MK233q9Bv", + "uuyLtY3l2uecjfskFyVKB+r6c1GAasB2mVwJnKezAjKuimvQARNNWURhPYWs94OQsDKKchW6IGGrK9mi", + "/x0Fx8r0znGpwhgYCaoMInC1l4hjuMAzLfEo7X7bQlUlUf05LprgIZGKUFKh61KjwaIcWeMOQqhelKlP", + "hU9lfrH7rT87cHwdI/DHX8y/pPsJiUjOZrUDuP1qsgd4U+zZOgn/Wc/pE8f0aiwu5nQupDY88szQI1RX", + "H81fH4SdHoSbVzZIzTOpLtsdjn+Q6rLrC8w7Rn5ZD7HqDD9DQwQMD0HwP709ArXh9rECRHPnj7QmfRWn", + "FIUubnThEkwSKeZMVbXyd242qL7qdiygJVymyhq7C/gxeIRM3I82bXbFrRtNDJFr9VPzIuj9DoxRv0hD", + "eJolLGWYVntgiQ02u4Sqm67s06MEbLser4RTVcVVsG9Bbf0P+l4cQrryG7aD0vv6dgWZKuB5bgVELTr3", + "6J8BRNSxeK1tqoILa3q6IAUPBoHWph8hVwseLaAd/A3bt+CpNMsuCmD43WPyHA9yZdts5zs26QjQmpYJ", + "s6CnyzS9OF5PHP3m9BQrYZmFTRF9cUx8suji/tBQqop2CrNIqDbkF4fhulM8xnFHL0CHWZnfrsNBLYH7", + "xyKEiQqQorZBPiMXFXjUixbsPs9vf5Zz/dmYisp0K3YuRhL3dETaZCLutTl58CRs+NkfjUJZADqitNph", + "3DJI69pgfpbzItVLjZRplnUlXzdMpOJlmm6gYbJTchCiTSxz81dtYqYUVnbU3UbcZIdG9h+GXgKhOoBP", + "f7B3x6JlqewMw0sFTLXX7zGRp73jX92/lmna6/fceKDelcnSSu6Gawj5W1Bvmw2+74d2qAJt+1U8vw5o", + "bZ3pV1BrGzeIe1a3S+YvbYE/vdXQ6+4+IRminNBQ5n5OomhlvHXFj5AF+i6O7NFdjAwhekmUSM1qhp4v", + "B+DPKbwasmO7wsiv8QCGF+c+I1oXT5ZzV/Xc1/wM3uDbfEb8mImf7p07j6yP4EsGK9BrswENWQMVbptX", + "yWdPSB9vS9am2oVCvtLm9bWNnQgT3gvrLMJViG2GTJobmVLDI8zOFi2k1BWyLyDcbR5Fp0QuKBNVLPa1", + "6yIJLoBUL5w6+sI9J46d6ozQ6ifXxxCru/iDcA3/qazxQ0U7UHD8vn8CYAYTDRmsFGczktFcM5DqclDp", + "rKKE+XR8DAzDEc1MrhhmGmUk5YKneVrF5ocdW1LEEbrYTy/6ZJobkoCaSBUfvdNNJNOUCcB+mEk1FgtG", + "l5wpWJSEGiai1UAzzFC+ZATSAySSxqhqABM9DAkznCoGFIiJDlJmaEwNRUHnAk78xAYzXRRJy+3zXrB3", + "JTWA8TMX39qsK9DshR/oBWGYVoDrRZHcNqIxE1EQbv/882ZjH18nfc5Mc6KfyEPoRrz0U7oMVXWvfjif", + "hzfRFwsN0YHNbxB6dfsTth4F4sno3/NI27n6OX4iQ1OxxJtO8edhYSqI7rOxMn16M5JUJM5td5VTiWT+", + "Z7UNFQyl6nSFEaZ2G29qICqyeBbLfC2et/eH//PkBrq8z4QT9lsf9m354spJfw4s163qjXjuJ1JiOl1S", + "VSf36ViwG9SnE5+kqnC5L0XZWkNoK/h2lTsZRfH1JcVXtt1k287x4aZs2+tm10z7FUbOxQB9RcMc3Klx", + "W1m1Ux38m0alNGZXYZmfnEWWlos7h1v0rDGjK1CV/BmchTfYjyKplIXBoIZ9WRDRFa1hNUwAdXNlIsq+", + "j9p8c3q628YllNnII5T5gjlEJTQHqqXxugL3xZIpxWMPg/nk9Klz2+UadKJD8iLlhhhJLhnLysgWjC4E", + "dMcCEKQx7CbyR7/HhFGrTHJhto6iLHo7gyl/kA4X5TMUJV3Oga/m8M7mcNTsf3nsDLkMocUENr9MDTVb", + "cyFzMZMqtXIZncocWgceBMsE+2kRC2Y8YXqlDUutdyJgGcBxw/w1Lke5q2d3uY++uXBybNhcxlTKteZS", + "6LFwMSMZU9A3VIf2K45WQYOAoQV/PbNM8vNw4oPBWL81atpWDaGbMPdx77i3R7NsL6aGtjiKueF9wJB+", + "QK88olfpFCIAwa3vUpOdhF/a5wlZapLAH7sb3fomWO9jZ2C/+cmClT4RMxnMb2lptiDmP1V0l2Nr3jD5", + "xbG156x6WDz/mclWtrY9x7tiNBlgznQP4ENywxP+u2V10AjXhkc29IgWa/fmtGCqw7E4ZUbxyNpYI3Ba", + "RmQDfF3uZUpGewD+dhhlHFHgDhkODhle++cUe3xy9hrLpSyVatUfCypcw68en1nr7oxGrDlQl9ydnOy9", + "2OLofI7L9G/sIWgnuBHFILjhX02C18caaT1DuuWIymzTU0lmf3oXVifBfdUrfJl6BaDhcjY7BcCXR+UK", + "6xCWMslT+If942Qbvpmh0eINFv1spF07nK3d+Al+EYfSzSlmNv/uJzF62AX7Un1WYeH8FFCIqXkDBm+B", + "x+bPSN0fX31fXcfP0NzpVtTntv5sztZd33xuDB5po7oeX8oxt5TmZ2LkZu3TFeXt2qfvExldagfJUlUb", + "EsUo4qzDjyUutjMRopiAEaLEQRlZwCym+2PRUEBa5B9NKDFMpVzQZA/nbBtBhG+vxaJLyTFQO8I4lYEG", + "9XImkwRhvBEGD2YDJWa+gYpFV7vsf9UyVWOkkYD1I1PmUc93Q0+3v1FufpCqDmH+ufDFV5X1R2hAylHf", + "vgW1vb3HD0JxP6Xv0FU6zp1B2Y9o57ksf7SqoD7BvRn3Dkd63OuTce8gHfdgB55QVKFSQ+6RlIvcABzi", + "U6vfwlDc+yOiWSRFrD34utfgHY50y8wdWbZEed7Hencp9jiqwqV86ToJsQcoR6A+Bu2QneqBc2cy7uOh", + "i4nMjVX3u3PlSsXMoHpk985ttZUz8vVt34WT/80d3xqPwl0GdlnZesvZs1wvWLvK7Web0Cg3U1iHIgGy", + "XpC/y6nug6e/1YYDzNoa34PaZ7aDu0g4AF1dJ9mAm/vXTAMdMg2UaxUGbbQOlnAle+qwyI3sXSaVgd9d", + "sLujIXxJIIIEpil88eRkLCJgRRZiULFUIndyuOj2Fn78t3Py7MnLPnmKyXjJj/l0d0heQHQLtqqdjWYs", + "rCRmmVdEAY3bziAOXc927Eg9t+ksDh18ouz29mQELCt+r7yTeL+3YDRGieSP3s/SdhZAH375Mxwg2D9X", + "s9j23kbhA3T4ajV4PDNMrTd76uKkRIGd4S5pD0XnBDfsGDvUHoGt7NPKBhYi4/CgF0DMeP81+cPtJ3G+", + "GysZ9uvS7kEE3ZeavxUPYsEcQyywel0X6RPaooQdL9v4wMAuT55+lEfFrVqGN/GuGsb8v+vpwpl+sYam", + "rLZPQMRF2pWtll4fHLywsMi2GoloRiNuVn1Ck8TdUe4mKDxSBoX4O1WMXoLefgjQVa5nH9ALttq+N9SS", + "mOtL24KzxQ4JuKTpfFoMjuBBs1ZjXHMIizWSRDSJ8oQaRthsxiKMxcU8LrrFllsMpXeLZ6fsJJh0puLV", + "nn9xue7CNIG7V5JFk+L27FbvKRYllKftIOROUEOHQ3Q1mEKjUoDvQmIpjkZKak1cUwOW8DmfJs5BCNIv", + "gJqLpmwssoQKwRTJtfWKh6EPMsW0zhXzDcDd5SiqT0qAwUxJ41wTEimVHgtP4W9OiTYs20BmL23Lpzjn", + "W5JtbeOup0+kpG6MoV0V4ooQ2BBLKXbBgY5ANfQJXNHtgD61lPilHPxXis/B3zRTkloma93x7LH2y2kP", + "fS1iuTXv5XlRqlvey6LVSlRiJWJvI0DcpMTcjnvX8/oLdH7JWzEE3afrRRH/BJU69l2PVg0Pwn36wFmG", + "Unj+O2bLPK8ECXZVYJUU/qWpkyojrx3VWqDtdlitzpG1txnp2hk/65PBZn3JaFm0Fj7b9uD9/AhhdLco", + "D3edbO3Lpq0a2lXtbdoS8r8dVf+zoMDbgdP/xCgnN4DT/6zi7oF2PiH+SfCgfqo4+prtWaqviPi3Gj5v", + "YfERjq0tfN5yPee8uvGh9MaV6fZMci3+mSR4u0DXkd/9sn999Xd4MlQWa5sJGgiepZlZuUreVlk6nWn+", + "Oxu2GIILv9XbMwXfwKXz45GHp9NWh84/Z478T+Iz6lIIcg1ufuvJ578wjMHqmatdLHtw6wyoihZ8ydqV", + "7vUT7JYoU2yQyQyNK7FdMLce/i4zVA3nvxPXvMNcdf/CHJQI1c9iEnPFIgPeIsJI5Ai2j280UVIa912q", + "VbuXiD0iPyiZPnaz2XIfujPllGGln2G6GsTU0MHSc5sNKrQP8O70/pTA8AgX5Pn3ZIe9M8pmuiAzePlA", + "hLdfUvYuYizWSJO71QHvj8JDhIYn82mXUW7IWfIC/6AJiXJtZOr3/uQp2cEcaHMmYC9A1J+hJJspueQx", + "i2tjBOd9u6r7LQt6Xb0rCBVFAjv/uLCD+yQyTJcLaf47z+psoXCJmXJBcXBbs4LUz5QN4of+KBfeAcft", + "kR/F1yvMvfx2/GMHKBHzcbpFNFJaiOfdr9fcl3zNVYOh/J1Wu+28e85m5XW3+KiOYUu3kfihiJ27W7X1", + "m88npIfrLzKax6nOl8WDtE1t/nmR4Oju7oe7Vpe/+YJDQEFZvmwsm21ALcME8zP6dMdsyRKZpZgXHcv2", + "+r1cJb3j3sKY7HhvD32/F1Kb46NHDw5779++//8HAO45oYlC+gEA", } // 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..038608400 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 diff --git a/spike/macvm/main.go b/spike/macvm/main.go new file mode 100644 index 000000000..5dd898cdb --- /dev/null +++ b/spike/macvm/main.go @@ -0,0 +1,518 @@ +//go:build darwin && arm64 + +package main + +import ( + "context" + "encoding/json" + "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 e := os.Mkdir(*dst, 0700); e != nil { + return e + } + 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) + 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 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() + } +} From 57cdeeaf302b549a7e52fb2a783af09b8d06eb2e Mon Sep 17 00:00:00 2001 From: chris lee Date: Thu, 8 Oct 2026 14:42:45 -0400 Subject: [PATCH 02/16] Roll back partial macOS storage clones without removing existing files --- lib/instances/macos_darwin.go | 21 ++++++++-- lib/instances/macos_darwin_test.go | 66 +++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/lib/instances/macos_darwin.go b/lib/instances/macos_darwin.go index 9b472ecc8..e7ea2ca68 100644 --- a/lib/instances/macos_darwin.go +++ b/lib/instances/macos_darwin.go @@ -12,12 +12,25 @@ import ( "golang.org/x/sys/unix" ) -func cloneMacOSStorage(root, disk, aux string) error { - for src, dst := range map[string]string{root: disk, filepath.Join(filepath.Dir(root), "aux.img"): aux} { - if err := unix.Clonefile(src, dst, unix.CLONE_NOFOLLOW|unix.CLONE_NOOWNERCOPY); err != nil { +func cloneMacOSStorage(root, 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 }{ + {root, disk}, + {filepath.Join(filepath.Dir(root), "aux.img"), aux}, + } { + if err = unix.Clonefile(file.src, file.dst, unix.CLONE_NOFOLLOW|unix.CLONE_NOOWNERCOPY); err != nil { return err } - if err := os.Chmod(dst, 0600); err != nil { + created = append(created, file.dst) + if err = os.Chmod(file.dst, 0600); err != nil { return err } } diff --git a/lib/instances/macos_darwin_test.go b/lib/instances/macos_darwin_test.go index 38aea2ffb..6aa6adefc 100644 --- a/lib/instances/macos_darwin_test.go +++ b/lib/instances/macos_darwin_test.go @@ -3,11 +3,75 @@ package instances import ( - "github.com/stretchr/testify/require" + "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, 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, 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, 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) From 550366383163810151da11090771833afec5da63 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:43:20 +0000 Subject: [PATCH 03/16] Harden macOS identity admission and boot storage lookup - Skip directories without instance metadata during identity admission so an in-progress create or leftover directory cannot block a macOS start. - Fail create when the image lookup errors instead of silently applying Linux disk and network defaults to a macOS guest. - Take the identity lock from a fixed /tmp path so separate server processes with different TMPDIR values contend on the same lock. - Resolve disk and aux storage from one image layout via GetBootStorage. - Remove the destination directory when a spike clone fails partway, so a retry is not blocked by a partial bundle. --- cmd/api/api/instances.go | 17 +++++++-- cmd/vz-shim/macos_identity.go | 3 +- lib/images/storage.go | 17 +++++++++ lib/instances/macos.go | 9 +++-- lib/instances/macos_darwin.go | 7 ++-- lib/instances/macos_darwin_test.go | 6 ++-- lib/instances/macos_other.go | 2 +- lib/instances/macos_test.go | 12 +++++++ spike/macvm/main.go | 57 +++++++++++++++++------------- 9 files changed, 92 insertions(+), 38 deletions(-) diff --git a/cmd/api/api/instances.go b/cmd/api/api/instances.go index da66f597f..6cafab0b5 100644 --- a/cmd/api/api/instances.go +++ b/cmd/api/api/instances.go @@ -275,10 +275,21 @@ func (s *ApiService) CreateInstance(ctx context.Context, request oapi.CreateInst // Imported desktop guests do not implement Linux disk/TAP shaping. Do not // silently advertise proportional limits that the backend would ignore. - macOSImage := false - if img, err := s.ImageManager.GetImage(ctx, request.Body.Image); err == nil { - macOSImage = img.MacOS != nil + img, err := s.ImageManager.GetImage(ctx, request.Body.Image) + if err != nil { + if errors.Is(err, images.ErrNotFound) { + return oapi.CreateInstance404JSONResponse{ + Code: "not_found", + Message: err.Error(), + }, nil + } + log.ErrorContext(ctx, "failed to resolve image for instance defaults", "error", err, "image", request.Body.Image) + return oapi.CreateInstance500JSONResponse{ + Code: "internal_error", + Message: "failed to create instance", + }, nil } + macOSImage := img.MacOS != nil if macOSImage && request.Body.Vcpus == nil { vcpus = 0 } diff --git a/cmd/vz-shim/macos_identity.go b/cmd/vz-shim/macos_identity.go index ee1860a3d..5a765d9f6 100644 --- a/cmd/vz-shim/macos_identity.go +++ b/cmd/vz-shim/macos_identity.go @@ -24,7 +24,8 @@ func lockMacOSIdentity(c *shimconfig.ShimConfig) (*os.File, error) { return nil, fmt.Errorf("decode Mac identity for admission: %w", err) } hash := sha256.Sum256(data) - path := filepath.Join(os.TempDir(), fmt.Sprintf("hypeman-mac-identity-%d-%x.lock", os.Getuid(), hash)) + // 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 diff --git a/lib/images/storage.go b/lib/images/storage.go index eefc56f94..652edb790 100644 --- a/lib/images/storage.go +++ b/lib/images/storage.go @@ -194,6 +194,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/instances/macos.go b/lib/instances/macos.go index 9d0c1fa29..247737b2c 100644 --- a/lib/instances/macos.go +++ b/lib/instances/macos.go @@ -3,6 +3,7 @@ package instances import ( "bytes" "context" + "errors" "fmt" "os" "path/filepath" @@ -41,11 +42,11 @@ func (m *manager) prepareBootStorage(stored *StoredMetadata, img *images.Image) if stored.MacOS == nil { return m.createOverlayDisk(stored.Id, stored.OverlaySize) } - root, err := images.GetDiskPath(m.paths, img.Name, img.Digest) + boot, err := images.GetBootStorage(m.paths, img.Name, img.Digest) if err != nil { return err } - return cloneMacOSStorage(root, m.paths.InstanceOverlay(stored.Id), filepath.Join(stored.DataDir, "mac-aux.img")) + 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, @@ -72,6 +73,10 @@ func (m *manager) checkMacOSIdentityAvailable(ctx context.Context, stored *Store 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) } diff --git a/lib/instances/macos_darwin.go b/lib/instances/macos_darwin.go index e7ea2ca68..c430e98eb 100644 --- a/lib/instances/macos_darwin.go +++ b/lib/instances/macos_darwin.go @@ -6,13 +6,12 @@ import ( "encoding/hex" "net" "os" - "path/filepath" "strings" "golang.org/x/sys/unix" ) -func cloneMacOSStorage(root, disk, aux string) (err error) { +func cloneMacOSStorage(rootDisk, rootAux, disk, aux string) (err error) { var created []string defer func() { if err != nil { @@ -23,8 +22,8 @@ func cloneMacOSStorage(root, disk, aux string) (err error) { } }() for _, file := range []struct{ src, dst string }{ - {root, disk}, - {filepath.Join(filepath.Dir(root), "aux.img"), aux}, + {rootDisk, disk}, + {rootAux, aux}, } { if err = unix.Clonefile(file.src, file.dst, unix.CLONE_NOFOLLOW|unix.CLONE_NOOWNERCOPY); err != nil { return err diff --git a/lib/instances/macos_darwin_test.go b/lib/instances/macos_darwin_test.go index 6aa6adefc..6d9c51be6 100644 --- a/lib/instances/macos_darwin_test.go +++ b/lib/instances/macos_darwin_test.go @@ -30,7 +30,7 @@ func TestMacOSStorageClone(t *testing.T) { 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, disk, 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) @@ -51,7 +51,7 @@ func TestMacOSStorageClone(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, disk, aux)) + require.Error(t, cloneMacOSStorage(source, auxSource, disk, aux)) _, err := os.Stat(disk) require.ErrorIs(t, err, os.ErrNotExist) data, err := os.ReadFile(aux) @@ -63,7 +63,7 @@ func TestMacOSStorageClone(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, disk, aux)) + require.Error(t, cloneMacOSStorage(source, auxSource, disk, aux)) data, err := os.ReadFile(disk) require.NoError(t, err) require.Equal(t, "existing disk", string(data)) diff --git a/lib/instances/macos_other.go b/lib/instances/macos_other.go index 0815bb4c2..d658e4a3f 100644 --- a/lib/instances/macos_other.go +++ b/lib/instances/macos_other.go @@ -4,7 +4,7 @@ package instances import "fmt" -func cloneMacOSStorage(string, string, string) error { +func cloneMacOSStorage(string, string, string, string) error { return fmt.Errorf("macOS image clones require APFS on macOS") } func macOSGuestIP(string) string { return "" } diff --git a/lib/instances/macos_test.go b/lib/instances/macos_test.go index a8bf3d129..df2b04767 100644 --- a/lib/instances/macos_test.go +++ b/lib/instances/macos_test.go @@ -52,6 +52,18 @@ func TestMacOSBootConfigNoLinuxDevices(t *testing.T) { 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} diff --git a/spike/macvm/main.go b/spike/macvm/main.go index 5dd898cdb..31fe0dab4 100644 --- a/spike/macvm/main.go +++ b/spike/macvm/main.go @@ -5,6 +5,7 @@ package main import ( "context" "encoding/json" + "errors" "flag" "fmt" "io" @@ -216,31 +217,11 @@ func execute() error { if e := os.Mkdir(*dst, 0700); e != nil { return e } - 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() + // 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)) } - if *state != "" { - if e := unix.Clonefile(*state, filepath.Join(*dst, "machine-state.vzm"), 0); e != nil { - return e - } - } - return writeConfig(*dst, c) + return nil case "ctl": if *dir == "" || len(fs.Args()) == 0 { return fmt.Errorf("--dir and action required") @@ -303,6 +284,34 @@ func execute() error { 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() From 1477337555d1615c57ecf827682a7079c13c9f3e Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:44:26 +0000 Subject: [PATCH 04/16] Start macOS guests without the imported template macOS instances own independent disk and auxiliary storage cloned at create time and keep their identity in metadata, so start no longer resolves the template image. Deleting an imported image leaves existing instances startable. Reject --with-state combined with --rekey or --new-mac in the spike clone command: saved state is only restorable into the configuration it was saved from. --- docs/macos-experimental.md | 4 +++- lib/instances/start.go | 30 ++++++++++++++++++------------ spike/macvm/main.go | 4 ++++ 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/docs/macos-experimental.md b/docs/macos-experimental.md index 0decda585..57e9f396d 100644 --- a/docs/macos-experimental.md +++ b/docs/macos-experimental.md @@ -54,7 +54,9 @@ credential rekeying; this spike is not a multi-tenant image format. 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. +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 diff --git a/lib/instances/start.go b/lib/instances/start.go index 12610920f..331ba6ba6 100644 --- a/lib/instances/start.go +++ b/lib/instances/start.go @@ -6,6 +6,7 @@ import ( "time" "github.com/kernel/hypeman/lib/egressproxy" + "github.com/kernel/hypeman/lib/images" "github.com/kernel/hypeman/lib/logger" "github.com/kernel/hypeman/lib/network" "go.opentelemetry.io/otel/attribute" @@ -100,18 +101,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 diff --git a/spike/macvm/main.go b/spike/macvm/main.go index 31fe0dab4..6a0678c60 100644 --- a/spike/macvm/main.go +++ b/spike/macvm/main.go @@ -214,6 +214,10 @@ func execute() error { 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 } From 5f40b64a104a59c92fc943bd1399e69268169d43 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:54:33 +0000 Subject: [PATCH 05/16] Let uncached images reach auto-pull on create The image lookup used for instance defaults returned 404 for an uncached image, which skipped the auto-pull path in resolveImageForCreate. Treat not-found as no macOS defaults and let create resolve and pull the image. --- cmd/api/api/instances.go | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/cmd/api/api/instances.go b/cmd/api/api/instances.go index 6cafab0b5..3bbef0d26 100644 --- a/cmd/api/api/instances.go +++ b/cmd/api/api/instances.go @@ -275,21 +275,17 @@ func (s *ApiService) CreateInstance(ctx context.Context, request oapi.CreateInst // Imported desktop guests do not implement Linux disk/TAP shaping. Do not // silently advertise proportional limits that the backend would ignore. + // An uncached image is not an error here: CreateInstance pulls it, and the + // defaults stay Linux-shaped until a macOS image is known. img, err := s.ImageManager.GetImage(ctx, request.Body.Image) - if err != nil { - if errors.Is(err, images.ErrNotFound) { - return oapi.CreateInstance404JSONResponse{ - Code: "not_found", - Message: err.Error(), - }, nil - } + if err != nil && !errors.Is(err, images.ErrNotFound) { log.ErrorContext(ctx, "failed to resolve image for instance defaults", "error", err, "image", request.Body.Image) return oapi.CreateInstance500JSONResponse{ Code: "internal_error", Message: "failed to create instance", }, nil } - macOSImage := img.MacOS != nil + macOSImage := err == nil && img.MacOS != nil if macOSImage && request.Body.Vcpus == nil { vcpus = 0 } From 8a133d87fc0b58db559373e146d7f02b072610fe Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:54:33 +0000 Subject: [PATCH 06/16] Reuse the resolved legacy layout in layout selection legacyImageExists rebuilt the legacy layout and re-read its metadata for a path the caller had already resolved. --- lib/images/storage.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/images/storage.go b/lib/images/storage.go index 652edb790..5b2c9594c 100644 --- a/lib/images/storage.go +++ b/lib/images/storage.go @@ -117,7 +117,7 @@ func resolveImageLayout(p *paths.Paths, repository, digestHex string) imageLayou 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 @@ -161,8 +161,7 @@ func legacyLayout(p *paths.Paths, repository, digestHex string) imageLayout { return layout } -func legacyImageExists(p *paths.Paths, repository, digestHex string) bool { - layout := legacyLayout(p, repository, digestHex) +func legacyImageExists(layout imageLayout) bool { _, metadataErr := os.Stat(layout.metadata) _, diskErr := os.Stat(layout.disk) return metadataErr == nil && diskErr == nil From 8f4bbae72b2e0c1164f303dac1ff71ab7d3c38eb Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:03:57 +0000 Subject: [PATCH 07/16] Derive Linux shaping defaults from the resolved image The API looked up the image by tag to decide whether to apply Linux disk and network defaults, while the manager resolved the image it actually creates from. A tag that moved, or an uncached image, could make the defaults describe a different guest. Move the Linux defaults (vCPUs, disk I/O, network bandwidth) into the manager, applied after image resolution and before resource reservation. macOS guests keep taking CPU and memory from the image and continue to reject shaping. The API no longer resolves images at all. --- cmd/api/api/instances.go | 34 +------------------- lib/instances/create.go | 25 +++++++++++++++ lib/instances/create_defaults_test.go | 45 +++++++++++++++++++++++++++ lib/instances/create_mdev_test.go | 4 +++ lib/instances/manager.go | 4 +++ 5 files changed, 79 insertions(+), 33 deletions(-) create mode 100644 lib/instances/create_defaults_test.go diff --git a/cmd/api/api/instances.go b/cmd/api/api/instances.go index 3bbef0d26..056addabb 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,38 +273,6 @@ func (s *ApiService) CreateInstance(ctx context.Context, request oapi.CreateInst } } - // Imported desktop guests do not implement Linux disk/TAP shaping. Do not - // silently advertise proportional limits that the backend would ignore. - // An uncached image is not an error here: CreateInstance pulls it, and the - // defaults stay Linux-shaped until a macOS image is known. - img, err := s.ImageManager.GetImage(ctx, request.Body.Image) - if err != nil && !errors.Is(err, images.ErrNotFound) { - log.ErrorContext(ctx, "failed to resolve image for instance defaults", "error", err, "image", request.Body.Image) - return oapi.CreateInstance500JSONResponse{ - Code: "internal_error", - Message: "failed to create instance", - }, nil - } - macOSImage := err == nil && img.MacOS != nil - if macOSImage && request.Body.Vcpus == nil { - vcpus = 0 - } - - // Calculate default resource limits when not specified (0 = auto) - // Uses proportional allocation based on CPU: (vcpus / cpuCapacity) * resourceCapacity - if diskIOBps == 0 && !macOSImage { - diskIOBps, _ = s.ResourceManager.DefaultDiskIOBandwidth(vcpus) - } - if !macOSImage && (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 { diff --git a/lib/instances/create.go b/lib/instances/create.go index 1aa624d34..6a80a6fbe 100644 --- a/lib/instances/create.go +++ b/lib/instances/create.go @@ -78,6 +78,26 @@ func generateVsockCID(instanceID string) int64 { // createInstance creates and starts a new instance // Multi-hop orchestration: Stopped → Created → Running +// 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 + } + } +} + func (m *manager) createInstance( ctx context.Context, req CreateInstanceRequest, @@ -230,6 +250,11 @@ func (m *manager) createInstance( 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 { 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_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/manager.go b/lib/instances/manager.go index cc5624fdf..a4f486a8f 100644 --- a/lib/instances/manager.go +++ b/lib/instances/manager.go @@ -162,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 { From 79f4cdc438aef3196b985d6988c690e9ad74db09 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:59:48 +0000 Subject: [PATCH 08/16] Tidy macOS lifecycle gates and drop dead macOS scaffolding - Move the macOS config-disk skip to the start and create call sites instead of checking inside createConfigDisk, so the Linux-only step is decided once by its caller. - Read the locally imported raw disk with a stat in legacyLayout instead of parsing metadata on every layout lookup. - Return real image lookup errors from the macOS-only pre-check rather than reporting them as "not imported". - Skip the Linux overlay limit for macOS boot disks, whose size comes from the imported image. - Return 501 from the instance stat endpoint for macOS instances before dialing the guest, matching exec. - Remove redundant macOS gates in stop, a stale fixture, a no-op TMPDIR setup in the identity lock test, and a misplaced doc comment. --- cmd/api/api/instances.go | 7 + cmd/api/api/macos_test.go | 8 +- cmd/vz-shim/macos_identity_test.go | 1 - lib/images/storage.go | 9 +- lib/instances/configdisk.go | 6 +- lib/instances/create.go | 22 +- lib/instances/start.go | 10 +- lib/instances/stop.go | 4 +- lib/oapi/oapi.go | 793 +++++++++++++++-------------- openapi.yaml | 6 + 10 files changed, 447 insertions(+), 419 deletions(-) diff --git a/cmd/api/api/instances.go b/cmd/api/api/instances.go index 056addabb..70c214646 100644 --- a/cmd/api/api/instances.go +++ b/cmd/api/api/instances.go @@ -948,6 +948,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 index bc4797b8b..ea74f393f 100644 --- a/cmd/api/api/macos_test.go +++ b/cmd/api/api/macos_test.go @@ -35,12 +35,6 @@ func TestMacOSExecRejectedBeforeWebsocketUpgrade(t *testing.T) { require.Contains(t, w.Body.String(), "not implemented") } -type macOSFixtureImages struct{ images.Manager } - -func (macOSFixtureImages) GetImage(context.Context, string) (*images.Image, error) { - return &images.Image{MacOS: &images.MacOSImage{}}, nil -} - func TestMacOSSchemaDefersTemplateDefaults(t *testing.T) { spec, err := oapi.GetSwagger() require.NoError(t, err) @@ -48,7 +42,7 @@ func TestMacOSSchemaDefersTemplateDefaults(t *testing.T) { require.Nil(t, spec.Components.Schemas["CreateInstanceRequest"].Value.Properties[name].Value.Default, name) } m := newCaptureCreateManager(nil) - s := &ApiService{InstanceManager: m, ImageManager: macOSFixtureImages{}, Config: &config.Config{}} + 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) diff --git a/cmd/vz-shim/macos_identity_test.go b/cmd/vz-shim/macos_identity_test.go index 718ffe1e3..af56eb4c4 100644 --- a/cmd/vz-shim/macos_identity_test.go +++ b/cmd/vz-shim/macos_identity_test.go @@ -9,7 +9,6 @@ import ( ) func TestMacOSIdentityLifetimeLock(t *testing.T) { - t.Setenv("TMPDIR", t.TempDir()) c := &shimconfig.ShimConfig{MacMachineIdentifierData: "aWRlbnRpdHk="} first, err := lockMacOSIdentity(c) require.NoError(t, err) diff --git a/lib/images/storage.go b/lib/images/storage.go index 5b2c9594c..a6331fbd3 100644 --- a/lib/images/storage.go +++ b/lib/images/storage.go @@ -152,11 +152,10 @@ func digestDir(p *paths.Paths, repository, digestHex string) string { 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)} - if b, err := os.ReadFile(layout.metadata); err == nil { - var meta imageMetadata - if json.Unmarshal(b, &meta) == nil && meta.MacOS != nil { - layout.disk = filepath.Join(layout.dir, "rootfs.raw") - } + // 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 } diff --git a/lib/instances/configdisk.go b/lib/instances/configdisk.go index 28d99eece..622abaed5 100644 --- a/lib/instances/configdisk.go +++ b/lib/instances/configdisk.go @@ -16,11 +16,9 @@ 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 { - if inst.MacOS != nil { - return nil - } // macOS boots the imported disk; no Linux config disk. // Create temporary directory for config files tmpDir, err := os.MkdirTemp("", "hypeman-config-*") if err != nil { diff --git a/lib/instances/create.go b/lib/instances/create.go index 6a80a6fbe..457477e27 100644 --- a/lib/instances/create.go +++ b/lib/instances/create.go @@ -76,8 +76,6 @@ func generateVsockCID(instanceID string) int64 { return (sum % 4294967292) + 3 } -// createInstance creates and starts a new instance -// Multi-hop orchestration: Stopped → Created → Running // 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) { @@ -98,6 +96,8 @@ func (m *manager) applyLinuxShapingDefaults(req *CreateInstanceRequest, vcpus in } } +// createInstance creates and starts a new instance +// Multi-hop orchestration: Stopped → Created → Running func (m *manager) createInstance( ctx context.Context, req CreateInstanceRequest, @@ -124,6 +124,9 @@ func (m *manager) createInstance( } 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) } @@ -242,8 +245,9 @@ 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 @@ -563,10 +567,12 @@ func (m *manager) createInstance( 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 stored.MacOS == nil { + 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) + } } configDiskSpanEnd(nil) diff --git a/lib/instances/start.go b/lib/instances/start.go index 331ba6ba6..7e4d450bd 100644 --- a/lib/instances/start.go +++ b/lib/instances/start.go @@ -206,10 +206,12 @@ func (m *manager) startInstance( 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) + if stored.MacOS == nil { + 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) + } } configDiskSpanEnd(nil) diff --git a/lib/instances/stop.go b/lib/instances/stop.go index 4c46be901..603b9a75e 100644 --- a/lib/instances/stop.go +++ b/lib/instances/stop.go @@ -205,7 +205,7 @@ func (m *manager) stopInstance( if inst.NetworkEnabled { m.unregisterEgressProxyInstance(ctx, id) } - if inst.NetworkEnabled && inst.MacOS == nil && networkAlloc != nil { + if inst.NetworkEnabled && networkAlloc != nil { log.DebugContext(ctx, "releasing network", "instance_id", id, "network", "default") releaseNetworkCtx, releaseNetworkSpanEnd := m.startLifecycleStep(ctx, "release_network", attribute.String("instance_id", id), @@ -219,7 +219,7 @@ func (m *manager) stopInstance( } else { releaseNetworkSpanEnd(nil) } - } else if inst.NetworkEnabled && inst.MacOS == nil && networkAllocErr != nil { + } else if inst.NetworkEnabled && networkAllocErr != nil { // GetAllocation failed earlier, so we don't have a full Allocation. Fall back // to deleting the TAP by deterministic name to avoid leaking it on the host. log.DebugContext(ctx, "releasing network by instance id (fallback)", "instance_id", id) diff --git a/lib/oapi/oapi.go b/lib/oapi/oapi.go index 8d33f7f85..1fa17d546 100644 --- a/lib/oapi/oapi.go +++ b/lib/oapi/oapi.go @@ -7524,6 +7524,7 @@ type StatInstancePathResponse struct { JSON404 *Error JSON409 *Error JSON500 *Error + JSON501 *Error } // Status returns HTTPResponse.Status @@ -10735,6 +10736,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 @@ -16460,6 +16468,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,394 +19258,394 @@ 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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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/openapi.yaml b/openapi.yaml index 038608400..0b9ff8771 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3762,6 +3762,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: From b81c38d60dfec0a243148e45ac4e56b837d10c39 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:04:46 +0000 Subject: [PATCH 09/16] Share macOS platform validation between import and OCI paths MacOSImage.Validate holds the field checks and the 6-byte Ethernet MAC rule. The local importer previously accepted EUI-64 and InfiniBand addresses that VZ rejects at boot. The OCI machine-image parser will use the same method. --- lib/images/macos_import_darwin.go | 8 ++------ lib/images/types.go | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/lib/images/macos_import_darwin.go b/lib/images/macos_import_darwin.go index 761ae6628..d0776acc4 100644 --- a/lib/images/macos_import_darwin.go +++ b/lib/images/macos_import_darwin.go @@ -9,7 +9,6 @@ import ( "encoding/json" "fmt" "io" - "net" "os" "os/exec" "path/filepath" @@ -39,11 +38,8 @@ func ImportMacOSImage(ctx context.Context, p *paths.Paths, name, source string) if err = json.Unmarshal(b, &mac); err != nil { return nil, err } - if len(mac.HardwareModel) == 0 || len(mac.MachineIdentifier) == 0 || mac.CPUs < 2 || mac.Memory < 4<<30 { - return nil, fmt.Errorf("invalid macOS bundle configuration") - } - if _, err = net.ParseMAC(mac.MAC); err != nil { - return nil, fmt.Errorf("invalid MAC: %w", err) + if err = mac.Validate(); err != nil { + return nil, err } for _, file := range []string{"disk.img", "aux.img"} { file = filepath.Join(source, file) diff --git a/lib/images/types.go b/lib/images/types.go index 959d7e26e..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" @@ -36,6 +38,19 @@ type MacOSImage struct { 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 From b1a20c726938046a7df351d890a62409670fb386 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:15:06 +0000 Subject: [PATCH 10/16] Share macOS network pinning and move lease parsing to the shared file - macOSNetworkConfig pins a networked macOS guest to its preserved MAC and returns its network config, replacing the copies in create and start. - Move the vmnet lease parser out of the darwin-only file. It only reads /var/db/dhcpd_leases, which is absent off macOS, so the non-darwin stub is no longer needed. - Drop the macOS guards on attach and detach volume. Both return "not yet implemented" for every instance, so the guard only added a metadata read. --- lib/instances/create.go | 5 ++- lib/instances/macos.go | 57 +++++++++++++++++++++++++++++++++++ lib/instances/macos_darwin.go | 46 ---------------------------- lib/instances/macos_other.go | 1 - lib/instances/manager.go | 6 ---- lib/instances/start.go | 5 ++- 6 files changed, 61 insertions(+), 59 deletions(-) diff --git a/lib/instances/create.go b/lib/instances/create.go index 457477e27..a08b71e51 100644 --- a/lib/instances/create.go +++ b/lib/instances/create.go @@ -464,9 +464,8 @@ func (m *manager) createInstance( // 14. Allocate network (if network enabled) var netConfig *network.NetworkConfig - if stored.MacOS != nil && stored.NetworkEnabled { - netConfig = &network.NetworkConfig{MAC: stored.MacOS.MAC} - stored.MAC = stored.MacOS.MAC + if cfg := macOSNetworkConfig(stored); cfg != nil { + netConfig = cfg } if networkName != "" && stored.MacOS == nil { log.DebugContext(ctx, "allocating network", "instance_id", id, "network", networkName, diff --git a/lib/instances/macos.go b/lib/instances/macos.go index 247737b2c..53cb07a8c 100644 --- a/lib/instances/macos.go +++ b/lib/instances/macos.go @@ -3,15 +3,19 @@ package instances import ( "bytes" "context" + "encoding/hex" "errors" "fmt" + "net" "os" "path/filepath" "runtime" + "strings" "github.com/kernel/hypeman/lib/hypervisor" "github.com/kernel/hypeman/lib/images" "github.com/kernel/hypeman/lib/instances/phasetracking" + "github.com/kernel/hypeman/lib/network" ) func prepareMacOSRequest(req *CreateInstanceRequest, img *images.Image, hv hypervisor.Type) error { @@ -100,3 +104,56 @@ func (m *manager) rejectMacOSOperation(id, operation string) error { } 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 "" +} + +// macOSNetworkConfig pins a networked macOS guest to its preserved MAC and returns +// its network config. It returns nil for Linux guests and networkless macOS guests. +func macOSNetworkConfig(stored *StoredMetadata) *network.NetworkConfig { + if stored.MacOS == nil || !stored.NetworkEnabled { + return nil + } + stored.MAC = stored.MacOS.MAC + return &network.NetworkConfig{MAC: stored.MacOS.MAC} +} diff --git a/lib/instances/macos_darwin.go b/lib/instances/macos_darwin.go index c430e98eb..884c794d7 100644 --- a/lib/instances/macos_darwin.go +++ b/lib/instances/macos_darwin.go @@ -3,10 +3,7 @@ package instances import ( - "encoding/hex" - "net" "os" - "strings" "golang.org/x/sys/unix" ) @@ -35,46 +32,3 @@ func cloneMacOSStorage(rootDisk, rootAux, disk, aux string) (err error) { } 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_other.go b/lib/instances/macos_other.go index d658e4a3f..581e3ba18 100644 --- a/lib/instances/macos_other.go +++ b/lib/instances/macos_other.go @@ -7,4 +7,3 @@ import "fmt" func cloneMacOSStorage(string, string, string, string) error { return fmt.Errorf("macOS image clones require APFS on macOS") } -func macOSGuestIP(string) string { return "" } diff --git a/lib/instances/manager.go b/lib/instances/manager.go index a4f486a8f..b0b40303c 100644 --- a/lib/instances/manager.go +++ b/lib/instances/manager.go @@ -914,17 +914,11 @@ func (m *manager) RotateLogs(ctx context.Context, maxBytes int64, maxFiles int) // AttachVolume attaches a volume to an instance (not yet implemented) func (m *manager) AttachVolume(ctx context.Context, id string, volumeId string, req AttachVolumeRequest) (*Instance, error) { - if err := m.rejectMacOSOperation(id, "attach volume"); err != nil { - return nil, err - } return nil, fmt.Errorf("attach volume not yet implemented") } // DetachVolume detaches a volume from an instance (not yet implemented) func (m *manager) DetachVolume(ctx context.Context, id string, volumeId string) (*Instance, error) { - if err := m.rejectMacOSOperation(id, "detach volume"); err != nil { - return nil, err - } return nil, fmt.Errorf("detach volume not yet implemented") } diff --git a/lib/instances/start.go b/lib/instances/start.go index 7e4d450bd..5fef07b4a 100644 --- a/lib/instances/start.go +++ b/lib/instances/start.go @@ -126,9 +126,8 @@ func (m *manager) startInstance( // 4. Allocate fresh network if network enabled var netConfig *network.NetworkConfig - if stored.MacOS != nil && stored.NetworkEnabled { - netConfig = &network.NetworkConfig{MAC: stored.MacOS.MAC} - stored.MAC = stored.MacOS.MAC + if cfg := macOSNetworkConfig(stored); cfg != nil { + netConfig = cfg stored.IP = "" } if stored.NetworkEnabled && stored.MacOS == nil { From 34e0380bd59f5ee0e7f98e478c5277e1f455f4b5 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:17:05 +0000 Subject: [PATCH 11/16] Test that macOS stat is rejected with 501 before dialing the guest --- cmd/api/api/macos_test.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cmd/api/api/macos_test.go b/cmd/api/api/macos_test.go index ea74f393f..a7de011ac 100644 --- a/cmd/api/api/macos_test.go +++ b/cmd/api/api/macos_test.go @@ -35,6 +35,17 @@ func TestMacOSExecRejectedBeforeWebsocketUpgrade(t *testing.T) { 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) From a6fcc6aa928ac7166234bf43508799c8691c55f4 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:30:52 +0000 Subject: [PATCH 12/16] Return 400 for rejected macOS restores instead of 500 RestoreInstance rejects macOS guests with ErrInvalidRequest but had no case for it, so the request fell through to the 500 default. Map it to 400 and declare that response in the spec. --- cmd/api/api/instances.go | 5 ++ lib/oapi/oapi.go | 114 ++++++++++++++++++++++----------------- openapi.yaml | 6 +++ 3 files changed, 77 insertions(+), 48 deletions(-) diff --git a/cmd/api/api/instances.go b/cmd/api/api/instances.go index 70c214646..06008e2b3 100644 --- a/cmd/api/api/instances.go +++ b/cmd/api/api/instances.go @@ -657,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", diff --git a/lib/oapi/oapi.go b/lib/oapi/oapi.go index 1fa17d546..908752181 100644 --- a/lib/oapi/oapi.go +++ b/lib/oapi/oapi.go @@ -7318,6 +7318,7 @@ type RestoreInstanceResponse struct { Body []byte HTTPResponse *http.Response JSON200 *Instance + JSON400 *Error JSON404 *Error JSON409 *Error JSON499 *Error @@ -10318,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 { @@ -16047,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 { @@ -19598,54 +19615,55 @@ var swaggerSpec = []string{ "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", + "0GD7AQA=", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/openapi.yaml b/openapi.yaml index 0b9ff8771..b23af2c15 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3075,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: From 354b9a832166862b57cae1bc3e58caf2a8d71abb Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:37:38 +0000 Subject: [PATCH 13/16] Validate macOS creates from capabilities and check unsupported ops in place - Split prepareMacOSRequest into validateMacOSCreate, a pure check over the request, the image and the backend's capabilities, and applyMacOSDefaults. The check no longer reads runtime.GOOS or the backend name, so the rejection list runs on every host with fixture capabilities. - Add Capabilities.SupportsMacOSBoot, set by the vz backend on arm64. - Drop the six pre-lock macOS rejections. Each operation checks the record it already loads for the work, so the instance is read once instead of twice. --- lib/hypervisor/hypervisor.go | 3 +++ lib/hypervisor/vz/client.go | 2 ++ lib/instances/create.go | 4 +++- lib/instances/fork.go | 3 +++ lib/instances/macos.go | 41 ++++++++++++++++++++++++------------ lib/instances/macos_test.go | 14 ++++++------ lib/instances/manager.go | 18 ---------------- lib/instances/restore.go | 3 +++ lib/instances/snapshot.go | 6 ++++++ lib/instances/standby.go | 3 +++ lib/instances/update.go | 3 +++ 11 files changed, 60 insertions(+), 40 deletions(-) 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/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/instances/create.go b/lib/instances/create.go index a08b71e51..7fbe504c3 100644 --- a/lib/instances/create.go +++ b/lib/instances/create.go @@ -165,9 +165,11 @@ func (m *manager) createInstance( return nil, fmt.Errorf("%w: server is configured for macOS guests only", ErrInvalidRequest) } if imageInfo.MacOS != nil { - if err := prepareMacOSRequest(&req, imageInfo, hvType); err != nil { + caps, _ := hypervisor.CapabilitiesForType(hvType) + if err := validateMacOSCreate(req, imageInfo, caps); err != nil { return nil, err } + applyMacOSDefaults(&req, imageInfo) } // A guest whose architecture differs from the host kernel can only boot via 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 index 53cb07a8c..06147bf38 100644 --- a/lib/instances/macos.go +++ b/lib/instances/macos.go @@ -9,7 +9,6 @@ import ( "net" "os" "path/filepath" - "runtime" "strings" "github.com/kernel/hypeman/lib/hypervisor" @@ -18,8 +17,13 @@ import ( "github.com/kernel/hypeman/lib/network" ) -func prepareMacOSRequest(req *CreateInstanceRequest, img *images.Image, hv hypervisor.Type) error { - if runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" || hv != hypervisor.TypeVZ { +// validateMacOSCreate rejects requests a macOS guest cannot honor. It reads only the +// request, the image and the backend's capabilities, so it runs on any host. +func validateMacOSCreate(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 { @@ -28,19 +32,31 @@ func prepareMacOSRequest(req *CreateInstanceRequest, img *images.Image, hv hyper 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) + } + return nil +} + +// applyMacOSDefaults fills unspecified CPU and memory from the imported template and +// sets the guest flags. Call it only after validateMacOSCreate accepted the request. +func applyMacOSDefaults(req *CreateInstanceRequest, img *images.Image) { if req.Size == 0 { req.Size = int64(img.MacOS.Memory) } if req.Vcpus == 0 { req.Vcpus = int(img.MacOS.CPUs) } - if req.Size < 4<<30 || req.Vcpus < 2 { - return fmt.Errorf("%w: macOS requires at least 4 GiB and 2 vCPUs", ErrInvalidRequest) - } 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 { @@ -94,12 +110,11 @@ func (m *manager) checkMacOSIdentityAvailable(ctx context.Context, stored *Store } return nil } -func (m *manager) rejectMacOSOperation(id, operation string) error { - meta, err := m.loadMetadata(id) - if err != nil { - return err - } - if meta.MacOS != 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 diff --git a/lib/instances/macos_test.go b/lib/instances/macos_test.go index df2b04767..dd1d60491 100644 --- a/lib/instances/macos_test.go +++ b/lib/instances/macos_test.go @@ -5,7 +5,6 @@ import ( "errors" "os" "path/filepath" - "runtime" "testing" "time" @@ -20,11 +19,10 @@ func testMacImage() *images.Image { 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) { - if runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" { - t.Skip("Mac request acceptance requires Apple silicon") - } + caps := hypervisor.Capabilities{SupportsMacOSBoot: true} req := CreateInstanceRequest{} - require.NoError(t, prepareMacOSRequest(&req, testMacImage(), hypervisor.TypeVZ)) + require.NoError(t, validateMacOSCreate(req, testMacImage(), caps)) + applyMacOSDefaults(&req, testMacImage()) require.Equal(t, int64(8<<30), req.Size) require.Equal(t, 4, req.Vcpus) require.Equal(t, int64(64<<30), req.OverlaySize) @@ -32,9 +30,9 @@ func TestMacOSRequestDefaultsAndRejections(t *testing.T) { 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"}}}, } { - require.ErrorIs(t, prepareMacOSRequest(&r, testMacImage(), hypervisor.TypeVZ), ErrInvalidRequest) + require.ErrorIs(t, validateMacOSCreate(r, testMacImage(), caps), ErrInvalidRequest) } - require.ErrorIs(t, prepareMacOSRequest(&CreateInstanceRequest{}, testMacImage(), hypervisor.TypeQEMU), ErrInvalidRequest) + require.ErrorIs(t, validateMacOSCreate(CreateInstanceRequest{}, testMacImage(), hypervisor.Capabilities{}), ErrInvalidRequest) } func TestMacOSBootConfigNoLinuxDevices(t *testing.T) { p := paths.New(t.TempDir()) @@ -80,5 +78,5 @@ func TestMacOSPreservedIdentityAdmission(t *testing.T) { 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, m.rejectMacOSOperation("active", "snapshot"), ErrInvalidRequest) + require.ErrorIs(t, stored.rejectMacOS("snapshot"), ErrInvalidRequest) } diff --git a/lib/instances/manager.go b/lib/instances/manager.go index b0b40303c..1316b04cd 100644 --- a/lib/instances/manager.go +++ b/lib/instances/manager.go @@ -510,9 +510,6 @@ func (m *manager) GetSnapshot(ctx context.Context, snapshotID string) (*Snapshot } func (m *manager) CreateSnapshot(ctx context.Context, id string, req CreateSnapshotRequest) (*Snapshot, error) { - if err := m.rejectMacOSOperation(id, "snapshot"); err != nil { - return nil, err - } lock := m.getInstanceLock(id) lock.Lock() defer lock.Unlock() @@ -525,9 +522,6 @@ func (m *manager) DeleteSnapshot(ctx context.Context, snapshotID string) error { // ForkInstance creates a forked copy of an instance. func (m *manager) ForkInstance(ctx context.Context, id string, req ForkInstanceRequest) (*Instance, error) { - if err := m.rejectMacOSOperation(id, "fork"); err != nil { - return nil, err - } lock := m.getInstanceLock(id) useReadLock := false var sourceState State @@ -617,9 +611,6 @@ func (m *manager) ForkSnapshot(ctx context.Context, snapshotID string, req ForkS // StandbyInstance puts an instance in standby (pause, snapshot, delete VMM) func (m *manager) StandbyInstance(ctx context.Context, id string, req StandbyInstanceRequest) (*Instance, error) { - if err := m.rejectMacOSOperation(id, "standby"); err != nil { - return nil, err - } lock := m.getInstanceLock(id) lock.Lock() defer lock.Unlock() @@ -641,9 +632,6 @@ func (m *manager) StandbyInstance(ctx context.Context, id string, req StandbyIns // RestoreInstance restores an instance from standby func (m *manager) RestoreInstance(ctx context.Context, id string) (*Instance, error) { - if err := m.rejectMacOSOperation(id, "restore"); err != nil { - return nil, err - } lock := m.getInstanceLock(id) lock.Lock() defer lock.Unlock() @@ -679,9 +667,6 @@ func (m *manager) RestoreInstance(ctx context.Context, id string) (*Instance, er } func (m *manager) RestoreSnapshot(ctx context.Context, id string, snapshotID string, req RestoreSnapshotRequest) (*Instance, error) { - if err := m.rejectMacOSOperation(id, "restore snapshot"); err != nil { - return nil, err - } lock := m.getInstanceLock(id) lock.Lock() defer lock.Unlock() @@ -764,9 +749,6 @@ func standbyRequestHasOptions(req StandbyInstanceRequest) bool { // UpdateInstance updates mutable properties of a running instance func (m *manager) UpdateInstance(ctx context.Context, id string, req UpdateInstanceRequest) (*Instance, error) { - if err := m.rejectMacOSOperation(id, "update"); err != nil { - return nil, err - } lock := m.getInstanceLock(id) lock.Lock() defer lock.Unlock() 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/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 { From 2a146ca38029ffd01e87578e511cb8710ccd5443 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:47:40 +0000 Subject: [PATCH 14/16] Assign the macOS network config in one step in create and start macOSNetworkConfig already returns nil for guests without a network, so the callers assign the result directly instead of testing it into a var first. --- lib/instances/create.go | 5 +---- lib/instances/start.go | 5 ++--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/lib/instances/create.go b/lib/instances/create.go index 7fbe504c3..cdc8054cd 100644 --- a/lib/instances/create.go +++ b/lib/instances/create.go @@ -465,10 +465,7 @@ func (m *manager) createInstance( } // 14. Allocate network (if network enabled) - var netConfig *network.NetworkConfig - if cfg := macOSNetworkConfig(stored); cfg != nil { - netConfig = cfg - } + netConfig := macOSNetworkConfig(stored) if networkName != "" && stored.MacOS == nil { log.DebugContext(ctx, "allocating network", "instance_id", id, "network", networkName, "download_bps", stored.NetworkBandwidthDownload, "upload_bps", stored.NetworkBandwidthUpload) diff --git a/lib/instances/start.go b/lib/instances/start.go index 5fef07b4a..060fb0fa0 100644 --- a/lib/instances/start.go +++ b/lib/instances/start.go @@ -125,9 +125,8 @@ func (m *manager) startInstance( defer cu.Clean() // 4. Allocate fresh network if network enabled - var netConfig *network.NetworkConfig - if cfg := macOSNetworkConfig(stored); cfg != nil { - netConfig = cfg + netConfig := macOSNetworkConfig(stored) + if netConfig != nil { stored.IP = "" } if stored.NetworkEnabled && stored.MacOS == nil { From b0428939a26348c6336e70cabac0969b42447dfd Mon Sep 17 00:00:00 2001 From: chris lee Date: Fri, 9 Oct 2026 16:22:53 -0400 Subject: [PATCH 15/16] Consolidate bounded macOS bundle validation and request defaults --- lib/images/macos_bundle.go | 108 ++++++++++++++++++++++++++++++ lib/images/macos_bundle_test.go | 60 +++++++++++++++++ lib/images/macos_import_darwin.go | 16 ++--- lib/instances/create.go | 3 +- lib/instances/macos.go | 20 ++---- lib/instances/macos_test.go | 9 +-- 6 files changed, 184 insertions(+), 32 deletions(-) create mode 100644 lib/images/macos_bundle.go create mode 100644 lib/images/macos_bundle_test.go 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 index d0776acc4..245085c96 100644 --- a/lib/images/macos_import_darwin.go +++ b/lib/images/macos_import_darwin.go @@ -30,17 +30,11 @@ func ImportMacOSImage(ctx context.Context, p *paths.Paths, name, source string) if ref.IsDigest() { return nil, fmt.Errorf("%w: import requires a tagged name", ErrInvalidName) } - b, err := os.ReadFile(filepath.Join(source, "config.json")) + bundle, err := readMacOSMachineBundle(source, "disk.img", "aux.img", "config.json", false) if err != nil { return nil, err } - var mac MacOSImage - if err = json.Unmarshal(b, &mac); err != nil { - return nil, err - } - if err = mac.Validate(); 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) @@ -66,8 +60,8 @@ func ImportMacOSImage(ctx context.Context, p *paths.Paths, name, source string) return nil, err } defer os.RemoveAll(stage) - for src, dst := range map[string]string{"disk.img": "rootfs.raw", "aux.img": "aux.img"} { - if err = unix.Clonefile(filepath.Join(source, src), filepath.Join(stage, dst), unix.CLONE_NOFOLLOW|unix.CLONE_NOOWNERCOPY); err != nil { + 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 { @@ -98,7 +92,7 @@ func ImportMacOSImage(ctx context.Context, p *paths.Paths, name, source string) } 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()} + 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 } diff --git a/lib/instances/create.go b/lib/instances/create.go index cdc8054cd..2162c5cae 100644 --- a/lib/instances/create.go +++ b/lib/instances/create.go @@ -166,10 +166,9 @@ func (m *manager) createInstance( } if imageInfo.MacOS != nil { caps, _ := hypervisor.CapabilitiesForType(hvType) - if err := validateMacOSCreate(req, imageInfo, caps); err != nil { + if err := prepareMacOSCreate(&req, imageInfo, caps); err != nil { return nil, err } - applyMacOSDefaults(&req, imageInfo) } // A guest whose architecture differs from the host kernel can only boot via diff --git a/lib/instances/macos.go b/lib/instances/macos.go index 06147bf38..5921f3223 100644 --- a/lib/instances/macos.go +++ b/lib/instances/macos.go @@ -17,9 +17,9 @@ import ( "github.com/kernel/hypeman/lib/network" ) -// validateMacOSCreate rejects requests a macOS guest cannot honor. It reads only the -// request, the image and the backend's capabilities, so it runs on any host. -func validateMacOSCreate(req CreateInstanceRequest, img *images.Image, caps hypervisor.Capabilities) error { +// 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) } @@ -42,21 +42,11 @@ func validateMacOSCreate(req CreateInstanceRequest, img *images.Image, caps hype if size < 4<<30 || vcpus < 2 { return fmt.Errorf("%w: macOS requires at least 4 GiB and 2 vCPUs", ErrInvalidRequest) } - return nil -} - -// applyMacOSDefaults fills unspecified CPU and memory from the imported template and -// sets the guest flags. Call it only after validateMacOSCreate accepted the request. -func applyMacOSDefaults(req *CreateInstanceRequest, img *images.Image) { - if req.Size == 0 { - req.Size = int64(img.MacOS.Memory) - } - if req.Vcpus == 0 { - req.Vcpus = int(img.MacOS.CPUs) - } + 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 { diff --git a/lib/instances/macos_test.go b/lib/instances/macos_test.go index dd1d60491..ba44e5b0b 100644 --- a/lib/instances/macos_test.go +++ b/lib/instances/macos_test.go @@ -21,8 +21,7 @@ func testMacImage() *images.Image { func TestMacOSRequestDefaultsAndRejections(t *testing.T) { caps := hypervisor.Capabilities{SupportsMacOSBoot: true} req := CreateInstanceRequest{} - require.NoError(t, validateMacOSCreate(req, testMacImage(), caps)) - applyMacOSDefaults(&req, testMacImage()) + 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) @@ -30,9 +29,11 @@ func TestMacOSRequestDefaultsAndRejections(t *testing.T) { 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"}}}, } { - require.ErrorIs(t, validateMacOSCreate(r, testMacImage(), caps), ErrInvalidRequest) + original := r + require.ErrorIs(t, prepareMacOSCreate(&r, testMacImage(), caps), ErrInvalidRequest) + require.Equal(t, original, r) } - require.ErrorIs(t, validateMacOSCreate(CreateInstanceRequest{}, testMacImage(), hypervisor.Capabilities{}), ErrInvalidRequest) + require.ErrorIs(t, prepareMacOSCreate(&CreateInstanceRequest{}, testMacImage(), hypervisor.Capabilities{}), ErrInvalidRequest) } func TestMacOSBootConfigNoLinuxDevices(t *testing.T) { p := paths.New(t.TempDir()) From e6e6ab70f5ea16d51b62566d013a7f28b9c9abdc Mon Sep 17 00:00:00 2001 From: chris lee Date: Fri, 9 Oct 2026 16:47:26 -0400 Subject: [PATCH 16/16] Share concrete network and guest boot preparation for create and start --- lib/instances/boot_prepare.go | 69 ++++++++++++++++++++++++ lib/instances/boot_prepare_test.go | 86 +++++++++++++++++++++++++++++ lib/instances/create.go | 87 ++++++------------------------ lib/instances/macos.go | 11 ---- lib/instances/start.go | 72 +++++-------------------- 5 files changed, 184 insertions(+), 141 deletions(-) create mode 100644 lib/instances/boot_prepare.go create mode 100644 lib/instances/boot_prepare_test.go 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/create.go b/lib/instances/create.go index 2162c5cae..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" @@ -295,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) @@ -464,45 +457,18 @@ func (m *manager) createInstance( } // 14. Allocate network (if network enabled) - netConfig := macOSNetworkConfig(stored) - if networkName != "" && stored.MacOS == nil { - 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 @@ -545,33 +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 stored.MacOS == nil { - 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. diff --git a/lib/instances/macos.go b/lib/instances/macos.go index 5921f3223..c02eb32d0 100644 --- a/lib/instances/macos.go +++ b/lib/instances/macos.go @@ -14,7 +14,6 @@ import ( "github.com/kernel/hypeman/lib/hypervisor" "github.com/kernel/hypeman/lib/images" "github.com/kernel/hypeman/lib/instances/phasetracking" - "github.com/kernel/hypeman/lib/network" ) // prepareMacOSCreate validates explicit options before applying machine defaults. @@ -152,13 +151,3 @@ func macOSLeaseIP(data string, wanted net.HardwareAddr) string { } return "" } - -// macOSNetworkConfig pins a networked macOS guest to its preserved MAC and returns -// its network config. It returns nil for Linux guests and networkless macOS guests. -func macOSNetworkConfig(stored *StoredMetadata) *network.NetworkConfig { - if stored.MacOS == nil || !stored.NetworkEnabled { - return nil - } - stored.MAC = stored.MacOS.MAC - return &network.NetworkConfig{MAC: stored.MacOS.MAC} -} diff --git a/lib/instances/start.go b/lib/instances/start.go index 060fb0fa0..b103615ac 100644 --- a/lib/instances/start.go +++ b/lib/instances/start.go @@ -5,7 +5,6 @@ import ( "fmt" "time" - "github.com/kernel/hypeman/lib/egressproxy" "github.com/kernel/hypeman/lib/images" "github.com/kernel/hypeman/lib/logger" "github.com/kernel/hypeman/lib/network" @@ -125,51 +124,12 @@ func (m *manager) startInstance( defer cu.Clean() // 4. Allocate fresh network if network enabled - netConfig := macOSNetworkConfig(stored) - if netConfig != nil { - stored.IP = "" + netConfig, releaseNetwork, err := m.prepareBootNetwork(ctx, stored, network.AllocateRequest{InstanceID: id, InstanceName: stored.Name}) + if err != nil { + return nil, err } - if stored.NetworkEnabled && stored.MacOS == nil { - 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, - }) - }) - } - - 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 @@ -196,22 +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 stored.MacOS == nil { - 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)