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 archives []int // sizes of accepted CopyToContainer tar streams failBuild bool failBuildHTTP bool failCreate bool failStart bool failStop bool failWait bool failVolumeCreate 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 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.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" ) // 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" + "# clone: create a real worktree so seeding (tarDir) has files to copy\n" + "if [ \"$1\" = clone ]; then d=$(eval \"echo \\${$#}\"); mkdir -p \"$d\" && printf 'fake-repo\n' > \"$d/README.md\"; fi\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(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 }