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 b30a0434f..e9ad77fbc 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 2d8ee7a39..96d983a7a 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.GuestAgentEnabled() { @@ -67,7 +71,7 @@ func (m *manager) tryGracefulGuestShutdown(ctx context.Context, inst *Instance, if pid == 0 { // Nothing was signalled. Linux treats an already-gone VMM as stopped, but // macOS only counts a stop as graceful after a shutdown attempt. - return inst.MacOS == nil + return !strict && inst.MacOS == nil } inst.HypervisorProcessIdentity.Set(pid) if inst.MacOS != nil { @@ -104,6 +108,14 @@ 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) { // The shim exits after a forced stop too, so macOS also needs its receipt that @@ -113,7 +125,7 @@ func (m *manager) tryGracefulGuestShutdown(ctx context.Context, inst *Instance, return false } 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) @@ -127,7 +139,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) @@ -180,7 +196,7 @@ func (m *manager) stopInstance( attribute.String("operation", "graceful_guest_shutdown"), attribute.Int("stop_timeout_seconds", stopTimeout), ) - gracefulShutdown := m.tryGracefulGuestShutdown(gracefulCtx, &inst, stopTimeout) + gracefulShutdown := m.tryGuestShutdown(gracefulCtx, &inst, stopTimeout, strict) if gracefulShutdown { gracefulSpanEnd(nil) } else { @@ -189,6 +205,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 4c604c755..7047bc998 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"` @@ -19326,397 +19335,401 @@ func (sh *strictHandler) GetVolume(w http.ResponseWriter, r *http.Request, id st // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", + "zdHB44f7vQ+/fvj/BwCuLc+mYgACAA==", } // 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 73aa90cde..b074298aa 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