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
17 changes: 15 additions & 2 deletions cmd/api/api/builds.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,15 @@ 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
if !s.Config.MacOSBuilds {
return oapi.CreateBuild501JSONResponse{Code: "unsupported", Message: "Linux builder VMs are unavailable in macOS-only mode"}, nil
}
req, source, err := parseMachineBuildMultipart(request.Body)
if err != nil {
return oapi.CreateBuild400JSONResponse{Code: "invalid_request", Message: err.Error()}, nil
}
return s.submitBuild(ctx, req, source)
}
log := logger.FromContext(ctx)

// Parse multipart form fields
var sourceData []byte
Expand All @@ -69,6 +75,8 @@ func (s *ApiService) CreateBuild(ctx context.Context, request oapi.CreateBuildRe
}

switch part.FormName() {
case "machine_base_image":
return oapi.CreateBuild400JSONResponse{Code: "unsupported", Message: "machine builds require macos_only and macos_builds"}, nil
case "source":
sourceData, err = io.ReadAll(part)
if err != nil {
Expand Down Expand Up @@ -256,6 +264,11 @@ func (s *ApiService) CreateBuild(ctx context.Context, request oapi.CreateBuildRe
}
}

return s.submitBuild(ctx, domainReq, sourceData)
}

func (s *ApiService) submitBuild(ctx context.Context, domainReq builds.CreateBuildRequest, sourceData []byte) (oapi.CreateBuildResponseObject, error) {
log := logger.FromContext(ctx)
build, err := s.BuildManager.CreateBuild(ctx, domainReq, sourceData)
if err != nil {
switch {
Expand Down
114 changes: 114 additions & 0 deletions cmd/api/api/machine_builds.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package api

import (
"fmt"
"io"
"mime/multipart"
"net/http"
"strconv"

"github.com/kernel/hypeman/lib/builds"
"github.com/kernel/hypeman/lib/images"
)

// maxConcurrentMachineBuildUploads bounds how many machine build bodies, each up
// to 65 MiB, are read into memory at once.
const maxConcurrentMachineBuildUploads = 4

// LimitMachineBuildBody must wrap the validator, which also consumes request bodies.
func LimitMachineBuildBody(next http.Handler) http.Handler {
uploads := make(chan struct{}, maxConcurrentMachineBuildUploads)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && r.URL.Path == "/builds" {
select {
case uploads <- struct{}{}:
defer func() { <-uploads }()
default:
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(`{"code":"too_many_requests","message":"too many machine build uploads in progress"}`))
return
}
r.Body = http.MaxBytesReader(w, r.Body, 65<<20)
}
next.ServeHTTP(w, r)
})
}

// Machine-only HTTP admission is bounded regardless of multipart field order.
// Linux retains its existing parser and source contract.
func parseMachineBuildMultipart(body *multipart.Reader) (builds.CreateBuildRequest, []byte, error) {
req := builds.CreateBuildRequest{BuildPolicy: &builds.BuildPolicy{}}
var source []byte
seen := map[string]bool{}
if body == nil {
return req, nil, fmt.Errorf("multipart source is required")
}
for count := 0; ; count++ {
part, err := body.NextPart()
if err == io.EOF {
break
}
if err != nil {
return req, nil, fmt.Errorf("invalid multipart form")
}
name := part.FormName()
if count >= 16 || seen[name] {
return req, nil, fmt.Errorf("duplicate or excessive multipart fields")
}
seen[name] = true
limit := int64(64 << 10)
switch name {
case "source":
limit = 64 << 20
case "machine_base_image", "timeout_seconds", "memory_mb", "cpus", "tags":
default:
return req, nil, fmt.Errorf("unsupported machine build field: %s", name)
}
data, err := io.ReadAll(io.LimitReader(part, limit+1))
if err != nil || int64(len(data)) > limit {
return req, nil, fmt.Errorf("invalid or oversized %s field", name)
}
if err = part.Close(); err != nil {
return req, nil, fmt.Errorf("invalid multipart field")
}
switch name {
case "source":
source = data
case "machine_base_image":
req.MachineBaseImage = string(data)
case "tags":
req.Tags, err = parseTagsJSON(string(data))
if err != nil {
return req, nil, fmt.Errorf("tags must be a JSON object of strings")
}
default:
value, err := strconv.Atoi(string(data))
if err != nil || value < 0 {
return req, nil, fmt.Errorf("%s must be a nonnegative integer", name)
}
switch name {
case "timeout_seconds":
req.BuildPolicy.TimeoutSeconds = value
case "memory_mb":
req.BuildPolicy.MemoryMB = value
case "cpus":
req.BuildPolicy.CPUs = value
}
}
}
if len(source) == 0 {
return req, nil, fmt.Errorf("source is required")
}
ref, err := images.ParseNormalizedRef(req.MachineBaseImage)
if err != nil || !ref.IsDigest() || !builds.ValidMachineDigest(ref.Digest()) {
return req, nil, fmt.Errorf("machine_base_image must be pinned by sha256 digest")
}
if req.BuildPolicy.TimeoutSeconds > 86400 {
return req, nil, fmt.Errorf("timeout_seconds exceeds 86400")
}
if err = req.BuildPolicy.Validate(); err != nil {
return req, nil, err
}
return req, source, nil
}
157 changes: 157 additions & 0 deletions cmd/api/api/machine_builds_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package api

import (
"bytes"
"context"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/kernel/hypeman/cmd/api/config"
"github.com/kernel/hypeman/lib/builds"
"github.com/kernel/hypeman/lib/oapi"
"github.com/stretchr/testify/require"
)

func machineMultipart(t *testing.T, fields ...[2]string) *multipart.Reader {
t.Helper()
var data bytes.Buffer
writer := multipart.NewWriter(&data)
for _, field := range fields {
require.NoError(t, writer.WriteField(field[0], field[1]))
}
require.NoError(t, writer.Close())
return multipart.NewReader(&data, writer.Boundary())
}

type machineZeroReader struct{}

func (machineZeroReader) Read(p []byte) (int, error) { clear(p); return len(p), nil }

func TestMachineBuildBodyBoundBeforeValidation(t *testing.T) {
for _, path := range []string{"/builds", "/images"} {
r := httptest.NewRequest(http.MethodPost, path, io.LimitReader(machineZeroReader{}, (65<<20)+1))
LimitMachineBuildBody(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n, err := io.Copy(io.Discard, r.Body)
if path == "/builds" {
require.Error(t, err)
require.EqualValues(t, 65<<20, n)
} else {
require.NoError(t, err)
require.EqualValues(t, (65<<20)+1, n)
}
})).ServeHTTP(httptest.NewRecorder(), r)
}
}

func TestMachineBuildUploadsAreBounded(t *testing.T) {
release := make(chan struct{})
entered := make(chan struct{}, maxConcurrentMachineBuildUploads)
handler := LimitMachineBuildBody(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
entered <- struct{}{}
<-release
}))
done := make(chan struct{})
for range maxConcurrentMachineBuildUploads {
go func() {
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/builds", nil))
done <- struct{}{}
}()
<-entered
}

w := httptest.NewRecorder()
handler.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/builds", nil))
require.Equal(t, http.StatusTooManyRequests, w.Code)

close(release)
for range maxConcurrentMachineBuildUploads {
<-done
}
w = httptest.NewRecorder()
go func() { <-entered }()
handler.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/builds", nil))
require.Equal(t, http.StatusOK, w.Code, "a slot frees once an upload finishes")
}

func TestMachineBuildMultipartAdmission(t *testing.T) {
base := "example.test/base@sha256:" + strings.Repeat("a", 64)
valid := [][2]string{{"source", "synthetic"}, {"machine_base_image", base}}
for _, tc := range []struct {
name string
extra [][2]string
ok bool
}{
{name: "inherit", ok: true},
{name: "policy", extra: [][2]string{{"cpus", "4"}, {"memory_mb", "8192"}, {"timeout_seconds", "1200"}}, ok: true},
{name: "duplicate", extra: [][2]string{{"source", "second"}}},
{name: "linux", extra: [][2]string{{"dockerfile", "FROM scratch"}}},
{name: "secret", extra: [][2]string{{"secrets", "[]"}}},
{name: "invalid number", extra: [][2]string{{"cpus", "nan"}}},
{name: "negative", extra: [][2]string{{"memory_mb", "-1"}}},
{name: "timeout", extra: [][2]string{{"timeout_seconds", "86401"}}},
{name: "metadata bound", extra: [][2]string{{"tags", strings.Repeat("x", (64<<10)+1)}}},
} {
t.Run(tc.name, func(t *testing.T) {
req, source, err := parseMachineBuildMultipart(machineMultipart(t, append(append([][2]string{}, valid...), tc.extra...)...))
if !tc.ok {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, base, req.MachineBaseImage)
require.Equal(t, []byte("synthetic"), source)
})
}
_, _, err := parseMachineBuildMultipart(machineMultipart(t, [2]string{"source", "synthetic"}, [2]string{"machine_base_image", "example.test/base:latest"}))
require.Error(t, err)
}

type machineHTTPManager struct {
builds.Manager
req builds.CreateBuildRequest
calls int
}

func (m *machineHTTPManager) CreateBuild(_ context.Context, req builds.CreateBuildRequest, source []byte) (*builds.Build, error) {
m.calls++
m.req = req
return &builds.Build{ID: "synthetic-build", Status: builds.StatusQueued}, nil
}

func TestMachineBuildHTTPGateAndDispatch(t *testing.T) {
base := "example.test/base@sha256:" + strings.Repeat("a", 64)
for _, tc := range []struct {
name string
cfg *config.Config
accepted bool
}{
{"disabled", &config.Config{MacOSOnly: true}, false},
{"linux", &config.Config{}, false},
{"enabled", &config.Config{MacOSOnly: true, MacOSBuilds: true}, true},
} {
t.Run(tc.name, func(t *testing.T) {
manager := &machineHTTPManager{}
service := &ApiService{Config: tc.cfg, BuildManager: manager}
response, err := service.CreateBuild(context.Background(), oapi.CreateBuildRequestObject{Body: machineMultipart(t, [2]string{"source", "synthetic"}, [2]string{"machine_base_image", base})})
require.NoError(t, err)
if tc.accepted {
require.IsType(t, oapi.CreateBuild202JSONResponse{}, response)
require.Equal(t, 1, manager.calls)
require.Equal(t, base, manager.req.MachineBaseImage)
} else {
// Disabled machine builds are unsupported (501) on macOS-only servers, and
// a Linux server rejects the machine selector as a bad request (400).
if tc.cfg.MacOSOnly {
require.IsType(t, oapi.CreateBuild501JSONResponse{}, response)
} else {
require.IsType(t, oapi.CreateBuild400JSONResponse{}, response)
}
require.Zero(t, manager.calls)
}
})
}
}
33 changes: 32 additions & 1 deletion cmd/api/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package config

import (
"fmt"
"net"
"os"
"path/filepath"
"runtime"
Expand Down Expand Up @@ -281,7 +282,8 @@ type Config struct {
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.
MacOSBuilds bool `koanf:"macos_builds"` // Experimental: enable machine builds on the macOS-only server.
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 @@ -593,6 +595,9 @@ 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 err := c.validateMacOSBuilds(); err != nil {
return err
}
if strings.TrimSpace(c.Metrics.ListenAddress) == "" {
return fmt.Errorf("metrics.listen_address must not be empty")
}
Expand Down Expand Up @@ -839,3 +844,29 @@ func (c *Config) validateFirecrackerUFFDGraduation() error {
func intPtr(v int) *int {
return &v
}

// validateMacOSBuilds enforces the experimental machine-build registry boundary.
func (c *Config) validateMacOSBuilds() error {
if c.MacOSBuilds && !c.MacOSOnly {
return fmt.Errorf("macos_builds requires macos_only")
}
if c.MacOSBuilds && c.Registry.CACertFile != "" {
return fmt.Errorf("macos_builds requires system-trusted registry TLS or loopback HTTP")
}
if c.MacOSBuilds && c.Registry.Insecure && strings.HasPrefix(c.Registry.URL, "https://") {
return fmt.Errorf("macos_builds requires system-trusted registry TLS or loopback HTTP")
}
if c.MacOSBuilds && strings.HasPrefix(c.Registry.URL, "http://") {
host := strings.TrimPrefix(c.Registry.URL, "http://")
if i := strings.IndexAny(host, "/?#"); i >= 0 {
host = host[:i]
}
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
if host != "localhost" && !net.ParseIP(host).IsLoopback() {
return fmt.Errorf("macos_builds plaintext registry must be loopback")
}
}
return nil
}
Loading
Loading