Skip to content
Merged
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
6 changes: 6 additions & 0 deletions cmd/api/api/ingress.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ func (s *ApiService) ListIngresses(ctx context.Context, request oapi.ListIngress

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

// Convert OAPI request to domain request
Expand Down
36 changes: 34 additions & 2 deletions cmd/api/api/instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ func (s *ApiService) ListInstances(ctx context.Context, request oapi.ListInstanc
func (s *ApiService) CreateInstance(ctx context.Context, request oapi.CreateInstanceRequestObject) (oapi.CreateInstanceResponseObject, error) {
log := logger.FromContext(ctx)

macOSCreate, err := s.isMacOSImage(ctx, request.Body.Image)
if err != nil {
log.ErrorContext(ctx, "failed to look up image for create", "error", err, "image", request.Body.Image)
return oapi.CreateInstance500JSONResponse{Code: "internal_error", Message: "failed to look up image"}, nil
}
if macOSCreate {
ignoreLinuxCreateDefaults(ctx, request.Body)
}

// Parse size (default: 1GB)
size := int64(0)
if request.Body.Size != nil && *request.Body.Size != "" {
Expand Down Expand Up @@ -273,12 +282,17 @@ func (s *ApiService) CreateInstance(ctx context.Context, request oapi.CreateInst
}
}

// macOS guests take CPU and memory from their image and have no I/O shaping.
if macOSCreate && request.Body.Vcpus == nil {
vcpus = 0
}

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

// Collect stats using vm_metrics manager
vmStats := s.VMMetricsManager.GetInstanceStats(ctx, info)
Expand Down Expand Up @@ -672,6 +687,11 @@ func (s *ApiService) RestoreInstance(ctx context.Context, request oapi.RestoreIn
Code: "not_found",
Message: "instance not found",
}, nil
case errors.Is(err, instances.ErrInvalidRequest):
return oapi.RestoreInstance400JSONResponse{
Code: "invalid_request",
Message: err.Error(),
}, nil
case errors.Is(err, instances.ErrInvalidState):
return oapi.RestoreInstance409JSONResponse{
Code: "invalid_state",
Expand Down Expand Up @@ -938,6 +958,11 @@ func (s *ApiService) GetInstanceLogs(ctx context.Context, request oapi.GetInstan
Code: "log_not_found",
Message: "requested log file does not exist yet",
}, nil
case errors.Is(err, instances.ErrNotSupported):
return oapi.GetInstanceLogs501JSONResponse{
Code: "not_supported",
Message: err.Error(),
}, nil
default:
return oapi.GetInstanceLogs500JSONResponse{
Code: "internal_error",
Expand All @@ -963,6 +988,13 @@ func (s *ApiService) StatInstancePath(ctx context.Context, request oapi.StatInst
}, nil
}

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

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

import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"runtime"

"github.com/kernel/hypeman/lib/images"
"github.com/kernel/hypeman/lib/ingress"
"github.com/kernel/hypeman/lib/instances"
"github.com/kernel/hypeman/lib/oapi"
)

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

// isMacOSImage reports whether a create names an imported macOS image. Other
// hosts skip the lookup, so Linux creates are unchanged. An image that is not
// cached yet is not a macOS image; any other lookup error is returned.
func (s *ApiService) isMacOSImage(ctx context.Context, name string) (bool, error) {
if !macOSHost || s.ImageManager == nil {
return false, nil
}
img, err := s.ImageManager.GetImage(ctx, name)
if errors.Is(err, images.ErrNotFound) {
return false, nil
}
if err != nil {
return false, err
}
return img.MacOS != nil, nil
}

// macOSIngressTargetReason refuses routes to macOS guests. Their addresses are NAT
// leases, and no ingress path has been proven for them, so mixed-mode servers must
// not route public traffic to them. A target that cannot be looked up is refused.
// Pattern hostnames resolve their target per request, where the ingress resolver
// refuses macOS guests, and a missing target is left to the ingress manager's 400.
func (s *ApiService) macOSIngressTargetReason(ctx context.Context, rules []oapi.IngressRule) string {
if !macOSHost || s.InstanceManager == nil {
return ""
}
for _, rule := range rules {
match := ingress.IngressMatch{Hostname: rule.Match.Hostname}
if match.IsPattern() {
continue
}
inst, err := s.InstanceManager.GetInstance(ctx, rule.Target.Instance)
if errors.Is(err, instances.ErrNotFound) {
continue
}
if err != nil {
return "cannot verify ingress target is not a macOS guest"
}
if inst.MacOS != nil {
return "ingress cannot target macOS guests"
}
}
return ""
}

// Linux defaults for CreateInstanceRequest in openapi.yaml.
const (
linuxDefaultSize = "1GB"
linuxDefaultOverlaySize = "10GB"
linuxDefaultVcpus = 2
)

type createFieldsKey struct{}

const maxRecordedCreateBody = 1 << 20

// RecordCreateFields notes which shaping fields a create body sets before the
// request validator fills in the Linux defaults. Only macOS hosts install it.
func RecordCreateFields(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/instances" || r.Body == nil {
next.ServeHTTP(w, r)
return
}
// This runs before authentication, so only a bounded body is buffered. A
// larger one is refused: without a record of its fields, defaults cannot be
// told apart from explicit values.
data, err := io.ReadAll(io.LimitReader(r.Body, maxRecordedCreateBody+1))
if err == nil && len(data) > maxRecordedCreateBody {
http.Error(w, `{"code":"request_too_large","message":"create request body exceeds 1 MiB"}`, http.StatusRequestEntityTooLarge)
return
}
r.Body = struct {
io.Reader
io.Closer
}{io.MultiReader(bytes.NewReader(data), r.Body), r.Body}
var fields map[string]json.RawMessage
if err != nil || json.Unmarshal(data, &fields) != nil {
next.ServeHTTP(w, r)
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), createFieldsKey{}, fields)))
})
}

// ignoreLinuxCreateDefaults clears create fields that hold their Linux schema
// default and that the client did not send. The request validator writes those
// defaults into every body; macOS defaults come from the image. Without a record
// of the sent fields, every value is kept as explicit.
func ignoreLinuxCreateDefaults(ctx context.Context, body *oapi.CreateInstanceJSONRequestBody) {
fields, recorded := ctx.Value(createFieldsKey{}).(map[string]json.RawMessage)
injected := func(name string) bool {
_, sent := fields[name]
return recorded && !sent
}
if body.Size != nil && *body.Size == linuxDefaultSize && injected("size") {
body.Size = nil
}
if body.OverlaySize != nil && *body.OverlaySize == linuxDefaultOverlaySize && injected("overlay_size") {
body.OverlaySize = nil
}
if body.Vcpus != nil && *body.Vcpus == linuxDefaultVcpus && injected("vcpus") {
body.Vcpus = nil
}
}
Loading
Loading