From 019cf8c0609d276872a176add5e22d5c745e6503 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:41:49 +0000 Subject: [PATCH 1/3] Add native macOS build option --- pkg/cmd/build.go | 111 ++++++++++++++++++++++++++-------------- pkg/cmd/build_test.go | 116 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+), 37 deletions(-) create mode 100644 pkg/cmd/build_test.go diff --git a/pkg/cmd/build.go b/pkg/cmd/build.go index d6d5722..2b15287 100644 --- a/pkg/cmd/build.go +++ b/pkg/cmd/build.go @@ -5,10 +5,12 @@ import ( "bytes" "compress/gzip" "context" + "encoding/hex" "fmt" "io" "os" "path/filepath" + "strings" "github.com/kernel/hypeman-go" "github.com/kernel/hypeman-go/option" @@ -58,6 +60,10 @@ Examples: Name: "base-image-digest", Usage: "Pinned base image digest for reproducible builds", }, + &cli.StringFlag{ + Name: "machine-base-image", + Usage: "Digest-pinned machine base image for a native macOS build", + }, &cli.StringFlag{ Name: "builder", Aliases: []string{"builder-id"}, @@ -162,37 +168,72 @@ func handleBuild(ctx context.Context, cmd *cli.Command) error { opts = append(opts, debugMiddlewareOption) } - // Build params - params := hypeman.BuildNewParams{ - Source: bytes.NewReader(tarball.Bytes()), - TimeoutSeconds: hypeman.Opt(int64(timeout)), + params, err := buildNewParams(cmd, tarball.Bytes(), dockerfileContent, timeout) + if err != nil { + return err } - if dockerfileContent != "" { - params.Dockerfile = hypeman.Opt(dockerfileContent) + // Start build + build, err := client.Builds.New(ctx, params, opts...) + if err != nil { + return fmt.Errorf("failed to start build: %w", err) } - if v := cmd.String("base-image-digest"); v != "" { - params.BaseImageDigest = hypeman.Opt(v) - } - if v := cmd.String("builder"); v != "" { - params.BuilderID = hypeman.Opt(v) - } - if v := cmd.String("cache-scope"); v != "" { - params.CacheScope = hypeman.Opt(v) - } - if v := cmd.String("global-cache-key"); v != "" { - params.GlobalCacheKey = hypeman.Opt(v) - } - if v := cmd.String("is-admin-build"); v != "" { - params.IsAdminBuild = hypeman.Opt(v) + fmt.Fprintf(os.Stderr, "Build started: %s\n", build.ID) + + // Stream build events + err = streamBuildEventsSDK(ctx, client, build.ID, opts) + if err != nil { + return fmt.Errorf("build failed: %w", err) } - if v := cmd.String("secrets"); v != "" { - params.Secrets = hypeman.Opt(v) + + return nil +} + +func buildNewParams(cmd *cli.Command, source []byte, dockerfile string, timeout int) (hypeman.BuildNewParams, error) { + params := hypeman.BuildNewParams{ + Source: bytes.NewReader(source), + TimeoutSeconds: hypeman.Opt(int64(timeout)), } - if v := cmd.String("image-name"); v != "" { - params.ImageName = hypeman.Opt(v) + + machineBaseImage := cmd.String("machine-base-image") + if machineBaseImage != "" { + if err := validateMachineBaseImage(machineBaseImage); err != nil { + return params, err + } + for _, flag := range []string{"file", "base-image-digest", "builder", "cache-scope", "global-cache-key", "is-admin-build", "secrets", "image-name"} { + if cmd.IsSet(flag) { + return params, fmt.Errorf("--%s cannot be used with --machine-base-image", flag) + } + } + params.SetExtraFields(map[string]any{"machine_base_image": machineBaseImage}) + } else { + if dockerfile != "" { + params.Dockerfile = hypeman.Opt(dockerfile) + } + if v := cmd.String("base-image-digest"); v != "" { + params.BaseImageDigest = hypeman.Opt(v) + } + if v := cmd.String("builder"); v != "" { + params.BuilderID = hypeman.Opt(v) + } + if v := cmd.String("cache-scope"); v != "" { + params.CacheScope = hypeman.Opt(v) + } + if v := cmd.String("global-cache-key"); v != "" { + params.GlobalCacheKey = hypeman.Opt(v) + } + if v := cmd.String("is-admin-build"); v != "" { + params.IsAdminBuild = hypeman.Opt(v) + } + if v := cmd.String("secrets"); v != "" { + params.Secrets = hypeman.Opt(v) + } + if v := cmd.String("image-name"); v != "" { + params.ImageName = hypeman.Opt(v) + } } + if cmd.IsSet("cpus") { params.CPUs = hypeman.Opt(int64(cmd.Int("cpus"))) } @@ -206,25 +247,21 @@ func handleBuild(ctx context.Context, cmd *cli.Command) error { if len(tags) > 0 { tagsJSON, err := marshalStringMap(tags) if err != nil { - return fmt.Errorf("failed to encode tags: %w", err) + return params, fmt.Errorf("failed to encode tags: %w", err) } params.Tags = hypeman.Opt(tagsJSON) } + return params, nil +} - // Start build - build, err := client.Builds.New(ctx, params, opts...) - if err != nil { - return fmt.Errorf("failed to start build: %w", err) +func validateMachineBaseImage(image string) error { + _, digest, ok := strings.Cut(image, "@sha256:") + if !ok || len(digest) != 64 { + return fmt.Errorf("--machine-base-image must be pinned by a sha256 digest") } - - fmt.Fprintf(os.Stderr, "Build started: %s\n", build.ID) - - // Stream build events - err = streamBuildEventsSDK(ctx, client, build.ID, opts) - if err != nil { - return fmt.Errorf("build failed: %w", err) + if _, err := hex.DecodeString(digest); err != nil { + return fmt.Errorf("--machine-base-image must be pinned by a sha256 digest") } - return nil } diff --git a/pkg/cmd/build_test.go b/pkg/cmd/build_test.go new file mode 100644 index 0000000..5bf89fe --- /dev/null +++ b/pkg/cmd/build_test.go @@ -0,0 +1,116 @@ +package cmd + +import ( + "bytes" + "context" + "io" + "mime" + "mime/multipart" + "sort" + "strings" + "testing" + + "github.com/kernel/hypeman-go" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +const testMachineBaseImage = "registry.example.com/macos/base@sha256:" + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + +func parseBuildParams(t *testing.T, args ...string) (hypeman.BuildNewParams, error) { + t.Helper() + var params hypeman.BuildNewParams + command := &cli.Command{ + Name: "build", + Flags: testBuildFlags(), + Action: func(_ context.Context, cmd *cli.Command) error { + var err error + params, err = buildNewParams(cmd, []byte("source archive"), "", cmd.Int("timeout")) + return err + }, + } + argv := append([]string{"build"}, args...) + return params, command.Run(context.Background(), argv) +} + +func testBuildFlags() []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{Name: "file", Aliases: []string{"f"}}, + &cli.IntFlag{Name: "timeout", Value: 600}, + &cli.StringFlag{Name: "base-image-digest"}, + &cli.StringFlag{Name: "machine-base-image"}, + &cli.StringFlag{Name: "builder", Aliases: []string{"builder-id"}}, + &cli.StringFlag{Name: "cache-scope"}, + &cli.StringFlag{Name: "global-cache-key"}, + &cli.StringFlag{Name: "is-admin-build"}, + &cli.StringFlag{Name: "secrets"}, + &cli.StringFlag{Name: "image-name"}, + &cli.IntFlag{Name: "cpus"}, + &cli.IntFlag{Name: "memory"}, + &cli.StringSliceFlag{Name: "tag"}, + } +} + +func multipartFieldNames(t *testing.T, params hypeman.BuildNewParams) []string { + t.Helper() + body, contentType, err := params.MarshalMultipart() + require.NoError(t, err) + _, parameter, err := mime.ParseMediaType(contentType) + require.NoError(t, err) + reader := multipart.NewReader(bytes.NewReader(body), parameter["boundary"]) + var names []string + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + require.NoError(t, err) + names = append(names, part.FormName()) + _ = part.Close() + } + sort.Strings(names) + return names +} + +func TestMachineBuildMultipartFields(t *testing.T) { + params, err := parseBuildParams(t, + "--machine-base-image", testMachineBaseImage, + "--cpus", "4", "--memory", "8192", "--tag", "team=cli") + require.NoError(t, err) + + got := multipartFieldNames(t, params) + want := []string{"cpus", "machine_base_image", "memory_mb", "source", "tags", "timeout_seconds"} + require.Equal(t, want, got) +} + +func TestMachineBuildRejectsLinuxOptions(t *testing.T) { + for _, option := range []string{ + "--file", "--base-image-digest", "--builder", "--cache-scope", + "--global-cache-key", "--is-admin-build", "--secrets", "--image-name", + } { + t.Run(strings.TrimLeft(option, "-"), func(t *testing.T) { + value := "value" + if option == "--file" { + value = "Dockerfile" + } + args := []string{"--machine-base-image", testMachineBaseImage, option, value} + _, err := parseBuildParams(t, args...) + require.ErrorContains(t, err, option+" cannot be used with --machine-base-image") + }) + } +} + +func TestMachineBuildRequiresDigestPinnedImage(t *testing.T) { + for _, image := range []string{"registry.example.com/macos/base:latest", "registry.example.com/macos/base@sha256:abc"} { + _, err := parseBuildParams(t, "--machine-base-image", image) + require.ErrorContains(t, err, "must be pinned by a sha256 digest") + } +} + +func TestLinuxBuildMultipartFieldsRemainUnchanged(t *testing.T) { + params, err := parseBuildParams(t, "--base-image-digest", "sha256:base") + require.NoError(t, err) + got := multipartFieldNames(t, params) + want := []string{"base_image_digest", "source", "timeout_seconds"} + require.Equal(t, want, got) +} From a34857447c23d81c6bb23123c1fabd9a5856875d Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:49:15 +0000 Subject: [PATCH 2/3] Document native builds and reject malformed machine digests --- pkg/cmd/build.go | 14 +++++++++----- pkg/cmd/build_test.go | 7 ++++++- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/build.go b/pkg/cmd/build.go index 2b15287..150cf36 100644 --- a/pkg/cmd/build.go +++ b/pkg/cmd/build.go @@ -21,9 +21,9 @@ import ( var buildCmd = cli.Command{ Name: "build", Aliases: []string{"builds"}, - Usage: "Build an image from a Dockerfile", + Usage: "Build an image from a Dockerfile or native macOS recipe", ArgsUsage: "[path]", - Description: `Build an image from a Dockerfile and source context. + Description: `Build an image from a Dockerfile or native macOS recipe and source context. The path argument specifies the build context directory containing the source code and Dockerfile. If not specified, the current directory is used. @@ -44,7 +44,11 @@ Examples: hypeman build -f Dockerfile.prod ./myapp # Build with custom timeout - hypeman build --timeout 1200 ./myapp`, + hypeman build --timeout 1200 ./myapp + + # Build from hypeman-macos-build.json using an installed, digest-pinned base + hypeman build --machine-base-image registry.example/macos/base@sha256: ./macos`, + Flags: []cli.Flag{ &cli.StringFlag{ Name: "file", @@ -255,8 +259,8 @@ func buildNewParams(cmd *cli.Command, source []byte, dockerfile string, timeout } func validateMachineBaseImage(image string) error { - _, digest, ok := strings.Cut(image, "@sha256:") - if !ok || len(digest) != 64 { + repository, digest, ok := strings.Cut(image, "@sha256:") + if !ok || repository == "" || strings.ContainsAny(repository, "@ \t\r\n") || len(digest) != 64 || digest != strings.ToLower(digest) { return fmt.Errorf("--machine-base-image must be pinned by a sha256 digest") } if _, err := hex.DecodeString(digest); err != nil { diff --git a/pkg/cmd/build_test.go b/pkg/cmd/build_test.go index 5bf89fe..5214e7c 100644 --- a/pkg/cmd/build_test.go +++ b/pkg/cmd/build_test.go @@ -101,7 +101,12 @@ func TestMachineBuildRejectsLinuxOptions(t *testing.T) { } func TestMachineBuildRequiresDigestPinnedImage(t *testing.T) { - for _, image := range []string{"registry.example.com/macos/base:latest", "registry.example.com/macos/base@sha256:abc"} { + for _, image := range []string{ + "registry.example.com/macos/base:latest", "registry.example.com/macos/base@sha256:abc", + "@sha256:" + strings.Repeat("a", 64), + "registry.example.com/macos/base@sha256:" + strings.Repeat("A", 64), + "registry.example.com/macos/base@extra@sha256:" + strings.Repeat("a", 64), + } { _, err := parseBuildParams(t, "--machine-base-image", image) require.ErrorContains(t, err, "must be pinned by a sha256 digest") } From 3b9a27d51f94477ec9fc1a5e4bdd05f372ace743 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:52:35 +0000 Subject: [PATCH 3/3] Allow explicit NAT egress for native machine builds --- pkg/cmd/build.go | 17 ++++++++++++++++- pkg/cmd/build_test.go | 11 +++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/build.go b/pkg/cmd/build.go index 150cf36..52cfb13 100644 --- a/pkg/cmd/build.go +++ b/pkg/cmd/build.go @@ -68,6 +68,10 @@ Examples: Name: "machine-base-image", Usage: "Digest-pinned machine base image for a native macOS build", }, + &cli.StringFlag{ + Name: "machine-network-mode", + Usage: "Native macOS build networking: isolated (default) or unrestricted NAT egress", + }, &cli.StringFlag{ Name: "builder", Aliases: []string{"builder-id"}, @@ -210,8 +214,19 @@ func buildNewParams(cmd *cli.Command, source []byte, dockerfile string, timeout return params, fmt.Errorf("--%s cannot be used with --machine-base-image", flag) } } - params.SetExtraFields(map[string]any{"machine_base_image": machineBaseImage}) + extra := map[string]any{"machine_base_image": machineBaseImage} + if cmd.IsSet("machine-network-mode") { + mode := cmd.String("machine-network-mode") + if mode != "isolated" && mode != "egress" { + return params, fmt.Errorf("--machine-network-mode must be isolated or egress") + } + extra["machine_network_mode"] = mode + } + params.SetExtraFields(extra) } else { + if cmd.IsSet("machine-network-mode") { + return params, fmt.Errorf("--machine-network-mode requires --machine-base-image") + } if dockerfile != "" { params.Dockerfile = hypeman.Opt(dockerfile) } diff --git a/pkg/cmd/build_test.go b/pkg/cmd/build_test.go index 5214e7c..c532b73 100644 --- a/pkg/cmd/build_test.go +++ b/pkg/cmd/build_test.go @@ -39,6 +39,7 @@ func testBuildFlags() []cli.Flag { &cli.IntFlag{Name: "timeout", Value: 600}, &cli.StringFlag{Name: "base-image-digest"}, &cli.StringFlag{Name: "machine-base-image"}, + &cli.StringFlag{Name: "machine-network-mode"}, &cli.StringFlag{Name: "builder", Aliases: []string{"builder-id"}}, &cli.StringFlag{Name: "cache-scope"}, &cli.StringFlag{Name: "global-cache-key"}, @@ -83,6 +84,16 @@ func TestMachineBuildMultipartFields(t *testing.T) { require.Equal(t, want, got) } +func TestMachineBuildNetworkSelection(t *testing.T) { + params, err := parseBuildParams(t, "--machine-base-image", testMachineBaseImage, "--machine-network-mode", "egress") + require.NoError(t, err) + require.Equal(t, []string{"machine_base_image", "machine_network_mode", "source", "timeout_seconds"}, multipartFieldNames(t, params)) + _, err = parseBuildParams(t, "--machine-base-image", testMachineBaseImage, "--machine-network-mode", "bridge") + require.ErrorContains(t, err, "must be isolated or egress") + _, err = parseBuildParams(t, "--machine-network-mode", "egress") + require.ErrorContains(t, err, "requires --machine-base-image") +} + func TestMachineBuildRejectsLinuxOptions(t *testing.T) { for _, option := range []string{ "--file", "--base-image-digest", "--builder", "--cache-scope",