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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 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.CreateBuild501JSONResponse{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
22 changes: 22 additions & 0 deletions cmd/api/api/ingress.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,30 @@ func (s *ApiService) ListIngresses(ctx context.Context, request oapi.ListIngress
return oapi.ListIngresses200JSONResponse(oapiIngresses), nil
}

// macOSIngressTargetReason refuses routes to macOS guests. Their addresses are NAT
// leases, and no ingress path has been proven for them, so mixed-mode servers must
// not route public traffic to them.
func (s *ApiService) macOSIngressTargetReason(ctx context.Context, rules []oapi.IngressRule) string {
if s.InstanceManager == nil {
return ""
}
for _, rule := range rules {
inst, err := s.InstanceManager.GetInstance(ctx, rule.Target.Instance)
if err == nil && inst.MacOS != nil {
return "ingress cannot target macOS guests"
}
}
return ""
}

// CreateIngress creates a new ingress resource
func (s *ApiService) CreateIngress(ctx context.Context, request oapi.CreateIngressRequestObject) (oapi.CreateIngressResponseObject, error) {
if s.Config != nil && s.Config.MacOSOnly {
return oapi.CreateIngress501JSONResponse{Code: "unsupported", Message: "ingress is not implemented in macOS-only mode"}, nil
}
if reason := s.macOSIngressTargetReason(ctx, request.Body.Rules); reason != "" {
return oapi.CreateIngress501JSONResponse{Code: "unsupported", Message: reason}, nil
}
log := logger.FromContext(ctx)

// Convert OAPI request to domain request
Expand Down
28 changes: 26 additions & 2 deletions cmd/api/api/instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,12 +273,18 @@ func (s *ApiService) CreateInstance(ctx context.Context, request oapi.CreateInst
}
}

// macOS guests take CPU and memory from their image and have no I/O shaping.
macOSCreate := s.isMacOSImage(ctx, request.Body.Image)
if macOSCreate && request.Body.Vcpus == nil {
vcpus = 0
}

// Calculate default resource limits when not specified (0 = auto)
// Uses proportional allocation based on CPU: (vcpus / cpuCapacity) * resourceCapacity
if diskIOBps == 0 {
if !macOSCreate && diskIOBps == 0 {
diskIOBps, _ = s.ResourceManager.DefaultDiskIOBandwidth(vcpus)
}
if networkBandwidthDownload == 0 || networkBandwidthUpload == 0 {
if !macOSCreate && (networkBandwidthDownload == 0 || networkBandwidthUpload == 0) {
defaultDown, defaultUp := s.ResourceManager.DefaultNetworkBandwidth(vcpus)
if networkBandwidthDownload == 0 {
networkBandwidthDownload = defaultDown
Expand Down Expand Up @@ -459,6 +465,7 @@ func (s *ApiService) GetInstanceStats(ctx context.Context, request oapi.GetInsta
inst.Vcpus,
inst.Size+inst.HotplugSize,
)
info.StoragePath = inst.VMStoragePath()

// Collect stats using vm_metrics manager
vmStats := s.VMMetricsManager.GetInstanceStats(ctx, info)
Expand Down Expand Up @@ -672,6 +679,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 @@ -938,6 +950,11 @@ func (s *ApiService) GetInstanceLogs(ctx context.Context, request oapi.GetInstan
Code: "log_not_found",
Message: "requested log file does not exist yet",
}, nil
case errors.Is(err, instances.ErrNotSupported):
return oapi.GetInstanceLogs501JSONResponse{
Code: "not_supported",
Message: err.Error(),
}, nil
default:
return oapi.GetInstanceLogs500JSONResponse{
Code: "internal_error",
Expand All @@ -963,6 +980,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
19 changes: 19 additions & 0 deletions cmd/api/api/macos.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package api

import (
"context"
"runtime"
)

// macOSHost is true where macOS guests can exist. Tests override it.
var macOSHost = runtime.GOOS == "darwin"

// isMacOSImage reports whether a create names an imported macOS image. Other
// hosts skip the lookup, so Linux creates are unchanged.
func (s *ApiService) isMacOSImage(ctx context.Context, name string) bool {
if !macOSHost || s.ImageManager == nil {
return false
}
img, err := s.ImageManager.GetImage(ctx, name)
return err == nil && img.MacOS != nil
}
93 changes: 93 additions & 0 deletions cmd/api/api/macos_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
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.CreateBuild501JSONResponse{}, build)
ingress, err := s.CreateIngress(context.Background(), oapi.CreateIngressRequestObject{})
require.NoError(t, err)
require.IsType(t, oapi.CreateIngress501JSONResponse{}, ingress)
}

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

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

func 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)
}
prev := macOSHost
macOSHost = true
t.Cleanup(func() { macOSHost = prev })
m := newCaptureCreateManager(nil)
s := &ApiService{InstanceManager: m, ImageManager: macOSImageManager{}, 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)
}

type macOSImageManager struct{ images.Manager }

func (macOSImageManager) GetImage(context.Context, string) (*images.Image, error) {
return &images.Image{Platform: "darwin/arm64", MacOS: &images.MacOSImage{}}, nil
}

type macOSIngressTargetManager struct {
instances.Manager
inst *instances.Instance
}

func (m macOSIngressTargetManager) GetInstance(context.Context, string) (*instances.Instance, error) {
return m.inst, nil
}

func TestIngressRejectsMacOSTargets(t *testing.T) {
rule := oapi.IngressRule{Match: oapi.IngressMatch{Hostname: "app.example.test"}, Target: oapi.IngressTarget{Instance: "mac", Port: 8080}}
macos := &instances.Instance{StoredMetadata: instances.StoredMetadata{MacOS: &images.MacOSImage{}}}
s := &ApiService{InstanceManager: macOSIngressTargetManager{inst: macos}}
require.Contains(t, s.macOSIngressTargetReason(context.Background(), []oapi.IngressRule{rule}), "macOS")

linux := &instances.Instance{}
s = &ApiService{InstanceManager: macOSIngressTargetManager{inst: linux}}
require.Empty(t, s.macOSIngressTargetReason(context.Background(), []oapi.IngressRule{rule}))
}
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
46 changes: 28 additions & 18 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,11 +406,17 @@ func run() error {

// Initialize ingress manager (starts Caddy daemon and DNS server for dynamic upstreams)
logger.Info("Initializing ingress manager...")
if err := app.IngressManager.Initialize(app.Ctx); err != nil {
logger.Error("failed to initialize ingress manager", "error", err)
return fmt.Errorf("initialize ingress manager: %w", err)
if !cfg.MacOSOnly {
if err := app.IngressManager.Initialize(app.Ctx); err != nil {
logger.Error("failed to initialize ingress manager", "error", err)
return fmt.Errorf("initialize ingress manager: %w", err)
}
} else {
logger.Info("macOS-only mode: ingress is unsupported; skipping Caddy and DNS startup")
}
if !cfg.MacOSOnly {
logger.Info("Ingress manager initialized", "listen_addr", cfg.Caddy.ListenAddress, "admin", app.IngressManager.AdminURL())
}
logger.Info("Ingress manager initialized", "listen_addr", cfg.Caddy.ListenAddress, "admin", app.IngressManager.AdminURL())

// Create router
r := chi.NewRouter()
Expand Down Expand Up @@ -572,7 +582,7 @@ func run() error {

// Create HTTP server
srv := &http.Server{
Addr: fmt.Sprintf(":%s", app.Config.Port),
Addr: net.JoinHostPort(app.Config.ListenAddress, app.Config.Port),
Handler: r,
}

Expand Down Expand Up @@ -616,16 +626,16 @@ func run() error {
)
}

// Start builders manager (reconcile builder state, idle reaper)
if err := app.BuilderManager.Start(gctx); err != nil {
logger.Error("failed to start builders manager", "error", err)
return err
}

// Start build manager background services (vsock handler for builder VMs)
if err := app.BuildManager.Start(gctx); err != nil {
logger.Error("failed to start build manager", "error", err)
return err
if !cfg.MacOSOnly {
// Linux builder VMs and their guest-agent service are not part of macOS-only mode.
if err := app.BuilderManager.Start(gctx); err != nil {
logger.Error("failed to start builders manager", "error", err)
return err
}
if err := app.BuildManager.Start(gctx); err != nil {
logger.Error("failed to start build manager", "error", err)
return err
}
}

grp.Go(func() error {
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)
}
Loading
Loading