352 lines
9.6 KiB
Go
352 lines
9.6 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 (
|
|
"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"`
|
|
} `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
|
|
|
|
failBuild bool
|
|
failBuildHTTP bool
|
|
failCreate bool
|
|
failStart bool
|
|
failStop bool
|
|
failWait bool
|
|
failVolumeCreate 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 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.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.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"
|
|
)
|
|
|
|
// 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).
|
|
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" +
|
|
"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)
|
|
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")
|
|
t.Setenv(envWorkerModels, "") // fall back to default path (absent) → no models bind
|
|
|
|
daemonToken = testToken
|
|
store := openTestStore(t)
|
|
hub := NewHub(store)
|
|
sp, err := NewSpawner(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
|
|
}
|