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.CreateBuild400JSONResponse{Code: "unsupported", Message: "Linux builder VMs are unavailable in macOS-only mode"}, nil
if !s.Config.MacOSBuilds {
return oapi.CreateBuild400JSONResponse{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
100 changes: 100 additions & 0 deletions cmd/api/api/machine_builds.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package api

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

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

// LimitMachineBuildBody must wrap the validator, which also consumes request bodies.
func LimitMachineBuildBody(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && r.URL.Path == "/builds" {
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() || len(ref.Digest()) != 71 || ref.Digest()[:7] != "sha256:" {
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
}
121 changes: 121 additions & 0 deletions cmd/api/api/machine_builds_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
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 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 {
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
}
31 changes: 31 additions & 0 deletions cmd/api/config/macos_builds_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package config

import (
"github.com/stretchr/testify/require"
"runtime"
"testing"
)

func TestMacOSBuildsFailClosedConfiguration(t *testing.T) {
if runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" {
t.Skip("macos_only requires Apple silicon")
}
require.False(t, (&Config{}).MacOSBuilds)
c := Config{MacOSBuilds: true}
require.ErrorContains(t, c.validateMacOSBuilds(), "requires macos_only")
c.MacOSOnly = true
c.Hypervisor.Default = "vz"
c.Registry.CACertFile = "custom.pem"
require.ErrorContains(t, c.validateMacOSBuilds(), "system-trusted registry TLS")
c.Registry.CACertFile = ""
c.Registry.URL = "https://registry.example"
c.Registry.Insecure = true
require.ErrorContains(t, c.validateMacOSBuilds(), "system-trusted registry TLS")
c.Registry.Insecure = false
c.Registry.URL = "http://registry.example:4973"
require.ErrorContains(t, c.validateMacOSBuilds(), "must be loopback")
c.Registry.URL = "http://127.0.0.1:4973"
require.NoError(t, c.validateMacOSBuilds())
c.Registry.URL = "http://localhost:4973"
require.NoError(t, c.validateMacOSBuilds())
}
5 changes: 5 additions & 0 deletions cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,11 @@ func run() error {
// even though the build is still running.
r.Use(timeoutNonStreamingRequests(60 * time.Second))

// Bound machine multipart input before OpenAPI validation reads the body.
if app.Config.MacOSBuilds {
r.Use(api.LimitMachineBuildBody)
}

// OpenAPI request validation with authentication
validatorOptions := &nethttpmiddleware.Options{
Options: openapi3filter.Options{
Expand Down
Loading
Loading