Skip to content
Open
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
91 changes: 56 additions & 35 deletions lib/builds/builder_agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
Expand Down Expand Up @@ -489,40 +490,30 @@ func runBuildProcess() {
}
}

// Ensure Dockerfile exists (either in source or provided via config)
dockerfilePath := filepath.Join(config.SourcePath, "Dockerfile")
if _, err := os.Stat(dockerfilePath); os.IsNotExist(err) {
// Check if Dockerfile was provided in config
if config.Dockerfile == "" {
setResult(BuildResult{
Success: false,
Error: "Dockerfile required: provide dockerfile parameter or include Dockerfile in source tarball",
Logs: logWriter.String(),
DurationMS: time.Since(start).Milliseconds(),
})
return
}
// Write provided Dockerfile to source directory
if err := os.WriteFile(dockerfilePath, []byte(config.Dockerfile), 0644); err != nil {
setResult(BuildResult{
Success: false,
Error: fmt.Sprintf("write dockerfile: %v", err),
Logs: logWriter.String(),
DurationMS: time.Since(start).Milliseconds(),
})
return
}
log.Println("Using Dockerfile from config")
} else {
dockerfileDir, cleanupDockerfile, err := prepareDockerfile(config)
if err != nil {
setResult(BuildResult{
Success: false,
Error: err.Error(),
Logs: logWriter.String(),
DurationMS: time.Since(start).Milliseconds(),
})
return
}
defer cleanupDockerfile()

if dockerfileDir == config.SourcePath {
log.Println("Using Dockerfile from source")
} else {
log.Println("Using Dockerfile from config")
}

// Compute provenance
provenance := computeProvenance(config)

// Run the build
log.Println("=== Starting Build ===")
digest, _, err := runBuild(ctx, config, logWriter)
digest, _, err := runBuild(ctx, config, dockerfileDir, logWriter)

duration := time.Since(start).Milliseconds()

Expand Down Expand Up @@ -748,7 +739,44 @@ func setupBuildkitdConfig(config *BuildConfig) error {
return nil
}

func runBuild(ctx context.Context, config *BuildConfig, logWriter io.Writer) (string, string, error) {
func prepareDockerfile(config *BuildConfig) (string, func(), error) {
dockerfilePath := filepath.Join(config.SourcePath, "Dockerfile")
if _, err := os.Stat(dockerfilePath); err == nil {
return config.SourcePath, func() {}, nil
} else if !os.IsNotExist(err) {
return "", nil, fmt.Errorf("stat dockerfile: %w", err)
}

if config.Dockerfile == "" {
return "", nil, errors.New("Dockerfile required: provide dockerfile parameter or include Dockerfile in source tarball")
}

dockerfileDir, err := os.MkdirTemp("", "hypeman-dockerfile-")
if err != nil {
return "", nil, fmt.Errorf("create dockerfile directory: %w", err)
}
cleanup := func() { _ = os.RemoveAll(dockerfileDir) }

if err := os.WriteFile(filepath.Join(dockerfileDir, "Dockerfile"), []byte(config.Dockerfile), 0644); err != nil {
cleanup()
return "", nil, fmt.Errorf("write dockerfile: %w", err)
}

return dockerfileDir, cleanup, nil
}

func baseBuildctlArgs(config *BuildConfig, dockerfileDir, outputOpts string) []string {
return []string{
"build",
"--frontend", "dockerfile.v0",
"--local", "context=" + config.SourcePath,
"--local", "dockerfile=" + dockerfileDir,
"--output", outputOpts,
"--metadata-file", "/tmp/build-metadata.json",
}
}

func runBuild(ctx context.Context, config *BuildConfig, dockerfileDir string, logWriter io.Writer) (string, string, error) {
var buildLogs bytes.Buffer

// Parse registry host (strip any scheme prefix for backwards compatibility)
Expand Down Expand Up @@ -777,14 +805,7 @@ func runBuild(ctx context.Context, config *BuildConfig, logWriter io.Writer) (st
log.Printf("Using HTTPS registry (secure mode): %s", registryHost)
}

args := []string{
"build",
"--frontend", "dockerfile.v0",
"--local", "context=" + config.SourcePath,
"--local", "dockerfile=" + config.SourcePath,
"--output", outputOpts,
"--metadata-file", "/tmp/build-metadata.json",
}
args := baseBuildctlArgs(config, dockerfileDir, outputOpts)

// Two-tier cache implementation:
// 1. Import from global cache (if runtime specified) - always read-only for regular builds
Expand Down
90 changes: 90 additions & 0 deletions lib/builds/builder_agent/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package main

import (
"os"
"path/filepath"
"slices"
"testing"
)

func TestBaseBuildctlArgsSeparatesDockerfileFromContext(t *testing.T) {
config := &BuildConfig{SourcePath: "/tmp/source"}

got := baseBuildctlArgs(config, "/tmp/dockerfile", "type=image,name=example")
want := []string{
"build",
"--frontend", "dockerfile.v0",
"--local", "context=/tmp/source",
"--local", "dockerfile=/tmp/dockerfile",
"--output", "type=image,name=example",
"--metadata-file", "/tmp/build-metadata.json",
}

if !slices.Equal(got, want) {
t.Fatalf("unexpected buildctl args:\n got: %q\nwant: %q", got, want)
}
}

func TestPrepareDockerfileKeepsConfiguredDockerfileOutsideSource(t *testing.T) {
sourceDir := t.TempDir()
dockerfile := "FROM alpine:3.21\nCOPY . /app\n"

dockerfileDir, cleanup, err := prepareDockerfile(&BuildConfig{
SourcePath: sourceDir,
Dockerfile: dockerfile,
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(cleanup)

if dockerfileDir == sourceDir {
t.Fatal("configured Dockerfile was written into the source directory")
}
if _, err := os.Stat(filepath.Join(sourceDir, "Dockerfile")); !os.IsNotExist(err) {
t.Fatalf("expected source directory to remain unchanged, got %v", err)
}

content, err := os.ReadFile(filepath.Join(dockerfileDir, "Dockerfile"))
if err != nil {
t.Fatal(err)
}
if string(content) != dockerfile {
t.Fatalf("unexpected Dockerfile content: %q", content)
}
}

func TestPrepareDockerfileUsesSourceDockerfile(t *testing.T) {
sourceDir := t.TempDir()
dockerfilePath := filepath.Join(sourceDir, "Dockerfile")
if err := os.WriteFile(dockerfilePath, []byte("FROM source\n"), 0644); err != nil {
t.Fatal(err)
}

dockerfileDir, cleanup, err := prepareDockerfile(&BuildConfig{
SourcePath: sourceDir,
Dockerfile: "FROM config\n",
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(cleanup)

if dockerfileDir != sourceDir {
t.Fatalf("expected source Dockerfile directory %q, got %q", sourceDir, dockerfileDir)
}
content, err := os.ReadFile(dockerfilePath)
if err != nil {
t.Fatal(err)
}
if string(content) != "FROM source\n" {
t.Fatalf("source Dockerfile was overwritten: %q", content)
}
}

func TestPrepareDockerfileRequiresDockerfile(t *testing.T) {
_, _, err := prepareDockerfile(&BuildConfig{SourcePath: t.TempDir()})
if err == nil {
t.Fatal("expected missing Dockerfile to fail")
}
}
Loading