Files
lvmh/daemon/docker_test.go
T

394 lines
11 KiB
Go

package main
// docker_test.go — spawner pipeline against a fake docker REST API and a
// fake git binary (PATH shim recording invocations).
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// --- fake docker daemon (just enough REST for the spawner pipeline) ---
type dockerCall struct {
Method string
Path string
Query string
Body string
}
func (c dockerCall) String() string { return c.Method + " " + c.Path + "?" + c.Query }
// recordedCreate is the parsed POST /containers/create request body.
type recordedCreate struct {
Name string
Image string `json:"Image"`
Env []string `json:"Env"`
Labels map[string]string `json:"Labels"`
Cmd []string `json:"Cmd"`
HostConfig struct {
Binds []string `json:"Binds"`
NetworkMode string `json:"NetworkMode"`
Init *bool `json:"Init"`
} `json:"HostConfig"`
}
type fakeDocker struct {
mu sync.Mutex
calls []dockerCall
images int // entries served by GET /images/json
volume map[string]bool // existing volumes
nextID int
create []recordedCreate
archives []int // sizes of accepted CopyToContainer tar streams
failBuild bool
failBuildHTTP bool
failCreate bool
failStart bool
failStop bool
failWait bool
failVolumeCreate bool
failVolumeDelete bool
failVolumeInspect bool
failArchive bool
archiveHang bool
waitHang bool
unknown []string
}
func newFakeDocker() *fakeDocker {
return &fakeDocker{images: 1, volume: map[string]bool{}}
}
func (f *fakeDocker) server(t *testing.T) *httptest.Server {
t.Helper()
ts := httptest.NewServer(f)
t.Cleanup(ts.Close)
return ts
}
// record appends a call; the /vX.Y version prefix negotiated by the SDK is
// stripped so assertions are version-agnostic.
func (f *fakeDocker) record(r *http.Request, body string) dockerCall {
path := r.URL.Path
if strings.HasPrefix(path, "/v") {
if i := strings.Index(path[1:], "/"); i >= 0 {
path = path[i+1:]
}
}
c := dockerCall{Method: r.Method, Path: path, Query: r.URL.RawQuery, Body: body}
f.mu.Lock()
f.calls = append(f.calls, c)
f.mu.Unlock()
return c
}
func (f *fakeDocker) hasCall(method, pathPrefix string) bool {
f.mu.Lock()
defer f.mu.Unlock()
for _, c := range f.calls {
if c.Method == method && strings.HasPrefix(c.Path, pathPrefix) {
return true
}
}
return false
}
func (f *fakeDocker) hasCallSuffix(method, suffix string) bool {
f.mu.Lock()
defer f.mu.Unlock()
for _, c := range f.calls {
if c.Method == method && strings.HasSuffix(c.Path, suffix) {
return true
}
}
return false
}
func (f *fakeDocker) countCalls(method, pathPrefix string) int {
f.mu.Lock()
defer f.mu.Unlock()
n := 0
for _, c := range f.calls {
if c.Method == method && strings.HasPrefix(c.Path, pathPrefix) {
n++
}
}
return n
}
func (f *fakeDocker) createsByName(prefix string) []recordedCreate {
f.mu.Lock()
defer f.mu.Unlock()
var out []recordedCreate
for _, c := range f.create {
if strings.HasPrefix(c.Name, prefix) {
out = append(out, c)
}
}
return out
}
func (f *fakeDocker) volumeExists(name string) bool {
f.mu.Lock()
defer f.mu.Unlock()
return f.volume[name]
}
func (f *fakeDocker) assertNoUnknown(t *testing.T) {
t.Helper()
f.mu.Lock()
defer f.mu.Unlock()
if len(f.unknown) > 0 {
t.Fatalf("fake docker got unhandled requests: %v", f.unknown)
}
}
func writeJSONNow(w http.ResponseWriter, status int, v string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = io.WriteString(w, v)
}
func (f *fakeDocker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
call := f.record(r, string(body))
switch {
case call.Path == "/_ping":
w.Header().Set("Api-Version", "1.44")
w.WriteHeader(http.StatusOK)
case call.Method == http.MethodGet && call.Path == "/images/json":
list := "[]"
if f.images > 0 {
list = `[{"Id":"sha256:abc","RepoTags":["lvmh-worker:latest"]}]`
}
writeJSONNow(w, http.StatusOK, list)
case call.Method == http.MethodPost && call.Path == "/build":
if f.failBuildHTTP {
writeJSONNow(w, http.StatusInternalServerError, `{"message":"build endpoint broken"}`)
return
}
if f.failBuild {
writeJSONNow(w, http.StatusOK, `{"errorDetail":{"message":"build exploded"},"error":"build exploded"}`+"\n")
return
}
writeJSONNow(w, http.StatusOK, `{"stream":"Successfully tagged lvmh-worker:latest"}`+"\n")
case call.Method == http.MethodPost && call.Path == "/volumes/create":
var req struct {
Name string `json:"Name"`
}
_ = json.Unmarshal(body, &req)
f.mu.Lock()
f.volume[req.Name] = true
f.mu.Unlock()
if f.failVolumeCreate {
writeJSONNow(w, http.StatusInternalServerError, `{"message":"volume create failed"}`)
return
}
writeJSONNow(w, http.StatusCreated, fmt.Sprintf(`{"Name":%q,"CreatedAt":"2024-01-01T00:00:00Z"}`, req.Name))
case call.Method == http.MethodGet && strings.HasPrefix(call.Path, "/volumes/"):
name := strings.TrimPrefix(call.Path, "/volumes/")
f.mu.Lock()
exists := f.volume[name]
f.mu.Unlock()
if f.failVolumeInspect {
writeJSONNow(w, http.StatusInternalServerError, `{"message":"transient docker error"}`)
return
}
if exists {
writeJSONNow(w, http.StatusOK, fmt.Sprintf(`{"Name":%q,"CreatedAt":"2024-01-01T00:00:00Z"}`, name))
return
}
writeJSONNow(w, http.StatusNotFound, `{"message":"no such volume"}`)
case call.Method == http.MethodDelete && strings.HasPrefix(call.Path, "/volumes/"):
if f.failVolumeDelete {
writeJSONNow(w, http.StatusInternalServerError, `{"message":"volume delete failed"}`)
return
}
name := strings.TrimPrefix(call.Path, "/volumes/")
f.mu.Lock()
delete(f.volume, name)
f.mu.Unlock()
w.WriteHeader(http.StatusNoContent)
case call.Method == http.MethodPost && call.Path == "/containers/create":
var rc recordedCreate
if err := json.Unmarshal(body, &rc); err != nil {
writeJSONNow(w, http.StatusBadRequest, `{"message":"bad create body"}`)
return
}
rc.Name = r.URL.Query().Get("name")
if f.failCreate {
writeJSONNow(w, http.StatusInternalServerError, `{"message":"create failed"}`)
return
}
f.mu.Lock()
f.nextID++
id := fmt.Sprintf("cid-%d", f.nextID)
f.create = append(f.create, rc)
f.mu.Unlock()
writeJSONNow(w, http.StatusCreated, fmt.Sprintf(`{"Id":%q,"Warnings":null}`, id))
case call.Method == http.MethodPost && strings.HasSuffix(call.Path, "/start"):
if f.failStart {
writeJSONNow(w, http.StatusInternalServerError, `{"message":"start failed"}`)
return
}
w.WriteHeader(http.StatusNoContent)
case call.Method == http.MethodPost && strings.HasSuffix(call.Path, "/stop"):
if f.failStop {
writeJSONNow(w, http.StatusInternalServerError, `{"message":"stop failed"}`)
return
}
w.WriteHeader(http.StatusNoContent)
case call.Method == http.MethodPost && strings.HasSuffix(call.Path, "/wait"):
if f.failWait {
writeJSONNow(w, http.StatusInternalServerError, `{"message":"wait failed"}`)
return
}
if f.waitHang {
<-r.Context().Done() // hold the connection until the client gives up
return
}
writeJSONNow(w, http.StatusOK, `{"StatusCode":0,"Error":null}`)
case call.Method == http.MethodPut && strings.HasSuffix(call.Path, "/archive"):
// CopyToContainer: accept the tar stream and record its size.
if f.archiveHang {
<-r.Context().Done()
return
}
if f.failArchive {
writeJSONNow(w, http.StatusInternalServerError, `{"message":"archive failed"}`)
return
}
f.mu.Lock()
f.archives = append(f.archives, len(body))
f.mu.Unlock()
w.WriteHeader(http.StatusOK)
case call.Method == http.MethodDelete && strings.HasPrefix(call.Path, "/containers/"):
w.WriteHeader(http.StatusNoContent)
default:
f.mu.Lock()
f.unknown = append(f.unknown, call.String())
f.mu.Unlock()
writeJSONNow(w, http.StatusNotFound, `{"message":"not implemented: `+call.String()+`"}`)
}
}
// --- fake git (PATH shim): logs invocations, optionally fails ---
const (
fakeGitModeOK string = "ok"
fakeGitModeFail string = "fail"
fakeGitModeNoisy string = "noisy"
fakeGitModeSilent string = "silent"
fakeGitModeHang string = "hang"
)
// useFakeGit puts a shim `git` first on PATH. Modes: ok (exit 0), fail
// (stderr + exit 1), noisy (600-byte stderr + exit 1, for truncation tests),
// hang (sleeps; only ctx cancellation can end it).
func useFakeGit(t *testing.T, mode string) string {
t.Helper()
dir := t.TempDir()
logPath := filepath.Join(t.TempDir(), "git-calls.log")
script := "#!/bin/sh\n" +
"printf '%s\\n' \"$*\" >> \"" + logPath + "\"\n" +
"case \"$FAKE_GIT_MODE\" in\n" +
" fail) echo 'fatal: repository not found'; exit 1;;\n" +
" noisy) printf '%s\\n' \"" + strings.Repeat("x", 600) + "\"; exit 1;;\n" +
" silent) exit 1;;\n" +
" hang) exec sleep 30;;\n" +
"esac\n" +
"# branch probe: emit the configured HEAD name\n" +
"if [ \"$1\" = '-C' ] && [ \"$3\" = rev-parse ]; then printf '%s' \"$FAKE_GIT_HEAD\"; exit 0; fi\n" +
"# clone: create a real worktree so seeding (tarDir) has files to copy\n" +
"# (clone may be $1 or $3, depending on leading -c auth args)\n" +
"case \" $* \" in *\" clone \"*) d=$(eval \"echo \\${$#}\"); mkdir -p \"$d\" && printf 'fake-repo\\n' > \"$d/README.md\";; esac\n" +
"exit 0\n"
if err := os.WriteFile(filepath.Join(dir, "git"), []byte(script), 0o755); err != nil {
t.Fatalf("write fake git: %v", err)
}
t.Setenv("PATH", dir+":"+os.Getenv("PATH"))
t.Setenv("FAKE_GIT_MODE", mode)
return logPath
}
func readGitLog(t *testing.T, path string) []string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
t.Fatalf("read git log: %v", err)
}
var out []string
for _, line := range strings.Split(strings.TrimSpace(string(b)), "\n") {
if line != "" {
out = append(out, line)
}
}
return out
}
// --- spawner construction wired at the fake daemon ---
func newTestSpawner(t *testing.T, f *fakeDocker) (*Spawner, *Store) {
t.Helper()
ts := f.server(t)
t.Setenv("DOCKER_HOST", "tcp://"+ts.Listener.Addr().String())
buildCtx := t.TempDir()
dockerfile := filepath.Join(buildCtx, "docker", "worker.Dockerfile")
if err := os.MkdirAll(filepath.Dir(dockerfile), 0o755); err != nil {
t.Fatalf("mkdir dockerfile dir: %v", err)
}
if err := os.WriteFile(dockerfile, []byte("FROM scratch\n"), 0o644); err != nil {
t.Fatalf("write dockerfile: %v", err)
}
if err := os.WriteFile(filepath.Join(buildCtx, "app.txt"), []byte("app"), 0o644); err != nil {
t.Fatalf("write app.txt: %v", err)
}
t.Setenv(envWorkerDockerfile, dockerfile)
t.Setenv(envWorkerContext, buildCtx)
t.Setenv(envRepoDir, t.TempDir())
t.Setenv(envProviderAPIKey, "key-123")
daemonToken = testToken
store := openTestStore(t)
hub := NewHub(store)
sp, err := NewSpawner(context.Background(), store, hub, "https://gitlab.example/")
if err != nil {
t.Fatalf("NewSpawner: %v", err)
}
return sp, store
}
// waitJobState polls JobsSnapshot until the job for sessionID reaches want.
func waitJobState(t *testing.T, sp *Spawner, sessionID, want string) SpawnJob {
t.Helper()
var job SpawnJob
waitFor(t, 5*time.Second, func() bool {
for _, j := range sp.JobsSnapshot() {
if j.SessionID == sessionID {
job = j
return j.State == want
}
}
return false
})
return job
}