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" ) // File names used in more than one test build context, as constants to // satisfy the goconst linter. const ( testMainGo = "main.go" testSecretFile = "secret.txt" testDeployDockerfile = "deploy/Dockerfile" testLowercaseDockerfile = "dockerfile" ) // 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: defaultDockerfileName, files: map[string]string{defaultDockerfileName: "", testMainGo: ""}, want: []string{defaultDockerfileName, testMainGo}, }, { name: "excludes and re-includes", dockerfile: defaultDockerfileName, files: map[string]string{ defaultDockerignoreName: "secret.txt\n*.md\n!README.md\n", defaultDockerfileName: "", "NOTES.md": "", "README.md": "", testMainGo: "", testSecretFile: "", }, want: []string{ defaultDockerignoreName, defaultDockerfileName, "README.md", testMainGo, }, }, { name: "keeps the Dockerfile and .dockerignore", dockerfile: defaultDockerfileName, files: map[string]string{ defaultDockerignoreName: "Dockerfile\n.dockerignore\nsecret.txt\n", defaultDockerfileName: "", testMainGo: "", testSecretFile: "", }, want: []string{defaultDockerignoreName, defaultDockerfileName, testMainGo}, }, { name: "keeps a lowercase dockerfile built in place of a missing Dockerfile", dockerfile: defaultDockerfileName, files: map[string]string{ defaultDockerignoreName: "*\n", testLowercaseDockerfile: "", }, want: []string{defaultDockerignoreName, testLowercaseDockerfile}, }, { name: "reads a lowercase dockerfile's own ignore file", dockerfile: defaultDockerfileName, files: map[string]string{ defaultDockerignoreName: "main.go\n", testLowercaseDockerfile: "", "dockerfile.dockerignore": "secret.txt\n", testMainGo: "", testSecretFile: "", }, want: []string{ defaultDockerignoreName, testLowercaseDockerfile, "dockerfile.dockerignore", testMainGo, }, }, { name: "an ignore file next to the Dockerfile wins over .dockerignore", dockerfile: testDeployDockerfile, files: map[string]string{ defaultDockerignoreName: "main.go\n", testDeployDockerfile: "", "deploy/Dockerfile.dockerignore": "secret.txt\n", testMainGo: "", testSecretFile: "", }, want: []string{ defaultDockerignoreName, testDeployDockerfile, "deploy/Dockerfile.dockerignore", testMainGo, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() contextDir := t.TempDir() writeFiles(t, contextDir, tt.files) 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) } }) } } // TestPerformBuildReadsDockerfilePathInsideContext checks that ./Dockerfile // and /Dockerfile name the Dockerfile at the root of the context, as Docker // reads them, so an ignore file that names the Dockerfile does not leave it // out. func TestPerformBuildReadsDockerfilePathInsideContext(t *testing.T) { t.Parallel() for _, dockerfile := range []string{"./Dockerfile", "/Dockerfile"} { t.Run(dockerfile, func(t *testing.T) { t.Parallel() contextDir := t.TempDir() writeFiles(t, contextDir, map[string]string{ defaultDockerignoreName: "Dockerfile\nsecret.txt\n", defaultDockerfileName: "", testMainGo: "", testSecretFile: "", }) got, err := buildContextFiles(t, contextDir, dockerfile) if err != nil { t.Fatal(err) } want := []string{defaultDockerignoreName, defaultDockerfileName, testMainGo} if !slices.Equal(got, want) { t.Errorf("build context holds %q, want %q", got, 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() writeFiles(t, contextDir, map[string]string{defaultDockerignoreName: "[\n"}) _, err := buildContextFiles(t, contextDir, defaultDockerfileName) 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, defaultDockerignoreName), 0o750) if err != nil { t.Fatal(err) } _, err = buildContextFiles(t, contextDir, defaultDockerfileName) 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) } } // writeFiles writes files, a map of paths inside dir to their contents, // creating the directories they are in. func writeFiles(t *testing.T, dir string, files map[string]string) { t.Helper() for name, contents := range files { path := filepath.Join(dir, 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) } } } // 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 }