daemon: agent-driven workspace images — repo→image registry (GET/PUT/DELETE /api/repos), spawn resolves custom image, self-healing lvmh-ops control container with docker.sock; 132 tests, e2e 87/87

This commit is contained in:
Raphael Westphal
2026-08-18 23:54:09 +02:00
parent 8fcaeb7c03
commit 7287b831e4
15 changed files with 1216 additions and 38 deletions
+134
View File
@@ -426,3 +426,137 @@ func TestAPIWebHandlerEmbedded(t *testing.T) {
}
}
}
func TestAPIRepoImagesCRUD(t *testing.T) {
ts, store := newTestServer(t)
auth := testToken
// auth required on the new namespace
if code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/repos", "", ""); code != http.StatusUnauthorized {
t.Fatal("GET /api/repos must require auth")
}
if code, _ := apiReq(t, http.MethodPut, ts.URL+"/api/repos/g/p/image", "", `{"image":"lvmh-worker-x"}`); code != http.StatusUnauthorized {
t.Fatal("PUT image must require auth")
}
// empty listing
code, body := apiReq(t, http.MethodGet, ts.URL+"/api/repos", auth, "")
if code != http.StatusOK || body != "[]\n" {
t.Fatalf("empty repos = %d %q, want 200 []", code, body)
}
// valid registration
code, body = apiReq(t, http.MethodPut, ts.URL+"/api/repos/group/project/image", auth, `{"image":"lvmh-worker-group--project-ab12cd"}`)
if code != http.StatusOK || !strings.Contains(body, `"ok":true`) {
t.Fatalf("put image = %d %s", code, body)
}
// a second repo, then listing is sorted by repo
if code, _ := apiReq(t, http.MethodPut, ts.URL+"/api/repos/alpha/repo/image", auth, `{"image":"lvmh-worker-alpha:1.2.3"}`); code != http.StatusOK {
t.Fatalf("put tagged image = %d", code)
}
code, body = apiReq(t, http.MethodGet, ts.URL+"/api/repos", auth, "")
if code != http.StatusOK || body != `[{"repo":"alpha/repo","image":"lvmh-worker-alpha:1.2.3"},{"repo":"group/project","image":"lvmh-worker-group--project-ab12cd"}]`+"\n" {
t.Fatalf("repos = %d %s", code, body)
}
// upsert via PUT
if code, _ := apiReq(t, http.MethodPut, ts.URL+"/api/repos/group/project/image", auth, `{"image":"lvmh-worker-other"}`); code != http.StatusOK {
t.Fatalf("upsert = %d", code)
}
if img, ok, _ := store.GetRepoImage("group/project"); !ok || img != "lvmh-worker-other" {
t.Fatalf("after upsert = %q %v", img, ok)
}
// delete → gone; idempotent delete
if code, _ := apiReq(t, http.MethodDelete, ts.URL+"/api/repos/group/project/image", auth, ""); code != http.StatusOK {
t.Fatal("delete image failed")
}
if code, _ := apiReq(t, http.MethodDelete, ts.URL+"/api/repos/group/project/image", auth, ""); code != http.StatusOK {
t.Fatal("delete absent image must stay 200")
}
code, body = apiReq(t, http.MethodGet, ts.URL+"/api/repos", auth, "")
if code != http.StatusOK || strings.Contains(body, "group/project") {
t.Fatalf("after delete = %d %s", code, body)
}
}
func TestAPIRepoImageValidation(t *testing.T) {
ts, _ := newTestServer(t)
auth := testToken
valid := []string{
"lvmh-worker-x",
"lvmh-worker-group--project-ab12cd",
"lvmh-worker-x:latest",
"lvmh-worker-x:1.2.3",
"lvmh-worker-x-y.z:tag-1_2",
}
for _, img := range valid {
if code, body := apiReq(t, http.MethodPut, ts.URL+"/api/repos/g/p/image", auth, `{"image":"`+img+`"}`); code != http.StatusOK {
t.Fatalf("image %q = %d %s, want 200", img, code, body)
}
}
invalid := []string{
"", // empty
"lvmh-worker-", // no name after the prefix
"lvmh-worker-:tag", // tag without a name
"lvmh-worker-X", // uppercase name part
"evil", // foreign namespace
"lvmh-worker-x;rm -rf /", // shell metachars
"lvmh-worker-x/y", // path separator
"lvmh-worker-x:tag with space", // space in tag
}
for _, img := range invalid {
code, body := apiReq(t, http.MethodPut, ts.URL+"/api/repos/g/p/image", auth, `{"image":"`+img+`"}`)
if code != http.StatusBadRequest || !strings.Contains(body, "invalid image name") {
t.Fatalf("image %q = %d %s, want 400 invalid image name", img, code, body)
}
}
// bad repo shapes (single-slash-safe: ServeMux collapses "//" via
// redirect before our handler sees the path)
for _, repo := range []string{
"noslash",
"group/proj!bad",
} {
code, body := apiReq(t, http.MethodPut, ts.URL+"/api/repos/"+repo+"/image", auth, `{"image":"lvmh-worker-x"}`)
if code != http.StatusBadRequest {
t.Fatalf("repo %q = %d %s, want 400", repo, code, body)
}
code, _ = apiReq(t, http.MethodDelete, ts.URL+"/api/repos/"+repo+"/image", auth, "")
if code != http.StatusBadRequest {
t.Fatalf("delete repo %q = %d, want 400", repo, code)
}
}
// not an image path at all
if code, _ := apiReq(t, http.MethodPut, ts.URL+"/api/repos/g/p/notimage", auth, `{"image":"lvmh-worker-x"}`); code != http.StatusBadRequest {
t.Fatal("non-image path under /api/repos must 400")
}
// malformed body
if code, _ := apiReq(t, http.MethodPut, ts.URL+"/api/repos/g/p/image", auth, `not json`); code != http.StatusBadRequest {
t.Fatal("bad body must 400")
}
// oversized body
big := `{"image":"` + strings.Repeat("x", 1<<20) + `"}`
if code, _ := apiReq(t, http.MethodPut, ts.URL+"/api/repos/g/p/image", auth, big); code != http.StatusBadRequest {
t.Fatal("oversized body must 400")
}
}
func TestAPIRepoImagesStoreFailure(t *testing.T) {
ts, store := newTestServer(t)
if err := store.Close(); err != nil {
t.Fatalf("close store: %v", err)
}
if code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/repos", testToken, ""); code != http.StatusInternalServerError {
t.Fatal("GET /api/repos with broken store must 500")
}
if code, _ := apiReq(t, http.MethodPut, ts.URL+"/api/repos/g/p/image", testToken, `{"image":"lvmh-worker-x"}`); code != http.StatusInternalServerError {
t.Fatal("PUT with broken store must 500")
}
if code, _ := apiReq(t, http.MethodDelete, ts.URL+"/api/repos/g/p/image", testToken, ""); code != http.StatusInternalServerError {
t.Fatal("DELETE with broken store must 500")
}
}