From 4c02348d973dff1371906de1d0f3e90a7c761e6d Mon Sep 17 00:00:00 2001 From: ClaytonNorthey92 Date: Thu, 14 Nov 2019 09:39:49 -0500 Subject: [PATCH] feature: Context as io.Reader resolves #110 Added a ContextArchive attribute to the FromDockerfile struct, this allows a user to pass in an io.Reader as the Docker build context. A user will now be allowed to create a dynamic build in code and send it to the build. Added test cases for both scenarios (using a local filepath and an archive). Added example to README. --- README.md | 27 +++++- container.go | 37 ++++++-- container_test.go | 165 ++++++++++++++++++++++++++++++++++ docker.go | 5 +- testresources/echo.Dockerfile | 3 + 5 files changed, 227 insertions(+), 10 deletions(-) create mode 100644 testresources/echo.Dockerfile diff --git a/README.md b/README.md index 244a77e01a..ed372fc10d 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,8 @@ nginxC.Terminate(ctx, t)`. `t` is `*testing.T` and it is used to notify is the Testcontainers-go gives you the ability to build and image and run a container from a Dockerfile. -You can do so by specifiying a `Context` and optionally a `Dockerfile` (defaults to "Dockerfile") like so: +You can do so by specifiying a `Context` (the filepath to the build context on your local filesystem) +and optionally a `Dockerfile` (defaults to "Dockerfile") like so: ``` req := ContainerRequest{ @@ -78,6 +79,30 @@ req := ContainerRequest{ } ``` +### Dynamic Build Context + +If you would like to send a build context that you created in code (maybe you have a dynamic Dockerfile), you can +send the build context as an `io.Reader` since the Docker Daemon accepts is as a tar file, you can use the [tar](https://golang.org/pkg/archive/tar/) package to create your context. + + +To do this you would use the `ContextArchive` attribute in the `FromDockerfile` struct. + +```go +var buf bytes.Buffer +tarWriter := tar.NewWriter(&buf) +// ... add some files +if err := tarWriter.Close(); err != nil { + // do something with err +} +reader := bytes.NewReader(buf.Bytes()) +fromDockerfile := testcontainers.FromDockerfile{ + ContextArchive: reader, +} +``` + +**Please Note** if you specify a `ContextArchive` this will cause testcontainers to ignore the path passed +in to `Context` + ## Sending a CMD to a Container If you would like to send a CMD (command) to a container, you can pass it in to the container request via the `Cmd` field... diff --git a/container.go b/container.go index 5a6c753bfc..bdb3f9dd3e 100644 --- a/container.go +++ b/container.go @@ -4,6 +4,7 @@ import ( "context" "io" + "github.com/docker/docker/pkg/archive" "github.com/docker/go-connections/nat" "github.com/pkg/errors" "github.com/testcontainers/testcontainers-go/wait" @@ -43,15 +44,17 @@ type Container interface { // ImageBuildInfo defines what is needed to build an image type ImageBuildInfo interface { - GetContext() string // the path to the build context - GetDockerfile() string // the relative path to the Dockerfile, including the fileitself + GetContext() (io.Reader, error) // the path to the build context + GetDockerfile() string // the relative path to the Dockerfile, including the fileitself + ShouldBuildImage() bool // return true if the image needs to be built } // FromDockerfile represents the parameters needed to build an image from a Dockerfile // rather than using a pre-built one type FromDockerfile struct { - Context string // the path to the context of of the docker build - Dockerfile string // the path from the context to the Dockerfile for the image, defaults to "Dockerfile" + Context string // the path to the context of of the docker build + ContextArchive io.Reader // the tar archive file to send to docker that contains the build context + Dockerfile string // the path from the context to the Dockerfile for the image, defaults to "Dockerfile" } // ContainerRequest represents the parameters used to get a running container @@ -99,6 +102,7 @@ func (c *ContainerRequest) Validate() error { validationMethods := []func() error{ c.validateContextAndImage, + c.validateContexOrImageIsSpecified, } var err error @@ -113,8 +117,17 @@ func (c *ContainerRequest) Validate() error { } // GetContext retrieve the build context for the request -func (c *ContainerRequest) GetContext() string { - return c.FromDockerfile.Context +func (c *ContainerRequest) GetContext() (io.Reader, error) { + if c.ContextArchive != nil { + return c.ContextArchive, nil + } + + buildContext, err := archive.TarWithOptions(c.Context, &archive.TarOptions{}) + if err != nil { + return nil, err + } + + return buildContext, nil } // GetDockerfile returns the Dockerfile from the ContainerRequest, defaults to "Dockerfile" @@ -127,6 +140,10 @@ func (c *ContainerRequest) GetDockerfile() string { return f } +func (c *ContainerRequest) ShouldBuildImage() bool { + return c.FromDockerfile.Context != "" || c.FromDockerfile.ContextArchive != nil +} + func (c *ContainerRequest) validateContextAndImage() error { if c.FromDockerfile.Context != "" && c.Image != "" { return errors.New("you cannot specify both an Image and Context in a ContainerRequest") @@ -134,3 +151,11 @@ func (c *ContainerRequest) validateContextAndImage() error { return nil } + +func (c *ContainerRequest) validateContexOrImageIsSpecified() error { + if c.FromDockerfile.Context == "" && c.FromDockerfile.ContextArchive == nil && c.Image == "" { + return errors.New("you must specify either a build context or an image") + } + + return nil +} diff --git a/container_test.go b/container_test.go index 5120a45268..52c33390fe 100644 --- a/container_test.go +++ b/container_test.go @@ -1,9 +1,15 @@ package testcontainers import ( + "archive/tar" + "bytes" + "context" + "io" "testing" + "time" "github.com/pkg/errors" + "github.com/testcontainers/testcontainers-go/wait" ) func Test_ContainerValidation(t *testing.T) { @@ -100,3 +106,162 @@ func Test_GetDockerfile(t *testing.T) { }) } } + +func Test_BuildImageWithContexts(t *testing.T) { + type TestCase struct { + Name string + ContextPath string + ContextArchive func() (io.Reader, error) + ExpectedEchoOutput string + Dockerfile string + ExpectedError error + } + + testCases := []TestCase{ + TestCase{ + Name: "test build from context archive", + ContextArchive: func() (io.Reader, error) { + var buf bytes.Buffer + tarWriter := tar.NewWriter(&buf) + files := []struct { + Name string + Contents string + }{ + { + Name: "Dockerfile", + Contents: `FROM alpine + CMD ["echo", "this is from the archive"]`, + }, + } + + for _, f := range files { + header := tar.Header{ + Name: f.Name, + Mode: 0777, + Size: int64(len(f.Contents)), + Typeflag: tar.TypeReg, + Format: tar.FormatGNU, + } + + if err := tarWriter.WriteHeader(&header); err != nil { + return nil, err + } + + if _, err := tarWriter.Write([]byte(f.Contents)); err != nil { + return nil, err + } + + if err := tarWriter.Close(); err != nil { + return nil, err + } + } + + reader := bytes.NewReader(buf.Bytes()) + + return reader, nil + }, + ExpectedEchoOutput: "this is from the archive", + }, + TestCase{ + Name: "test build from context archive and be able to use files in it", + ContextArchive: func() (io.Reader, error) { + var buf bytes.Buffer + tarWriter := tar.NewWriter(&buf) + files := []struct { + Name string + Contents string + }{ + { + Name: "say_hi.sh", + Contents: `echo hi this is from the say_hi.sh file!`, + }, + { + Name: "Dockerfile", + Contents: `FROM alpine + WORKDIR /app + COPY . . + CMD ["sh", "./say_hi.sh"]`, + }, + } + + for _, f := range files { + header := tar.Header{ + Name: f.Name, + Mode: 0777, + Size: int64(len(f.Contents)), + Typeflag: tar.TypeReg, + Format: tar.FormatGNU, + } + + if err := tarWriter.WriteHeader(&header); err != nil { + return nil, err + } + + if _, err := tarWriter.Write([]byte(f.Contents)); err != nil { + return nil, err + } + } + + if err := tarWriter.Close(); err != nil { + return nil, err + } + + reader := bytes.NewReader(buf.Bytes()) + + return reader, nil + }, + ExpectedEchoOutput: "hi this is from the say_hi.sh file!", + }, + TestCase{ + Name: "test buildling from a context on the filesystem", + ContextPath: "./testresources", + Dockerfile: "echo.Dockerfile", + ExpectedEchoOutput: "this is from the echo test Dockerfile", + ContextArchive: func() (io.Reader, error) { + return nil, nil + }, + }, + TestCase{ + Name: "it should error if neither a context nor a context archive are specified", + ContextPath: "", + ContextArchive: func() (io.Reader, error) { + return nil, nil + }, + ExpectedError: errors.New("failed to create container: you must specify either a build context or an image"), + }, + } + + for _, testCase := range testCases { + t.Run(testCase.Name, func(t *testing.T) { + ctx := context.Background() + a, err := testCase.ContextArchive() + if err != nil { + t.Fatal(err) + } + req := ContainerRequest{ + FromDockerfile: FromDockerfile{ + ContextArchive: a, + Context: testCase.ContextPath, + Dockerfile: testCase.Dockerfile, + }, + WaitingFor: wait.ForLog(testCase.ExpectedEchoOutput).WithStartupTimeout(1 * time.Minute), + } + + c, err := GenericContainer(ctx, GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if testCase.ExpectedError != nil && err != nil { + if testCase.ExpectedError.Error() != err.Error() { + t.Fatalf("unexpected error: %s, was expecting %s", err.Error(), testCase.ExpectedError.Error()) + } + } else if err != nil { + t.Fatal(err) + } else { + c.Terminate(ctx) + } + + }) + + } +} diff --git a/docker.go b/docker.go index 91514372a9..1ba1b647ef 100644 --- a/docker.go +++ b/docker.go @@ -17,7 +17,6 @@ import ( "github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/network" "github.com/docker/docker/client" - "github.com/docker/docker/pkg/archive" "github.com/docker/go-connections/nat" "github.com/pkg/errors" @@ -275,7 +274,7 @@ func (p *DockerProvider) BuildImage(ctx context.Context, img ImageBuildInfo) (st repoTag := fmt.Sprintf("%s:%s", repo, tag) - buildContext, err := archive.TarWithOptions(img.GetContext(), &archive.TarOptions{}) + buildContext, err := img.GetContext() if err != nil { return "", err } @@ -344,7 +343,7 @@ func (p *DockerProvider) CreateContainer(ctx context.Context, req ContainerReque } var tag string - if req.FromDockerfile.Context != "" { + if req.ShouldBuildImage() { tag, err = p.BuildImage(ctx, &req) if err != nil { return nil, err diff --git a/testresources/echo.Dockerfile b/testresources/echo.Dockerfile new file mode 100644 index 0000000000..36951e1aa6 --- /dev/null +++ b/testresources/echo.Dockerfile @@ -0,0 +1,3 @@ +FROM alpine + +CMD ["echo", "this is from the echo test Dockerfile"] \ No newline at end of file