Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cmd/api/api/builds.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions cmd/api/api/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions cmd/api/api/images.go
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
3 changes: 3 additions & 0 deletions cmd/api/api/ingress.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 13 additions & 16 deletions cmd/api/api/instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -273,21 +273,6 @@ func (s *ApiService) CreateInstance(ctx context.Context, request oapi.CreateInst
}
}

// Calculate default resource limits when not specified (0 = auto)
// Uses proportional allocation based on CPU: (vcpus / cpuCapacity) * resourceCapacity
if diskIOBps == 0 {
diskIOBps, _ = s.ResourceManager.DefaultDiskIOBandwidth(vcpus)
}
if networkBandwidthDownload == 0 || networkBandwidthUpload == 0 {
defaultDown, defaultUp := s.ResourceManager.DefaultNetworkBandwidth(vcpus)
if networkBandwidthDownload == 0 {
networkBandwidthDownload = defaultDown
}
if networkBandwidthUpload == 0 {
networkBandwidthUpload = defaultUp
}
}

// Parse command overrides (like docker run --entrypoint / docker run <image> <command>)
var entrypoint []string
if request.Body.Entrypoint != nil {
Expand Down Expand Up @@ -672,6 +657,11 @@ func (s *ApiService) RestoreInstance(ctx context.Context, request oapi.RestoreIn
Code: "not_found",
Message: "instance not found",
}, nil
case errors.Is(err, instances.ErrInvalidRequest):
return oapi.RestoreInstance400JSONResponse{
Code: "invalid_request",
Message: err.Error(),
}, nil
case errors.Is(err, instances.ErrInvalidState):
return oapi.RestoreInstance409JSONResponse{
Code: "invalid_state",
Expand Down Expand Up @@ -963,6 +953,13 @@ func (s *ApiService) StatInstancePath(ctx context.Context, request oapi.StatInst
}, nil
}

if inst.MacOS != nil {
return oapi.StatInstancePath501JSONResponse{
Code: "unsupported",
Message: "stat is not implemented for experimental macOS instances",
}, nil
}

if inst.State != instances.StateRunning {
return oapi.StatInstancePath409JSONResponse{
Code: "invalid_state",
Expand Down
64 changes: 64 additions & 0 deletions cmd/api/api/macos_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package api

import (
"context"
"net/http"
"net/http/httptest"
"testing"

"github.com/kernel/hypeman/cmd/api/config"
"github.com/kernel/hypeman/lib/images"
"github.com/kernel/hypeman/lib/instances"
mw "github.com/kernel/hypeman/lib/middleware"
"github.com/kernel/hypeman/lib/oapi"
"github.com/stretchr/testify/require"
)

func TestMacOSOnlyRejectsBuildersAndIngress(t *testing.T) {
s := &ApiService{Config: &config.Config{MacOSOnly: true}}
build, err := s.CreateBuild(context.Background(), oapi.CreateBuildRequestObject{})
require.NoError(t, err)
require.IsType(t, oapi.CreateBuild400JSONResponse{}, build)
ingress, err := s.CreateIngress(context.Background(), oapi.CreateIngressRequestObject{})
require.NoError(t, err)
require.IsType(t, oapi.CreateIngress400JSONResponse{}, ingress)
}

func TestMacOSExecRejectedBeforeWebsocketUpgrade(t *testing.T) {
s := &ApiService{}
inst := &instances.Instance{StoredMetadata: instances.StoredMetadata{MacOS: &images.MacOSImage{}}}
ctx := mw.WithResolvedInstance(context.Background(), "test", inst)
r := httptest.NewRequest(http.MethodGet, "/instances/test/exec", nil).WithContext(ctx)
w := httptest.NewRecorder()
s.ExecHandler(w, r)
require.Equal(t, http.StatusNotImplemented, w.Code)
require.Contains(t, w.Body.String(), "not implemented")
}

func TestMacOSStatRejectedBeforeGuestDial(t *testing.T) {
s := &ApiService{}
inst := &instances.Instance{StoredMetadata: instances.StoredMetadata{MacOS: &images.MacOSImage{}}, State: instances.StateRunning}
ctx := mw.WithResolvedInstance(context.Background(), "test", inst)
resp, err := s.StatInstancePath(ctx, oapi.StatInstancePathRequestObject{Id: "test"})
require.NoError(t, err)
unsupported, ok := resp.(oapi.StatInstancePath501JSONResponse)
require.True(t, ok, "macOS stat must be rejected with 501, got %T", resp)
require.Equal(t, "unsupported", unsupported.Code)
}

func TestMacOSSchemaDefersTemplateDefaults(t *testing.T) {
spec, err := oapi.GetSwagger()
require.NoError(t, err)
for _, name := range []string{"size", "vcpus", "overlay_size"} {
require.Nil(t, spec.Components.Schemas["CreateInstanceRequest"].Value.Properties[name].Value.Default, name)
}
m := newCaptureCreateManager(nil)
s := &ApiService{InstanceManager: m, Config: &config.Config{}}
platform := "darwin/arm64"
_, err = s.CreateInstance(context.Background(), oapi.CreateInstanceRequestObject{Body: &oapi.CreateInstanceRequest{Name: "macos", Image: "localhost/macos:spike", Platform: &platform}})
require.NoError(t, err)
require.NotNil(t, m.lastReq)
require.Zero(t, m.lastReq.Vcpus)
require.Zero(t, m.lastReq.Size)
require.Zero(t, m.lastReq.OverlaySize)
}
15 changes: 10 additions & 5 deletions cmd/api/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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")
}
Expand Down
42 changes: 25 additions & 17 deletions cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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())

Expand Down Expand Up @@ -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,
}

Expand Down Expand Up @@ -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 {
Expand Down
35 changes: 35 additions & 0 deletions cmd/import-macos/main.go
Original file line number Diff line number Diff line change
@@ -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)
}
69 changes: 69 additions & 0 deletions cmd/vz-shim/macos_arm64.go
Original file line number Diff line number Diff line change
@@ -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
}
24 changes: 24 additions & 0 deletions cmd/vz-shim/macos_arm64_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading
Loading