package main // api_prepare_test.go — POST /api/repos/{repo}/prepare (ops prompt routing), // GET /api/repos built flags, POST /api/spawn imageUsed resolution. import ( "context" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" ) // TestAPIPrepareRepoRoutesToOps: with a live ops agent WS the prepare route // delivers the prompt; without it the route 409s. func TestAPIPrepareRepoRoutesToOps(t *testing.T) { useFakeGit(t, fakeGitModeOK) f := newFakeDocker() tsD := f.server(t) t.Setenv("DOCKER_HOST", "tcp://"+tsD.Listener.Addr().String()) buildCtx := t.TempDir() if err := os.MkdirAll(filepath.Join(buildCtx, "docker"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(buildCtx, "docker", "worker.Dockerfile"), []byte("FROM scratch\n"), 0o644); err != nil { t.Fatal(err) } t.Setenv(envWorkerDockerfile, filepath.Join(buildCtx, "docker", "worker.Dockerfile")) t.Setenv(envWorkerContext, buildCtx) t.Setenv(envRepoDir, t.TempDir()) t.Setenv(envControlDockerfile, filepath.Join(buildCtx, "docker", "control.Dockerfile")) if err := os.WriteFile(filepath.Join(buildCtx, "docker", "control.Dockerfile"), []byte("FROM scratch\n"), 0o644); err != nil { t.Fatal(err) } t.Setenv("LVMH_TEST_OPS_BOOT_WAIT", "3s") daemonToken = testToken store := openTestStore(t) hub := NewHub(store) sp, err := NewSpawner(context.Background(), store, hub, "https://gitlab.example/") if err != nil { t.Fatal(err) } srv := &Server{store: store, hub: hub, spawn: sp, gitlab: NewGitLab(store, "https://gitlab.example")} ts := httptest.NewServer(srv.Routes("")) t.Cleanup(ts.Close) auth := testToken prepareURL := ts.URL + "/api/repos/g/p/prepare" // ops offline (and cannot come online — no real agent) → 409 after short wait if code, body := apiReq(t, http.MethodPost, prepareURL, auth, ""); code != http.StatusConflict { t.Fatalf("prepare offline = %d %s, want 409", code, body) } // path validation for _, path := range []string{"/api/repos/noslash/prepare", "/api/repos/g/p/notprepare"} { if code, _ := apiReq(t, http.MethodPost, ts.URL+path, auth, ""); code != http.StatusBadRequest { t.Fatalf("POST %s = %d, want 400", path, code) } } if code, _ := apiReq(t, http.MethodPost, prepareURL, "", ""); code != http.StatusUnauthorized { t.Fatal("prepare must require auth") } // online ops: prompt delivered on the ops session WS; the reset removes // the ops container + wipes its transcript first. if err := store.UpsertSession(SessionInfo{ID: opsSessionID}); err != nil { t.Fatal(err) } if err := store.AppendEvent(Event{SessionID: opsSessionID, Seq: 1, TS: 1, Type: "message_end", Payload: json.RawMessage(`{}`)}); err != nil { t.Fatal(err) } ws := dialAgent(t, ts) if err := ws.WriteJSON(helloFrame(opsSessionID)); err != nil { t.Fatalf("hello: %v", err) } if welcome := readFrame(t, ws); welcome["type"] != evWelcome { t.Fatalf("welcome = %v", welcome) } code, body := apiReq(t, http.MethodPost, ts.URL+"/api/repos/g%2Fp/prepare", auth, "") if code != http.StatusOK || !strings.Contains(body, `"ok":true`) { t.Fatalf("prepare = %d %s, want 200 ok", code, body) } prompt := readFrame(t, ws) if prompt["type"] != evPrompt { t.Fatalf("prompt frame = %v", prompt) } want := "prepare g/p: clone, build a worker image, register it" if prompt["message"] != want { t.Fatalf("prompt message = %q, want %q", prompt["message"], want) } // transcript wiped by the reset evs, err := store.EventsAfter(opsSessionID, 0, 100) if err != nil { t.Fatal(err) } if len(evs) != 0 { t.Fatalf("ops transcript not cleared: %d events", len(evs)) } } func TestAPIRepoImagesBuiltFlag(t *testing.T) { ts, f := newSpawnAPIServer(t) auth := testToken custom := "lvmh-worker-group--project-ab12cd" f.imageTags = map[string]bool{imageRefWorker: true, custom: true} if code, _ := apiReq(t, http.MethodPut, ts.URL+"/api/repos/group/project/image", auth, `{"image":"`+custom+`"}`); code != http.StatusOK { t.Fatal("register custom image failed") } if code, _ := apiReq(t, http.MethodPut, ts.URL+"/api/repos/other/repo/image", auth, `{"image":"lvmh-worker-other"}`); code != http.StatusOK { t.Fatal("register second image failed") } code, body := apiReq(t, http.MethodGet, ts.URL+"/api/repos", auth, "") if code != http.StatusOK { t.Fatalf("repos = %d %s", code, body) } var rows []RepoImageRow if err := json.Unmarshal([]byte(body), &rows); err != nil { t.Fatalf("decode: %v", err) } if len(rows) != 2 { t.Fatalf("rows = %+v", rows) } for _, r := range rows { wantBuilt := r.Image == custom if r.Built != wantBuilt { t.Fatalf("row %+v built = %v, want %v", r, r.Built, wantBuilt) } } // docker unreachable → 500, not a silent built=false f.failImages = true if code, body := apiReq(t, http.MethodGet, ts.URL+"/api/repos", auth, ""); code != http.StatusInternalServerError { t.Fatalf("repos with dead docker = %d %s, want 500", code, body) } } // TestAPISpawnImageUsed: the spawn response names the image the job will use // (registry lookup), default or custom; the running job line carries it too. func TestAPISpawnImageUsed(t *testing.T) { ts, f := newSpawnAPIServer(t) auth := testToken custom := "lvmh-worker-group--project-ab12cd" f.imageTags = map[string]bool{imageRefWorker: true, custom: true} // unregistered repo → default worker image code, body := apiReq(t, http.MethodPost, ts.URL+"/api/spawn", auth, `{"repo":"plain/repo"}`) if code != http.StatusCreated { t.Fatalf("default spawn = %d %s", code, body) } var res SpawnResult if err := json.Unmarshal([]byte(body), &res); err != nil { t.Fatalf("decode: %v", err) } if res.ImageUsed != imageRefWorker { t.Fatalf("default imageUsed = %q, want %q", res.ImageUsed, imageRefWorker) } // registered + built custom image → custom in response and job line if code, _ := apiReq(t, http.MethodPut, ts.URL+"/api/repos/group/project/image", auth, `{"image":"`+custom+`"}`); code != http.StatusOK { t.Fatal("register custom failed") } code, body = apiReq(t, http.MethodPost, ts.URL+"/api/spawn", auth, `{"repo":"group/project"}`) if code != http.StatusCreated { t.Fatalf("custom spawn = %d %s", code, body) } if err := json.Unmarshal([]byte(body), &res); err != nil { t.Fatalf("decode: %v", err) } if res.ImageUsed != custom { t.Fatalf("custom imageUsed = %q, want %q", res.ImageUsed, custom) } // running job snapshot reports the image var runningJob SpawnJob waitFor(t, 5*time.Second, func() bool { _, body := apiReq(t, http.MethodGet, ts.URL+"/api/spawn/status", auth, "") var jobs []SpawnJob _ = json.Unmarshal([]byte(body), &jobs) for _, j := range jobs { if j.SessionID == res.SessionID && j.State == stateRunning { runningJob = j return true } } return false }) if runningJob.Message != "image "+custom { t.Fatalf("running message = %q, want %q", runningJob.Message, "image "+custom) } // registered but NOT built: response still names the custom image (the // job itself errors later with the ask-ops message) if code, _ := apiReq(t, http.MethodPut, ts.URL+"/api/repos/other/repo/image", auth, `{"image":"lvmh-worker-unbuilt"}`); code != http.StatusOK { t.Fatal("register unbuilt failed") } code, body = apiReq(t, http.MethodPost, ts.URL+"/api/spawn", auth, `{"repo":"other/repo"}`) if code != http.StatusCreated { t.Fatalf("unbuilt spawn = %d %s", code, body) } var res2 SpawnResult _ = json.Unmarshal([]byte(body), &res2) if res2.ImageUsed != "lvmh-worker-unbuilt" { t.Fatalf("unbuilt imageUsed = %q, want lvmh-worker-unbuilt", res2.ImageUsed) } }