Check / check (pull_request) Skipped
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, with the ignorefile reader of moby/patternmatcher: <Dockerfile>.dockerignore next to the Dockerfile if there is one, otherwise .dockerignore at the root. As Docker does, the Dockerfile path is read as a path inside the clone, and when a Dockerfile named Dockerfile is missing, a lowercase dockerfile next to it is used instead. As the docker command line does, the Dockerfile and .dockerignore are never left out. The ignore file is read through os.Root. An ignore file that cannot be read, or a malformed pattern, fails the build. Model: opus-5-5
297 lines
7.8 KiB
Go
297 lines
7.8 KiB
Go
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
|
|
}
|