diff --git a/pkg/cmd/build.go b/pkg/cmd/build.go index d6d5722..52cfb13 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" @@ -19,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. @@ -42,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", @@ -58,6 +64,14 @@ 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: "machine-network-mode", + Usage: "Native macOS build networking: isolated (default) or unrestricted NAT egress", + }, &cli.StringFlag{ Name: "builder", Aliases: []string{"builder-id"}, @@ -162,37 +176,83 @@ 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) + } + } + 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) + } + 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 +266,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 { + 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") } - - 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..c532b73 --- /dev/null +++ b/pkg/cmd/build_test.go @@ -0,0 +1,132 @@ +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: "machine-network-mode"}, + &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 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", + "--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", + "@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") + } +} + +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) +}