From 42cd8fa493525b82babf14b59c8d6fd6592551db Mon Sep 17 00:00:00 2001 From: clawbot Date: Sat, 3 Oct 2026 00:26:36 +0000 Subject: [PATCH] Leave out of the build context what the app's .dockerignore names (closes #274) Docker does not apply .dockerignore to a build context sent as a tar, so upaas sent every file in the clone. The build now reads the ignore file as docker build does, .dockerignore next to the Dockerfile if there is one, otherwise .dockerignore at the root, with the ignorefile reader of moby/patternmatcher, and passes its patterns as exclude patterns. As the docker command line does, the Dockerfile and .dockerignore are never left out. An ignore file that cannot be read, or a malformed pattern, fails the build. Model: opus-5-5 --- README.md | 6 + TODO.md | 7 + go.mod | 2 +- internal/docker/client.go | 12 +- internal/docker/dockerignore.go | 62 ++++++++ internal/docker/dockerignore_test.go | 224 +++++++++++++++++++++++++++ 6 files changed, 310 insertions(+), 3 deletions(-) create mode 100644 internal/docker/dockerignore.go create mode 100644 internal/docker/dockerignore_test.go diff --git a/README.md b/README.md index a563186..a9e9a25 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,12 @@ upaas fails the deploy instead of building. A Dockerfile that uses `RUN --network` needs Docker Engine 23.0 or later unless its `# syntax=` line names Dockerfile frontend 1.3 or later, such as `docker/dockerfile:1`. +The build context leaves out the files the app's ignore file names, as +`docker build` does: `.dockerignore` next to the app's Dockerfile if +there is one, otherwise `.dockerignore` at the root of the repository. The +Dockerfile and `.dockerignore` are always sent, even when the ignore file names +them. + Session secrets are automatically generated on first startup and persisted to `$UPAAS_DATA_DIR/session.key`. diff --git a/TODO.md b/TODO.md index 196aba3..6d40707 100644 --- a/TODO.md +++ b/TODO.md @@ -20,6 +20,13 @@ regress. # Completed Steps +- 2026-10-03: An app's build context leaves out the files its `.dockerignore` + names, such as `.git/config`, as `docker build` does; before, every file in + the clone was sent. An ignore file next to the Dockerfile, + `.dockerignore`, is read instead when there is one. The Dockerfile + and `.dockerignore` are always sent. An ignore file that cannot be read, or + holds a pattern Docker rejects, fails the build (#274). + - 2026-10-02: In a window too narrow for the top bar, such as 390 px, the New App and Logout buttons move to a second row instead of running into "by @sneak"; the bar keeps a gap between its two sides at every width (#272). diff --git a/go.mod b/go.mod index b2de996..6dc00c9 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/joho/godotenv v1.5.1 github.com/mattn/go-sqlite3 v1.14.32 github.com/moby/buildkit v0.16.0 + github.com/moby/patternmatcher v0.6.0 github.com/oklog/ulid/v2 v2.1.1 github.com/prometheus/client_golang v1.23.2 github.com/spf13/viper v1.21.0 @@ -60,7 +61,6 @@ require ( github.com/klauspost/compress v1.18.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/locker v1.0.1 // indirect - github.com/moby/patternmatcher v0.6.0 // indirect github.com/moby/sys/sequential v0.6.0 // indirect github.com/moby/sys/signal v0.7.1 // indirect github.com/moby/sys/user v0.4.0 // indirect diff --git a/internal/docker/client.go b/internal/docker/client.go index 1247e0d..0e2bb0a 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -659,8 +659,16 @@ func (c *Client) performBuild( ) } - // Create tar archive of build context - tarArchive, err := archive.TarWithOptions(opts.ContextDir, &archive.TarOptions{}) + // Docker does not apply the app's .dockerignore to a build context sent + // as a tar, so leave out the files it names here, as docker build does. + excludes, err := readDockerignore(opts.ContextDir, opts.DockerfilePath) + if err != nil { + return "", fmt.Errorf("failed to create build context: %w", err) + } + + tarArchive, err := archive.TarWithOptions(opts.ContextDir, &archive.TarOptions{ + ExcludePatterns: excludes, + }) if err != nil { return "", fmt.Errorf("failed to create build context: %w", err) } diff --git a/internal/docker/dockerignore.go b/internal/docker/dockerignore.go new file mode 100644 index 0000000..46b293e --- /dev/null +++ b/internal/docker/dockerignore.go @@ -0,0 +1,62 @@ +package docker + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + + "github.com/moby/patternmatcher" + "github.com/moby/patternmatcher/ignorefile" +) + +// readDockerignore returns the patterns of the files to leave out of the +// build context in contextDir, read as docker build reads them for the +// Dockerfile at dockerfile, a path inside contextDir: from +// .dockerignore next to the Dockerfile if there is one, otherwise +// from .dockerignore at the root of the context. Without either file there +// are no patterns. +func readDockerignore(contextDir, dockerfile string) ([]string, error) { + if dockerfile == "" { + dockerfile = "Dockerfile" // what Docker builds when none is named + } + + name := dockerfile + ".dockerignore" + + file, err := os.Open(filepath.Join(contextDir, name)) + if errors.Is(err, fs.ErrNotExist) { + name = ".dockerignore" + file, err = os.Open(filepath.Join(contextDir, name)) + } + + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + + if err != nil { + return nil, fmt.Errorf("failed to read %s: %w", name, err) + } + + defer func() { _ = file.Close() }() + + excludes, err := ignorefile.ReadAll(file) + if err != nil { + return nil, fmt.Errorf("failed to read %s: %w", name, err) + } + + // Like the docker command line, never leave out .dockerignore or the + // Dockerfile: Docker reads the Dockerfile from the context. + for _, keep := range []string{".dockerignore", filepath.ToSlash(dockerfile)} { + excluded, err := patternmatcher.MatchesOrParentMatches(keep, excludes) + if err != nil { + return nil, fmt.Errorf("invalid pattern in %s: %w", name, err) + } + + if excluded { + excludes = append(excludes, "!"+keep) + } + } + + return excludes, nil +} diff --git a/internal/docker/dockerignore_test.go b/internal/docker/dockerignore_test.go new file mode 100644 index 0000000..e956c0a --- /dev/null +++ b/internal/docker/dockerignore_test.go @@ -0,0 +1,224 @@ +package docker //nolint:testpackage // tests the unexported performBuild + +import ( + "archive/tar" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/docker/docker/client" +) + +// TestPerformBuildFollowsDockerignore runs builds against a fake Docker API +// and checks which files the build context sent to it holds. +func TestPerformBuildFollowsDockerignore(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + dockerfile string + files map[string]string // path in the context: contents + want []string // files sent in the build context + }{ + { + name: "no ignore file", + dockerfile: "Dockerfile", + files: map[string]string{"Dockerfile": "", "main.go": ""}, + want: []string{"Dockerfile", "main.go"}, + }, + { + name: "excludes and re-includes", + dockerfile: "Dockerfile", + files: map[string]string{ + ".dockerignore": "secret.txt\n*.md\n!README.md\n", + "Dockerfile": "", + "NOTES.md": "", + "README.md": "", + "main.go": "", + "secret.txt": "", + }, + want: []string{".dockerignore", "Dockerfile", "README.md", "main.go"}, + }, + { + name: "keeps the Dockerfile and .dockerignore", + dockerfile: "Dockerfile", + files: map[string]string{ + ".dockerignore": "Dockerfile\n.dockerignore\nsecret.txt\n", + "Dockerfile": "", + "main.go": "", + "secret.txt": "", + }, + want: []string{".dockerignore", "Dockerfile", "main.go"}, + }, + { + name: "an ignore file next to the Dockerfile wins over .dockerignore", + dockerfile: "deploy/Dockerfile", + files: map[string]string{ + ".dockerignore": "main.go\n", + "deploy/Dockerfile": "", + "deploy/Dockerfile.dockerignore": "secret.txt\n", + "main.go": "", + "secret.txt": "", + }, + want: []string{ + ".dockerignore", + "deploy/Dockerfile", + "deploy/Dockerfile.dockerignore", + "main.go", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + contextDir := t.TempDir() + + for name, contents := range tt.files { + path := filepath.Join(contextDir, name) + + err := os.MkdirAll(filepath.Dir(path), 0o750) + if err != nil { + t.Fatal(err) + } + + err = os.WriteFile(path, []byte(contents), 0o600) + if err != nil { + t.Fatal(err) + } + } + + got, err := buildContextFiles(t, contextDir, tt.dockerfile) + if err != nil { + t.Fatal(err) + } + + if !slices.Equal(got, tt.want) { + t.Errorf("build context holds %q, want %q", got, tt.want) + } + }) + } +} + +// TestPerformBuildFailsOnMalformedDockerignore checks that a pattern the +// docker command line would reject fails the build. +func TestPerformBuildFailsOnMalformedDockerignore(t *testing.T) { + t.Parallel() + + contextDir := t.TempDir() + + err := os.WriteFile(filepath.Join(contextDir, ".dockerignore"), []byte("[\n"), 0o600) + if err != nil { + t.Fatal(err) + } + + _, err = buildContextFiles(t, contextDir, "Dockerfile") + + want := "failed to create build context: " + + "invalid pattern in .dockerignore: syntax error in pattern" + if err == nil || err.Error() != want { + t.Errorf("got error %v, want %q", err, want) + } +} + +// TestPerformBuildFailsOnUnreadableDockerignore checks that an ignore file +// that cannot be read, here because it is a directory, fails the build. +func TestPerformBuildFailsOnUnreadableDockerignore(t *testing.T) { + t.Parallel() + + contextDir := t.TempDir() + + err := os.Mkdir(filepath.Join(contextDir, ".dockerignore"), 0o750) + if err != nil { + t.Fatal(err) + } + + _, err = buildContextFiles(t, contextDir, "Dockerfile") + + want := "failed to create build context: failed to read .dockerignore: " + if err == nil || !strings.HasPrefix(err.Error(), want) { + t.Errorf("got error %v, want one starting %q", err, want) + } +} + +// buildContextFiles runs a build of contextDir against a fake Docker API and +// returns the names of the files in the build context sent to it, sorted. +func buildContextFiles(t *testing.T, contextDir, dockerfile string) ([]string, error) { + t.Helper() + + sent := make(chan []string, 1) + + srv := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/version"): + _, _ = w.Write([]byte(`{"Version":"27.3.1","ApiVersion":"1.47"}`)) + case strings.HasSuffix(r.URL.Path, "/session"): + serveSession(t, w, r, make(chan string, 1)) + case strings.HasSuffix(r.URL.Path, "/build"): + sent <- tarFileNames(t, r.Body) + default: + t.Errorf("unexpected request to %s", r.URL.Path) + } + }, + )) + t.Cleanup(srv.Close) + + dockerAPI, err := client.NewClientWithOpts( + client.WithHost("tcp://" + srv.Listener.Addr().String()), + ) + if err != nil { + t.Fatal(err) + } + + c := &Client{docker: dockerAPI, log: slog.Default()} + + _, err = c.performBuild(t.Context(), BuildImageOptions{ + ContextDir: contextDir, + DockerfilePath: dockerfile, + }) + if err != nil { + return nil, err + } + + return <-sent, nil +} + +// tarFileNames returns the names of the regular files in the tar read from r, +// sorted. +func tarFileNames(t *testing.T, r io.Reader) []string { + t.Helper() + + var names []string + + reader := tar.NewReader(r) + + for { + header, err := reader.Next() + if errors.Is(err, io.EOF) { + break + } + + if err != nil { + t.Errorf("reading the build context: %v", err) + + return nil + } + + if header.Typeflag == tar.TypeReg { + names = append(names, header.Name) + } + } + + slices.Sort(names) + + return names +}