diff --git a/lib/builds/dockerfile.go b/lib/builds/dockerfile.go index 2df033580..a1ed8523f 100644 --- a/lib/builds/dockerfile.go +++ b/lib/builds/dockerfile.go @@ -7,11 +7,31 @@ import ( "io" "os" "path/filepath" + "regexp" "strings" "github.com/google/go-containerregistry/pkg/name" ) +var fromImagePattern = regexp.MustCompile(`(?i)^(\s*FROM\s+(?:--\S+\s+)*)(\S+)`) + +// rewriteMirroredFROMs routes cached non-Docker-Hub bases through the host's +// registry so the guest only needs its scoped local-registry token. +func rewriteMirroredFROMs(content string, replacements map[string]string) string { + lines := strings.Split(content, "\n") + for i, line := range lines { + match := fromImagePattern.FindStringSubmatchIndex(line) + if match == nil { + continue + } + start, end := match[4], match[5] + if replacement, ok := replacements[normalizeImageRef(line[start:end])]; ok { + lines[i] = line[:start] + replacement + line[end:] + } + } + return strings.Join(lines, "\n") +} + // ParseDockerfileFROMs extracts and deduplicates base image references from // Dockerfile content. It reuses the same parsing logic as the builder agent's // rewriteDockerfileFROMs: split lines, find FROM, skip flags/comments/scratch, @@ -125,6 +145,48 @@ func normalizeImageRef(ref string) string { return repo + suffix } +// writeSourceWithDockerfile prepares a build input without modifying the stored +// source archive. Older builder images prefer the archive's Dockerfile to config. +func writeSourceWithDockerfile(source io.Reader, destination io.Writer, dockerfile string) error { + reader, err := gzip.NewReader(source) + if err != nil { + return err + } + defer reader.Close() + compressed := gzip.NewWriter(destination) + defer compressed.Close() + writer := tar.NewWriter(compressed) + archive := tar.NewReader(reader) + for { + header, err := archive.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + if header.Name == "Dockerfile" || header.Name == "./Dockerfile" { + continue + } + if err := writer.WriteHeader(header); err != nil { + return err + } + if _, err := io.Copy(writer, archive); err != nil { + return err + } + } + if err := writer.WriteHeader(&tar.Header{Name: "Dockerfile", Mode: 0644, Size: int64(len(dockerfile))}); err != nil { + return err + } + if _, err := io.WriteString(writer, dockerfile); err != nil { + return err + } + if err := writer.Close(); err != nil { + return err + } + return compressed.Close() +} + // ExtractDockerfileFromTarball reads just the Dockerfile entry from a .tar.gz // archive and returns its content as a string. It looks for entries named // "Dockerfile" or "./Dockerfile" at the root of the archive. diff --git a/lib/builds/dockerfile_test.go b/lib/builds/dockerfile_test.go index 39674c849..6de72c3cc 100644 --- a/lib/builds/dockerfile_test.go +++ b/lib/builds/dockerfile_test.go @@ -3,6 +3,7 @@ package builds import ( "archive/tar" "compress/gzip" + "io" "os" "path/filepath" "testing" @@ -198,6 +199,69 @@ func TestExtractDockerfileFromTarball_DotSlashPrefix(t *testing.T) { } } +func TestWriteSourceWithDockerfile(t *testing.T) { + for _, originalName := range []string{"Dockerfile", "./Dockerfile", "other.txt"} { + t.Run(originalName, func(t *testing.T) { + dir := t.TempDir() + original := filepath.Join(dir, "source.tar.gz") + createTarball(t, original, map[string]string{originalName: "FROM old:latest", "app.txt": "application"}) + source, err := os.Open(original) + if err != nil { + t.Fatal(err) + } + defer source.Close() + prepared, err := os.Create(filepath.Join(dir, "prepared.tar.gz")) + if err != nil { + t.Fatal(err) + } + defer prepared.Close() + const replacement = "FROM mirror.example/runtime@sha256:abc\n" + if err := writeSourceWithDockerfile(source, prepared, replacement); err != nil { + t.Fatal(err) + } + got, err := ExtractDockerfileFromTarball(prepared.Name()) + if err != nil || got != replacement { + t.Fatalf("replacement: %q, %v", got, err) + } + if originalName != "other.txt" { + got, err = ExtractDockerfileFromTarball(original) + if err != nil || got != "FROM old:latest" { + t.Fatalf("stored source changed: %q, %v", got, err) + } + } + if _, err := prepared.Seek(0, 0); err != nil { + t.Fatal(err) + } + gz, err := gzip.NewReader(prepared) + if err != nil { + t.Fatal(err) + } + defer gz.Close() + tr := tar.NewReader(gz) + foundApp := false + for { + h, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatal(err) + } + if h.Name == "app.txt" { + content, err := io.ReadAll(tr) + if err != nil || string(content) != "application" { + t.Fatalf("app content: %q, %v", content, err) + } + foundApp = true + } + } + if !foundApp { + t.Fatal("lost app file") + } + }) + } +} + // createTarball creates a .tar.gz file with the given files (name -> content). func createTarball(t *testing.T, path string, files map[string]string) { t.Helper() diff --git a/lib/builds/manager.go b/lib/builds/manager.go index 806ae5622..3e829b7d9 100644 --- a/lib/builds/manager.go +++ b/lib/builds/manager.go @@ -6,6 +6,7 @@ import ( _ "embed" "encoding/json" "fmt" + "io" "log/slog" "net" "os" @@ -556,8 +557,8 @@ func (m *manager) CreateBuild(ctx context.Context, req CreateBuildRequest, sourc refs := ParseDockerfileFROMs(dockerfileContent) seen := make(map[string]bool) for _, ref := range refs { - repo := ref - if idx := strings.LastIndex(repo, ":"); idx > 0 { + repo := strings.SplitN(ref, "@", 2)[0] + if idx := strings.LastIndex(repo, ":"); idx > strings.LastIndex(repo, "/") { repo = repo[:idx] } if !seen[repo] { @@ -688,13 +689,12 @@ func (m *manager) runBuild(ctx context.Context, id string, req CreateBuildReques // Mirror base images to the local registry before launching the VM. // BuildKit is configured with our registry as a mirror for docker.io, // so pre-cached images will be served locally without pulling from Docker Hub. - if err := m.mirrorBaseImagesForBuild(buildCtx, id, req); err != nil { - m.logger.Warn("failed to mirror base images", "id", id, "error", err) + var result *BuildResult + err := m.mirrorBaseImagesForBuild(buildCtx, id, req) + if err == nil { + result, err = m.executeBuild(buildCtx, id, req, policy) } - // Run the build in a builder VM - result, err := m.executeBuild(buildCtx, id, req, policy) - // executeBuild's defers release the builder acquisition when it returns, // so same-builder builds may start while this build waits for image // conversion and re-tagging below. @@ -813,6 +813,26 @@ func (m *manager) executeBuild(ctx context.Context, id string, req CreateBuildRe } defer sourceFile.Close() + config, err := readBuildConfig(m.paths, id) + if err != nil { + return nil, err + } + if config.Dockerfile != "" && config.Dockerfile != req.Dockerfile { + prepared, err := os.CreateTemp(m.paths.BuildSourceDir(id), "mirrored-*.tar.gz") + if err != nil { + return nil, err + } + defer os.Remove(prepared.Name()) + defer prepared.Close() + if err := writeSourceWithDockerfile(sourceFile, prepared, config.Dockerfile); err != nil { + return nil, fmt.Errorf("prepare mirrored Dockerfile: %w", err) + } + if _, err := prepared.Seek(0, io.SeekStart); err != nil { + return nil, err + } + sourceFile = prepared + } + // Create volume with source (using the volume manager's archive import) _, err = m.volumeManager.CreateVolumeFromArchive(ctx, volumes.CreateVolumeFromArchiveRequest{ Id: &sourceVolID, @@ -1558,8 +1578,8 @@ func (m *manager) refreshBuildToken(buildID string, req *CreateBuildRequest) err refs := ParseDockerfileFROMs(dockerfileContent) seen := make(map[string]bool) for _, ref := range refs { - repo := ref - if idx := strings.LastIndex(repo, ":"); idx > 0 { + repo := strings.SplitN(ref, "@", 2)[0] + if idx := strings.LastIndex(repo, ":"); idx > strings.LastIndex(repo, "/") { repo = repo[:idx] } if !seen[repo] { diff --git a/lib/builds/mirror.go b/lib/builds/mirror.go index 577975371..85db5db53 100644 --- a/lib/builds/mirror.go +++ b/lib/builds/mirror.go @@ -6,6 +6,7 @@ import ( "time" "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" "github.com/kernel/hypeman/lib/images" ) @@ -72,18 +73,37 @@ func (m *manager) mirrorBaseImagesForBuild(ctx context.Context, id string, req C // our token handler extracts the JWT from the username field only. authConfig := &authn.AuthConfig{Username: registryToken, Password: "x"} + replacements := make(map[string]string) for _, ref := range refs { + parsed, err := name.ParseReference(ref) + if err != nil { + m.logger.Warn("failed to mirror base image", "id", id, "image", ref, "error", err) + continue + } + useLocalRegistry := parsed.Context().RegistryStr() != "index.docker.io" result, err := images.MirrorBaseImage(ctx, m.config.RegistryURL, images.MirrorRequest{ - SourceImage: ref, + SourceImage: ref, + PreserveIndex: useLocalRegistry, }, authConfig) if err != nil { m.logger.Warn("failed to mirror base image", "id", id, "image", ref, "error", err) continue } + if useLocalRegistry { + replacements[ref] = stripRegistryScheme(m.config.RegistryURL) + "/" + parsed.Context().Name() + "@" + result.Digest + } m.logger.Info("mirrored base image", "id", id, "image", ref, "local_ref", result.LocalRef, "digest", result.Digest) } + if len(replacements) > 0 { + config, err := readBuildConfig(m.paths, id) + if err != nil { + return err + } + config.Dockerfile = rewriteMirroredFROMs(dockerfileContent, replacements) + return writeBuildConfig(m.paths, id, config) + } return nil } diff --git a/lib/builds/mirror_test.go b/lib/builds/mirror_test.go new file mode 100644 index 000000000..df8010067 --- /dev/null +++ b/lib/builds/mirror_test.go @@ -0,0 +1,132 @@ +package builds + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/registry" + "github.com/google/go-containerregistry/pkg/v1/random" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/kernel/hypeman/lib/volumes" + "github.com/stretchr/testify/require" +) + +func TestRewriteMirroredFROMs(t *testing.T) { + const source = "registry.example.com/runtime:1" + const target = "10.0.0.1:4973/registry.example.com/runtime@sha256:abc" + replacements := map[string]string{source: target} + for _, tc := range []struct{ input, want string }{ + {"FROM " + source + " AS base\nRUN echo ok\n", "FROM " + target + " AS base\nRUN echo ok\n"}, + {" from --platform=linux/amd64 " + source + " as base # comment", " from --platform=linux/amd64 " + target + " as base # comment"}, + {"FROM ubuntu:22.04\nFROM base\nFROM scratch\nFROM ${BASE}\n# FROM " + source, "FROM ubuntu:22.04\nFROM base\nFROM scratch\nFROM ${BASE}\n# FROM " + source}, + } { + require.Equal(t, tc.want, rewriteMirroredFROMs(tc.input, replacements)) + } +} + +type mirrorTestTransport func(*http.Request) (*http.Response, error) + +func (f mirrorTestTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestMirrorUnresolvedBuildArgKeepsFallback(t *testing.T) { + mgr, _, _, dir := setupTestManager(t) + defer os.RemoveAll(dir) + err := mgr.mirrorBaseImagesForBuild(context.Background(), "variable-base", CreateBuildRequest{ + Dockerfile: "ARG BASE=alpine:latest\nFROM $BASE\n", + }) + require.NoError(t, err) +} + +func TestMirrorPrivateBaseKeepsCredentialsOutOfBuildConfig(t *testing.T) { + const password = "private-source-test-password" + handler := registry.New() + source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, pass, ok := r.BasicAuth() + if !ok || user != "reader" || pass != password { + w.Header().Set("WWW-Authenticate", `Basic realm="test"`) + w.WriteHeader(http.StatusUnauthorized) + return + } + handler.ServeHTTP(w, r) + })) + defer source.Close() + destination := httptest.NewServer(registry.New()) + defer destination.Close() + seed, err := name.ParseReference(strings.TrimPrefix(source.URL, "http://") + "/runtime:1") + require.NoError(t, err) + index, err := random.Index(32, 1, 2) + require.NoError(t, err) + require.NoError(t, remote.WriteIndex(seed, index, remote.WithAuth(&authn.Basic{Username: "reader", Password: password}))) + + sourceURL, err := url.Parse(source.URL) + require.NoError(t, err) + transport := remote.DefaultTransport + remote.DefaultTransport = mirrorTestTransport(func(r *http.Request) (*http.Response, error) { + clone := r.Clone(r.Context()) + if clone.URL.Host == "private.example.com" { + clone.URL.Host = sourceURL.Host + clone.URL.Scheme = "http" + } + return transport.RoundTrip(clone) + }) + t.Cleanup(func() { remote.DefaultTransport = transport }) + dockerConfig := t.TempDir() + t.Setenv("DOCKER_CONFIG", dockerConfig) + auth := base64.StdEncoding.EncodeToString([]byte("reader:" + password)) + require.NoError(t, os.WriteFile(filepath.Join(dockerConfig, "config.json"), []byte(`{"auths":{"private.example.com":{"auth":"`+auth+`"}}}`), 0600)) + + mgr, _, volumeManager, dir := setupTestManager(t) + defer os.RemoveAll(dir) + mgr.config.RegistryURL = destination.URL + const id = "build-private-test" + original := "FROM private.example.com/runtime:1\nRUN echo ready\n" + require.NoError(t, writeBuildConfig(mgr.paths, id, &BuildConfig{Dockerfile: original, RegistryToken: "scoped-host-token"})) + require.NoError(t, mgr.mirrorBaseImagesForBuild(context.Background(), id, CreateBuildRequest{Dockerfile: original})) + config, err := readBuildConfig(mgr.paths, id) + require.NoError(t, err) + digest, err := index.Digest() + require.NoError(t, err) + target := strings.TrimPrefix(destination.URL, "http://") + "/private.example.com/runtime@" + digest.String() + require.Equal(t, "FROM "+target+"\nRUN echo ready\n", config.Dockerfile) + require.Equal(t, "scoped-host-token", config.RegistryToken) + encoded, err := json.Marshal(config) + require.NoError(t, err) + require.NotContains(t, string(encoded), password) + ref, err := name.ParseReference(target) + require.NoError(t, err) + _, err = remote.Get(ref) + require.NoError(t, err) + + sourcePath := filepath.Join(mgr.paths.BuildSourceDir(id), "source.tar.gz") + require.NoError(t, os.MkdirAll(filepath.Dir(sourcePath), 0755)) + createTarball(t, sourcePath, map[string]string{"Dockerfile": original, "app.txt": "app"}) + captured := errors.New("captured source volume") + volumeManager.createFromArchiveFunc = func(_ context.Context, _ volumes.CreateVolumeFromArchiveRequest, archive io.Reader) (*volumes.Volume, error) { + data, err := io.ReadAll(archive) + require.NoError(t, err) + path := filepath.Join(t.TempDir(), "source.tar.gz") + require.NoError(t, os.WriteFile(path, data, 0600)) + got, err := ExtractDockerfileFromTarball(path) + require.NoError(t, err) + require.Equal(t, config.Dockerfile, got) + return nil, captured + } + policy := DefaultBuildPolicy() + _, err = mgr.executeBuild(context.Background(), id, CreateBuildRequest{Dockerfile: original}, &policy) + require.ErrorIs(t, err, captured) + stored, err := ExtractDockerfileFromTarball(sourcePath) + require.NoError(t, err) + require.Equal(t, original, stored) +} diff --git a/lib/images/mirror.go b/lib/images/mirror.go index 17df7c8d1..1eb968aee 100644 --- a/lib/images/mirror.go +++ b/lib/images/mirror.go @@ -18,6 +18,8 @@ type MirrorRequest struct { // Platform selects which platform variant to mirror as os/arch[/variant] // (e.g., linux/amd64). Empty means the host platform. Platform string + // PreserveIndex mirrors all platforms when a tag names an index. + PreserveIndex bool } // MirrorResult contains the result of a mirror operation @@ -55,8 +57,7 @@ func MirrorBaseImage(ctx context.Context, registryURL string, req MirrorRequest, // verification at the destination ("digest mismatch"), which is exactly // what any Dockerfile with a digest-pinned FROM produces. An index named // by digest is therefore mirrored as the index itself, byte for byte. - // Tag references keep the existing behavior of mirroring only the - // requested platform, which is what saves storage. + // Tag references mirror only the requested platform unless PreserveIndex is set. desc, err := remote.Get(srcRef, remote.WithContext(ctx), remote.WithAuthFromKeychain(authn.DefaultKeychain), @@ -92,7 +93,7 @@ func MirrorBaseImage(ctx context.Context, registryURL string, req MirrorRequest, opts = append(opts, remote.WithAuth(authn.FromConfig(*authConfig))) } - digest, err := pushMirrored(desc, isDigestRef, dstRef, opts...) + digest, err := pushMirrored(desc, isDigestRef || req.PreserveIndex, dstRef, opts...) if err != nil { return nil, err } @@ -111,8 +112,8 @@ func MirrorBaseImage(ctx context.Context, registryURL string, req MirrorRequest, // then the index manifest — so the destination stores content whose digest is // exactly the one the caller pinned. Everything else keeps the original // behavior: the (platform-resolved) image is written, under its own digest. -func pushMirrored(desc *remote.Descriptor, isDigestRef bool, dstRef name.Reference, opts ...remote.Option) (v1.Hash, error) { - if isDigestRef && desc.MediaType.IsIndex() { +func pushMirrored(desc *remote.Descriptor, preserveIndex bool, dstRef name.Reference, opts ...remote.Option) (v1.Hash, error) { + if preserveIndex && desc.MediaType.IsIndex() { idx, err := desc.ImageIndex() if err != nil { return v1.Hash{}, fmt.Errorf("load source index: %w", err)