Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 96 additions & 40 deletions pkg/cmd/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.
Expand All @@ -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:<digest> ./macos`,

Flags: []cli.Flag{
&cli.StringFlag{
Name: "file",
Expand All @@ -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"},
Expand Down Expand Up @@ -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")))
}
Expand All @@ -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
}

Expand Down
132 changes: 132 additions & 0 deletions pkg/cmd/build_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading