Files
docker-cli/cli/command/image/build_test.go
Sebastiaan van Stijn ab230240ad test spring-cleaning
This makes a quick pass through our tests;

Discard output/err
----------------------------------------------

Many tests were testing for error-conditions, but didn't discard output.
This produced a lot of noise when running the tests, and made it hard
to discover if there were actual failures, or if the output was expected.
For example:

    === RUN   TestConfigCreateErrors
    Error: "create" requires exactly 2 arguments.
    See 'create --help'.

    Usage:  create [OPTIONS] CONFIG file|- [flags]

    Create a config from a file or STDIN
    Error: "create" requires exactly 2 arguments.
    See 'create --help'.

    Usage:  create [OPTIONS] CONFIG file|- [flags]

    Create a config from a file or STDIN
    Error: error creating config
    --- PASS: TestConfigCreateErrors (0.00s)

And after discarding output:

    === RUN   TestConfigCreateErrors
    --- PASS: TestConfigCreateErrors (0.00s)

Use sub-tests where possible
----------------------------------------------

Some tests were already set-up to use test-tables, and even had a usable
name (or in some cases "error" to check for). Change them to actual sub-
tests. Same test as above, but now with sub-tests and output discarded:

    === RUN   TestConfigCreateErrors
    === RUN   TestConfigCreateErrors/requires_exactly_2_arguments
    === RUN   TestConfigCreateErrors/requires_exactly_2_arguments#01
    === RUN   TestConfigCreateErrors/error_creating_config
    --- PASS: TestConfigCreateErrors (0.00s)
        --- PASS: TestConfigCreateErrors/requires_exactly_2_arguments (0.00s)
        --- PASS: TestConfigCreateErrors/requires_exactly_2_arguments#01 (0.00s)
        --- PASS: TestConfigCreateErrors/error_creating_config (0.00s)
    PASS

It's not perfect in all cases (in the above, there's duplicate "expected"
errors, but Go conveniently adds "#01" for the duplicate). There's probably
also various tests I missed that could still use the same changes applied;
we can improve these in follow-ups.

Set cmd.Args to prevent test-failures
----------------------------------------------

When running tests from my IDE, it compiles the tests before running,
then executes the compiled binary to run the tests. Cobra doesn't like
that, because in that situation `os.Args` is taken as argument for the
command that's executed. The command that's tested now sees the test-
flags as arguments (`-test.v -test.run ..`), which causes various tests
to fail ("Command XYZ does not accept arguments").

    # compile the tests:
    go test -c -o foo.test

    # execute the test:
    ./foo.test -test.v -test.run TestFoo
    === RUN   TestFoo
    Error: "foo" accepts no arguments.

The Cobra maintainers ran into the same situation, and for their own
use have added a special case to ignore `os.Args` in these cases;
https://github.com/spf13/cobra/blob/v1.8.1/command.go#L1078-L1083

    args := c.args

    // Workaround FAIL with "go test -v" or "cobra.test -test.v", see #155
    if c.args == nil && filepath.Base(os.Args[0]) != "cobra.test" {
        args = os.Args[1:]
    }

Unfortunately, that exception is too specific (only checks for `cobra.test`),
so doesn't automatically fix the issue for other test-binaries. They did
provide a `cmd.SetArgs()` utility for this purpose
https://github.com/spf13/cobra/blob/v1.8.1/command.go#L276-L280

    // SetArgs sets arguments for the command. It is set to os.Args[1:] by default, if desired, can be overridden
    // particularly useful when testing.
    func (c *Command) SetArgs(a []string) {
        c.args = a
    }

And the fix is to explicitly set the command's args to an empty slice to
prevent Cobra from falling back to using `os.Args[1:]` as arguments.

    cmd := newSomeThingCommand()
    cmd.SetArgs([]string{})

Some tests already take this issue into account, and I updated some tests
for this, but there's likely many other ones that can use the same treatment.

Perhaps the Cobra maintainers would accept a contribution to make their
condition less specific and to look for binaries ending with a `.test`
suffix (which is what compiled binaries usually are named as).

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2024-07-04 01:35:12 +02:00

220 lines
6.1 KiB
Go

package image
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"io"
"os"
"path/filepath"
"sort"
"testing"
"github.com/docker/cli/cli/streams"
"github.com/docker/cli/internal/test"
"github.com/docker/docker/api/types"
"github.com/docker/docker/pkg/archive"
"github.com/google/go-cmp/cmp"
"gotest.tools/v3/assert"
"gotest.tools/v3/fs"
"gotest.tools/v3/skip"
)
func TestRunBuildDockerfileFromStdinWithCompress(t *testing.T) {
t.Setenv("DOCKER_BUILDKIT", "0")
buffer := new(bytes.Buffer)
fakeBuild := newFakeBuild()
fakeImageBuild := func(ctx context.Context, buildContext io.Reader, options types.ImageBuildOptions) (types.ImageBuildResponse, error) {
tee := io.TeeReader(buildContext, buffer)
gzipReader, err := gzip.NewReader(tee)
assert.NilError(t, err)
return fakeBuild.build(ctx, gzipReader, options)
}
cli := test.NewFakeCli(&fakeClient{imageBuildFunc: fakeImageBuild})
dockerfile := bytes.NewBufferString(`
FROM alpine:frozen
COPY foo /
`)
cli.SetIn(streams.NewIn(io.NopCloser(dockerfile)))
dir := fs.NewDir(t, t.Name(),
fs.WithFile("foo", "some content"))
defer dir.Remove()
options := newBuildOptions()
options.compress = true
options.dockerfileName = "-"
options.context = dir.Path()
options.untrusted = true
assert.NilError(t, runBuild(context.TODO(), cli, options))
expected := []string{fakeBuild.options.Dockerfile, ".dockerignore", "foo"}
assert.DeepEqual(t, expected, fakeBuild.filenames(t))
header := buffer.Bytes()[:10]
assert.Equal(t, archive.Gzip, archive.DetectCompression(header))
}
func TestRunBuildResetsUidAndGidInContext(t *testing.T) {
skip.If(t, os.Getuid() != 0, "root is required to chown files")
t.Setenv("DOCKER_BUILDKIT", "0")
fakeBuild := newFakeBuild()
cli := test.NewFakeCli(&fakeClient{imageBuildFunc: fakeBuild.build})
dir := fs.NewDir(t, "test-build-context",
fs.WithFile("foo", "some content", fs.AsUser(65534, 65534)),
fs.WithFile("Dockerfile", `
FROM alpine:frozen
COPY foo bar /
`),
)
defer dir.Remove()
options := newBuildOptions()
options.context = dir.Path()
options.untrusted = true
assert.NilError(t, runBuild(context.TODO(), cli, options))
headers := fakeBuild.headers(t)
expected := []*tar.Header{
{Name: "Dockerfile"},
{Name: "foo"},
}
cmpTarHeaderNameAndOwner := cmp.Comparer(func(x, y tar.Header) bool {
return x.Name == y.Name && x.Uid == y.Uid && x.Gid == y.Gid
})
assert.DeepEqual(t, expected, headers, cmpTarHeaderNameAndOwner)
}
func TestRunBuildDockerfileOutsideContext(t *testing.T) {
t.Setenv("DOCKER_BUILDKIT", "0")
dir := fs.NewDir(t, t.Name(),
fs.WithFile("data", "data file"))
defer dir.Remove()
// Dockerfile outside of build-context
df := fs.NewFile(t, t.Name(),
fs.WithContent(`
FROM FOOBAR
COPY data /data
`),
)
defer df.Remove()
fakeBuild := newFakeBuild()
cli := test.NewFakeCli(&fakeClient{imageBuildFunc: fakeBuild.build})
options := newBuildOptions()
options.context = dir.Path()
options.dockerfileName = df.Path()
options.untrusted = true
assert.NilError(t, runBuild(context.TODO(), cli, options))
expected := []string{fakeBuild.options.Dockerfile, ".dockerignore", "data"}
assert.DeepEqual(t, expected, fakeBuild.filenames(t))
}
// TestRunBuildFromGitHubSpecialCase tests that build contexts
// starting with `github.com/` are special-cased, and the build command attempts
// to clone the remote repo.
// TODO: test "context selection" logic directly when runBuild is refactored
// to support testing (ex: docker/cli#294)
func TestRunBuildFromGitHubSpecialCase(t *testing.T) {
t.Setenv("DOCKER_BUILDKIT", "0")
cmd := NewBuildCommand(test.NewFakeCli(&fakeClient{}))
// Clone a small repo that exists so git doesn't prompt for credentials
cmd.SetArgs([]string{"github.com/docker/for-win"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
assert.ErrorContains(t, err, "unable to prepare context")
assert.ErrorContains(t, err, "docker-build-git")
}
// TestRunBuildFromLocalGitHubDir tests that a local directory
// starting with `github.com` takes precedence over the `github.com` special
// case.
func TestRunBuildFromLocalGitHubDir(t *testing.T) {
t.Setenv("DOCKER_BUILDKIT", "0")
buildDir := filepath.Join(t.TempDir(), "github.com", "docker", "no-such-repository")
err := os.MkdirAll(buildDir, 0o777)
assert.NilError(t, err)
err = os.WriteFile(filepath.Join(buildDir, "Dockerfile"), []byte("FROM busybox\n"), 0o644)
assert.NilError(t, err)
client := test.NewFakeCli(&fakeClient{})
cmd := NewBuildCommand(client)
cmd.SetArgs([]string{buildDir})
cmd.SetOut(io.Discard)
err = cmd.Execute()
assert.NilError(t, err)
}
func TestRunBuildWithSymlinkedContext(t *testing.T) {
t.Setenv("DOCKER_BUILDKIT", "0")
dockerfile := `
FROM alpine:frozen
RUN echo hello world
`
tmpDir := fs.NewDir(t, t.Name(),
fs.WithDir("context",
fs.WithFile("Dockerfile", dockerfile)),
fs.WithSymlink("context-link", "context"))
defer tmpDir.Remove()
fakeBuild := newFakeBuild()
cli := test.NewFakeCli(&fakeClient{imageBuildFunc: fakeBuild.build})
options := newBuildOptions()
options.context = tmpDir.Join("context-link")
options.untrusted = true
assert.NilError(t, runBuild(context.TODO(), cli, options))
assert.DeepEqual(t, fakeBuild.filenames(t), []string{"Dockerfile"})
}
type fakeBuild struct {
context *tar.Reader
options types.ImageBuildOptions
}
func newFakeBuild() *fakeBuild {
return &fakeBuild{}
}
func (f *fakeBuild) build(_ context.Context, buildContext io.Reader, options types.ImageBuildOptions) (types.ImageBuildResponse, error) {
f.context = tar.NewReader(buildContext)
f.options = options
body := new(bytes.Buffer)
return types.ImageBuildResponse{Body: io.NopCloser(body)}, nil
}
func (f *fakeBuild) headers(t *testing.T) []*tar.Header {
t.Helper()
headers := []*tar.Header{}
for {
hdr, err := f.context.Next()
switch err {
case io.EOF:
return headers
case nil:
headers = append(headers, hdr)
default:
assert.NilError(t, err)
}
}
}
func (f *fakeBuild) filenames(t *testing.T) []string {
t.Helper()
names := []string{}
for _, header := range f.headers(t) {
names = append(names, header.Name)
}
sort.Strings(names)
return names
}