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
124 changes: 124 additions & 0 deletions cmd/api/api/macos_builds.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package api

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

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

// 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.
// A build upload is authenticated before it takes an upload slot, so unauthenticated
// requests cannot hold the slots.
func LimitMachineBuildBody(jwtSecret string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
uploads := make(chan struct{}, maxConcurrentMachineBuildUploads)
upload := mw.JwtAuth(jwtSecret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
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)
}))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && r.URL.Path == "/builds" {
upload.ServeHTTP(w, r)
return
}
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
}
193 changes: 193 additions & 0 deletions cmd/api/api/macos_builds_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
package api

import (
"time"

"bytes"
"context"
"github.com/golang-jwt/jwt/v5"
"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 := authorizedMachineBuildRequest(t, path, io.LimitReader(machineZeroReader{}, (65<<20)+1))
LimitMachineBuildBody(machineBuildTestSecret)(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(machineBuildTestSecret)(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(), authorizedMachineBuildRequest(t, "/builds", nil))
done <- struct{}{}
}()
<-entered
}

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

close(release)
for range maxConcurrentMachineBuildUploads {
<-done
}
w = httptest.NewRecorder()
go func() { <-entered }()
handler.ServeHTTP(w, authorizedMachineBuildRequest(t, "/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)
}
})
}
}

const machineBuildTestSecret = "machine-build-test-secret"

func authorizedMachineBuildRequest(t *testing.T, path string, body io.Reader) *http.Request {
t.Helper()
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": "user",
"iat": time.Now().Unix(),
"exp": time.Now().Add(time.Hour).Unix(),
}).SignedString([]byte(machineBuildTestSecret))
require.NoError(t, err)
r := httptest.NewRequest(http.MethodPost, path, body)
r.Header.Set("Authorization", "Bearer "+token)
return r
}

func TestMachineBuildUploadsRequireAuthBeforeTakingASlot(t *testing.T) {
reached := false
handler := LimitMachineBuildBody(machineBuildTestSecret)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
reached = true
}))
for range maxConcurrentMachineBuildUploads + 1 {
w := httptest.NewRecorder()
handler.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/builds", nil))
require.Equal(t, http.StatusUnauthorized, w.Code)
}
require.False(t, reached)

w := httptest.NewRecorder()
handler.ServeHTTP(w, authorizedMachineBuildRequest(t, "/builds", nil))
require.Equal(t, http.StatusOK, w.Code, "unauthenticated requests hold no upload slot")
require.True(t, reached)
}
Loading
Loading