From a60c74404f1f80b015b1bf5307f658a3aaa3dba0 Mon Sep 17 00:00:00 2001 From: chris lee Date: Sun, 11 Oct 2026 17:49:10 -0400 Subject: [PATCH] macOS guests 5/6: experimental machine-image builds Opt-in macos_builds: pinned installed base, versioned recipe, strict export under the instance lock, streaming publication, abort without export, prior-builder reap. Linux builds are unchanged. --- cmd/api/api/builds.go | 17 +- cmd/api/api/machine_builds.go | 114 +++ cmd/api/api/machine_builds_test.go | 157 ++++ cmd/api/config/config.go | 33 +- cmd/api/config/macos_builds_test.go | 31 + cmd/api/main.go | 5 + docs/macos-builds.md | 161 ++++ lib/builds/machine.go | 266 ++++++ lib/builds/machine_abort_test.go | 74 ++ lib/builds/machine_cancel.go | 58 ++ lib/builds/machine_cancel_test.go | 34 + lib/builds/machine_failure_test.go | 135 +++ lib/builds/machine_guest.go | 191 +++++ lib/builds/machine_job_test.go | 59 ++ lib/builds/machine_manager.go | 146 ++++ lib/builds/machine_manager_failure_test.go | 34 + lib/builds/machine_manager_test.go | 98 +++ lib/builds/machine_publisher.go | 240 ++++++ lib/builds/machine_publisher_test.go | 90 ++ lib/builds/machine_reap_test.go | 67 ++ lib/builds/machine_recipe.go | 169 ++++ lib/builds/machine_recipe_test.go | 70 ++ lib/builds/machine_test.go | 294 +++++++ lib/builds/manager.go | 51 +- lib/builds/source.go | 75 ++ lib/builds/source_test.go | 36 + lib/builds/types.go | 4 + lib/instances/macos_build_export.go | 5 + lib/instances/macos_build_export_darwin.go | 136 +++ .../macos_build_export_darwin_test.go | 52 ++ lib/instances/stop.go | 29 +- lib/oapi/oapi.go | 793 +++++++++--------- lib/providers/providers.go | 25 +- openapi.yaml | 10 + 34 files changed, 3352 insertions(+), 407 deletions(-) create mode 100644 cmd/api/api/machine_builds.go create mode 100644 cmd/api/api/machine_builds_test.go create mode 100644 cmd/api/config/macos_builds_test.go create mode 100644 docs/macos-builds.md create mode 100644 lib/builds/machine.go create mode 100644 lib/builds/machine_abort_test.go create mode 100644 lib/builds/machine_cancel.go create mode 100644 lib/builds/machine_cancel_test.go create mode 100644 lib/builds/machine_failure_test.go create mode 100644 lib/builds/machine_guest.go create mode 100644 lib/builds/machine_job_test.go create mode 100644 lib/builds/machine_manager.go create mode 100644 lib/builds/machine_manager_failure_test.go create mode 100644 lib/builds/machine_manager_test.go create mode 100644 lib/builds/machine_publisher.go create mode 100644 lib/builds/machine_publisher_test.go create mode 100644 lib/builds/machine_reap_test.go create mode 100644 lib/builds/machine_recipe.go create mode 100644 lib/builds/machine_recipe_test.go create mode 100644 lib/builds/machine_test.go create mode 100644 lib/builds/source.go create mode 100644 lib/builds/source_test.go create mode 100644 lib/instances/macos_build_export.go create mode 100644 lib/instances/macos_build_export_darwin.go create mode 100644 lib/instances/macos_build_export_darwin_test.go diff --git a/cmd/api/api/builds.go b/cmd/api/api/builds.go index ae8e74c27..5ce8ccf16 100644 --- a/cmd/api/api/builds.go +++ b/cmd/api/api/builds.go @@ -44,9 +44,15 @@ func (s *ApiService) ListBuilds(ctx context.Context, request oapi.ListBuildsRequ // CreateBuild creates a new build job func (s *ApiService) CreateBuild(ctx context.Context, request oapi.CreateBuildRequestObject) (oapi.CreateBuildResponseObject, error) { if s.Config != nil && s.Config.MacOSOnly { - return oapi.CreateBuild501JSONResponse{Code: "unsupported", Message: "Linux builder VMs are unavailable in macOS-only mode"}, nil + if !s.Config.MacOSBuilds { + return oapi.CreateBuild501JSONResponse{Code: "unsupported", Message: "Linux builder VMs are unavailable in macOS-only mode"}, nil + } + req, source, err := parseMachineBuildMultipart(request.Body) + if err != nil { + return oapi.CreateBuild400JSONResponse{Code: "invalid_request", Message: err.Error()}, nil + } + return s.submitBuild(ctx, req, source) } - log := logger.FromContext(ctx) // Parse multipart form fields var sourceData []byte @@ -69,6 +75,8 @@ func (s *ApiService) CreateBuild(ctx context.Context, request oapi.CreateBuildRe } switch part.FormName() { + case "machine_base_image": + return oapi.CreateBuild400JSONResponse{Code: "unsupported", Message: "machine builds require macos_only and macos_builds"}, nil case "source": sourceData, err = io.ReadAll(part) if err != nil { @@ -256,6 +264,11 @@ func (s *ApiService) CreateBuild(ctx context.Context, request oapi.CreateBuildRe } } + return s.submitBuild(ctx, domainReq, sourceData) +} + +func (s *ApiService) submitBuild(ctx context.Context, domainReq builds.CreateBuildRequest, sourceData []byte) (oapi.CreateBuildResponseObject, error) { + log := logger.FromContext(ctx) build, err := s.BuildManager.CreateBuild(ctx, domainReq, sourceData) if err != nil { switch { diff --git a/cmd/api/api/machine_builds.go b/cmd/api/api/machine_builds.go new file mode 100644 index 000000000..8c5230da0 --- /dev/null +++ b/cmd/api/api/machine_builds.go @@ -0,0 +1,114 @@ +package api + +import ( + "fmt" + "io" + "mime/multipart" + "net/http" + "strconv" + + "github.com/kernel/hypeman/lib/builds" + "github.com/kernel/hypeman/lib/images" +) + +// maxConcurrentMachineBuildUploads bounds how many machine build bodies, each up +// to 65 MiB, are read into memory at once. +const maxConcurrentMachineBuildUploads = 4 + +// LimitMachineBuildBody must wrap the validator, which also consumes request bodies. +func LimitMachineBuildBody(next http.Handler) http.Handler { + uploads := make(chan struct{}, maxConcurrentMachineBuildUploads) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && r.URL.Path == "/builds" { + select { + case uploads <- struct{}{}: + defer func() { <-uploads }() + default: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"code":"too_many_requests","message":"too many machine build uploads in progress"}`)) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 65<<20) + } + next.ServeHTTP(w, r) + }) +} + +// Machine-only HTTP admission is bounded regardless of multipart field order. +// Linux retains its existing parser and source contract. +func parseMachineBuildMultipart(body *multipart.Reader) (builds.CreateBuildRequest, []byte, error) { + req := builds.CreateBuildRequest{BuildPolicy: &builds.BuildPolicy{}} + var source []byte + seen := map[string]bool{} + if body == nil { + return req, nil, fmt.Errorf("multipart source is required") + } + for count := 0; ; count++ { + part, err := body.NextPart() + if err == io.EOF { + break + } + if err != nil { + return req, nil, fmt.Errorf("invalid multipart form") + } + name := part.FormName() + if count >= 16 || seen[name] { + return req, nil, fmt.Errorf("duplicate or excessive multipart fields") + } + seen[name] = true + limit := int64(64 << 10) + switch name { + case "source": + limit = 64 << 20 + case "machine_base_image", "timeout_seconds", "memory_mb", "cpus", "tags": + default: + return req, nil, fmt.Errorf("unsupported machine build field: %s", name) + } + data, err := io.ReadAll(io.LimitReader(part, limit+1)) + if err != nil || int64(len(data)) > limit { + return req, nil, fmt.Errorf("invalid or oversized %s field", name) + } + if err = part.Close(); err != nil { + return req, nil, fmt.Errorf("invalid multipart field") + } + switch name { + case "source": + source = data + case "machine_base_image": + req.MachineBaseImage = string(data) + case "tags": + req.Tags, err = parseTagsJSON(string(data)) + if err != nil { + return req, nil, fmt.Errorf("tags must be a JSON object of strings") + } + default: + value, err := strconv.Atoi(string(data)) + if err != nil || value < 0 { + return req, nil, fmt.Errorf("%s must be a nonnegative integer", name) + } + switch name { + case "timeout_seconds": + req.BuildPolicy.TimeoutSeconds = value + case "memory_mb": + req.BuildPolicy.MemoryMB = value + case "cpus": + req.BuildPolicy.CPUs = value + } + } + } + if len(source) == 0 { + return req, nil, fmt.Errorf("source is required") + } + ref, err := images.ParseNormalizedRef(req.MachineBaseImage) + if err != nil || !ref.IsDigest() || !builds.ValidMachineDigest(ref.Digest()) { + return req, nil, fmt.Errorf("machine_base_image must be pinned by sha256 digest") + } + if req.BuildPolicy.TimeoutSeconds > 86400 { + return req, nil, fmt.Errorf("timeout_seconds exceeds 86400") + } + if err = req.BuildPolicy.Validate(); err != nil { + return req, nil, err + } + return req, source, nil +} diff --git a/cmd/api/api/machine_builds_test.go b/cmd/api/api/machine_builds_test.go new file mode 100644 index 000000000..d7da1d17e --- /dev/null +++ b/cmd/api/api/machine_builds_test.go @@ -0,0 +1,157 @@ +package api + +import ( + "bytes" + "context" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/kernel/hypeman/cmd/api/config" + "github.com/kernel/hypeman/lib/builds" + "github.com/kernel/hypeman/lib/oapi" + "github.com/stretchr/testify/require" +) + +func machineMultipart(t *testing.T, fields ...[2]string) *multipart.Reader { + t.Helper() + var data bytes.Buffer + writer := multipart.NewWriter(&data) + for _, field := range fields { + require.NoError(t, writer.WriteField(field[0], field[1])) + } + require.NoError(t, writer.Close()) + return multipart.NewReader(&data, writer.Boundary()) +} + +type machineZeroReader struct{} + +func (machineZeroReader) Read(p []byte) (int, error) { clear(p); return len(p), nil } + +func TestMachineBuildBodyBoundBeforeValidation(t *testing.T) { + for _, path := range []string{"/builds", "/images"} { + r := httptest.NewRequest(http.MethodPost, path, io.LimitReader(machineZeroReader{}, (65<<20)+1)) + LimitMachineBuildBody(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n, err := io.Copy(io.Discard, r.Body) + if path == "/builds" { + require.Error(t, err) + require.EqualValues(t, 65<<20, n) + } else { + require.NoError(t, err) + require.EqualValues(t, (65<<20)+1, n) + } + })).ServeHTTP(httptest.NewRecorder(), r) + } +} + +func TestMachineBuildUploadsAreBounded(t *testing.T) { + release := make(chan struct{}) + entered := make(chan struct{}, maxConcurrentMachineBuildUploads) + handler := LimitMachineBuildBody(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + entered <- struct{}{} + <-release + })) + done := make(chan struct{}) + for range maxConcurrentMachineBuildUploads { + go func() { + handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/builds", nil)) + done <- struct{}{} + }() + <-entered + } + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/builds", nil)) + require.Equal(t, http.StatusTooManyRequests, w.Code) + + close(release) + for range maxConcurrentMachineBuildUploads { + <-done + } + w = httptest.NewRecorder() + go func() { <-entered }() + handler.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/builds", nil)) + require.Equal(t, http.StatusOK, w.Code, "a slot frees once an upload finishes") +} + +func TestMachineBuildMultipartAdmission(t *testing.T) { + base := "example.test/base@sha256:" + strings.Repeat("a", 64) + valid := [][2]string{{"source", "synthetic"}, {"machine_base_image", base}} + for _, tc := range []struct { + name string + extra [][2]string + ok bool + }{ + {name: "inherit", ok: true}, + {name: "policy", extra: [][2]string{{"cpus", "4"}, {"memory_mb", "8192"}, {"timeout_seconds", "1200"}}, ok: true}, + {name: "duplicate", extra: [][2]string{{"source", "second"}}}, + {name: "linux", extra: [][2]string{{"dockerfile", "FROM scratch"}}}, + {name: "secret", extra: [][2]string{{"secrets", "[]"}}}, + {name: "invalid number", extra: [][2]string{{"cpus", "nan"}}}, + {name: "negative", extra: [][2]string{{"memory_mb", "-1"}}}, + {name: "timeout", extra: [][2]string{{"timeout_seconds", "86401"}}}, + {name: "metadata bound", extra: [][2]string{{"tags", strings.Repeat("x", (64<<10)+1)}}}, + } { + t.Run(tc.name, func(t *testing.T) { + req, source, err := parseMachineBuildMultipart(machineMultipart(t, append(append([][2]string{}, valid...), tc.extra...)...)) + if !tc.ok { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, base, req.MachineBaseImage) + require.Equal(t, []byte("synthetic"), source) + }) + } + _, _, err := parseMachineBuildMultipart(machineMultipart(t, [2]string{"source", "synthetic"}, [2]string{"machine_base_image", "example.test/base:latest"})) + require.Error(t, err) +} + +type machineHTTPManager struct { + builds.Manager + req builds.CreateBuildRequest + calls int +} + +func (m *machineHTTPManager) CreateBuild(_ context.Context, req builds.CreateBuildRequest, source []byte) (*builds.Build, error) { + m.calls++ + m.req = req + return &builds.Build{ID: "synthetic-build", Status: builds.StatusQueued}, nil +} + +func TestMachineBuildHTTPGateAndDispatch(t *testing.T) { + base := "example.test/base@sha256:" + strings.Repeat("a", 64) + for _, tc := range []struct { + name string + cfg *config.Config + accepted bool + }{ + {"disabled", &config.Config{MacOSOnly: true}, false}, + {"linux", &config.Config{}, false}, + {"enabled", &config.Config{MacOSOnly: true, MacOSBuilds: true}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + manager := &machineHTTPManager{} + service := &ApiService{Config: tc.cfg, BuildManager: manager} + response, err := service.CreateBuild(context.Background(), oapi.CreateBuildRequestObject{Body: machineMultipart(t, [2]string{"source", "synthetic"}, [2]string{"machine_base_image", base})}) + require.NoError(t, err) + if tc.accepted { + require.IsType(t, oapi.CreateBuild202JSONResponse{}, response) + require.Equal(t, 1, manager.calls) + require.Equal(t, base, manager.req.MachineBaseImage) + } else { + // Disabled machine builds are unsupported (501) on macOS-only servers, and + // a Linux server rejects the machine selector as a bad request (400). + if tc.cfg.MacOSOnly { + require.IsType(t, oapi.CreateBuild501JSONResponse{}, response) + } else { + require.IsType(t, oapi.CreateBuild400JSONResponse{}, response) + } + require.Zero(t, manager.calls) + } + }) + } +} diff --git a/cmd/api/config/config.go b/cmd/api/config/config.go index fe1fc74ee..9b273bf17 100644 --- a/cmd/api/config/config.go +++ b/cmd/api/config/config.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "net" "os" "path/filepath" "runtime" @@ -281,7 +282,8 @@ type Config struct { JwtSecret string `koanf:"jwt_secret"` Env string `koanf:"env"` Version string `koanf:"version"` - MacOSOnly bool `koanf:"macos_only"` // Experimental: omit Linux boot downloads and reject Linux creates. + MacOSBuilds bool `koanf:"macos_builds"` // Experimental: enable machine builds on the macOS-only server. + MacOSOnly bool `koanf:"macos_only"` // Experimental: omit Linux boot downloads and reject Linux creates. Network NetworkConfig `koanf:"network"` Caddy CaddyConfig `koanf:"caddy"` @@ -593,6 +595,9 @@ func (c *Config) Validate() error { if c.MacOSOnly && (runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" || c.Hypervisor.Default != "vz") { return fmt.Errorf("macos_only requires vz on Apple silicon") } + if err := c.validateMacOSBuilds(); err != nil { + return err + } if strings.TrimSpace(c.Metrics.ListenAddress) == "" { return fmt.Errorf("metrics.listen_address must not be empty") } @@ -839,3 +844,29 @@ func (c *Config) validateFirecrackerUFFDGraduation() error { func intPtr(v int) *int { return &v } + +// validateMacOSBuilds enforces the experimental machine-build registry boundary. +func (c *Config) validateMacOSBuilds() error { + if c.MacOSBuilds && !c.MacOSOnly { + return fmt.Errorf("macos_builds requires macos_only") + } + if c.MacOSBuilds && c.Registry.CACertFile != "" { + return fmt.Errorf("macos_builds requires system-trusted registry TLS or loopback HTTP") + } + if c.MacOSBuilds && c.Registry.Insecure && strings.HasPrefix(c.Registry.URL, "https://") { + return fmt.Errorf("macos_builds requires system-trusted registry TLS or loopback HTTP") + } + if c.MacOSBuilds && strings.HasPrefix(c.Registry.URL, "http://") { + host := strings.TrimPrefix(c.Registry.URL, "http://") + if i := strings.IndexAny(host, "/?#"); i >= 0 { + host = host[:i] + } + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + if host != "localhost" && !net.ParseIP(host).IsLoopback() { + return fmt.Errorf("macos_builds plaintext registry must be loopback") + } + } + return nil +} diff --git a/cmd/api/config/macos_builds_test.go b/cmd/api/config/macos_builds_test.go new file mode 100644 index 000000000..559ac6b5d --- /dev/null +++ b/cmd/api/config/macos_builds_test.go @@ -0,0 +1,31 @@ +package config + +import ( + "github.com/stretchr/testify/require" + "runtime" + "testing" +) + +func TestMacOSBuildsFailClosedConfiguration(t *testing.T) { + if runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" { + t.Skip("macos_only requires Apple silicon") + } + require.False(t, (&Config{}).MacOSBuilds) + c := Config{MacOSBuilds: true} + require.ErrorContains(t, c.validateMacOSBuilds(), "requires macos_only") + c.MacOSOnly = true + c.Hypervisor.Default = "vz" + c.Registry.CACertFile = "custom.pem" + require.ErrorContains(t, c.validateMacOSBuilds(), "system-trusted registry TLS") + c.Registry.CACertFile = "" + c.Registry.URL = "https://registry.example" + c.Registry.Insecure = true + require.ErrorContains(t, c.validateMacOSBuilds(), "system-trusted registry TLS") + c.Registry.Insecure = false + c.Registry.URL = "http://registry.example:4973" + require.ErrorContains(t, c.validateMacOSBuilds(), "must be loopback") + c.Registry.URL = "http://127.0.0.1:4973" + require.NoError(t, c.validateMacOSBuilds()) + c.Registry.URL = "http://localhost:4973" + require.NoError(t, c.validateMacOSBuilds()) +} diff --git a/cmd/api/main.go b/cmd/api/main.go index e9d85f2fe..f4056b384 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -535,6 +535,11 @@ func run() error { // even though the build is still running. r.Use(timeoutNonStreamingRequests(60 * time.Second)) + // Bound machine multipart input before OpenAPI validation reads the body. + if app.Config.MacOSBuilds { + r.Use(api.LimitMachineBuildBody) + } + // OpenAPI request validation with authentication validatorOptions := &nethttpmiddleware.Options{ Options: openapi3filter.Options{ diff --git a/docs/macos-builds.md b/docs/macos-builds.md new file mode 100644 index 000000000..c4dd7906e --- /dev/null +++ b/docs/macos-builds.md @@ -0,0 +1,161 @@ +# 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. 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. + +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, destroys +the stopped builder and consumes staged source **before** publication. 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. + +A private 0600 gzip spool computes compressed digest/size and uncompressed DiffID +with bounded memory. 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. Private +spools are removed on success/error. 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. + +Separate isolated Darwin native QA completed a real normal queued build from a +manually provisioned, pinned installed base: root provisioning command, source +sanitation, acknowledged no-force shutdown, owned VMM exit, matching export, +authenticated streamed 28.85GB layer publication, image-readiness gate, fresh +HTTP pull/cache destination and cold boot. Root exec found the build marker and +verified absence of the source workspace. The run took 483.74s; test-process RSS +peaked at 36.88MiB, with 313.17GiB minimum free disk. The initial attempt correctly +refused publication on lost shutdown acknowledgement, leading to the guest reply +scheduling fix. This is not HTTP build activation, automatic clean-base installation +or repeat/recovery proof. Final cold-boot test cleanup used the ordinary short +stop timeout and forced fallback; no export used that cleanup stop. + +A subsequent live test used the actual opt-in API server/provider wiring: +unauthenticated/invalid tokens returned 401, a read-only token returned 403, and +POST /builds returned 202. GET reached Ready after the real provision/export/upload +and readiness gate. POST /instances cold-booted the returned image_ref, and the +normal authenticated exec WebSocket read the new root marker and verified source +absence. No fresh cache claim for this second run (the preceding run separately +proved fresh pull). Both runs used the private development base, not production +credential sanitation. External SDK releases remain separate. + +The supported build suite excludes exactly five existing mkfs.ext4-dependent +Linux config-volume tests on this Darwin host. Independent review remains blocked by an authentication error; no clean independent review is claimed. + +Remaining: external SDK releases, common safe recipe logs, +clean-template sanitation policy/audit, toolchain/version provenance, production +storage quotas/floors, repeat-build/cancel/recovery native QA and Linux runtime +regressions. Keep PR5 draft and experimental HTTP activation disabled by default. diff --git a/lib/builds/machine.go b/lib/builds/machine.go new file mode 100644 index 000000000..64fb32e6b --- /dev/null +++ b/lib/builds/machine.go @@ -0,0 +1,266 @@ +package builds + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/kernel/hypeman/lib/images" +) + +// 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 struct{ Graceful, VMMExited bool } +type MachinePublication struct{ Reference, Digest string } +type MachineBuildResult struct { + Publication MachinePublication + Provenance BuildProvenance + BuilderInstanceID string +} + +type MachineBuildDriver interface { + // Start owns an isolated instance and identity lease. A partially created + // session must be returned even with an error so cleanup can stop it. + Start(context.Context, *images.Image, 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 { 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} } + +// 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() || !machineDigest(ref.Digest()) { + return nil, fmt.Errorf("machine base must be pinned by sha256 digest") + } + policy := req.Policy + if policy.TimeoutSeconds < 0 { + return nil, fmt.Errorf("invalid machine build timeout") + } + if policy.TimeoutSeconds == 0 { + policy.TimeoutSeconds = 600 + } + if policy.TimeoutSeconds > 86400 { + return nil, fmt.Errorf("machine build timeout exceeds 24 hours") + } + if len(policy.AllowedDomains) != 0 { + return nil, fmt.Errorf("macOS domain-restricted egress is unsupported") + } + if policy.NetworkMode == "" { + policy.NetworkMode = "isolated" + } + if policy.NetworkMode != "isolated" && policy.NetworkMode != "egress" { + return nil, fmt.Errorf("unsupported machine network mode") + } + 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, 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 = os.Remove(sourcePath); e != nil { + return nil, machineFailure("cleanup_source", e) + } + 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() || !machineDigest(publication.Digest) || output.Digest() != publication.Digest { + return nil, fmt.Errorf("machine publication receipt is unverified") + } + return &MachineBuildResult{Publication: publication, BuilderInstanceID: instanceID, 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 machineDigest(s string) bool { return ValidMachineDigest(s) } + +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 +} diff --git a/lib/builds/machine_abort_test.go b/lib/builds/machine_abort_test.go new file mode 100644 index 000000000..c0b0020d1 --- /dev/null +++ b/lib/builds/machine_abort_test.go @@ -0,0 +1,74 @@ +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 + 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 + } + 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.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 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/machine_cancel.go b/lib/builds/machine_cancel.go new file mode 100644 index 000000000..2547142fb --- /dev/null +++ b/lib/builds/machine_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/machine_cancel_test.go b/lib/builds/machine_cancel_test.go new file mode 100644 index 000000000..7c08d30da --- /dev/null +++ b/lib/builds/machine_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, nil, 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/machine_failure_test.go b/lib/builds/machine_failure_test.go new file mode 100644 index 000000000..e02c36cc4 --- /dev/null +++ b/lib/builds/machine_failure_test.go @@ -0,0 +1,135 @@ +package builds + +import ( + "context" + "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"} { + 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) + } + return nil + } + result, err := r.Run(context.Background(), machineTestRequest(), strings.NewReader("source")) + if err == nil || result != nil || f.publisherCalled { + t.Fatal("malformed export published") + } + }) + } +} diff --git a/lib/builds/machine_guest.go b/lib/builds/machine_guest.go new file mode 100644 index 000000000..7cc670608 --- /dev/null +++ b/lib/builds/machine_guest.go @@ -0,0 +1,191 @@ +package builds + +import ( + "context" + "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, 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 || !machineDigest(base.Digest) { + return nil, fmt.Errorf("provisioned pinned macOS base required") + } + if err := policy.Validate(); err != nil { + return nil, err + } + if policy.CPUs != int(base.MacOS.CPUs) || policy.MemoryMB != int(base.MacOS.Memory>>20) || len(policy.AllowedDomains) != 0 || (policy.NetworkMode != "isolated" && policy.NetworkMode != "egress") { + return nil, fmt.Errorf("machine policy must preserve resources and supported networking") + } + ref, err := images.ParseNormalizedRef(base.Name) + if err != nil { + return nil, err + } + dir, err := os.MkdirTemp(d.WorkDir, "guest-machine-") + if err != nil { + return nil, err + } + inst, err := d.Instances.CreateInstance(ctx, instances.CreateInstanceRequest{Name: filepath.Base(dir), 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 || exit.Code != 0 { + return fmt.Errorf("machine guest command did not succeed") + } + return nil +} + +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 _, step := range recipe.Steps { + if err = s.exec(ctx, step.Command, s.guestDir, step.Env, 0); err != nil { + 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 = MachineStopReceipt{Graceful: receipt.Graceful, VMMExited: receipt.VMMExited} + 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 = MachineStopReceipt{Graceful: receipt.Graceful, VMMExited: true} + return s.receipt, nil + } + if inst, err := s.manager.GetInstance(ctx, s.ID()); err == nil && inst.State == instances.StateStopped { + s.receipt = MachineStopReceipt{VMMExited: true} + return s.receipt, nil + } + 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/machine_job_test.go b/lib/builds/machine_job_test.go new file mode 100644 index 000000000..d06ec3b37 --- /dev/null +++ b/lib/builds/machine_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/machine_manager.go b/lib/builds/machine_manager.go new file mode 100644 index 000000000..69c260aa3 --- /dev/null +++ b/lib/builds/machine_manager.go @@ -0,0 +1,146 @@ +package builds + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/kernel/hypeman/lib/images" + "github.com/kernel/hypeman/lib/instances" +) + +// 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 + } + 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 + } + if meta.BuilderInstance == nil || *meta.BuilderInstance == "" { + return nil + } + 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, prior); err != nil { + return fmt.Errorf("stop prior builder: %w", err) + } + } + if err := m.instanceManager.DeleteInstance(ctx, prior); 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", prior) + 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() || !machineDigest(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 policy.TimeoutSeconds == 0 { + policy.TimeoutSeconds = 600 + } + if policy.TimeoutSeconds < 1 || policy.TimeoutSeconds > 86400 { + return nil, fmt.Errorf("invalid machine build timeout") + } + if policy.NetworkMode == "" { + policy.NetworkMode = "isolated" + } + if len(policy.AllowedDomains) != 0 || (policy.NetworkMode != "isolated" && policy.NetworkMode != "egress") { + return nil, fmt.Errorf("unsupported machine network policy") + } + 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 +} diff --git a/lib/builds/machine_manager_failure_test.go b/lib/builds/machine_manager_failure_test.go new file mode 100644 index 000000000..0746f8336 --- /dev/null +++ b/lib/builds/machine_manager_failure_test.go @@ -0,0 +1,34 @@ +package builds + +import ( + "context" + "testing" + "time" + + "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, nil, 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") +} diff --git a/lib/builds/machine_manager_test.go b/lib/builds/machine_manager_test.go new file mode 100644 index 000000000..71ebd7483 --- /dev/null +++ b/lib/builds/machine_manager_test.go @@ -0,0 +1,98 @@ +package builds + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "path/filepath" + "testing" + "time" + + "github.com/kernel/hypeman/lib/images" + "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 +} + +func (p *machineJobPublisher) Publish(ctx context.Context, id, bundle string) (MachinePublication, error) { + p.publishedID = id + // Reuse the fixture's export/source-scrubbing 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, nil, nil, nil, machineJobImages{fixture: f}, nil, nil, nil) + require.NoError(t, err) + m := api.(*manager) + 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) +} diff --git a/lib/builds/machine_publisher.go b/lib/builds/machine_publisher.go new file mode 100644 index 000000000..8239fdeb8 --- /dev/null +++ b/lib/builds/machine_publisher.go @@ -0,0 +1,240 @@ +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. Blobs are spooled privately, 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 { + path string + 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 + } + spool, err := os.MkdirTemp(filepath.Dir(bundle), "machine-publish-") + if err != nil { + return MachinePublication{}, err + } + defer os.RemoveAll(spool) + layer, diffID, err := spoolMachineLayer(ctx, bundle, spool) + 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 + } + configPath := filepath.Join(spool, "config.json") + if err = os.WriteFile(configPath, configBytes, 0600); err != nil { + return MachinePublication{}, err + } + configBlob := machineBlob{path: configPath, 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} { + file, e := os.Open(blob.path) + if e != nil { + return MachinePublication{}, e + } + response, e := machineRegistryRequest(ctx, client, token, http.MethodPost, base+"/blobs/uploads/?digest="+blob.descriptor.Digest.String(), "application/octet-stream", io.LimitReader(file, blob.descriptor.Size), blob.descriptor.Size) + closeErr := file.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[:])} +} + +type machineContextReader struct { + ctx context.Context + reader io.Reader +} + +func (r machineContextReader) Read(data []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + return r.reader.Read(data) +} + +func spoolMachineLayer(ctx context.Context, bundle, spool string) (blob machineBlob, diffID v1.Hash, err error) { + name := filepath.Join(spool, "layer.tar.gz") + file, err := os.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + return blob, diffID, err + } + defer file.Close() + compressedHash := sha256.New() + gz, err := gzip.NewWriterLevel(io.MultiWriter(file, compressedHash), gzip.BestSpeed) + if err != nil { + return blob, 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 blob, diffID, e + } + info, e := source.Stat() + if e != nil { + source.Close() + return blob, diffID, e + } + e = archive.WriteHeader(&tar.Header{Name: name, Mode: 0600, Size: info.Size(), Typeflag: tar.TypeReg}) + if e == nil { + _, e = io.CopyN(archive, machineContextReader{ctx, source}, info.Size()) + } + closeErr := source.Close() + if e != nil { + return blob, diffID, e + } + if closeErr != nil { + return blob, diffID, closeErr + } + } + if err = archive.Close(); err != nil { + return blob, diffID, err + } + if err = gz.Close(); err != nil { + return blob, diffID, err + } + if err = file.Close(); err != nil { + return blob, diffID, err + } + info, err := os.Stat(file.Name()) + if err != nil { + return blob, diffID, err + } + return machineBlob{path: file.Name(), descriptor: v1.Descriptor{MediaType: types.OCILayer, Digest: v1.Hash{Algorithm: "sha256", Hex: hex.EncodeToString(compressedHash.Sum(nil))}, Size: info.Size()}}, v1.Hash{Algorithm: "sha256", Hex: hex.EncodeToString(plainHash.Sum(nil))}, nil +} diff --git a/lib/builds/machine_publisher_test.go b/lib/builds/machine_publisher_test.go new file mode 100644 index 000000000..2f745c822 --- /dev/null +++ b/lib/builds/machine_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/machine_reap_test.go b/lib/builds/machine_reap_test.go new file mode 100644 index 000000000..f33a4b2b3 --- /dev/null +++ b/lib/builds/machine_reap_test.go @@ -0,0 +1,67 @@ +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 + state instances.State + missing bool + stopped bool + deleted bool + deleteErr error +} + +func (r *reapInstances) GetInstance(context.Context, string) (*instances.Instance, error) { + 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) +} diff --git a/lib/builds/machine_recipe.go b/lib/builds/machine_recipe.go new file mode 100644 index 000000000..ffb85349e --- /dev/null +++ b/lib/builds/machine_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/machine_recipe_test.go b/lib/builds/machine_recipe_test.go new file mode 100644 index 000000000..cce154654 --- /dev/null +++ b/lib/builds/machine_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/machine_test.go b/lib/builds/machine_test.go new file mode 100644 index 000000000..073322c24 --- /dev/null +++ b/lib/builds/machine_test.go @@ -0,0 +1,294 @@ +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 + extraFile, changedIdentity, unknownMetadata bool +} + +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, _ *images.Image, p BuildPolicy) (MachineBuildSession, error) { + if p.CPUs != 4 || p.MemoryMB != 8192 { + return nil, errors.New("wrong resource defaults") + } + 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 + } + platform := *f.image.MacOS + if f.changedIdentity { + platform.MachineIdentifier = []byte{9} + } + data, _ := json.Marshal(platform) + if f.unknownMetadata { + var raw map[string]any + _ = json.Unmarshal(data, &raw) + raw["password"] = "must-not-publish" + data, _ = json.Marshal(raw) + } + 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.extraFile { + return os.WriteFile(filepath.Join(root, "secret.txt"), []byte("must-not-publish"), 0600) + } + 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") + } + if _, err := os.Stat(filepath.Join(filepath.Dir(root), "source.tar.gz")); !os.IsNotExist(err) { + return MachinePublication{}, errors.New("source remains during publish") + } + 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) + } +} + +func TestMachineBuildExportAdmission(t *testing.T) { + for _, kind := range []string{"identity", "extra file", "unknown metadata"} { + t.Run(kind, func(t *testing.T) { + f := newMachineFixture() + f.changedIdentity = kind == "identity" + f.extraFile = kind == "extra file" + f.unknownMetadata = kind == "unknown metadata" + _, err := machineTestRunner(t, f).Run(context.Background(), machineTestRequest(), strings.NewReader("source")) + if err == nil || f.publisherCalled { + t.Fatal("unsafe export published") + } + if strings.Contains(err.Error(), "must-not-publish") { + t.Fatal("export secret leaked in failure") + } + }) + } +} + +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..b810ec2bc 100644 --- a/lib/builds/manager.go +++ b/lib/builds/manager.go @@ -87,6 +87,9 @@ 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 + // MaxConcurrentBuilds is the maximum number of concurrent builds MaxConcurrentBuilds int @@ -149,6 +152,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 @@ -444,7 +448,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 +495,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 +696,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 @@ -798,6 +821,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 +1132,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 +1283,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 { @@ -1490,6 +1524,9 @@ func (m *manager) RecoverPendingBuilds() { // 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..fd9839a91 --- /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, false) + 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.InstanceOverlay(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..dbe3b073f --- /dev/null +++ b/lib/instances/macos_build_export_darwin_test.go @@ -0,0 +1,52 @@ +//go:build darwin + +package instances + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/kernel/hypeman/lib/images" + "github.com/stretchr/testify/require" +) + +func TestMacOSBuildBundleRequiresClosedDistinctStorage(t *testing.T) { + root := t.TempDir() + disk := filepath.Join(root, "overlay.raw") + 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 7ecc75927..e2058efc6 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, requireAcknowledgement bool) bool { log := logger.FromContext(ctx) if inst.SkipGuestAgent { @@ -65,7 +69,7 @@ func (m *manager) tryGracefulGuestShutdown(ctx context.Context, inst *Instance, return false } if pid == 0 { - return true + return !requireAcknowledgement } inst.HypervisorProcessIdentity.Set(pid) @@ -99,10 +103,18 @@ func (m *manager) tryGracefulGuestShutdown(ctx context.Context, inst *Instance, // If we couldn't signal the guest, don't burn the full graceful timeout. waitTimeout = shutdownFailureFallbackWait } + if requireAcknowledgement { + if ctx.Err() != nil { + return false + } + if deadline, ok := ctx.Deadline(); ok && time.Until(deadline) < waitTimeout { + waitTimeout = max(0, time.Until(deadline)) + } + } if WaitForProcessExit(pid, waitTimeout) { log.DebugContext(ctx, "VM shut down gracefully", "instance_id", inst.Id) - return true + return shutdownSent || !requireAcknowledgement } log.WarnContext(ctx, "graceful shutdown timed out, falling back to hypervisor shutdown", "instance_id", inst.Id) @@ -116,7 +128,11 @@ func (m *manager) tryGracefulGuestShutdown(ctx context.Context, inst *Instance, func (m *manager) stopInstance( ctx context.Context, id string, -) (_ *Instance, retErr error) { +) (*Instance, error) { + return m.stopInstanceWithFallback(ctx, id, true) +} + +func (m *manager) stopInstanceWithFallback(ctx context.Context, id string, allowFallback bool) (_ *Instance, retErr error) { start := time.Now() log := logger.FromContext(ctx) log.InfoContext(ctx, "stopping instance", "instance_id", id) @@ -170,13 +186,18 @@ func (m *manager) stopInstance( attribute.Int("stop_timeout_seconds", stopTimeout), ) macOSVMMLive := inst.MacOS != nil && hypervisorProcessLive(&inst) - gracefulShutdown := m.tryGracefulGuestShutdown(gracefulCtx, &inst, stopTimeout) + gracefulShutdown := m.tryGuestShutdown(gracefulCtx, &inst, stopTimeout, !allowFallback) if gracefulShutdown { gracefulSpanEnd(nil) } else { gracefulSpanEnd(errGracefulShutdownFailed) } + // Export callers must never turn a forced stop into a graceful receipt. + if !gracefulShutdown && !allowFallback { + return nil, errGracefulShutdownFailed + } + // 5. Fallback hypervisor shutdown if guest graceful shutdown didn't work if !gracefulShutdown { log.DebugContext(ctx, "shutting down hypervisor (fallback)", "instance_id", id) diff --git a/lib/oapi/oapi.go b/lib/oapi/oapi.go index 6fedcea4b..7d5e1863d 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,396 +19335,400 @@ 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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", - "tTk8ePRgv/fx3cf/fwCmtEtTif0BAA==", + "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", + "mf8BAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/lib/providers/providers.go b/lib/providers/providers.go index bc028293b..8d665cc00 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,9 +419,13 @@ 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) @@ -437,6 +442,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 c2a57e0d4..47ad881ba 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -4680,6 +4680,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