From 34bc0d4b72bf37347f04f85a7fdef635c7c4650a Mon Sep 17 00:00:00 2001 From: chris lee Date: Sun, 11 Oct 2026 17:49:10 -0400 Subject: [PATCH] macOS guests 6/6: experimental machine-image builds Opt-in macos_builds: pinned installed base, versioned recipe, strict export under the instance lock, streaming publication, abort without export, prior-builder reap. Linux builds are unchanged. --- cmd/api/api/builds.go | 17 +- cmd/api/api/macos_builds.go | 124 +++ cmd/api/api/macos_builds_test.go | 193 +++++ cmd/api/config/config.go | 32 +- cmd/api/config/macos_builds_test.go | 33 + cmd/api/main.go | 13 +- docs/macos-builds.md | 158 ++++ lib/builds/macos_build.go | 288 +++++++ lib/builds/macos_build_abort_test.go | 97 +++ lib/builds/macos_build_cancel.go | 58 ++ lib/builds/macos_build_cancel_test.go | 34 + lib/builds/macos_build_failure_test.go | 157 ++++ lib/builds/macos_build_guest.go | 211 +++++ lib/builds/macos_build_job_test.go | 59 ++ lib/builds/macos_build_layer_test.go | 62 ++ lib/builds/macos_build_manager.go | 169 ++++ .../macos_build_manager_failure_test.go | 44 + lib/builds/macos_build_manager_test.go | 115 +++ lib/builds/macos_build_policy_test.go | 59 ++ lib/builds/macos_build_publisher.go | 235 ++++++ lib/builds/macos_build_publisher_test.go | 90 ++ lib/builds/macos_build_reap_test.go | 79 ++ lib/builds/macos_build_recipe.go | 169 ++++ lib/builds/macos_build_recipe_test.go | 70 ++ lib/builds/macos_build_recovery_test.go | 79 ++ lib/builds/macos_build_test.go | 263 ++++++ lib/builds/manager.go | 68 +- lib/builds/source.go | 75 ++ lib/builds/source_test.go | 36 + lib/builds/types.go | 4 + lib/instances/macos_build_export.go | 5 + lib/instances/macos_build_export_darwin.go | 136 +++ .../macos_build_export_darwin_test.go | 53 ++ lib/instances/stop.go | 28 +- lib/oapi/oapi.go | 793 +++++++++--------- lib/providers/providers.go | 26 +- openapi.yaml | 10 + 37 files changed, 3730 insertions(+), 412 deletions(-) create mode 100644 cmd/api/api/macos_builds.go create mode 100644 cmd/api/api/macos_builds_test.go create mode 100644 cmd/api/config/macos_builds_test.go create mode 100644 docs/macos-builds.md create mode 100644 lib/builds/macos_build.go create mode 100644 lib/builds/macos_build_abort_test.go create mode 100644 lib/builds/macos_build_cancel.go create mode 100644 lib/builds/macos_build_cancel_test.go create mode 100644 lib/builds/macos_build_failure_test.go create mode 100644 lib/builds/macos_build_guest.go create mode 100644 lib/builds/macos_build_job_test.go create mode 100644 lib/builds/macos_build_layer_test.go create mode 100644 lib/builds/macos_build_manager.go create mode 100644 lib/builds/macos_build_manager_failure_test.go create mode 100644 lib/builds/macos_build_manager_test.go create mode 100644 lib/builds/macos_build_policy_test.go create mode 100644 lib/builds/macos_build_publisher.go create mode 100644 lib/builds/macos_build_publisher_test.go create mode 100644 lib/builds/macos_build_reap_test.go create mode 100644 lib/builds/macos_build_recipe.go create mode 100644 lib/builds/macos_build_recipe_test.go create mode 100644 lib/builds/macos_build_recovery_test.go create mode 100644 lib/builds/macos_build_test.go create mode 100644 lib/builds/source.go create mode 100644 lib/builds/source_test.go create mode 100644 lib/instances/macos_build_export.go create mode 100644 lib/instances/macos_build_export_darwin.go create mode 100644 lib/instances/macos_build_export_darwin_test.go diff --git a/cmd/api/api/builds.go b/cmd/api/api/builds.go index ae8e74c27..5ce8ccf16 100644 --- a/cmd/api/api/builds.go +++ b/cmd/api/api/builds.go @@ -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 @@ -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 { @@ -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 { diff --git a/cmd/api/api/macos_builds.go b/cmd/api/api/macos_builds.go new file mode 100644 index 000000000..74d9d6144 --- /dev/null +++ b/cmd/api/api/macos_builds.go @@ -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 +} diff --git a/cmd/api/api/macos_builds_test.go b/cmd/api/api/macos_builds_test.go new file mode 100644 index 000000000..6a69d7767 --- /dev/null +++ b/cmd/api/api/macos_builds_test.go @@ -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) +} diff --git a/cmd/api/config/config.go b/cmd/api/config/config.go index fe1fc74ee..c1fad2af4 100644 --- a/cmd/api/config/config.go +++ b/cmd/api/config/config.go @@ -2,6 +2,8 @@ package config import ( "fmt" + "net" + "net/url" "os" "path/filepath" "runtime" @@ -281,7 +283,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"` @@ -593,6 +596,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") } @@ -839,3 +845,27 @@ 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://") { + u, err := url.Parse(c.Registry.URL) + if err != nil { + return fmt.Errorf("macos_builds registry URL: %w", err) + } + host := u.Hostname() + if host != "localhost" && !net.ParseIP(host).IsLoopback() { + return fmt.Errorf("macos_builds plaintext registry must be loopback") + } + } + return nil +} diff --git a/cmd/api/config/macos_builds_test.go b/cmd/api/config/macos_builds_test.go new file mode 100644 index 000000000..b1e407cbf --- /dev/null +++ b/cmd/api/config/macos_builds_test.go @@ -0,0 +1,33 @@ +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()) + c.Registry.URL = "http://[::1]" + require.NoError(t, c.validateMacOSBuilds()) +} diff --git a/cmd/api/main.go b/cmd/api/main.go index 20430211c..7ba37042b 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -533,6 +533,11 @@ func run() error { r.Use(api.RecordCreateFields) } + // Bound machine multipart input before OpenAPI validation reads the body. + if app.Config.MacOSBuilds { + r.Use(api.LimitMachineBuildBody(app.Config.JwtSecret)) + } + // OpenAPI request validation with authentication validatorOptions := &nethttpmiddleware.Options{ Options: openapi3filter.Options{ @@ -630,10 +635,10 @@ func run() error { 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 - } + } + if err := app.BuildManager.Start(gctx); err != nil { + logger.Error("failed to start build manager", "error", err) + return err } grp.Go(func() error { diff --git a/docs/macos-builds.md b/docs/macos-builds.md new file mode 100644 index 000000000..cf2063c8b --- /dev/null +++ b/docs/macos-builds.md @@ -0,0 +1,158 @@ +# macOS image builds (experimental, opt-in HTTP) + +Ordinary build jobs can provision a digest-pinned installed macOS base in an +isolated VZ VM and publish a complete cold-boot OCI machine image. This is not +macOS installation/bootstrap, Linux rootfs conversion, memory forking or identity +rekeying. This branch depends on PR3's shared system GuestService. + +`Config.MachineBuild` opts into `CreateBuildRequest.MachineBaseImage` at the Go +boundary. Queue, persisted request, deadline, source staging/hash, status, log +completion, provenance and image-readiness gate remain owned by the normal build +manager. The concrete implementations are `GuestMachineBuildDriver` and +`StreamingMachinePublisher`. HTTP activation is disabled by default. Set both +`macos_only: true` and `macos_builds: true` on an Apple-silicon VZ server, with +an explicit `registry.url` reachable from the host (not a guest gateway). HTTPS +uses system trust; custom CA/insecure HTTPS machine configurations are rejected. +Loopback HTTP remains supported. Linux servers reject the machine selector. + +Use the existing authenticated `POST /builds` multipart endpoint: + +```sh +curl -H "Authorization: Bearer $TOKEN" \ + -F "machine_base_image=registry.example/base@sha256:$BASE_DIGEST" \ + -F "source=@source.tar.gz;type=application/gzip" \ + -F "timeout_seconds=1200" "$API/builds" +``` + +The installed base must already be Ready. A 202 response queues an ordinary job; +GET /builds/{id}, cancellation and status/provenance use the shared APIs. OpenAPI +and generated server request types include machine_base_image; external language +SDK releases are separate, not claimed here. Only source, machine_base_image, +timeout_seconds, memory_mb, cpus and tags are accepted for machine HTTP builds. +Duplicates/unknown fields, malformed numbers and mutable bases return 400. Metadata +is limited to 64 KiB per field, source 64 MiB and the entire HTTP body 65 MiB before OpenAPI +validation. These limits do not change Linux source parsing. + +## Base and policy + +The ready installed base must be pinned by canonical SHA256, declare a provisioned +system agent on port 2222 and be `darwin/arm64`. Driver provisioning additionally checks +actual guest UID0. The driver never installs an agent or falls back to host +SSH/sudo. CPU/memory match the base exactly; zero request values inherit them. +Build limits, exact MiB precision and bounded deadlines still apply. Timeout +normally defaults to 600 s, capped at 24 hours; full machine-image compression/pull can need a +longer explicitly selected deadline. + +Networking defaults isolated. Optional egress means unrestricted VZ NAT, not +Linux TAP/domain-policy parity. Domain allowlists, Linux Dockerfile/builder/cache/ +build-argument/secret/admin options are rejected, not ignored. Template identity +is preserved: concurrent same-identity guests remain forbidden. + +## Experimental versioned provisioning recipe + +Source is the normal build-owned tar.gz, privately staged and SHA256 verified, +including recovery. Compressed machine input is capped at 64 MiB; Linux staging gets +no new size cap. Host extraction into an exclusive 0700 temporary directory limits +uncompressed data 128 MiB, entries 1024 and individual regular files 64 MiB. Only regular +files/directories are accepted; duplicate names, links/devices and path escapes +fail before transfer. Files are 0600 or owner-executable 0700. Guest tar is not used. + +The archive must contain `hypeman-macos-build.json`, limited to 64 KiB: + +```json +{"version":1,"steps":[{"command":["/bin/sh","build.sh"],"env":{"MODE":"release"}}]} +``` + +At at most 32 steps execute as root in one exclusive `/var/root/hypeman-build-{id}` +directory. Command executable paths must be absolute; arguments/environment are +bounded and reject NUL. Commands run only in the guest, never a host shell. +Unknown JSON fields and extra JSON objects fail. Version 1 remains experimental; +public source/schema/toolchain contracts require review before merge. + +Sanitation removes and verifies absence of the build-owned guest source directory +and removes extracted host source before stopping. There is no injected registry +token, host credential or secret in the guest. Guest stdout/stderr are not forwarded +or retained yet; recipe-level log streaming is still a gate. A failed step reports +only its number and exit code, for example `machine build provision failed: recipe +step 1 exited with code 1`. Host admission errors, such as the base's +machine identity already being used by a running instance, are also shown; the +builder boots that identity, so instances made from the base must be stopped. This does not sanitize +inherited template accounts, SSH keys, browser state or credentials: use a separately +reviewed clean base, not a private development guest, for distributable images. + +## Stop, export and cleanup safety + +The publication path is two instance operations, not one copy-inside-stop: +`StopMacOSBuildInstance` performs only the strict graceful stop (no storage copy) +and rejects forced fallback, a pre-existing Stopped state, unmanaged/non-macOS +guests and any stop without shutdown acknowledgement plus owned VMM exit. +`ExportStoppedMacOSBuildInstance` later copies the closed storage of a +still-Stopped instance under the instance lock, so a concurrent Start makes export +fail rather than race it. Each storage file is copied once, into a private +exclusive 0700 output that holds only 0600 disk.img, aux.img and bounded config.json. + +Failure cleanup uses `Abort`, not `Stop`. Nothing from a failed build is exported, +so cleanup first attempts the strict stop, then uses the ordinary forced +hypervisor stop and deletes storage only after confirmed VMM exit. A VM that still +has no confirmed exit remains quarantined for operator recovery. A re-enqueued +attempt of the same job first reaps the builder recorded by the interrupted attempt +(stop if running, then delete) and fails rather than start a second builder if that +delete fails. Builders orphaned outside this job path still have no general reaper. Cleanup has a 2-minute budget (strict stop window plus forced +fallback and deletion), separate from the build deadline. + +On macOS-only server startup, the build manager recovers queued, building and +pushing machine jobs without preparing a Linux builder image or starting Linux +builder services. Persisted Linux jobs, and machine jobs whose backend is disabled, +remain unchanged on disk for recovery when their supported mode is enabled. + +Darwin shutdown accepts a detached, short-delayed power-off command so gRPC can +send its acceptance reply before vsock disappears. RPC cancellation after +acceptance does not undo shutdown. The host still independently awaits VMM exit; +an acknowledgement alone is insufficient. + +The backend validates complete output and preserved identity/resources and destroys +the stopped builder **before** publication. Staged source is removed only once the +image is ready, so a job interrupted while pushing or converting is rebuilt on +recovery. Each attempt first removes export and guest workspaces left by an +interrupted attempt of the same job; every export holds a full disk image. Cancellation +interrupts the active normal machine job, not forced instance deletion. It records +the owned builder before provisioning and keeps Cancelled terminal. Potentially secret-bearing guest errors remain masked in +ordinary error/log strings while errors.Is/As work internally. + +## Streaming publication + +The publisher uses a server-configured registry and a token scoped only to +`builds/{id}`. Recipes cannot select destinations or credentials. HTTPS uses system +trust; plaintext is permitted only on loopback. Redirects are never followed and +environment HTTP proxies are disabled for these credential-bearing requests. + +The layer is never written to disk. A first pass generates the gzipped tar from +the exported bundle only to compute its compressed digest/size and uncompressed +DiffID; the upload pass generates the same bytes again (tar headers carry no +timestamps, so the output is deterministic) and the registry rejects any digest +mismatch. Configuration and layer blobs stream through monolithic POST uploads, +not go-containerregistry's large-buffer PATCH path. Each persisted +blob is independently GET/hash/size verified. The `latest` manifest is committed +last (the normal manager's image-readiness convention), then immutable digest +content is verified. Ambiguous errors do not return successful receipts. Disk +quota/floor policy belongs to deployment; a compressed-size bound is not a storage quota. + +## Validation and remaining gates + +Repeated race tests cover source/recipe admission, shared queue/status/provenance, +cancellation without publication, phase failures/quarantine, bundle identity/modes, +closed-storage/hardlink rejection and authenticated production-registry publication +with manifest-last ordering and redirect isolation. Tiny test disks are synthetic, +not bootability evidence. + +Native Apple-silicon QA has run real builds end to end through the HTTP API: +provisioning from a pinned installed base, sanitation, strict stop, export, +publication, the readiness gate and a cold boot of the result with the build +marker present. It also covered recipe-step failures, cancellation, API crashes +during provisioning, publication and conversion (each rebuilt on recovery), and +host admission errors. These runs used a development base, not a sanitized +production template. + +Remaining: external SDK releases, common safe recipe logs, +clean-template sanitation policy/audit, toolchain/version provenance, production +storage quotas/floors and Linux runtime regressions. Keep PR6 draft and experimental HTTP activation disabled by default. diff --git a/lib/builds/macos_build.go b/lib/builds/macos_build.go new file mode 100644 index 000000000..5b58a6235 --- /dev/null +++ b/lib/builds/macos_build.go @@ -0,0 +1,288 @@ +package builds + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/kernel/hypeman/lib/images" + "github.com/kernel/hypeman/lib/instances" +) + +// MachineBuildRequest is an internal backend contract, not a new recipe syntax +// or an HTTP API. The normal build manager owns ID, source staging and publish +// authorization. A concrete driver must require a provisioned system agent. +type MachineBuildRequest struct { + ID string + BaseImage string // Installed darwin/arm64 image, pinned by sha256 digest. + SourceHash string // Optional expected lowercase SHA256 of the source archive. + Policy BuildPolicy // Zero CPU/memory inherit the base machine's exact resources. + onStarted func(string) error // Normal manager records the owned builder before provisioning. +} + +type MachineStopReceipt = instances.MacOSBuildExportReceipt +type MachinePublication struct{ Reference, Digest string } +type MachineBuildResult struct { + Publication MachinePublication + Provenance BuildProvenance +} + +type MachineBuildDriver interface { + // Start owns an isolated instance and identity lease, named name. A partially + // created session must be returned even with an error so cleanup can stop it. + Start(ctx context.Context, name string, base *images.Image, policy BuildPolicy) (MachineBuildSession, error) +} + +type MachineBuildSession interface { + ID() string + Provision(context.Context, string) error // Build-owned staged source, never a client host path. + Sanitize(context.Context) error // Remove build credentials, source and secret injection artifacts. + // Stop must await actual VMM exit. Forced-stop fallback must not claim graceful. + Stop(context.Context) (MachineStopReceipt, error) + // Abort is failure-only cleanup. It may force-stop because nothing is exported. + Abort(context.Context) (MachineStopReceipt, error) + Export(context.Context, string) error // Stopped disk.img, aux.img, config.json only. + // Destroy removes instance storage only after proven VMM exit, not the export. + Destroy(context.Context) error +} + +type MachineBuildPublisher interface { + // Publisher owns registry address, scoped token and builds/{id} destination. + // It must stream blobs and commit/verify the manifest last. An ambiguous + // network error is not a verified successful publication. + Publish(context.Context, string, string) (MachinePublication, error) +} + +type MachineBuildBackend struct { + Driver MachineBuildDriver + Publisher MachineBuildPublisher +} + +// machinePhaseError keeps potentially secret-bearing guest/command errors out of +// ordinary log/error text, while retaining errors.Is/As for internal handling. +type machinePhaseError struct { + phase string + cause error +} + +func (e *machinePhaseError) Error() string { + if step := (*machineStepError)(nil); errors.As(e.cause, &step) { + return "machine build " + e.phase + " failed: " + step.Error() + } + // Host admission errors, such as a template identity already in use, carry + // no guest output. + if errors.Is(e.cause, instances.ErrInsufficientResources) || errors.Is(e.cause, instances.ErrInvalidState) { + return "machine build " + e.phase + " failed: " + e.cause.Error() + } + return "machine build " + e.phase + " failed" +} +func (e *machinePhaseError) Unwrap() error { return e.cause } +func machineFailure(phase string, err error) error { return &machinePhaseError{phase, err} } + +// machineStepError names the recipe step that exited nonzero. It holds no guest +// output, so it is safe to show. +type machineStepError struct{ step, code int } + +func (e *machineStepError) Error() string { + return fmt.Sprintf("recipe step %d exited with code %d", e.step, e.code) +} + +// normalizeMachinePolicy applies machine build policy defaults and rejects what +// machine builds do not support. Admission, the runner and the guest driver share it. +func normalizeMachinePolicy(policy *BuildPolicy) error { + if policy.TimeoutSeconds == 0 { + policy.TimeoutSeconds = 600 + } + if policy.TimeoutSeconds < 1 || policy.TimeoutSeconds > 86400 { + return fmt.Errorf("invalid machine build timeout") + } + if policy.NetworkMode == "" { + policy.NetworkMode = "isolated" + } + if len(policy.AllowedDomains) != 0 || (policy.NetworkMode != "isolated" && policy.NetworkMode != "egress") { + return fmt.Errorf("unsupported machine network policy") + } + return nil +} + +// runPrepared executes only machine-specific phases. The shared build manager +// owns resolution, source staging/hash verification, deadline, queue and status. +func (r *MachineBuildBackend) runPrepared(ctx context.Context, req MachineBuildRequest, base *images.Image, sourcePath, workDir string) (result *MachineBuildResult, err error) { + if r.Driver == nil || r.Publisher == nil || !filepath.IsAbs(workDir) || !filepath.IsAbs(sourcePath) || len(req.SourceHash) != 64 || strings.Trim(req.SourceHash, "0123456789abcdef") != "" { + return nil, fmt.Errorf("machine driver, publisher, private workspace and verified source required") + } + if _, bounded := ctx.Deadline(); !bounded { + return nil, fmt.Errorf("prepared machine job requires a deadline") + } + if len(req.ID) == 0 || len(req.ID) > 128 || strings.Trim(req.ID, "abcdefghijklmnopqrstuvwxyz0123456789-") != "" { + return nil, fmt.Errorf("invalid machine build ID") + } + ref, e := images.ParseNormalizedRef(req.BaseImage) + if e != nil || !ref.IsDigest() || !ValidMachineDigest(ref.Digest()) { + return nil, fmt.Errorf("machine base must be pinned by sha256 digest") + } + policy := req.Policy + if e = normalizeMachinePolicy(&policy); e != nil { + return nil, e + } + if base == nil || base.Status != images.StatusReady || base.Platform != "darwin/arm64" || base.MacOS == nil || base.Digest != ref.Digest() || base.MacOS.CPUs < 2 || base.MacOS.CPUs > MaxBuildCPUs || base.MacOS.Memory < 4<<30 || base.MacOS.Memory > uint64(MaxBuildMemoryMB)<<20 || base.MacOS.Memory%(1<<20) != 0 { + return nil, fmt.Errorf("base is not the pinned ready macOS machine") + } + if base.MacOS.Validate() != nil { + return nil, fmt.Errorf("base has invalid machine identity") + } + memory := int(base.MacOS.Memory >> 20) + cpus := int(base.MacOS.CPUs) + if (policy.MemoryMB != 0 && policy.MemoryMB != memory) || (policy.CPUs != 0 && policy.CPUs != cpus) { + return nil, fmt.Errorf("machine resources must match installed base") + } + policy.MemoryMB = memory + policy.CPUs = cpus + if e = policy.Validate(); e != nil { + return nil, e + } + workspace, e := os.MkdirTemp(workDir, "machine-build-") + if e != nil { + return nil, machineFailure("workspace", e) + } + defer os.RemoveAll(workspace) + if e = verifyBuildSource(ctx, sourcePath, req.SourceHash); e != nil { + return nil, machineFailure("verify_source", e) + } + if e = ctx.Err(); e != nil { + return nil, e + } + session, e := r.Driver.Start(ctx, machineBuilderName(req.ID), base, policy) + if session == nil { + if e == nil { + e = fmt.Errorf("driver returned no session") + } + return nil, machineFailure("start", e) + } + stopped, destroyed := false, false + defer func() { + cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), machineCleanupTimeout) + defer cancel() + if !stopped { + receipt, stopErr := session.Abort(cleanup) + stopped = receipt.VMMExited + if !stopped { + err = errors.Join(err, machineFailure("quarantine_unconfirmed_vmm_exit", stopErr)) + result = nil + return + } + } + if !destroyed { + if destroyErr := session.Destroy(cleanup); destroyErr != nil { + err = errors.Join(err, machineFailure("cleanup", destroyErr)) + result = nil + } + } + }() + instanceID := session.ID() + if req.onStarted != nil { + if recordErr := req.onStarted(instanceID); recordErr != nil { + return nil, machineFailure("record_builder", recordErr) + } + } + if e != nil { + return nil, machineFailure("start", e) + } + if e = session.Provision(ctx, sourcePath); e != nil { + return nil, machineFailure("provision", e) + } + if e = session.Sanitize(ctx); e != nil { + return nil, machineFailure("sanitize", e) + } + receipt, e := session.Stop(ctx) + stopped = receipt.VMMExited + if e != nil || !receipt.Graceful || !stopped { + return nil, machineFailure("graceful_stop", e) + } + bundle := filepath.Join(workspace, "bundle") + if e = os.Mkdir(bundle, 0700); e != nil { + return nil, machineFailure("export", e) + } + if e = session.Export(ctx, bundle); e != nil { + return nil, machineFailure("export", e) + } + if e = validateMachineExport(bundle, base.MacOS); e != nil { + return nil, machineFailure("validate_export", e) + } + if e = session.Destroy(ctx); e != nil { + return nil, machineFailure("cleanup", e) + } + destroyed = true + if e = ctx.Err(); e != nil { + return nil, e + } + publication, e := r.Publisher.Publish(ctx, req.ID, bundle) + if e != nil { + return nil, machineFailure("publish", e) + } + output, e := images.ParseNormalizedRef(publication.Reference) + if e != nil || !output.IsDigest() || !ValidMachineDigest(publication.Digest) || output.Digest() != publication.Digest { + return nil, fmt.Errorf("machine publication receipt is unverified") + } + return &MachineBuildResult{Publication: publication, Provenance: BuildProvenance{BaseImageDigest: base.Digest, SourceHash: req.SourceHash, Timestamp: time.Now().UTC()}}, nil +} + +// machineCleanupTimeout covers a strict graceful stop attempt (30s power-off +// budget) plus forced fallback and storage deletion. Export is outside cleanup. +const machineCleanupTimeout = 2 * time.Minute + +// ValidMachineDigest reports whether s is a canonical lowercase sha256 digest. +// It is the single validation rule for machine base and output digests. +func ValidMachineDigest(s string) bool { + if !strings.HasPrefix(s, "sha256:") || len(s) != 71 { + return false + } + return strings.Trim(s[7:], "0123456789abcdef") == "" +} + +func validateMachineExport(root string, base *images.MacOSImage) error { + directory, err := os.Lstat(root) + if err != nil || !directory.IsDir() { + return fmt.Errorf("export root must be a real directory") + } + entries, err := os.ReadDir(root) + if err != nil { + return err + } + if len(entries) != 3 { + return fmt.Errorf("export must contain only machine payload files") + } + for _, entry := range entries { + if entry.Name() != "disk.img" && entry.Name() != "aux.img" && entry.Name() != "config.json" { + return fmt.Errorf("unexpected export file") + } + info, err := os.Lstat(filepath.Join(root, entry.Name())) + if err != nil || !info.Mode().IsRegular() { + return fmt.Errorf("export files must be regular") + } + } + platform, err := images.ValidateMacOSBundle(root) + if err != nil { + return err + } + if !bytes.Equal(platform.HardwareModel, base.HardwareModel) || !bytes.Equal(platform.MachineIdentifier, base.MachineIdentifier) || !strings.EqualFold(platform.MAC, base.MAC) || platform.CPUs != base.CPUs || platform.Memory != base.Memory { + return fmt.Errorf("machine export changed preserved identity/resources") + } + for _, entry := range entries { + if err = os.Chmod(filepath.Join(root, entry.Name()), 0600); err != nil { + return err + } + } + return nil +} + +// machineBuilderName names a build's builder instance after the build, so a retry +// can find and reap a builder that an interrupted attempt created but never recorded. +func machineBuilderName(buildID string) string { + return "macos-build-" + strings.ToLower(buildID) +} diff --git a/lib/builds/macos_build_abort_test.go b/lib/builds/macos_build_abort_test.go new file mode 100644 index 000000000..9d2cec357 --- /dev/null +++ b/lib/builds/macos_build_abort_test.go @@ -0,0 +1,97 @@ +package builds + +import ( + "context" + "errors" + "testing" + + "github.com/kernel/hypeman/lib/instances" + "github.com/stretchr/testify/require" +) + +type abortInstances struct { + instances.Manager + state instances.State + forcedState instances.State + forced bool + forcedErr error +} + +func (a *abortInstances) GetInstance(context.Context, string) (*instances.Instance, error) { + return &instances.Instance{State: a.state}, nil +} + +func (a *abortInstances) StopInstance(context.Context, string) (*instances.Instance, error) { + a.forced = true + if a.forcedErr != nil { + return nil, a.forcedErr + } + if a.forcedState != "" { + a.state = a.forcedState + } + return &instances.Instance{State: a.state}, nil +} + +type abortExporter struct { + receipt instances.MacOSBuildExportReceipt + err error +} + +func (e abortExporter) StopMacOSBuildInstance(context.Context, string) (instances.MacOSBuildExportReceipt, error) { + return e.receipt, e.err +} + +func (abortExporter) ExportStoppedMacOSBuildInstance(context.Context, string, string) error { + return errors.New("abort must not export") +} + +func newAbortSession(m *abortInstances, e abortExporter) *guestMachineSession { + return &guestMachineSession{manager: m, exporter: e, instance: &instances.Instance{StoredMetadata: instances.StoredMetadata{Id: "builder"}}} +} + +func TestMachineAbortUsesStrictStopBeforeForcedFallback(t *testing.T) { + m := &abortInstances{state: instances.StateRunning} + s := newAbortSession(m, abortExporter{receipt: instances.MacOSBuildExportReceipt{Graceful: true, VMMExited: true}}) + receipt, err := s.Abort(context.Background()) + require.NoError(t, err) + require.True(t, receipt.VMMExited) + require.True(t, receipt.Graceful) + require.False(t, m.forced, "a confirmed graceful exit must not force-stop") +} + +func TestMachineAbortForcedFallbackConfirmsVMMExit(t *testing.T) { + m := &abortInstances{state: instances.StateStopped} + s := newAbortSession(m, abortExporter{err: errors.New("agent never became ready")}) + s.instance = &instances.Instance{StoredMetadata: instances.StoredMetadata{Id: "builder"}} + receipt, err := s.Abort(context.Background()) + require.NoError(t, err) + require.True(t, receipt.VMMExited) + require.False(t, receipt.Graceful) +} + +func TestMachineAbortForcesRunningInstanceToStopped(t *testing.T) { + m := &abortInstances{state: instances.StateRunning, forcedState: instances.StateStopped} + s := newAbortSession(m, abortExporter{err: errors.New("agent never became ready")}) + receipt, err := s.Abort(context.Background()) + require.NoError(t, err) + require.Equal(t, MachineStopReceipt{VMMExited: true}, receipt) + require.True(t, m.forced) +} + +func TestMachineAbortRejectsUnconfirmedForcedStop(t *testing.T) { + m := &abortInstances{state: instances.StateRunning} + s := newAbortSession(m, abortExporter{err: errors.New("agent never became ready")}) + receipt, err := s.Abort(context.Background()) + require.EqualError(t, err, "forced stop did not confirm VMM exit") + require.False(t, receipt.VMMExited) + require.True(t, m.forced) +} + +func TestMachineAbortWithoutConfirmedExitStaysQuarantined(t *testing.T) { + m := &abortInstances{state: instances.StateRunning, forcedErr: errors.New("hypervisor still alive")} + s := newAbortSession(m, abortExporter{err: errors.New("agent never became ready")}) + receipt, err := s.Abort(context.Background()) + require.Error(t, err) + require.False(t, receipt.VMMExited) + require.Error(t, s.Destroy(context.Background()), "Destroy must refuse without confirmed VMM exit") +} diff --git a/lib/builds/macos_build_cancel.go b/lib/builds/macos_build_cancel.go new file mode 100644 index 000000000..2547142fb --- /dev/null +++ b/lib/builds/macos_build_cancel.go @@ -0,0 +1,58 @@ +package builds + +import ( + "context" + "fmt" + "sync" + "time" +) + +type machineBuildControl struct { + mu sync.Mutex + cancel context.CancelFunc +} + +func (m *manager) recordMachineBuilder(id, instanceID string) error { + if active, ok := m.machineBuilds.Load(id); ok { + control := active.(*machineBuildControl) + control.mu.Lock() + defer control.mu.Unlock() + } + meta, err := readMetadata(m.paths, id) + if err != nil { + return err + } + if meta.Status == StatusCancelled { + return context.Canceled + } + meta.BuilderInstance = &instanceID + return writeMetadata(m.paths, meta) +} + +func (m *manager) cancelMachineBuild(id string) error { + active, ok := m.machineBuilds.Load(id) + if !ok { + return ErrBuildInProgress + } + control := active.(*machineBuildControl) + control.mu.Lock() + defer control.mu.Unlock() + meta, err := readMetadata(m.paths, id) + if err != nil { + return err + } + if meta.Status != StatusBuilding && meta.Status != StatusPushing { + return fmt.Errorf("machine build no longer running") + } + // Persist cancellation before interrupting phases. The backend performs bounded + // graceful cleanup or quarantine; Cancel must not force-delete mutable storage. + meta.Status = StatusCancelled + now := time.Now() + meta.CompletedAt = &now + if err := writeMetadata(m.paths, meta); err != nil { + return err + } + control.cancel() + m.notifyStatusChange(id, StatusCancelled) + return nil +} diff --git a/lib/builds/macos_build_cancel_test.go b/lib/builds/macos_build_cancel_test.go new file mode 100644 index 000000000..8da2577c3 --- /dev/null +++ b/lib/builds/macos_build_cancel_test.go @@ -0,0 +1,34 @@ +package builds + +import ( + "context" + "testing" + "time" + + "github.com/kernel/hypeman/lib/paths" + "github.com/stretchr/testify/require" +) + +func TestMachineCancellationInterruptsPhasesWithoutPublishing(t *testing.T) { + fixture := newMachineFixture() + fixture.waitForCancel = true + config := DefaultConfig() + config.RegistrySecret = "synthetic secret" + config.MachineBuild = &MachineBuildBackend{Driver: fixture, Publisher: fixture} + api, e := NewManager(paths.New(t.TempDir()), config, noPriorBuilder{}, nil, nil, machineJobImages{fixture: fixture}, nil, nil, nil) + require.NoError(t, e) + build, e := api.CreateBuild(context.Background(), CreateBuildRequest{MachineBaseImage: "registry/base@" + machineTestDigest}, []byte("synthetic source")) + require.NoError(t, e) + require.Eventually(t, func() bool { + meta, e := readMetadata(api.(*manager).paths, build.ID) + return e == nil && meta.BuilderInstance != nil + }, 5*time.Second, 10*time.Millisecond) + require.NoError(t, api.CancelBuild(context.Background(), build.ID)) + require.Eventually(t, func() bool { _, active := api.(*manager).machineBuilds.Load(build.ID); return !active }, 5*time.Second, 10*time.Millisecond) + done, e := api.GetBuild(context.Background(), build.ID) + require.NoError(t, e) + require.Equal(t, StatusCancelled, done.Status) + require.False(t, fixture.publisherCalled) + require.Contains(t, fixture.calls, "abort") + require.Contains(t, fixture.calls, "destroy") +} diff --git a/lib/builds/macos_build_failure_test.go b/lib/builds/macos_build_failure_test.go new file mode 100644 index 000000000..847b01183 --- /dev/null +++ b/lib/builds/macos_build_failure_test.go @@ -0,0 +1,157 @@ +package builds + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +type failingMachineSource struct{} + +func (failingMachineSource) Read(p []byte) (int, error) { + return copy(p, "partial"), errors.New("secret-source-error") +} + +func TestMachineBuildSourceReadFailure(t *testing.T) { + f := newMachineFixture() + r := machineTestRunner(t, f) + result, err := r.Run(context.Background(), machineTestRequest(), failingMachineSource{}) + if err == nil || result != nil || strings.Contains(err.Error(), "secret-source-error") { + t.Fatal("source failure was not masked") + } + if !reflect.DeepEqual(f.calls, []string{"resolve"}) { + t.Fatalf("source failure started guest: %v", f.calls) + } + entries, err := os.ReadDir(r.WorkDir) + if err != nil || len(entries) != 0 { + t.Fatal("partial source retained") + } +} + +func TestMachineBuildTimeoutCleanup(t *testing.T) { + f := newMachineFixture() + f.waitForCancel = true + r := machineTestRunner(t, f) + req := machineTestRequest() + req.Policy.TimeoutSeconds = 1 + parent, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + result, err := r.Run(parent, req, strings.NewReader("source")) + if !errors.Is(err, context.DeadlineExceeded) || result != nil || f.publisherCalled || !f.cleanupContextLive { + t.Fatal("timeout cleanup failed") + } + if !reflect.DeepEqual(f.calls, []string{"resolve", "start", "abort", "destroy"}) { + t.Fatalf("timeout cleanup order: %v", f.calls) + } +} + +func TestMachineBuildPublicationReceipt(t *testing.T) { + for _, kind := range []string{"ambiguous error", "tag receipt", "digest mismatch", "invalid digest"} { + t.Run(kind, func(t *testing.T) { + f := newMachineFixture() + r := machineTestRunner(t, f) + switch kind { + case "ambiguous error": + f.fail = "publish" + case "tag receipt": + f.publicationOverride = &MachinePublication{Reference: "localhost/builds/build-test:latest", Digest: machineTestDigest} + case "digest mismatch": + f.publicationOverride = &MachinePublication{Reference: "localhost/builds/build-test@" + machineTestDigest, Digest: "sha256:" + strings.Repeat("b", 64)} + case "invalid digest": + f.publicationOverride = &MachinePublication{Reference: "localhost/builds/build-test@" + machineTestDigest, Digest: "not-a-digest"} + } + result, err := r.Run(context.Background(), machineTestRequest(), strings.NewReader("source")) + if err == nil || result != nil || !f.publisherCalled { + t.Fatal("unverified receipt became ready") + } + if strings.Contains(err.Error(), "sensitive-command-argument") { + t.Fatal("publisher error leaked") + } + count := 0 + for _, phase := range f.calls { + if phase == "publish" { + count++ + } + } + if count != 1 { + t.Fatal("ambiguous publication was retried") + } + }) + } +} + +func TestMachineBuildMalformedExport(t *testing.T) { + for _, kind := range []string{"empty disk", "empty aux", "symlink disk", "hardlink payloads", "truncated config", "oversized config", "resource change", "bad MAC", "identity", "extra file", "unknown metadata"} { + t.Run(kind, func(t *testing.T) { + f := newMachineFixture() + r := machineTestRunner(t, f) + f.mutateExport = func(root string) error { + disk, aux, config := filepath.Join(root, "disk.img"), filepath.Join(root, "aux.img"), filepath.Join(root, "config.json") + switch kind { + case "empty disk": + return os.Truncate(disk, 0) + case "empty aux": + return os.Truncate(aux, 0) + case "symlink disk": + if err := os.Remove(disk); err != nil { + return err + } + return os.Symlink(aux, disk) + case "hardlink payloads": + if err := os.Remove(aux); err != nil { + return err + } + return os.Link(disk, aux) + case "truncated config": + return os.WriteFile(config, []byte(`{"hardware_model":`), 0600) + case "oversized config": + return os.WriteFile(config, []byte(strings.Repeat(" ", 65<<10)), 0600) + case "resource change": + data, err := os.ReadFile(config) + if err != nil { + return err + } + return os.WriteFile(config, []byte(strings.Replace(string(data), `"cpus":4`, `"cpus":2`, 1)), 0600) + case "bad MAC": + data, err := os.ReadFile(config) + if err != nil { + return err + } + return os.WriteFile(config, []byte(strings.Replace(string(data), "02:00:00:00:00:01", "02:00:00:00:00:01:02:03", 1)), 0600) + case "identity", "unknown metadata": + var raw map[string]any + data, err := os.ReadFile(config) + if err == nil { + err = json.Unmarshal(data, &raw) + } + if err != nil { + return err + } + if kind == "identity" { + raw["machine_identifier"] = []byte{9} + } else { + raw["password"] = "must-not-publish" + } + data, _ = json.Marshal(raw) + return os.WriteFile(config, data, 0600) + case "extra file": + return os.WriteFile(filepath.Join(root, "secret.txt"), []byte("must-not-publish"), 0600) + } + return nil + } + result, err := r.Run(context.Background(), machineTestRequest(), strings.NewReader("source")) + if err == nil || result != nil || f.publisherCalled { + t.Fatal("malformed export published") + } + if strings.Contains(err.Error(), "must-not-publish") { + t.Fatal("export secret leaked in failure") + } + }) + } +} diff --git a/lib/builds/macos_build_guest.go b/lib/builds/macos_build_guest.go new file mode 100644 index 000000000..98a902a21 --- /dev/null +++ b/lib/builds/macos_build_guest.go @@ -0,0 +1,211 @@ +package builds + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/kernel/hypeman/lib/guest" + "github.com/kernel/hypeman/lib/hypervisor" + "github.com/kernel/hypeman/lib/images" + "github.com/kernel/hypeman/lib/instances" +) + +type machineExportManager interface { + StopMacOSBuildInstance(context.Context, string) (instances.MacOSBuildExportReceipt, error) + ExportStoppedMacOSBuildInstance(context.Context, string, string) error +} + +// GuestMachineBuildDriver uses ordinary instance lifecycle and the shared system +// GuestService. It never installs an agent or silently upgrades an unmanaged base. +type GuestMachineBuildDriver struct { + Instances instances.Manager + WorkDir string +} + +func (d *GuestMachineBuildDriver) Start(ctx context.Context, name string, base *images.Image, policy BuildPolicy) (MachineBuildSession, error) { + exporter, ok := d.Instances.(machineExportManager) + if !ok || !filepath.IsAbs(d.WorkDir) { + return nil, fmt.Errorf("strict machine export and private workspace required") + } + if _, ok = ctx.Deadline(); !ok { + return nil, fmt.Errorf("machine start requires a deadline") + } + if base == nil || base.Status != images.StatusReady || base.Platform != "darwin/arm64" || base.MacOS == nil || !base.MacOS.GuestAgent || base.MacOS.Validate() != nil || !ValidMachineDigest(base.Digest) { + return nil, fmt.Errorf("provisioned pinned macOS base required") + } + if err := policy.Validate(); err != nil { + return nil, err + } + if err := normalizeMachinePolicy(&policy); err != nil { + return nil, err + } + if policy.CPUs != int(base.MacOS.CPUs) || policy.MemoryMB != int(base.MacOS.Memory>>20) { + return nil, fmt.Errorf("machine policy must preserve resources and supported networking") + } + ref, err := images.ParseNormalizedRef(base.Name) + if err != nil { + return nil, err + } + // Workspaces are named per builder, so one left by an interrupted attempt + // of this job is removed here. + prefix := "guest-machine-" + name + "-" + stale, _ := filepath.Glob(filepath.Join(d.WorkDir, prefix+"*")) + for _, dir := range stale { + _ = os.RemoveAll(dir) + } + dir, err := os.MkdirTemp(d.WorkDir, prefix) + if err != nil { + return nil, err + } + inst, err := d.Instances.CreateInstance(ctx, instances.CreateInstanceRequest{Name: name, Image: ref.Repository() + "@" + base.Digest, Platform: "darwin/arm64", Hypervisor: hypervisor.TypeVZ, Size: int64(base.MacOS.Memory), Vcpus: int(base.MacOS.CPUs), NetworkEnabled: policy.NetworkMode == "egress"}) + if inst == nil { + _ = os.RemoveAll(dir) + if err == nil { + err = fmt.Errorf("instance creation returned no instance") + } + return nil, err + } + session := &guestMachineSession{manager: d.Instances, exporter: exporter, instance: inst, workspace: dir, guestDir: "/var/root/hypeman-build-" + inst.Id} + return session, err +} + +type guestMachineSession struct { + manager instances.Manager + exporter machineExportManager + instance *instances.Instance + workspace, guestDir string + receipt MachineStopReceipt + sanitized bool +} + +func (s *guestMachineSession) ID() string { return s.instance.Id } +func (s *guestMachineSession) dialer() (hypervisor.VsockDialer, error) { + return hypervisor.NewVsockDialer(s.instance.HypervisorType, s.instance.VsockSocket, s.instance.VsockCID) +} +func (s *guestMachineSession) exec(ctx context.Context, command []string, cwd string, env map[string]string, wait time.Duration) error { + dialer, err := s.dialer() + if err != nil { + return err + } + exit, err := guest.ExecIntoInstance(ctx, dialer, guest.ExecOptions{Command: command, Cwd: cwd, Env: env, WaitForAgent: wait}) + if err != nil { + return err + } + if exit == nil { + return fmt.Errorf("machine guest command did not succeed") + } + if exit.Code != 0 { + return &machineExitError{code: exit.Code} + } + return nil +} + +// machineExitError is a guest command's nonzero exit code. +type machineExitError struct{ code int } + +func (e *machineExitError) Error() string { return "machine guest command did not succeed" } + +func (s *guestMachineSession) Provision(ctx context.Context, source string) error { + root := filepath.Join(s.workspace, "source") + recipe, err := extractMachineRecipe(ctx, source, root) + if err != nil { + return err + } + if err = s.exec(ctx, []string{"/bin/sh", "-c", `test "$(/usr/bin/id -u)" -eq 0`}, "", nil, 30*time.Second); err != nil { + return err + } + if err = s.exec(ctx, []string{"/bin/test", "!", "-e", s.guestDir}, "", nil, 30*time.Second); err != nil { + return err + } + if err = s.exec(ctx, []string{"/bin/mkdir", "-m", "700", s.guestDir}, "", nil, 0); err != nil { + return err + } + dialer, err := s.dialer() + if err != nil { + return err + } + if err = guest.CopyToInstance(ctx, dialer, guest.CopyToInstanceOptions{SrcPath: root, DstPath: s.guestDir}); err != nil { + return err + } + for i, step := range recipe.Steps { + if err = s.exec(ctx, step.Command, s.guestDir, step.Env, 0); err != nil { + if exitErr := (*machineExitError)(nil); errors.As(err, &exitErr) { + return &machineStepError{step: i + 1, code: exitErr.code} + } + return err + } + } + return nil +} + +func (s *guestMachineSession) Sanitize(ctx context.Context) error { + if err := s.exec(ctx, []string{"/bin/rm", "-rf", "--", s.guestDir}, "", nil, 0); err != nil { + return err + } + if err := s.exec(ctx, []string{"/bin/test", "!", "-e", s.guestDir}, "", nil, 0); err != nil { + return err + } + if err := os.RemoveAll(filepath.Join(s.workspace, "source")); err != nil { + return err + } + s.sanitized = true + return nil +} + +// Stop is the publication path: only an acknowledged graceful power-off with +// confirmed VMM exit may produce a receipt that permits export. +func (s *guestMachineSession) Stop(ctx context.Context) (MachineStopReceipt, error) { + if s.receipt.VMMExited { + return s.receipt, nil + } + receipt, err := s.exporter.StopMacOSBuildInstance(ctx, s.ID()) + s.receipt = receipt + return s.receipt, err +} + +// Abort is failure cleanup only. Nothing from a failed build is exported, so after +// a strict attempt it may use the ordinary forced hypervisor stop. Destroy still +// requires confirmed VMM exit; a non-exited instance remains quarantined. +func (s *guestMachineSession) Abort(ctx context.Context) (MachineStopReceipt, error) { + if s.receipt.VMMExited { + return s.receipt, nil + } + if receipt, err := s.exporter.StopMacOSBuildInstance(ctx, s.ID()); err == nil && receipt.VMMExited { + s.receipt = receipt + return s.receipt, nil + } + if inst, err := s.manager.GetInstance(ctx, s.ID()); err != nil || inst.State != instances.StateStopped { + inst, err = s.manager.StopInstance(ctx, s.ID()) + if err != nil { + return s.receipt, err + } + if inst == nil || inst.State != instances.StateStopped { + return s.receipt, fmt.Errorf("forced stop did not confirm VMM exit") + } + } + s.receipt = MachineStopReceipt{VMMExited: true} + return s.receipt, nil +} + +// Export copies only from closed storage of a Stopped instance, under the +// instance lock, after a strict graceful receipt and sanitation. +func (s *guestMachineSession) Export(ctx context.Context, destination string) error { + if !s.receipt.Graceful || !s.receipt.VMMExited || !s.sanitized { + return fmt.Errorf("sanitized strict stop receipt required") + } + return s.exporter.ExportStoppedMacOSBuildInstance(ctx, s.ID(), destination) +} + +func (s *guestMachineSession) Destroy(ctx context.Context) error { + if !s.receipt.VMMExited { + return fmt.Errorf("unconfirmed VMM exit: instance quarantined") + } + if err := s.manager.DeleteInstance(ctx, s.ID()); err != nil { + return err + } + return os.RemoveAll(s.workspace) +} diff --git a/lib/builds/macos_build_job_test.go b/lib/builds/macos_build_job_test.go new file mode 100644 index 000000000..d06ec3b37 --- /dev/null +++ b/lib/builds/macos_build_job_test.go @@ -0,0 +1,59 @@ +package builds + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "time" +) + +type machineRunnerFixture struct { + MachineBuildBackend + WorkDir string + MaxSourceBytes int64 +} + +// Run is test-only: production invokes the prepared machine phase from runBuild. +// Existing failure fixtures still exercise resolution/staging before that phase. +func (r *machineRunnerFixture) Run(ctx context.Context, req MachineBuildRequest, source io.Reader) (*MachineBuildResult, error) { + if source == nil || !filepath.IsAbs(r.WorkDir) { + return nil, fmt.Errorf("workspace/source required") + } + timeout := req.Policy.TimeoutSeconds + if timeout == 0 { + timeout = 600 + } + if timeout < 0 || timeout > 86400 { + return nil, fmt.Errorf("invalid timeout") + } + ctx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second) + defer cancel() + base, err := r.Driver.(*machineFixture).ResolveBase(ctx, req.BaseImage) + if err != nil { + return nil, machineFailure("resolve_base", err) + } + job, err := os.MkdirTemp(r.WorkDir, "prepared-job-") + if err != nil { + return nil, err + } + defer os.RemoveAll(job) + limit := r.MaxSourceBytes + if limit == 0 { + limit = 64 << 20 + } + if limit < 1 || limit > 1<<30 { + return nil, fmt.Errorf("invalid source limit") + } + sourcePath := filepath.Join(job, "source.tar.gz") + hash, err := stageBuildSource(ctx, source, sourcePath, limit) + if err != nil { + return nil, machineFailure("stage_source", err) + } + if req.SourceHash != "" && req.SourceHash != hash { + return nil, ErrSourceHashMismatch + } + req.SourceHash = hash + return r.runPrepared(ctx, req, base, sourcePath, r.WorkDir) +} diff --git a/lib/builds/macos_build_layer_test.go b/lib/builds/macos_build_layer_test.go new file mode 100644 index 000000000..74714fc19 --- /dev/null +++ b/lib/builds/macos_build_layer_test.go @@ -0,0 +1,62 @@ +package builds + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "io" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestMachineLayerRegeneratesHashedBytesWithoutSpooling(t *testing.T) { + parent := t.TempDir() + bundle := filepath.Join(parent, "bundle") + require.NoError(t, os.Mkdir(bundle, 0700)) + disk := make([]byte, 3<<20) // mostly zeros, like a sparse machine disk + copy(disk[1<<20:], "data") + for name, data := range map[string][]byte{"disk.img": disk, "aux.img": []byte("aux"), "config.json": []byte("{}")} { + require.NoError(t, os.WriteFile(filepath.Join(bundle, name), data, 0600)) + } + + blob, diffID, err := hashMachineLayer(context.Background(), bundle) + require.NoError(t, err) + require.NotEmpty(t, diffID.Hex) + for range 2 { + body := blob.open() + sum := sha256.New() + n, err := io.Copy(sum, body) + require.NoError(t, err) + require.NoError(t, body.Close()) + require.Equal(t, blob.descriptor.Size, n) + require.Equal(t, blob.descriptor.Digest.Hex, hex.EncodeToString(sum.Sum(nil))) + } + entries, err := os.ReadDir(parent) + require.NoError(t, err) + require.Len(t, entries, 1, "nothing is spooled beside the bundle") +} + +func TestMachineLayerAbandonedUploadStopsGenerator(t *testing.T) { + bundle := t.TempDir() + for name, data := range map[string][]byte{"disk.img": make([]byte, 8<<20), "aux.img": []byte("aux"), "config.json": []byte("{}")} { + require.NoError(t, os.WriteFile(filepath.Join(bundle, name), data, 0600)) + } + blob, _, err := hashMachineLayer(context.Background(), bundle) + require.NoError(t, err) + before := runtime.NumGoroutine() + body := blob.open() + _, err = io.CopyN(io.Discard, body, 16) + require.NoError(t, err) + require.NoError(t, body.Close()) + // Polled directly: require.Eventually runs its condition in another goroutine. + deadline := time.Now().Add(5 * time.Second) + for runtime.NumGoroutine() > before && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + require.LessOrEqual(t, runtime.NumGoroutine(), before, "the layer generator exits when the upload is abandoned") +} diff --git a/lib/builds/macos_build_manager.go b/lib/builds/macos_build_manager.go new file mode 100644 index 000000000..d5eb92bd2 --- /dev/null +++ b/lib/builds/macos_build_manager.go @@ -0,0 +1,169 @@ +package builds + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/kernel/hypeman/lib/images" + "github.com/kernel/hypeman/lib/instances" +) + +func (m *manager) skipMachineBuildRecovery(meta *buildMetadata) bool { + if !m.config.MachineBuildOnly { + return false + } + if meta.Request != nil && meta.Request.MachineBaseImage != "" && m.config.MachineBuild != nil { + return false + } + m.logger.Warn("leaving unsupported build pending in machine-build-only mode", "id", meta.ID) + return true +} + +// executeMachineBuild is a backend of the same queued build job. runBuild owns +// deadline/status/log completion, and CreateBuild already staged verified source. +func (m *manager) executeMachineBuild(ctx context.Context, id string, req CreateBuildRequest, policy *BuildPolicy) (*BuildResult, error) { + backend := m.config.MachineBuild + if backend == nil { + return nil, fmt.Errorf("machine build backend is not configured") + } + if err := m.reapPriorMachineBuilder(ctx, id); err != nil { + return nil, machineFailure("reap_prior_builder", err) + } + base, err := m.imageManager.GetImage(ctx, req.MachineBaseImage) + if err != nil { + return nil, machineFailure("resolve_base", err) + } + sourcePath, err := filepath.Abs(filepath.Join(m.paths.BuildSourceDir(id), "source.tar.gz")) + if err != nil { + return nil, err + } + // An interrupted attempt skips its own cleanup; drop its workspace first. + removeMachineWorkspaces(m.paths.BuildDir(id)) + defer removeMachineWorkspaces(m.paths.BuildDir(id)) + result, err := backend.runPrepared(ctx, MachineBuildRequest{ID: id, BaseImage: req.MachineBaseImage, SourceHash: req.SourceHash, Policy: *policy, onStarted: func(instanceID string) error { return m.recordMachineBuilder(id, instanceID) }}, base, sourcePath, filepath.Dir(filepath.Dir(sourcePath))) + if err != nil { + return nil, err + } + ref, err := images.ParseNormalizedRef(result.Publication.Reference) + if err != nil || ref.Repository() != strings.TrimSuffix(stripRegistryScheme(m.config.RegistryURL), "/")+"/builds/"+id { + return nil, fmt.Errorf("machine publication is outside the authorized job repository") + } + return &BuildResult{Success: true, ImageDigest: result.Publication.Digest, Provenance: result.Provenance}, nil +} + +// reapPriorMachineBuilder removes the builder recorded by an interrupted attempt of +// this same job before a re-enqueued attempt starts. Nothing from that builder is +// published, so a forced stop is acceptable; a failed delete fails this attempt +// rather than leaving two builders with storage in play. +func (m *manager) reapPriorMachineBuilder(ctx context.Context, id string) error { + meta, err := readMetadata(m.paths, id) + if err != nil { + return err + } + // An attempt interrupted before it recorded its builder still named it after the build. + prior := machineBuilderName(id) + if meta.BuilderInstance != nil && *meta.BuilderInstance != "" { + prior = *meta.BuilderInstance + } + inst, err := m.instanceManager.GetInstance(ctx, prior) + if errors.Is(err, instances.ErrNotFound) { + return nil + } + if err != nil { + return err + } + if inst.State != instances.StateStopped { + if _, err := m.instanceManager.StopInstance(ctx, inst.Id); err != nil { + return fmt.Errorf("stop prior builder: %w", err) + } + } + if err := m.instanceManager.DeleteInstance(ctx, inst.Id); err != nil && !errors.Is(err, instances.ErrNotFound) { + return fmt.Errorf("delete prior builder: %w", err) + } + m.logger.Info("reaped prior machine builder", "build_id", id, "instance_id", inst.Id) + return nil +} + +// storeMachineSource stages machine source privately and records its SHA256 +// provenance. A caller-supplied source hash must match. +func (m *manager) storeMachineSource(ctx context.Context, meta *buildMetadata, data []byte) error { + sourceDir := m.paths.BuildSourceDir(meta.ID) + if err := os.MkdirAll(sourceDir, 0700); err != nil { + return fmt.Errorf("store source: %w", err) + } + hash, err := stageBuildSource(ctx, bytes.NewReader(data), filepath.Join(sourceDir, "source.tar.gz"), int64(len(data))) + if err != nil { + return fmt.Errorf("store source: %w", err) + } + if meta.Request.SourceHash != "" && meta.Request.SourceHash != hash { + return ErrSourceHashMismatch + } + meta.Request.SourceHash = hash + if err := writeMetadata(m.paths, meta); err != nil { + return fmt.Errorf("write source provenance: %w", err) + } + return nil +} + +// admitMachineBuild validates a machine build request and returns its policy. +// Machine resources inherit the installed base, not Linux builder defaults. +func (m *manager) admitMachineBuild(req *CreateBuildRequest, sourceData []byte) (*BuildPolicy, error) { + if m.config.MachineBuild == nil || m.config.MachineBuild.Driver == nil || m.config.MachineBuild.Publisher == nil { + return nil, fmt.Errorf("machine build backend is not configured") + } + if req.Dockerfile != "" || req.BuilderID != "" || len(req.BuildArgs) != 0 || len(req.Secrets) != 0 || req.CacheScope != "" || req.GlobalCacheKey != "" || req.IsAdminBuild { + return nil, fmt.Errorf("machine builds do not support Linux builder/cache/secret options") + } + if len(sourceData) > maxBuildSourceBytes { + return nil, fmt.Errorf("machine build source too large") + } + ref, err := images.ParseNormalizedRef(req.MachineBaseImage) + if err != nil || !ref.IsDigest() || !ValidMachineDigest(ref.Digest()) { + return nil, fmt.Errorf("machine base must be pinned by sha256 digest") + } + policy := &BuildPolicy{} + if req.BuildPolicy != nil { + requested := *req.BuildPolicy + policy = &requested + } + if err := normalizeMachinePolicy(policy); err != nil { + return nil, err + } + req.BuildPolicy = policy + return policy, nil +} + +// enqueueMachineBuild queues a machine build whose source is already staged. +func (m *manager) enqueueMachineBuild(meta *buildMetadata, req CreateBuildRequest, policy *BuildPolicy) *Build { + queuePos := m.queue.EnqueueSerial(meta.ID, req, req.BuilderID, func() { + m.runBuild(context.Background(), meta.ID, req, policy) + }) + build := meta.toBuild() + if queuePos > 0 { + build.QueuePosition = &queuePos + } + m.logger.Info("build created", "id", meta.ID, "queue_position", queuePos) + return build +} + +// removeMachineWorkspaces deletes export workspaces under a build directory. Each +// holds a full exported disk. +func removeMachineWorkspaces(buildDir string) { + matches, _ := filepath.Glob(filepath.Join(buildDir, "machine-build-*")) + for _, dir := range matches { + _ = os.RemoveAll(dir) + } +} + +// removeMachineSource deletes a machine build's staged source once its image is +// ready. It stays until then so recovery can rebuild an interrupted job. +func (m *manager) removeMachineSource(id string) { + if err := os.Remove(filepath.Join(m.paths.BuildSourceDir(id), "source.tar.gz")); err != nil && !errors.Is(err, os.ErrNotExist) { + m.logger.Warn("failed to remove machine build source", "id", id, "error", err) + } +} diff --git a/lib/builds/macos_build_manager_failure_test.go b/lib/builds/macos_build_manager_failure_test.go new file mode 100644 index 000000000..828cc4701 --- /dev/null +++ b/lib/builds/macos_build_manager_failure_test.go @@ -0,0 +1,44 @@ +package builds + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/kernel/hypeman/lib/instances" + "github.com/kernel/hypeman/lib/paths" + "github.com/stretchr/testify/require" +) + +func TestMachineFailureUsesSharedFailedStatusWithoutPublication(t *testing.T) { + f := newMachineFixture() + f.fail = "provision" + config := DefaultConfig() + config.RegistrySecret = "synthetic-test-secret" + config.MachineBuild = &MachineBuildBackend{Driver: f, Publisher: f} + api, err := NewManager(paths.New(t.TempDir()), config, noPriorBuilder{}, nil, nil, machineJobImages{fixture: f}, nil, nil, nil) + require.NoError(t, err) + m := api.(*manager) + build, err := m.CreateBuild(context.Background(), CreateBuildRequest{MachineBaseImage: "registry/base@" + machineTestDigest}, []byte("source")) + require.NoError(t, err) + require.Eventually(t, func() bool { + b, e := m.GetBuild(context.Background(), build.ID) + return e == nil && b.Status == StatusFailed + }, 5*time.Second, 10*time.Millisecond) + done, err := m.GetBuild(context.Background(), build.ID) + require.NoError(t, err) + require.Equal(t, StatusFailed, done.Status) + require.Nil(t, done.ImageDigest) + require.NotContains(t, f.calls, "publish") + require.Contains(t, f.calls, "destroy") + require.Equal(t, "machine build provision failed", *done.Error, "guest error text must remain masked") +} + +func TestMachineFailureShowsOnlyRecipeStepAndExitCode(t *testing.T) { + err := machineFailure("provision", fmt.Errorf("guest said secret-token: %w", &machineStepError{step: 2, code: 7})) + require.Equal(t, "machine build provision failed: recipe step 2 exited with code 7", err.Error()) + require.Equal(t, "machine build provision failed", machineFailure("provision", fmt.Errorf("secret-token")).Error()) + admission := fmt.Errorf("%w: macOS template identity is already in use", instances.ErrInsufficientResources) + require.Contains(t, machineFailure("start", admission).Error(), "machine build start failed: insufficient resources") +} diff --git a/lib/builds/macos_build_manager_test.go b/lib/builds/macos_build_manager_test.go new file mode 100644 index 000000000..04b441269 --- /dev/null +++ b/lib/builds/macos_build_manager_test.go @@ -0,0 +1,115 @@ +package builds + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/kernel/hypeman/lib/images" + "github.com/kernel/hypeman/lib/instances" + "github.com/kernel/hypeman/lib/paths" + "github.com/stretchr/testify/require" +) + +type machineJobImages struct { + images.Manager + fixture *machineFixture +} + +func (m machineJobImages) GetImage(ctx context.Context, ref string) (*images.Image, error) { + if ref == "registry/base@"+machineTestDigest { + return m.fixture.ResolveBase(ctx, ref) + } + return &images.Image{Status: images.StatusReady, Digest: machineTestDigest}, nil +} + +func (m machineJobImages) WaitForReady(context.Context, string) error { return nil } + +type machineJobPublisher struct { + fixture *machineFixture + publishedID string + // sourcePath, when set, must still exist at publish time. + sourcePath func(id string) string +} + +func (p *machineJobPublisher) Publish(ctx context.Context, id, bundle string) (MachinePublication, error) { + p.publishedID = id + if p.sourcePath != nil { + if _, err := os.Stat(p.sourcePath(id)); err != nil { + return MachinePublication{}, errors.New("source removed before publish") + } + } + // Reuse the fixture's export assertions with its fixed ID. + receipt, err := p.fixture.Publish(ctx, "build-test", bundle) + if err == nil { + receipt.Reference = "localhost:4973/builds/" + id + "@" + receipt.Digest + } + return receipt, err +} +func TestMachineBuildUsesSharedQueueStatusAndSourceLifecycle(t *testing.T) { + f := newMachineFixture() + publisher := &machineJobPublisher{fixture: f} + config := DefaultConfig() + config.RegistrySecret = "synthetic-test-secret" + config.MachineBuild = &MachineBuildBackend{Driver: f, Publisher: publisher} + api, err := NewManager(paths.New(t.TempDir()), config, noPriorBuilder{}, nil, nil, machineJobImages{fixture: f}, nil, nil, nil) + require.NoError(t, err) + m := api.(*manager) + publisher.sourcePath = func(id string) string { return filepath.Join(m.paths.BuildSourceDir(id), "source.tar.gz") } + source := []byte("synthetic compressed-source bytes") + sum := sha256.Sum256(source) + req := CreateBuildRequest{MachineBaseImage: "registry/base@" + machineTestDigest, SourceHash: hex.EncodeToString(sum[:])} + build, err := m.CreateBuild(context.Background(), req, source) + require.NoError(t, err) + require.Eventually(t, func() bool { + b, e := m.GetBuild(context.Background(), build.ID) + return e == nil && (b.Status == StatusReady || b.Status == StatusFailed) + }, 5*time.Second, 10*time.Millisecond) + done, err := m.GetBuild(context.Background(), build.ID) + require.NoError(t, err) + require.Equal(t, StatusReady, done.Status, "failure: %v", done.Error) + require.Equal(t, build.ID, publisher.publishedID) + require.Equal(t, req.SourceHash, done.Provenance.SourceHash) + require.Equal(t, machineTestDigest, done.Provenance.BaseImageDigest) + require.Equal(t, f.ID(), *done.BuilderInstanceID) + meta, err := readMetadata(m.paths, build.ID) + require.NoError(t, err) + require.Equal(t, req.MachineBaseImage, meta.Request.MachineBaseImage) + require.Equal(t, "isolated", meta.Request.BuildPolicy.NetworkMode) + require.Equal(t, 0, meta.Request.BuildPolicy.CPUs, "machine defaults must not use Linux CPU defaults") + _, err = readBuildConfig(m.paths, build.ID) + require.Error(t, err, "machine builds must not persist a builder config or registry token") + require.NoFileExists(t, filepath.Join(m.paths.BuildSourceDir(build.ID), "source.tar.gz")) +} +func TestMachineBuildAdmissionAndSourceHash(t *testing.T) { + config := DefaultConfig() + config.RegistrySecret = "synthetic-test-secret" + api, err := NewManager(paths.New(t.TempDir()), config, nil, nil, nil, nil, nil, nil, nil) + require.NoError(t, err) + m := api.(*manager) + _, err = m.CreateBuild(context.Background(), CreateBuildRequest{MachineBaseImage: "registry/base@" + machineTestDigest}, []byte("source")) + require.ErrorContains(t, err, "not configured") + f := newMachineFixture() + m.config.MachineBuild = &MachineBuildBackend{Driver: f, Publisher: f} + _, err = m.CreateBuild(context.Background(), CreateBuildRequest{MachineBaseImage: "registry/base:mutable"}, []byte("source")) + require.ErrorContains(t, err, "pinned") + _, err = m.CreateBuild(context.Background(), CreateBuildRequest{MachineBaseImage: "registry/base@" + machineTestDigest, Secrets: []SecretRef{{ID: "unsupported"}}}, []byte("source")) + require.ErrorContains(t, err, "secret options") + _, err = m.CreateBuild(context.Background(), CreateBuildRequest{MachineBaseImage: "registry/base@" + machineTestDigest, SourceHash: "wrong"}, []byte("source")) + require.True(t, errors.Is(err, ErrSourceHashMismatch)) + list, err := m.ListBuilds(context.Background()) + require.NoError(t, err) + require.Empty(t, list) +} + +// noPriorBuilder reports that no earlier attempt left a builder instance behind. +type noPriorBuilder struct{ instances.Manager } + +func (noPriorBuilder) GetInstance(context.Context, string) (*instances.Instance, error) { + return nil, instances.ErrNotFound +} diff --git a/lib/builds/macos_build_policy_test.go b/lib/builds/macos_build_policy_test.go new file mode 100644 index 000000000..f44f84354 --- /dev/null +++ b/lib/builds/macos_build_policy_test.go @@ -0,0 +1,59 @@ +package builds + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMachinePolicyNormalization(t *testing.T) { + for _, tc := range []struct { + name string + policy BuildPolicy + err string + }{ + {name: "defaults"}, + {name: "minimum timeout", policy: BuildPolicy{TimeoutSeconds: 1}}, + {name: "maximum timeout", policy: BuildPolicy{TimeoutSeconds: 86400}}, + {name: "egress", policy: BuildPolicy{NetworkMode: "egress"}}, + {name: "negative timeout", policy: BuildPolicy{TimeoutSeconds: -1}, err: "invalid machine build timeout"}, + {name: "excessive timeout", policy: BuildPolicy{TimeoutSeconds: 86401}, err: "invalid machine build timeout"}, + {name: "unsupported mode", policy: BuildPolicy{NetworkMode: "host"}, err: "unsupported machine network policy"}, + {name: "domains", policy: BuildPolicy{NetworkMode: "egress", AllowedDomains: []string{"example.test"}}, err: "unsupported machine network policy"}, + } { + t.Run(tc.name, func(t *testing.T) { + policy := tc.policy + err := normalizeMachinePolicy(&policy) + if tc.err != "" { + require.EqualError(t, err, tc.err) + return + } + require.NoError(t, err) + want := tc.policy + if want.TimeoutSeconds == 0 { + want.TimeoutSeconds = 600 + } + if want.NetworkMode == "" { + want.NetworkMode = "isolated" + } + require.Equal(t, want, policy) + require.NoError(t, normalizeMachinePolicy(&policy)) + require.Equal(t, want, policy) + }) + } +} + +func TestMachineBuildAdmissionRejectsInvalidPolicy(t *testing.T) { + f := newMachineFixture() + m := &manager{config: Config{MachineBuild: &MachineBuildBackend{Driver: f, Publisher: f}}} + for _, policy := range []BuildPolicy{ + {TimeoutSeconds: -1}, + {TimeoutSeconds: 86401}, + {NetworkMode: "host"}, + {AllowedDomains: []string{"example.test"}}, + } { + req := CreateBuildRequest{MachineBaseImage: "registry/base@" + machineTestDigest, BuildPolicy: &policy} + _, err := m.admitMachineBuild(&req, []byte("source")) + require.Error(t, err) + } +} diff --git a/lib/builds/macos_build_publisher.go b/lib/builds/macos_build_publisher.go new file mode 100644 index 000000000..cf850ba8c --- /dev/null +++ b/lib/builds/macos_build_publisher.go @@ -0,0 +1,235 @@ +package builds + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/kernel/hypeman/lib/images" +) + +// StreamingMachinePublisher targets only the configured internal registry and a +// build-scoped repository. The layer is generated twice from the exported bundle, +// once to hash it and once to stream it, so it is never written to disk. Blobs are +// uploaded with bounded memory, independently re-read/hash verified, then the +// manifest is committed last. +// No credentials, redirect destinations or arbitrary output repositories come from recipes. +type StreamingMachinePublisher struct { + RegistryURL string + Tokens *RegistryTokenGenerator +} + +type machineBlob struct { + open func() io.ReadCloser + descriptor v1.Descriptor +} + +func (p *StreamingMachinePublisher) Publish(ctx context.Context, id, bundle string) (MachinePublication, error) { + deadline, ok := ctx.Deadline() + if !ok || ctx.Err() != nil { + return MachinePublication{}, fmt.Errorf("bounded publication required") + } + if id == "" || len(id) > 128 || strings.Trim(id, "abcdefghijklmnopqrstuvwxyz0123456789-") != "" || p.Tokens == nil { + return MachinePublication{}, fmt.Errorf("scoped machine publisher required") + } + address := p.RegistryURL + if !strings.Contains(address, "://") { + address = "https://" + address + } + registry, err := url.Parse(address) + if err != nil || registry.Host == "" || registry.User != nil || registry.RawQuery != "" || registry.Fragment != "" || (registry.Path != "" && registry.Path != "/") || (registry.Scheme != "http" && registry.Scheme != "https") { + return MachinePublication{}, fmt.Errorf("invalid internal registry address") + } + if registry.Scheme == "http" && registry.Hostname() != "localhost" && !net.ParseIP(registry.Hostname()).IsLoopback() { + return MachinePublication{}, fmt.Errorf("plaintext registry must be loopback") + } + platform, err := images.ValidateMacOSBundle(bundle) + if err != nil { + return MachinePublication{}, err + } + if err = validateMachineExport(bundle, platform); err != nil { + return MachinePublication{}, err + } + layer, diffID, err := hashMachineLayer(ctx, bundle) + if err != nil { + return MachinePublication{}, err + } + config := v1.ConfigFile{OS: "darwin", Architecture: "arm64", RootFS: v1.RootFS{Type: "layers", DiffIDs: []v1.Hash{diffID}}, Config: v1.Config{Labels: map[string]string{ + images.MacOSMachineVersionLabel: "1", images.MacOSMachineKindLabel: "macos-image", images.MacOSMachineFormatLabel: "raw", images.MacOSMachineDiskLabel: "disk.img", images.MacOSMachineAuxLabel: "aux.img", images.MacOSMachinePlatformLabel: "config.json", + }}} + configBytes, err := json.Marshal(config) + if err != nil { + return MachinePublication{}, err + } + configBlob := machineBlob{open: func() io.ReadCloser { return io.NopCloser(bytes.NewReader(configBytes)) }, descriptor: v1.Descriptor{MediaType: types.OCIConfigJSON, Digest: machineHash(configBytes), Size: int64(len(configBytes))}} + manifest := v1.Manifest{SchemaVersion: 2, MediaType: types.OCIManifestSchema1, Config: configBlob.descriptor, Layers: []v1.Descriptor{layer.descriptor}} + manifestBytes, err := json.Marshal(manifest) + if err != nil { + return MachinePublication{}, err + } + digest := machineHash(manifestBytes).String() + repo := "builds/" + id + token, err := p.Tokens.GeneratePushToken(id, []string{repo}, time.Until(deadline)) + if err != nil { + return MachinePublication{}, err + } + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = nil + defer transport.CloseIdleConnections() + client := &http.Client{Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + base := strings.TrimRight(address, "/") + "/v2/" + repo + for _, blob := range []machineBlob{configBlob, layer} { + body := blob.open() + response, e := machineRegistryRequest(ctx, client, token, http.MethodPost, base+"/blobs/uploads/?digest="+blob.descriptor.Digest.String(), "application/octet-stream", io.LimitReader(body, blob.descriptor.Size), blob.descriptor.Size) + closeErr := body.Close() + if e != nil { + return MachinePublication{}, e + } + if closeErr != nil { + response.Body.Close() + return MachinePublication{}, closeErr + } + status := response.StatusCode + response.Body.Close() + if status != http.StatusCreated { + return MachinePublication{}, fmt.Errorf("machine blob upload was not committed") + } + response, e = machineRegistryRequest(ctx, client, token, http.MethodGet, base+"/blobs/"+blob.descriptor.Digest.String(), "", nil, 0) + if e != nil { + return MachinePublication{}, e + } + sum := sha256.New() + n, e := io.Copy(sum, io.LimitReader(response.Body, blob.descriptor.Size+1)) + response.Body.Close() + if e != nil || response.StatusCode != http.StatusOK || n != blob.descriptor.Size || hex.EncodeToString(sum.Sum(nil)) != blob.descriptor.Digest.Hex { + return MachinePublication{}, fmt.Errorf("machine blob verification failed") + } + } + target := base + "/manifests/latest" + response, err := machineRegistryRequest(ctx, client, token, http.MethodPut, target, string(types.OCIManifestSchema1), bytes.NewReader(manifestBytes), int64(len(manifestBytes))) + if err != nil { + return MachinePublication{}, err + } + status := response.StatusCode + response.Body.Close() + if status != http.StatusCreated { + return MachinePublication{}, fmt.Errorf("machine manifest commit unconfirmed") + } + response, err = machineRegistryRequest(ctx, client, token, http.MethodGet, base+"/manifests/"+digest, "", nil, 0) + if err != nil { + return MachinePublication{}, err + } + published, err := io.ReadAll(io.LimitReader(response.Body, int64(len(manifestBytes))+1)) + response.Body.Close() + if err != nil || response.StatusCode != http.StatusOK || !bytes.Equal(published, manifestBytes) { + return MachinePublication{}, fmt.Errorf("machine manifest verification failed") + } + return MachinePublication{Reference: registry.Host + "/" + repo + "@" + digest, Digest: digest}, nil +} + +func machineRegistryRequest(ctx context.Context, client *http.Client, token, method, target, media string, body io.Reader, size int64) (*http.Response, error) { + request, err := http.NewRequestWithContext(ctx, method, target, body) + if err != nil { + return nil, err + } + request.ContentLength = size + request.Header.Set("Authorization", "Bearer "+token) + if media != "" { + request.Header.Set("Content-Type", media) + } + request.Header.Set("Accept", string(types.OCIManifestSchema1)) + response, err := client.Do(request) + if err != nil { + return nil, fmt.Errorf("machine registry request failed") + } + return response, nil +} + +func machineHash(data []byte) v1.Hash { + sum := sha256.Sum256(data) + return v1.Hash{Algorithm: "sha256", Hex: hex.EncodeToString(sum[:])} +} + +// hashMachineLayer hashes the bundle's gzipped tar layer without storing it. The +// returned blob regenerates the same bytes when opened; the registry rejects them +// if the bundle changed in between. +func hashMachineLayer(ctx context.Context, bundle string) (blob machineBlob, diffID v1.Hash, err error) { + compressed := sha256.New() + counter := &machineByteCounter{} + plain, err := writeMachineLayer(ctx, bundle, io.MultiWriter(compressed, counter)) + if err != nil { + return blob, diffID, err + } + open := func() io.ReadCloser { + reader, writer := io.Pipe() + go func() { + _, err := writeMachineLayer(ctx, bundle, writer) + writer.CloseWithError(err) + }() + return reader + } + digest := v1.Hash{Algorithm: "sha256", Hex: hex.EncodeToString(compressed.Sum(nil))} + return machineBlob{open: open, descriptor: v1.Descriptor{MediaType: types.OCILayer, Digest: digest, Size: counter.n}}, plain, nil +} + +type machineByteCounter struct{ n int64 } + +func (c *machineByteCounter) Write(data []byte) (int, error) { + c.n += int64(len(data)) + return len(data), nil +} + +// writeMachineLayer writes the bundle as a gzipped tar and returns the digest of +// the uncompressed tar. Headers carry no timestamps, so the output is deterministic. +func writeMachineLayer(ctx context.Context, bundle string, w io.Writer) (diffID v1.Hash, err error) { + gz, err := gzip.NewWriterLevel(w, gzip.BestSpeed) + if err != nil { + return diffID, err + } + plainHash := sha256.New() + archive := tar.NewWriter(io.MultiWriter(gz, plainHash)) + for _, name := range []string{"disk.img", "aux.img", "config.json"} { + source, e := os.Open(filepath.Join(bundle, name)) + if e != nil { + return diffID, e + } + info, e := source.Stat() + if e != nil { + source.Close() + return diffID, e + } + e = archive.WriteHeader(&tar.Header{Name: name, Mode: 0600, Size: info.Size(), Typeflag: tar.TypeReg}) + if e == nil { + _, e = io.CopyN(archive, buildContextReader{ctx, source}, info.Size()) + } + closeErr := source.Close() + if e != nil { + return diffID, e + } + if closeErr != nil { + return diffID, closeErr + } + } + if err = archive.Close(); err != nil { + return diffID, err + } + if err = gz.Close(); err != nil { + return diffID, err + } + return v1.Hash{Algorithm: "sha256", Hex: hex.EncodeToString(plainHash.Sum(nil))}, nil +} diff --git a/lib/builds/macos_build_publisher_test.go b/lib/builds/macos_build_publisher_test.go new file mode 100644 index 000000000..2f745c822 --- /dev/null +++ b/lib/builds/macos_build_publisher_test.go @@ -0,0 +1,90 @@ +package builds_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/kernel/hypeman/lib/builds" + "github.com/kernel/hypeman/lib/images" + "github.com/kernel/hypeman/lib/middleware" + "github.com/kernel/hypeman/lib/paths" + "github.com/kernel/hypeman/lib/registry" + "github.com/stretchr/testify/require" +) + +type machineImportStub struct { + images.Manager + imported chan string +} + +func (m *machineImportStub) ImportLocalImage(_ context.Context, repo, reference, digest string) (*images.Image, error) { + m.imported <- repo + return &images.Image{Status: images.StatusReady, Digest: digest}, nil +} + +func machinePublisherBundle(t *testing.T) string { + t.Helper() + root := t.TempDir() + platform := images.MacOSImage{HardwareModel: []byte{1}, MachineIdentifier: []byte{2}, MAC: "02:00:00:00:00:01", CPUs: 4, Memory: 8 << 30, GuestAgent: true} + data, e := json.Marshal(platform) + require.NoError(t, e) + for name, data := range map[string][]byte{"config.json": data, "disk.img": []byte("synthetic disk"), "aux.img": []byte("synthetic aux")} { + require.NoError(t, os.WriteFile(filepath.Join(root, name), data, 0600)) + } + return root +} + +func TestStreamingMachinePublisherAuthenticatedRegistry(t *testing.T) { + // Tiny storage is intentional: this proves auth/streaming/commit ordering, not bootability. + imports := &machineImportStub{imported: make(chan string, 1)} + reg, e := registry.New(paths.New(t.TempDir()), imports) + require.NoError(t, e) + var uploads atomic.Int32 + handler := middleware.JwtAuth("synthetic registry secret")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + require.Greater(t, r.ContentLength, int64(0)) + uploads.Add(1) + } + if r.Method == http.MethodPut { + require.Equal(t, int32(2), uploads.Load(), "manifest must be last") + } + reg.Handler().ServeHTTP(w, r) + })) + server := httptest.NewServer(handler) + defer server.Close() + publisher := &builds.StreamingMachinePublisher{RegistryURL: server.URL, Tokens: builds.NewRegistryTokenGenerator("synthetic registry secret")} + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + result, e := publisher.Publish(ctx, "qa-build", machinePublisherBundle(t)) + require.NoError(t, e) + require.Contains(t, result.Reference, "/builds/qa-build@sha256:") + select { + case repo := <-imports.imported: + require.Equal(t, "builds/qa-build", repo) + case <-ctx.Done(): + t.Fatal("manifest did not reach production conversion boundary") + } +} + +func TestStreamingMachinePublisherRejectsRedirectWithoutLeakingToken(t *testing.T) { + var visited atomic.Bool + other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { visited.Store(true) })) + defer other.Close() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, other.URL, http.StatusTemporaryRedirect) + })) + defer server.Close() + publisher := &builds.StreamingMachinePublisher{RegistryURL: server.URL, Tokens: builds.NewRegistryTokenGenerator("synthetic secret")} + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, e := publisher.Publish(ctx, "qa-build", machinePublisherBundle(t)) + require.Error(t, e) + require.False(t, visited.Load()) +} diff --git a/lib/builds/macos_build_reap_test.go b/lib/builds/macos_build_reap_test.go new file mode 100644 index 000000000..2c13ac65a --- /dev/null +++ b/lib/builds/macos_build_reap_test.go @@ -0,0 +1,79 @@ +package builds + +import ( + "context" + "errors" + "log/slog" + "testing" + + "github.com/kernel/hypeman/lib/instances" + "github.com/kernel/hypeman/lib/paths" + "github.com/stretchr/testify/require" +) + +type reapInstances struct { + instances.Manager + lookedUp string + state instances.State + missing bool + stopped bool + deleted bool + deleteErr error +} + +func (r *reapInstances) GetInstance(_ context.Context, idOrName string) (*instances.Instance, error) { + r.lookedUp = idOrName + if r.missing { + return nil, instances.ErrNotFound + } + return &instances.Instance{StoredMetadata: instances.StoredMetadata{Id: "prior"}, State: r.state}, nil +} + +func (r *reapInstances) StopInstance(context.Context, string) (*instances.Instance, error) { + r.stopped = true + r.state = instances.StateStopped + return &instances.Instance{State: instances.StateStopped}, nil +} + +func (r *reapInstances) DeleteInstance(context.Context, string) error { + r.deleted = true + return r.deleteErr +} + +func reapManager(t *testing.T, fake *reapInstances, prior string) *manager { + t.Helper() + p := paths.New(t.TempDir()) + require.NoError(t, writeMetadata(p, &buildMetadata{ID: "job", Status: StatusQueued, BuilderInstance: &prior})) + return &manager{paths: p, instanceManager: fake, logger: slog.Default()} +} + +func TestReapPriorMachineBuilderRemovesOwnedBuilderBeforeRetry(t *testing.T) { + fake := &reapInstances{state: instances.StateRunning} + m := reapManager(t, fake, "prior") + require.NoError(t, m.reapPriorMachineBuilder(context.Background(), "job")) + require.True(t, fake.stopped, "a running prior builder must be stopped") + require.True(t, fake.deleted, "the prior builder must be deleted before a retry") +} + +func TestReapPriorMachineBuilderFailsWhenDeleteFails(t *testing.T) { + fake := &reapInstances{state: instances.StateStopped, deleteErr: errors.New("storage still open")} + m := reapManager(t, fake, "prior") + require.Error(t, m.reapPriorMachineBuilder(context.Background(), "job"), "a failed delete must fail the retry") +} + +func TestReapPriorMachineBuilderNoopWhenAbsent(t *testing.T) { + fake := &reapInstances{missing: true} + m := reapManager(t, fake, "prior") + require.NoError(t, m.reapPriorMachineBuilder(context.Background(), "job")) + require.False(t, fake.deleted) +} + +func TestReapPriorMachineBuilderFindsUnrecordedBuilderByName(t *testing.T) { + fake := &reapInstances{state: instances.StateRunning} + p := paths.New(t.TempDir()) + require.NoError(t, writeMetadata(p, &buildMetadata{ID: "job", Status: StatusQueued})) + m := &manager{paths: p, instanceManager: fake, logger: slog.Default()} + require.NoError(t, m.reapPriorMachineBuilder(context.Background(), "job")) + require.Equal(t, machineBuilderName("job"), fake.lookedUp, "a builder created before it was recorded is found by its build-derived name") + require.True(t, fake.deleted) +} diff --git a/lib/builds/macos_build_recipe.go b/lib/builds/macos_build_recipe.go new file mode 100644 index 000000000..ffb85349e --- /dev/null +++ b/lib/builds/macos_build_recipe.go @@ -0,0 +1,169 @@ +package builds + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "encoding/json" + "fmt" + "io" + "os" + "path" + "path/filepath" + "regexp" + "strings" + "unicode/utf8" +) + +const machineRecipeName = "hypeman-macos-build.json" +const maxMachineExtractBytes = 128 << 20 + +type machineRecipe struct { + Version int `json:"version"` + Steps []machineRecipeStep `json:"steps"` +} +type machineRecipeStep struct { + Command []string `json:"command"` + Env map[string]string `json:"env,omitempty"` +} + +var machineEnvName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +// Extract on the host into an exclusive build-owned directory, never with guest +// tar or a caller-supplied destination. Links/devices, duplicates and bombs fail +// before any build source is copied to the VM. +func extractMachineRecipe(ctx context.Context, source, destination string) (*machineRecipe, error) { + file, err := os.Open(source) + if err != nil { + return nil, err + } + defer file.Close() + compressed, err := gzip.NewReader(file) + if err != nil { + return nil, err + } + defer compressed.Close() + compressed.Multistream(false) + if err = os.Mkdir(destination, 0700); err != nil { + return nil, err + } + complete := false + defer func() { + if !complete { + _ = os.RemoveAll(destination) + } + }() + limited := &io.LimitedReader{R: compressed, N: maxMachineExtractBytes + 1} + reader := tar.NewReader(limited) + seen := map[string]bool{} + var total int64 + for count := 0; ; count++ { + if err = ctx.Err(); err != nil { + return nil, err + } + header, e := reader.Next() + if e == io.EOF { + break + } + if e != nil { + return nil, e + } + if count >= 1024 { + return nil, fmt.Errorf("too many machine source entries") + } + name := path.Clean(header.Name) + if name == "." && header.Typeflag == tar.TypeDir { + continue + } + if !utf8.ValidString(name) || strings.ContainsAny(name, "\\\x00") || !filepath.IsLocal(name) || seen[name] { + return nil, fmt.Errorf("invalid or duplicate machine source path") + } + seen[name] = true + if header.Size < 0 || header.Size > 64<<20 || total+header.Size > maxMachineExtractBytes { + return nil, fmt.Errorf("machine source expands beyond limit") + } + total += header.Size + target := filepath.Join(destination, name) + switch header.Typeflag { + case tar.TypeDir: + if header.Size != 0 { + return nil, fmt.Errorf("directory has data") + } + if err = os.MkdirAll(target, 0700); err != nil { + return nil, err + } + case tar.TypeReg, tar.TypeRegA: + if err = os.MkdirAll(filepath.Dir(target), 0700); err != nil { + return nil, err + } + output, e := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600|os.FileMode(header.Mode)&0100) + if e != nil { + return nil, e + } + _, copyErr := io.Copy(output, reader) + closeErr := output.Close() + if copyErr != nil { + return nil, copyErr + } + if closeErr != nil { + return nil, closeErr + } + default: + return nil, fmt.Errorf("machine source must contain only regular files and directories") + } + } + // Consume bounded trailing tar padding to verify gzip integrity without allowing + // an unbounded post-tar decompression stream. + _, err = io.Copy(io.Discard, limited) + if err != nil { + return nil, err + } + if limited.N <= 0 { + return nil, fmt.Errorf("machine source expands beyond limit") + } + metadata, err := os.Open(filepath.Join(destination, machineRecipeName)) + if err != nil { + return nil, err + } + data, readErr := io.ReadAll(io.LimitReader(metadata, 64<<10+1)) + closeErr := metadata.Close() + if readErr != nil { + return nil, readErr + } + if closeErr != nil { + return nil, closeErr + } + if len(data) > 64<<10 { + return nil, fmt.Errorf("machine recipe too large") + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + var recipe machineRecipe + if err = decoder.Decode(&recipe); err != nil { + return nil, err + } + if err = decoder.Decode(new(any)); err != io.EOF { + return nil, fmt.Errorf("recipe must contain one JSON object") + } + if recipe.Version != 1 || len(recipe.Steps) == 0 || len(recipe.Steps) > 32 { + return nil, fmt.Errorf("invalid machine recipe version or step count") + } + for _, step := range recipe.Steps { + if len(step.Command) == 0 || len(step.Command) > 128 || !filepath.IsAbs(step.Command[0]) || len(step.Env) > 128 { + return nil, fmt.Errorf("invalid machine command") + } + for _, arg := range step.Command { + if len(arg) > 8192 || !utf8.ValidString(arg) || strings.ContainsRune(arg, 0) { + return nil, fmt.Errorf("invalid machine argument") + } + } + for key, value := range step.Env { + if !machineEnvName.MatchString(key) || len(value) > 8192 || !utf8.ValidString(value) || strings.ContainsRune(value, 0) { + return nil, fmt.Errorf("invalid machine environment") + } + } + } + complete = true + return &recipe, nil +} diff --git a/lib/builds/macos_build_recipe_test.go b/lib/builds/macos_build_recipe_test.go new file mode 100644 index 000000000..cce154654 --- /dev/null +++ b/lib/builds/macos_build_recipe_test.go @@ -0,0 +1,70 @@ +package builds + +import ( + "archive/tar" + "compress/gzip" + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func writeMachineSource(t *testing.T, headers []*tar.Header, bodies []string) string { + t.Helper() + name := filepath.Join(t.TempDir(), "source.tar.gz") + f, e := os.Create(name) + require.NoError(t, e) + gz := gzip.NewWriter(f) + writer := tar.NewWriter(gz) + for i, h := range headers { + require.NoError(t, writer.WriteHeader(h)) + if h.Typeflag == tar.TypeReg { + _, e = writer.Write([]byte(bodies[i])) + require.NoError(t, e) + } + } + require.NoError(t, writer.Close()) + require.NoError(t, gz.Close()) + require.NoError(t, f.Close()) + return name +} + +func TestMachineRecipeExtraction(t *testing.T) { + recipe := `{"version":1,"steps":[{"command":["/bin/sh","build.sh"],"env":{"MODE":"qa"}}]}` + source := writeMachineSource(t, []*tar.Header{{Name: machineRecipeName, Size: int64(len(recipe)), Typeflag: tar.TypeReg}, {Name: "build.sh", Size: 4, Typeflag: tar.TypeReg, Mode: 0755}}, []string{recipe, "true"}) + root := filepath.Join(t.TempDir(), "source") + result, e := extractMachineRecipe(context.Background(), source, root) + require.NoError(t, e) + require.Len(t, result.Steps, 1) + info, e := os.Stat(filepath.Join(root, "build.sh")) + require.NoError(t, e) + require.Equal(t, os.FileMode(0700), info.Mode().Perm()) +} + +func TestMachineRecipeRejectsUnsafeArchivesAndCommands(t *testing.T) { + for _, tc := range []struct { + name string + headers []*tar.Header + bodies []string + }{ + {"traversal", []*tar.Header{{Name: "../outside", Typeflag: tar.TypeReg, Size: 1}}, []string{"x"}}, + {"symlink", []*tar.Header{{Name: "link", Typeflag: tar.TypeSymlink, Linkname: "/etc"}}, []string{""}}, + {"hardlink", []*tar.Header{{Name: "link", Typeflag: tar.TypeLink, Linkname: "other"}}, []string{""}}, + {"duplicate", []*tar.Header{{Name: "a", Typeflag: tar.TypeReg, Size: 1}, {Name: "a", Typeflag: tar.TypeReg, Size: 1}}, []string{"x", "y"}}, + } { + t.Run(tc.name, func(t *testing.T) { + root := filepath.Join(t.TempDir(), "source") + _, e := extractMachineRecipe(context.Background(), writeMachineSource(t, tc.headers, tc.bodies), root) + require.Error(t, e) + _, e = os.Stat(root) + require.True(t, os.IsNotExist(e)) + }) + } + for _, body := range []string{`{"version":2,"steps":[]}`, `{"version":1,"steps":[{"command":["sh"]}]}`, `{"version":1,"steps":[{"command":["/bin/true"],"secret":"no"}]}`, `{"version":1,"steps":[{"command":["/bin/true"]}]} {}`} { + source := writeMachineSource(t, []*tar.Header{{Name: machineRecipeName, Typeflag: tar.TypeReg, Size: int64(len(body))}}, []string{body}) + _, e := extractMachineRecipe(context.Background(), source, filepath.Join(t.TempDir(), "source")) + require.Error(t, e) + } +} diff --git a/lib/builds/macos_build_recovery_test.go b/lib/builds/macos_build_recovery_test.go new file mode 100644 index 000000000..056056526 --- /dev/null +++ b/lib/builds/macos_build_recovery_test.go @@ -0,0 +1,79 @@ +package builds + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/kernel/hypeman/lib/paths" + "github.com/stretchr/testify/require" +) + +func TestMachineOnlyStartRecoversInterruptedBuilds(t *testing.T) { + for _, status := range []string{StatusQueued, StatusBuilding, StatusPushing} { + t.Run(status, func(t *testing.T) { + f := newMachineFixture() + config := DefaultConfig() + config.MachineBuildOnly = true + config.BuilderImage = "linux-builder-must-not-start" + config.MachineBuild = &MachineBuildBackend{Driver: f, Publisher: &machineJobPublisher{fixture: f}} + api, err := NewManager(paths.New(t.TempDir()), config, noPriorBuilder{}, nil, nil, machineJobImages{fixture: f}, nil, nil, nil) + require.NoError(t, err) + m := api.(*manager) + policy := &BuildPolicy{} + require.NoError(t, normalizeMachinePolicy(policy)) + meta := &buildMetadata{ID: "build-test", Status: status, Request: &CreateBuildRequest{MachineBaseImage: "registry/base@" + machineTestDigest, BuildPolicy: policy}} + require.NoError(t, m.storeMachineSource(context.Background(), meta, []byte("synthetic compressed-source bytes"))) + // A workspace left by the interrupted attempt. + stale := filepath.Join(m.paths.BuildDir(meta.ID), "machine-build-interrupted") + require.NoError(t, os.MkdirAll(filepath.Join(stale, "bundle"), 0700)) + require.NoError(t, os.WriteFile(filepath.Join(stale, "bundle", "disk.img"), []byte("disk"), 0600)) + require.NoError(t, writeMetadata(m.paths, &buildMetadata{ID: "linux-job", Status: StatusQueued, Request: &CreateBuildRequest{BuilderID: "linux-builder"}})) + t.Cleanup(func() { + require.Eventually(t, func() bool { return m.queue.ActiveCount() == 0 }, 5*time.Second, 10*time.Millisecond) + }) + + // Hold the recovered build active so the second recovery sees it running. + f.startGate = make(chan struct{}) + require.NoError(t, m.Start(context.Background())) + require.Eventually(t, func() bool { return m.queue.ActiveCount() == 1 }, 5*time.Second, 10*time.Millisecond) + m.RecoverPendingBuilds() + close(f.startGate) + require.Eventually(t, func() bool { return m.queue.ActiveCount() == 0 && m.queue.PendingCount() == 0 }, 5*time.Second, 10*time.Millisecond) + build, err := m.GetBuild(context.Background(), meta.ID) + require.NoError(t, err) + require.Equal(t, StatusReady, build.Status, "failure: %v", build.Error) + require.NoDirExists(t, stale, "the interrupted attempt's workspace is removed") + require.NoFileExists(t, filepath.Join(m.paths.BuildSourceDir(meta.ID), "source.tar.gz"), "source is removed once the image is ready") + require.False(t, m.ReadyForBuilds(), "Linux builder image must not be prepared") + var starts int + for _, call := range f.calls { + if call == "start" { + starts++ + } + } + require.Equal(t, 1, starts, "recovery must not duplicate an active build") + linux, err := readMetadata(m.paths, "linux-job") + require.NoError(t, err) + require.Equal(t, StatusQueued, linux.Status, "Linux jobs are preserved for Linux-mode recovery") + require.True(t, m.BuilderHasBuilds("linux-builder"), "unrecovered disk jobs still protect their builders") + }) + } +} + +func TestMachineOnlyStartPreservesBuildsWhenBackendDisabled(t *testing.T) { + config := DefaultConfig() + config.MachineBuildOnly = true + api, err := NewManager(paths.New(t.TempDir()), config, nil, nil, nil, nil, nil, nil, nil) + require.NoError(t, err) + m := api.(*manager) + require.NoError(t, writeMetadata(m.paths, &buildMetadata{ID: "disabled", Status: StatusBuilding, Request: &CreateBuildRequest{MachineBaseImage: "registry/base@" + machineTestDigest}})) + require.NoError(t, m.Start(context.Background())) + require.Equal(t, 0, m.queue.ActiveCount()) + meta, err := readMetadata(m.paths, "disabled") + require.NoError(t, err) + require.Equal(t, StatusBuilding, meta.Status) + require.False(t, m.ReadyForBuilds()) +} diff --git a/lib/builds/macos_build_test.go b/lib/builds/macos_build_test.go new file mode 100644 index 000000000..b669beaeb --- /dev/null +++ b/lib/builds/macos_build_test.go @@ -0,0 +1,263 @@ +package builds + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/kernel/hypeman/lib/images" +) + +const machineTestDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +type machineFixture struct { + image *images.Image + calls []string + fail string + stop MachineStopReceipt + cancel context.CancelFunc + waitForCancel bool + mutateExport func(string) error + publicationOverride *MachinePublication + publisherCalled bool + cleanupContextLive bool + startGate chan struct{} +} + +func newMachineFixture() *machineFixture { + return &machineFixture{image: &images.Image{Digest: machineTestDigest, Status: images.StatusReady, Platform: "darwin/arm64", MacOS: &images.MacOSImage{HardwareModel: []byte{1}, MachineIdentifier: []byte{2}, MAC: "02:00:00:00:00:01", CPUs: 4, Memory: 8 << 30}}, stop: MachineStopReceipt{Graceful: true, VMMExited: true}} +} +func (f *machineFixture) record(phase string) error { + f.calls = append(f.calls, phase) + if f.fail == phase { + return errors.New("sensitive-command-argument") + } + return nil +} +func (f *machineFixture) ResolveBase(context.Context, string) (*images.Image, error) { + if err := f.record("resolve"); err != nil { + return nil, err + } + return f.image, nil +} +func (f *machineFixture) Start(_ context.Context, _ string, _ *images.Image, p BuildPolicy) (MachineBuildSession, error) { + if p.CPUs != 4 || p.MemoryMB != 8192 { + return nil, errors.New("wrong resource defaults") + } + if f.startGate != nil { + <-f.startGate + } + return f, f.record("start") +} +func (f *machineFixture) ID() string { return "isolated-test-instance" } +func (f *machineFixture) Provision(ctx context.Context, source string) error { + if _, err := os.ReadFile(source); err != nil { + return err + } + if f.cancel != nil { + f.cancel() + return ctx.Err() + } + if f.waitForCancel { + <-ctx.Done() + return ctx.Err() + } + return f.record("provision") +} +func (f *machineFixture) Sanitize(context.Context) error { return f.record("sanitize") } +func (f *machineFixture) Stop(ctx context.Context) (MachineStopReceipt, error) { + f.cleanupContextLive = ctx.Err() == nil + return f.stop, f.record("stop") +} +func (f *machineFixture) Abort(ctx context.Context) (MachineStopReceipt, error) { + f.cleanupContextLive = ctx.Err() == nil + return f.stop, f.record("abort") +} +func (f *machineFixture) Export(_ context.Context, root string) error { + if err := f.record("export"); err != nil { + return err + } + data, _ := json.Marshal(*f.image.MacOS) + for name, content := range map[string][]byte{"disk.img": []byte("disk"), "aux.img": []byte("aux"), "config.json": data} { + if err := os.WriteFile(filepath.Join(root, name), content, 0666); err != nil { + return err + } + } + if f.mutateExport != nil { + return f.mutateExport(root) + } + return nil +} +func (f *machineFixture) Destroy(ctx context.Context) error { + f.cleanupContextLive = ctx.Err() == nil + return f.record("destroy") +} +func (f *machineFixture) Publish(ctx context.Context, id, root string) (MachinePublication, error) { + f.publisherCalled = true + if err := ctx.Err(); err != nil { + return MachinePublication{}, err + } + if id != "build-test" { + return MachinePublication{}, errors.New("wrong job namespace") + } + for _, name := range []string{"disk.img", "aux.img", "config.json"} { + info, err := os.Stat(filepath.Join(root, name)) + if err != nil || info.Mode().Perm() != 0600 { + return MachinePublication{}, errors.New("unsafe export mode") + } + } + publication := MachinePublication{Reference: "localhost/builds/build-test@" + machineTestDigest, Digest: machineTestDigest} + if f.publicationOverride != nil { + publication = *f.publicationOverride + } + return publication, f.record("publish") +} +func machineTestRunner(t *testing.T, f *machineFixture) *machineRunnerFixture { + t.Helper() + return &machineRunnerFixture{MachineBuildBackend: MachineBuildBackend{Driver: f, Publisher: f}, WorkDir: t.TempDir()} +} +func machineTestRequest() MachineBuildRequest { + return MachineBuildRequest{ID: "build-test", BaseImage: "localhost/macos@" + machineTestDigest} +} + +func TestMachineBuildPublicationOrder(t *testing.T) { + f := newMachineFixture() + r := machineTestRunner(t, f) + result, err := r.Run(context.Background(), machineTestRequest(), strings.NewReader("source")) + if err != nil { + t.Fatal(err) + } + expected := []string{"resolve", "start", "provision", "sanitize", "stop", "export", "destroy", "publish"} + if !reflect.DeepEqual(f.calls, expected) { + t.Fatalf("wrong phase order: %v", f.calls) + } + hash := sha256.Sum256([]byte("source")) + if result.Provenance.SourceHash != hex.EncodeToString(hash[:]) || result.Provenance.BaseImageDigest != machineTestDigest { + t.Fatal("unverified provenance") + } + entries, err := os.ReadDir(r.WorkDir) + if err != nil || len(entries) != 0 { + t.Fatal("workspace not cleaned") + } +} + +func TestMachineBuildFailuresDoNotPublish(t *testing.T) { + for _, phase := range []string{"resolve", "start", "provision", "sanitize", "stop", "export", "destroy"} { + t.Run(phase, func(t *testing.T) { + f := newMachineFixture() + f.fail = phase + r := machineTestRunner(t, f) + result, err := r.Run(context.Background(), machineTestRequest(), strings.NewReader("source")) + if err == nil || result != nil || f.publisherCalled { + t.Fatal("failed build reached publication") + } + if strings.Contains(err.Error(), "sensitive-command-argument") { + t.Fatal("raw guest error leaked") + } + }) + } +} + +func TestMachineBuildStopReceipts(t *testing.T) { + for _, receipt := range []MachineStopReceipt{{}, {VMMExited: true}} { + f := newMachineFixture() + f.stop = receipt + r := machineTestRunner(t, f) + _, err := r.Run(context.Background(), machineTestRequest(), strings.NewReader("source")) + if err == nil || f.publisherCalled { + t.Fatal("unconfirmed/forced stop published") + } + for _, phase := range f.calls { + if phase == "export" { + t.Fatal("unconfirmed/forced stop exported") + } + if phase == "destroy" && !receipt.VMMExited { + t.Fatal("destroyed unconfirmed live storage") + } + } + } +} + +func TestMachineBuildCancellationUsesIndependentCleanup(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + f := newMachineFixture() + f.cancel = cancel + r := machineTestRunner(t, f) + _, err := r.Run(ctx, machineTestRequest(), strings.NewReader("source")) + if !errors.Is(err, context.Canceled) || !f.cleanupContextLive || f.publisherCalled { + t.Fatal("cancellation did not retain safe cleanup") + } + if !reflect.DeepEqual(f.calls, []string{"resolve", "start", "abort", "destroy"}) { + t.Fatalf("cancel cleanup: %v", f.calls) + } +} + +func TestMachineBuildInputAdmission(t *testing.T) { + for _, kind := range []string{"floating base", "wrong digest", "linux", "pending", "shaping", "domains", "source hash", "oversized source", "CPU overflow", "memory precision"} { + t.Run(kind, func(t *testing.T) { + f := newMachineFixture() + r := machineTestRunner(t, f) + req := machineTestRequest() + switch kind { + case "floating base": + req.BaseImage = "localhost/macos:latest" + case "wrong digest": + f.image.Digest = "sha256:" + strings.Repeat("b", 64) + case "linux": + f.image.Platform = "linux/arm64" + case "pending": + f.image.Status = images.StatusPending + case "shaping": + req.Policy.MemoryMB = 4096 + case "domains": + req.Policy.AllowedDomains = []string{"example.com"} + case "source hash": + req.SourceHash = strings.Repeat("0", 64) + case "oversized source": + r.MaxSourceBytes = 3 + case "CPU overflow": + f.image.MacOS.CPUs = ^uint(0) + case "memory precision": + f.image.MacOS.Memory++ + } + _, err := r.Run(context.Background(), req, strings.NewReader("source")) + if err == nil { + t.Fatal("bad input accepted") + } + for _, phase := range f.calls { + if phase == "start" { + t.Fatal("bad input started a VM") + } + } + }) + } +} + +type cancelMachineSource struct{ cancel context.CancelFunc } + +func (s cancelMachineSource) Read(p []byte) (int, error) { + s.cancel() + return copy(p, "source"), io.EOF +} +func TestMachineBuildCanceledSourceDoesNotStart(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + f := newMachineFixture() + _, err := machineTestRunner(t, f).Run(ctx, machineTestRequest(), cancelMachineSource{cancel}) + if !errors.Is(err, context.Canceled) || !reflect.DeepEqual(f.calls, []string{"resolve"}) { + t.Fatalf("canceled source started builder: %v", f.calls) + } +} + +var _ MachineBuildDriver = (*machineFixture)(nil) +var _ MachineBuildSession = (*machineFixture)(nil) +var _ MachineBuildPublisher = (*machineFixture)(nil) diff --git a/lib/builds/manager.go b/lib/builds/manager.go index 806ae5622..7389e744e 100644 --- a/lib/builds/manager.go +++ b/lib/builds/manager.go @@ -87,6 +87,12 @@ type Manager interface { // Config holds configuration for the build manager type Config struct { + // MachineBuild is an opt-in internal executor. No HTTP recipe mode is exposed. + MachineBuild *MachineBuildBackend + + // MachineBuildOnly disables Linux builder preparation and recovery. + MachineBuildOnly bool + // MaxConcurrentBuilds is the maximum number of concurrent builds MaxConcurrentBuilds int @@ -149,6 +155,7 @@ type manager struct { logger *slog.Logger metrics *Metrics createMu sync.Mutex + machineBuilds sync.Map // build ID -> *machineBuildControl builderReady atomic.Bool // pendingRecovered is set once RecoverPendingBuilds has re-enqueued // persisted pending builds; until then BuilderHasBuilds also scans @@ -204,6 +211,10 @@ func NewManager( // Start starts the build manager's background services func (m *manager) Start(ctx context.Context) error { + if m.config.MachineBuildOnly { + m.RecoverPendingBuilds() + return nil + } go func() { for { m.ensureBuilderImage(ctx) @@ -444,7 +455,12 @@ func (m *manager) CreateBuild(ctx context.Context, req CreateBuildRequest, sourc // Apply defaults to build policy policy := req.BuildPolicy - if policy == nil { + if req.MachineBaseImage != "" { + var err error + if policy, err = m.admitMachineBuild(&req, sourceData); err != nil { + return nil, err + } + } else if policy == nil { defaultPolicy := DefaultBuildPolicy() policy = &defaultPolicy } else { @@ -486,6 +502,15 @@ func (m *manager) CreateBuild(ctx context.Context, req CreateBuildRequest, sourc return nil, fmt.Errorf("write metadata: %w", err) } + if req.MachineBaseImage != "" { + // Machine builds publish with their own scoped token, not a builder config. + if err := m.storeMachineSource(ctx, meta, sourceData); err != nil { + deleteBuild(m.paths, id) + return nil, err + } + return m.enqueueMachineBuild(meta, req, policy), nil + } + // Store source data if err := m.storeSource(id, sourceData); err != nil { deleteBuild(m.paths, id) @@ -678,18 +703,23 @@ func (m *manager) runBuild(ctx context.Context, id string, req CreateBuildReques start := time.Now() m.logger.Info("starting build", "id", id) - // Update status to building - m.updateStatus(id, StatusBuilding, nil) - - // Create timeout context + // Register machine cancellation before exposing the running status. Ordinary + // Linux builder cancellation retains its existing instance-delete behavior. buildCtx, cancel := context.WithTimeout(ctx, time.Duration(policy.TimeoutSeconds)*time.Second) defer cancel() + if req.MachineBaseImage != "" { + m.machineBuilds.Store(id, &machineBuildControl{cancel: cancel}) + defer m.machineBuilds.Delete(id) + } + m.updateStatus(id, StatusBuilding, nil) // Mirror base images to the local registry before launching the VM. // BuildKit is configured with our registry as a mirror for docker.io, // so pre-cached images will be served locally without pulling from Docker Hub. - if err := m.mirrorBaseImagesForBuild(buildCtx, id, req); err != nil { - m.logger.Warn("failed to mirror base images", "id", id, "error", err) + if req.MachineBaseImage == "" { + if err := m.mirrorBaseImagesForBuild(buildCtx, id, req); err != nil { + m.logger.Warn("failed to mirror base images", "id", id, "error", err) + } } // Run the build in a builder VM @@ -783,6 +813,9 @@ func (m *manager) runBuild(ctx context.Context, id string, req CreateBuildReques duration = time.Since(start) durationMS = duration.Milliseconds() + if req.MachineBaseImage != "" { + m.removeMachineSource(id) + } m.updateBuildComplete(id, StatusReady, &result.ImageDigest, nil, &result.Provenance, &durationMS) // Update with image ref @@ -798,6 +831,9 @@ func (m *manager) runBuild(ctx context.Context, id string, req CreateBuildReques // executeBuild runs the build in a builder VM func (m *manager) executeBuild(ctx context.Context, id string, req CreateBuildRequest, policy *BuildPolicy) (*BuildResult, error) { + if req.MachineBaseImage != "" { + return m.executeMachineBuild(ctx, id, req, policy) + } if !m.builderReady.Load() { return nil, fmt.Errorf("builder image is being prepared, please retry shortly") } @@ -1106,6 +1142,11 @@ func (m *manager) updateStatus(id string, status string, err error) { // updateBuildComplete updates the build with final results func (m *manager) updateBuildComplete(id string, status string, digest *string, errMsg *string, provenance *BuildProvenance, durationMS *int64) { + if active, ok := m.machineBuilds.Load(id); ok { + control := active.(*machineBuildControl) + control.mu.Lock() + defer control.mu.Unlock() + } meta, readErr := readMetadata(m.paths, id) if readErr != nil { m.logger.Error("read metadata for completion", "id", id, "error", readErr) @@ -1252,6 +1293,9 @@ func (m *manager) CancelBuild(ctx context.Context, id string) error { return ErrBuildInProgress // Was already picked up case StatusBuilding, StatusPushing: + if meta.Request != nil && meta.Request.MachineBaseImage != "" { + return m.cancelMachineBuild(id) + } // Can't cancel a running build easily // Would need to terminate the builder instance if meta.BuilderInstance != nil { @@ -1458,6 +1502,9 @@ func (m *manager) RecoverPendingBuilds() { for _, meta := range pending { meta := meta // Shadow loop variable for closure capture m.logger.Info("recovering build", "id", meta.ID, "status", meta.Status) + if m.skipMachineBuildRecovery(meta) { + continue + } // Re-enqueue the build if meta.Request != nil { @@ -1485,11 +1532,16 @@ func (m *manager) RecoverPendingBuilds() { if len(pending) > 0 { m.logger.Info("recovered pending builds", "count", len(pending)) } - m.pendingRecovered.Store(true) + if !m.config.MachineBuildOnly { + m.pendingRecovered.Store(true) + } } // refreshBuildToken regenerates the registry token for a build and updates the config file func (m *manager) refreshBuildToken(buildID string, req *CreateBuildRequest) error { + if req.MachineBaseImage != "" { + return nil // Machine builds have no builder config or registry token. + } // Read existing build config config, err := readBuildConfig(m.paths, buildID) if err != nil { diff --git a/lib/builds/source.go b/lib/builds/source.go new file mode 100644 index 000000000..b2075607f --- /dev/null +++ b/lib/builds/source.go @@ -0,0 +1,75 @@ +package builds + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" +) + +const maxBuildSourceBytes = 64 << 20 + +type buildContextReader struct { + ctx context.Context + r io.Reader +} + +func (r buildContextReader) Read(p []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + return r.r.Read(p) +} + +// stageBuildSource streams compressed source once into a private, exclusive job +// file. Both backends consume this file and use its hash for input verification. +func stageBuildSource(ctx context.Context, source io.Reader, path string, limit int64) (hash string, err error) { + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return "", err + } + defer func() { + if closeErr := file.Close(); err == nil { + err = closeErr + } + }() + h := sha256.New() + n, err := io.Copy(io.MultiWriter(file, h), io.LimitReader(buildContextReader{ctx, source}, limit+1)) + if err != nil { + return "", err + } + if n > limit { + return "", fmt.Errorf("build source too large") + } + if err = file.Sync(); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +// verifyBuildSource rejects missing/replaced source on recovery before a machine +// is launched. It never writes a second source copy. +func verifyBuildSource(ctx context.Context, path, expected string) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if !info.Mode().IsRegular() || info.Size() > maxBuildSourceBytes { + return fmt.Errorf("invalid staged source") + } + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + h := sha256.New() + if _, err = io.Copy(h, io.LimitReader(buildContextReader{ctx, file}, maxBuildSourceBytes+1)); err != nil { + return err + } + if hex.EncodeToString(h.Sum(nil)) != expected { + return ErrSourceHashMismatch + } + return nil +} diff --git a/lib/builds/source_test.go b/lib/builds/source_test.go new file mode 100644 index 000000000..1bb7e8a84 --- /dev/null +++ b/lib/builds/source_test.go @@ -0,0 +1,36 @@ +package builds + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSharedBuildSourceStagingAndRecoveryVerification(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "source.tar.gz") + hash, err := stageBuildSource(ctx, strings.NewReader("source"), path, maxBuildSourceBytes) + require.NoError(t, err) + info, err := os.Stat(path) + require.NoError(t, err) + require.Equal(t, os.FileMode(0600), info.Mode().Perm()) + require.NoError(t, verifyBuildSource(ctx, path, hash)) + _, err = stageBuildSource(ctx, strings.NewReader("replacement"), path, maxBuildSourceBytes) + require.Error(t, err, "exclusive staging must preserve existing source") + require.NoError(t, verifyBuildSource(ctx, path, hash)) + canceled, cancel := context.WithCancel(ctx) + cancel() + require.ErrorIs(t, verifyBuildSource(canceled, path, hash), context.Canceled) + require.NoError(t, os.WriteFile(path, []byte("changed"), 0600)) + require.ErrorIs(t, verifyBuildSource(ctx, path, hash), ErrSourceHashMismatch) + require.NoError(t, os.Remove(path)) + require.Error(t, verifyBuildSource(ctx, path, hash)) + target := filepath.Join(filepath.Dir(path), "target") + require.NoError(t, os.WriteFile(target, []byte("source"), 0600)) + require.NoError(t, os.Symlink(target, path)) + require.ErrorContains(t, verifyBuildSource(ctx, path, hash), "invalid staged source") +} diff --git a/lib/builds/types.go b/lib/builds/types.go index 4ad6008e9..931ddec6a 100644 --- a/lib/builds/types.go +++ b/lib/builds/types.go @@ -43,6 +43,10 @@ type CreateBuildRequest struct { // The Dockerfile specifies the runtime (e.g., FROM node:20-alpine). Dockerfile string `json:"dockerfile,omitempty"` + // MachineBaseImage selects the installed-machine backend. The HTTP multipart + // field machine_base_image maps here; it is enabled only with macos_builds. + MachineBaseImage string `json:"machine_base_image,omitempty"` + // BaseImageDigest optionally pins the base image by digest for reproducibility BaseImageDigest string `json:"base_image_digest,omitempty"` diff --git a/lib/instances/macos_build_export.go b/lib/instances/macos_build_export.go new file mode 100644 index 000000000..13539cd15 --- /dev/null +++ b/lib/instances/macos_build_export.go @@ -0,0 +1,5 @@ +package instances + +// MacOSBuildExportReceipt describes one stop/export operation, not persisted state. +// A failed export may still confirm that instance cleanup is safe. +type MacOSBuildExportReceipt struct{ Graceful, VMMExited bool } diff --git a/lib/instances/macos_build_export_darwin.go b/lib/instances/macos_build_export_darwin.go new file mode 100644 index 000000000..9419ae9ab --- /dev/null +++ b/lib/instances/macos_build_export_darwin.go @@ -0,0 +1,136 @@ +//go:build darwin + +package instances + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/kernel/hypeman/lib/forkvm" + "github.com/kernel/hypeman/lib/images" +) + +// StopMacOSBuildInstance performs only the strict graceful stop used by build +// export. It copies no storage and never falls back to a forced hypervisor stop. +// Success requires the shutdown acknowledgement and confirmed VMM exit. +func (m *manager) StopMacOSBuildInstance(ctx context.Context, id string) (receipt MacOSBuildExportReceipt, err error) { + if err = ctx.Err(); err != nil { + return + } + if _, bounded := ctx.Deadline(); !bounded { + return receipt, fmt.Errorf("machine stop requires a deadline") + } + lock := m.getInstanceLock(id) + lock.Lock() + defer lock.Unlock() + current, err := m.currentInstanceWithoutHydration(ctx, id) + if err != nil { + return receipt, err + } + if current.MacOS == nil || !current.GuestAgentEnabled() || (current.State != StateRunning && current.State != StateInitializing) { + return receipt, fmt.Errorf("strict stop requires an active macOS guest with a system agent") + } + if err = m.markRestartManualStopLocked(ctx, id); err != nil { + return + } + stopped, err := m.stopInstanceWithFallback(ctx, id, true) + if err != nil { + return receipt, err + } + receipt = MacOSBuildExportReceipt{Graceful: true, VMMExited: true} + m.notifyLifecycleEvent(ctx, LifecycleEventStop, stopped) + return receipt, nil +} + +// ExportStoppedMacOSBuildInstance copies closed storage for an instance that is +// still Stopped when the copy starts. The instance lock excludes Start during the +// check and copy; a concurrently restarted instance fails export instead. +func (m *manager) ExportStoppedMacOSBuildInstance(ctx context.Context, id, destination string) error { + if err := ctx.Err(); err != nil { + return err + } + if _, bounded := ctx.Deadline(); !bounded { + return fmt.Errorf("machine export requires a deadline") + } + if !filepath.IsAbs(destination) { + return fmt.Errorf("export destination must be absolute") + } + lock := m.getInstanceLock(id) + lock.Lock() + defer lock.Unlock() + current, err := m.currentInstanceWithoutHydration(ctx, id) + if err != nil { + return err + } + if current.State != StateStopped || current.MacOS == nil { + return fmt.Errorf("export requires a stopped macOS instance") + } + if rel, e := filepath.Rel(current.DataDir, destination); e != nil || filepath.IsLocal(rel) { + return fmt.Errorf("export must be outside instance storage") + } + return writeStoppedMacOSBuildBundle(ctx, current, m.paths.InstanceMacOSBootDisk(id), destination) +} + +func writeStoppedMacOSBuildBundle(ctx context.Context, inst *Instance, disk, destination string) error { + if inst.State != StateStopped || inst.MacOS == nil { + return fmt.Errorf("machine must be stopped") + } + aux := filepath.Join(inst.DataDir, "mac-aux.img") + var diskInfo os.FileInfo + for _, source := range []string{disk, aux} { + info, err := os.Lstat(source) + if err != nil { + return err + } + if !info.Mode().IsRegular() || info.Size() == 0 { + return fmt.Errorf("machine storage must be nonempty regular files") + } + if diskInfo != nil && os.SameFile(diskInfo, info) { + return fmt.Errorf("machine storage files must be distinct") + } + diskInfo = info + err = exec.CommandContext(ctx, "/usr/sbin/lsof", "-t", source).Run() + exit, ok := err.(*exec.ExitError) + if !ok || exit.ExitCode() != 1 { + return fmt.Errorf("cannot confirm closed machine storage") + } + } + for _, file := range []struct{ source, name string }{{disk, "disk.img"}, {aux, "aux.img"}} { + if err := ctx.Err(); err != nil { + return err + } + path := filepath.Join(destination, file.name) + if err := forkvm.CopyRegularFile(file.source, path); err != nil { + return err + } + if err := os.Chmod(path, 0600); err != nil { + return err + } + } + data, err := json.Marshal(inst.MacOS) + if err != nil { + return err + } + path := filepath.Join(destination, "config.json") + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return err + } + _, writeErr := file.Write(data) + closeErr := file.Close() + if writeErr != nil { + return writeErr + } + if closeErr != nil { + return closeErr + } + if err = ctx.Err(); err != nil { + return err + } + _, err = images.ValidateMacOSBundle(destination) + return err +} diff --git a/lib/instances/macos_build_export_darwin_test.go b/lib/instances/macos_build_export_darwin_test.go new file mode 100644 index 000000000..0d357bed1 --- /dev/null +++ b/lib/instances/macos_build_export_darwin_test.go @@ -0,0 +1,53 @@ +//go:build darwin + +package instances + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/kernel/hypeman/lib/images" + "github.com/kernel/hypeman/lib/paths" + "github.com/stretchr/testify/require" +) + +func TestMacOSBuildBundleRequiresClosedDistinctStorage(t *testing.T) { + root := t.TempDir() + disk := filepath.Join(root, paths.MacOSBootDiskFile) + aux := filepath.Join(root, "mac-aux.img") + require.NoError(t, os.WriteFile(disk, []byte("synthetic disk"), 0600)) + require.NoError(t, os.WriteFile(aux, []byte("synthetic aux"), 0600)) + inst := &Instance{State: StateStopped, StoredMetadata: StoredMetadata{DataDir: root, MacOS: &images.MacOSImage{HardwareModel: []byte{1}, MachineIdentifier: []byte{2}, MAC: "02:00:00:00:00:01", CPUs: 4, Memory: 8 << 30, GuestAgent: true}}} + file, e := os.Open(disk) + require.NoError(t, e) + require.Error(t, writeStoppedMacOSBuildBundle(context.Background(), inst, disk, t.TempDir())) + require.NoError(t, file.Close()) + out := t.TempDir() + require.NoError(t, writeStoppedMacOSBuildBundle(context.Background(), inst, disk, out)) + _, e = images.ValidateMacOSBundle(out) + require.NoError(t, e) + for _, name := range []string{"disk.img", "aux.img", "config.json"} { + info, e := os.Stat(filepath.Join(out, name)) + require.NoError(t, e) + require.Equal(t, os.FileMode(0600), info.Mode().Perm()) + } + require.NoError(t, os.Remove(aux)) + require.NoError(t, os.Link(disk, aux)) + require.Error(t, writeStoppedMacOSBuildBundle(context.Background(), inst, disk, t.TempDir())) + inst.State = StateRunning + require.Error(t, writeStoppedMacOSBuildBundle(context.Background(), inst, disk, t.TempDir())) +} + +func TestMacOSBuildStopAndExportRequireBoundedInputs(t *testing.T) { + m := &manager{} + _, e := m.StopMacOSBuildInstance(context.Background(), "unused") + require.Error(t, e) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, e = m.StopMacOSBuildInstance(ctx, "unused") + require.ErrorIs(t, e, context.Canceled) + require.Error(t, m.ExportStoppedMacOSBuildInstance(context.Background(), "unused", t.TempDir())) + require.Error(t, m.ExportStoppedMacOSBuildInstance(ctx, "unused", t.TempDir())) +} diff --git a/lib/instances/stop.go b/lib/instances/stop.go index c96ebdd8e..a1bd6b38d 100644 --- a/lib/instances/stop.go +++ b/lib/instances/stop.go @@ -42,6 +42,10 @@ func hasVFIODevices(stored *StoredMetadata) bool { // tryGracefulGuestShutdown asks guest init to shut down and waits for the // hypervisor process to exit. Returns true if the process exited in time. func (m *manager) tryGracefulGuestShutdown(ctx context.Context, inst *Instance, stopTimeout int) bool { + return m.tryGuestShutdown(ctx, inst, stopTimeout, false) +} + +func (m *manager) tryGuestShutdown(ctx context.Context, inst *Instance, stopTimeout int, strict bool) bool { log := logger.FromContext(ctx) if inst.SkipGuestAgent { @@ -65,7 +69,7 @@ func (m *manager) tryGracefulGuestShutdown(ctx context.Context, inst *Instance, return false } if pid == 0 { - return true + return !strict } inst.HypervisorProcessIdentity.Set(pid) @@ -99,10 +103,18 @@ func (m *manager) tryGracefulGuestShutdown(ctx context.Context, inst *Instance, // If we couldn't signal the guest, don't burn the full graceful timeout. waitTimeout = shutdownFailureFallbackWait } + if strict { + if ctx.Err() != nil { + return false + } + if deadline, ok := ctx.Deadline(); ok && time.Until(deadline) < waitTimeout { + waitTimeout = max(0, time.Until(deadline)) + } + } if WaitForProcessExit(pid, waitTimeout) { log.DebugContext(ctx, "VM shut down gracefully", "instance_id", inst.Id) - return true + return shutdownSent || !strict } log.WarnContext(ctx, "graceful shutdown timed out, falling back to hypervisor shutdown", "instance_id", inst.Id) @@ -116,7 +128,11 @@ func (m *manager) tryGracefulGuestShutdown(ctx context.Context, inst *Instance, func (m *manager) stopInstance( ctx context.Context, id string, -) (_ *Instance, retErr error) { +) (*Instance, error) { + return m.stopInstanceWithFallback(ctx, id, false) +} + +func (m *manager) stopInstanceWithFallback(ctx context.Context, id string, strict bool) (_ *Instance, retErr error) { start := time.Now() log := logger.FromContext(ctx) log.InfoContext(ctx, "stopping instance", "instance_id", id) @@ -170,7 +186,7 @@ func (m *manager) stopInstance( attribute.Int("stop_timeout_seconds", stopTimeout), ) macOSVMMLive := inst.MacOS != nil && hypervisorProcessLive(&inst) - gracefulShutdown := m.tryGracefulGuestShutdown(gracefulCtx, &inst, stopTimeout) + gracefulShutdown := m.tryGuestShutdown(gracefulCtx, &inst, stopTimeout, strict) if gracefulShutdown { gracefulSpanEnd(nil) } else { @@ -179,6 +195,10 @@ func (m *manager) stopInstance( // 5. Fallback hypervisor shutdown if guest graceful shutdown didn't work if !gracefulShutdown { + // Export callers must never turn a forced stop into a graceful receipt. + if strict { + return nil, errGracefulShutdownFailed + } log.DebugContext(ctx, "shutting down hypervisor (fallback)", "instance_id", id) shutdownCtx, shutdownSpanEnd := m.startLifecycleStep(ctx, "shutdown_hypervisor", attribute.String("instance_id", id), diff --git a/lib/oapi/oapi.go b/lib/oapi/oapi.go index ce5626905..a1e920735 100644 --- a/lib/oapi/oapi.go +++ b/lib/oapi/oapi.go @@ -2002,6 +2002,15 @@ type CreateBuildMultipartBody struct { // Admin builds can populate the shared global cache that all tenant builds read from. IsAdminBuild *string `json:"is_admin_build,omitempty"` + // MachineBaseImage Experimental installed darwin/arm64 base pinned by sha256 digest. + // Requires server macos_only and macos_builds. Source must contain + // hypeman-macos-build.json (version 1). Machine source is limited to + // 64 MiB compressed; only timeout_seconds, memory_mb, cpus and tags + // are accepted alongside source and this selector. CPU/memory zero + // inherit the base. Linux/cache/secrets/admin options are rejected. + // Output retains the base identity and cannot run concurrently with it. + MachineBaseImage *string `json:"machine_base_image,omitempty"` + // MemoryMb Memory limit for builder VM in MB (default 2048) MemoryMb *int `json:"memory_mb,omitempty"` @@ -19327,396 +19336,400 @@ func (sh *strictHandler) GetVolume(w http.ResponseWriter, r *http.Request, id st var swaggerSpec = []string{ "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", - "kNocHjx6sN/7+P7j/z8Agm/bWh/+AQA=", + "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", + "LwACAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/lib/providers/providers.go b/lib/providers/providers.go index bc028293b..e857e430e 100644 --- a/lib/providers/providers.go +++ b/lib/providers/providers.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "os" + "path/filepath" "strconv" "strings" "time" @@ -418,15 +419,20 @@ func ProvideBuildManager(p *paths.Paths, cfg *config.Config, instanceManager ins log.Info("registry CA certificate loaded", "file", cfg.Registry.CACertFile) } - registryURL, err := builderRegistryURL(cfg.Registry.URL, networkManager) - if err != nil { - return nil, err + registryURL := cfg.Registry.URL + if !cfg.MacOSBuilds { + var err error + registryURL, err = builderRegistryURL(registryURL, networkManager) + if err != nil { + return nil, err + } } if registryURL != cfg.Registry.URL { log.Info("resolved registry URL for builder VMs", "original", cfg.Registry.URL, "resolved", registryURL) } buildConfig := builds.Config{ + MachineBuildOnly: cfg.MacOSOnly, MaxConcurrentBuilds: cfg.Build.MaxConcurrentSourceBuilds, BuilderImage: cfg.Build.BuilderImage, RegistryURL: registryURL, @@ -437,6 +443,20 @@ func ProvideBuildManager(p *paths.Paths, cfg *config.Config, instanceManager ins DockerSocket: cfg.Build.DockerSocket, } + if cfg.MacOSBuilds { + if !cfg.MacOSOnly || registryURL == "" { + return nil, fmt.Errorf("machine builds require macOS-only mode and an explicit host registry URL") + } + workDir := filepath.Join(p.BuildsDir(), "machine-work") + if err := os.MkdirAll(workDir, 0700); err != nil { + return nil, err + } + buildConfig.MachineBuild = &builds.MachineBuildBackend{ + Driver: &builds.GuestMachineBuildDriver{Instances: instanceManager, WorkDir: workDir}, + Publisher: &builds.StreamingMachinePublisher{RegistryURL: registryURL, Tokens: builds.NewRegistryTokenGenerator(cfg.JwtSecret)}, + } + } + // Configure secret provider (use NoOpSecretProvider as fallback to avoid nil panics) var secretProvider builds.SecretProvider if cfg.Build.SecretsDir != "" { diff --git a/openapi.yaml b/openapi.yaml index 14850b18f..b43665038 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -4683,6 +4683,16 @@ paths: dockerfile: type: string description: Dockerfile content. Required if not included in the source tarball. + machine_base_image: + type: string + description: | + Experimental installed darwin/arm64 base pinned by sha256 digest. + Requires server macos_only and macos_builds. Source must contain + hypeman-macos-build.json (version 1). Machine source is limited to + 64 MiB compressed; only timeout_seconds, memory_mb, cpus and tags + are accepted alongside source and this selector. CPU/memory zero + inherit the base. Linux/cache/secrets/admin options are rejected. + Output retains the base identity and cannot run concurrently with it. base_image_digest: type: string description: Optional pinned base image digest