From ebebfd21558f7d54b4f7b67d524ffd7d94be3d95 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 12:40:30 +0000 Subject: [PATCH] fix(build): emit docker tarball artifacts --- pkg/build/builders/docker.go | 38 +++++++------ pkg/build/builders/docker_test.go | 94 ++++++++++++++++++++++++++++++- 2 files changed, 113 insertions(+), 19 deletions(-) diff --git a/pkg/build/builders/docker.go b/pkg/build/builders/docker.go index fbe6b79..b023316 100644 --- a/pkg/build/builders/docker.go +++ b/pkg/build/builders/docker.go @@ -3,6 +3,7 @@ package builders import ( "context" + "strings" "dappco.re/go/core" "dappco.re/go/core/build/internal/ax" @@ -82,6 +83,10 @@ func (b *DockerBuilder) Build(ctx context.Context, cfg *build.Config, targets [] } // If no targets specified, use current platform + buildTargets := targets + if len(buildTargets) == 0 { + buildTargets = []build.Target{{OS: "linux", Arch: "amd64"}} + } if len(platforms) == 0 { platforms = []string{"linux/amd64"} } @@ -141,18 +146,15 @@ func (b *DockerBuilder) Build(ctx context.Context, cfg *build.Config, targets [] args = append(args, "--build-arg", core.Sprintf("VERSION=%s", cfg.Version)) } + safeImageName := strings.ReplaceAll(imageName, "/", "_") + // Output to local docker images or push if cfg.Push { args = append(args, "--push") } else { - // For multi-platform builds without push, we need to load or output somewhere - if len(platforms) == 1 { - args = append(args, "--load") - } else { - // Multi-platform builds can't use --load, output to tarball - outputPath := ax.Join(cfg.OutputDir, core.Sprintf("%s.tar", imageName)) - args = append(args, "--output", core.Sprintf("type=oci,dest=%s", outputPath)) - } + // Local Docker builds emit an OCI archive so the build output is a file. + outputPath := ax.Join(cfg.OutputDir, core.Sprintf("%s.tar", safeImageName)) + args = append(args, "--output", core.Sprintf("type=oci,dest=%s", outputPath)) } // Build context (project directory) @@ -167,21 +169,23 @@ func (b *DockerBuilder) Build(ctx context.Context, cfg *build.Config, targets [] core.Print(nil, " Platforms: %s", core.Join(", ", platforms...)) core.Print(nil, " Tags: %s", core.Join(", ", imageRefs...)) + // Build once for the full platform set. Docker buildx produces a single + // multi-arch image or OCI archive from the combined platform list. if err := ax.ExecDir(ctx, cfg.ProjectDir, dockerCommand, args...); err != nil { return nil, coreerr.E("DockerBuilder.Build", "buildx build failed", err) } - // Create artifacts for each platform - var artifacts []build.Artifact - for _, t := range targets { - artifacts = append(artifacts, build.Artifact{ - Path: imageRefs[0], // Primary image reference - OS: t.OS, - Arch: t.Arch, - }) + artifactPath := imageRefs[0] + if !cfg.Push { + artifactPath = ax.Join(cfg.OutputDir, core.Sprintf("%s.tar", safeImageName)) } - return artifacts, nil + primaryTarget := buildTargets[0] + return []build.Artifact{{ + Path: artifactPath, + OS: primaryTarget.OS, + Arch: primaryTarget.Arch, + }}, nil } // resolveDockerCli returns the executable path for the docker CLI. diff --git a/pkg/build/builders/docker_test.go b/pkg/build/builders/docker_test.go index 36aa336..77c58a4 100644 --- a/pkg/build/builders/docker_test.go +++ b/pkg/build/builders/docker_test.go @@ -1,23 +1,56 @@ package builders import ( + "context" + "os" + "strings" "testing" "dappco.re/go/core/build/internal/ax" "dappco.re/go/core/build/pkg/build" - "dappco.re/go/core/io" + coreio "dappco.re/go/core/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func setupFakeDockerToolchain(t *testing.T, binDir string) { + t.Helper() + + script := `#!/bin/sh +set -eu + +log_file="${DOCKER_BUILD_LOG_FILE:-}" +if [ -n "$log_file" ]; then + printf '%s\n' "$*" >> "$log_file" +fi + +if [ "${1:-}" = "buildx" ] && [ "${2:-}" = "build" ]; then + dest="" + while [ $# -gt 0 ]; do + if [ "$1" = "--output" ]; then + shift + dest="$(printf '%s' "$1" | sed -n 's#type=oci,dest=##p')" + fi + shift + done + if [ -n "$dest" ]; then + mkdir -p "$(dirname "$dest")" + printf 'oci archive\n' > "$dest" + fi +fi +` + + require.NoError(t, ax.WriteFile(ax.Join(binDir, "docker"), []byte(script), 0o755)) +} + func TestDocker_DockerBuilderName_Good(t *testing.T) { builder := NewDockerBuilder() assert.Equal(t, "docker", builder.Name()) } func TestDocker_DockerBuilderDetect_Good(t *testing.T) { - fs := io.Local + fs := coreio.Local t.Run("detects Dockerfile", func(t *testing.T) { dir := t.TempDir() @@ -102,3 +135,60 @@ func TestDocker_DockerBuilderResolveDockerCli_Bad(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "docker CLI not found") } + +func TestDocker_DockerBuilderBuild_Good(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + binDir := t.TempDir() + setupFakeDockerToolchain(t, binDir) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + projectDir := t.TempDir() + require.NoError(t, ax.WriteFile(ax.Join(projectDir, "Dockerfile"), []byte("FROM alpine:latest\n"), 0o644)) + + outputDir := t.TempDir() + logDir := t.TempDir() + logPath := ax.Join(logDir, "docker.log") + t.Setenv("DOCKER_BUILD_LOG_FILE", logPath) + + builder := NewDockerBuilder() + cfg := &build.Config{ + FS: coreio.Local, + ProjectDir: projectDir, + OutputDir: outputDir, + Name: "sample-app", + Image: "owner/repo", + } + targets := []build.Target{ + {OS: "linux", Arch: "amd64"}, + {OS: "linux", Arch: "arm64"}, + } + + artifacts, err := builder.Build(context.Background(), cfg, targets) + require.NoError(t, err) + require.Len(t, artifacts, 1) + + expectedPath := ax.Join(outputDir, "owner_repo.tar") + assert.Equal(t, expectedPath, artifacts[0].Path) + assert.Equal(t, "linux", artifacts[0].OS) + assert.Equal(t, "amd64", artifacts[0].Arch) + assert.FileExists(t, expectedPath) + + logContent, err := ax.ReadFile(logPath) + require.NoError(t, err) + + log := string(logContent) + assert.Equal(t, 1, strings.Count(log, "buildx build")) + assert.Contains(t, log, "--platform") + assert.Contains(t, log, "linux/amd64,linux/arm64") + assert.Contains(t, log, "--output") + assert.Contains(t, log, "type=oci,dest="+expectedPath) + + artifacts, err = builder.Build(context.Background(), cfg, nil) + require.NoError(t, err) + require.Len(t, artifacts, 1) + assert.Equal(t, "linux", artifacts[0].OS) + assert.Equal(t, "amd64", artifacts[0].Arch) +}