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
+78 -1
View File
@@ -12,6 +12,7 @@ import (
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
)
@@ -25,8 +26,15 @@ const (
maxEventsLimit int = 10000
maxBodyBytes int64 = 1 << 20
webIndexFallback string = "index.html"
reposPathPrefix string = "/api/repos/"
reposPathSuffix string = "/image"
)
// imageNameRe pins registered images to the lvmh-worker- namespace so agents
// can only point spawns at images we built.
var imageNameRe = regexp.MustCompile(`^lvmh-worker-[a-z0-9][a-z0-9.-]*:?[a-zA-Z0-9._-]*$`)
//go:embed webdist
var embeddedWeb embed.FS
@@ -60,6 +68,11 @@ func (s *Server) Routes(webdist string) http.Handler {
api.HandleFunc("DELETE /api/sessions/{id}/container", s.handleDeleteContainer)
api.HandleFunc("POST /api/spawn", s.handleSpawn)
api.HandleFunc("GET /api/spawn/status", s.handleSpawnStatus)
api.HandleFunc("GET /api/repos", s.handleRepoImages)
// repo paths contain "/" (group/project), which a single {repo} wildcard
// segment cannot match — subtree routes with manual path parsing instead.
api.HandleFunc("PUT /api/repos/", s.handleSetRepoImage)
api.HandleFunc("DELETE /api/repos/", s.handleDeleteRepoImage)
api.HandleFunc("GET /api/gitlab/status", s.handleGitLabStatus)
api.HandleFunc("POST /api/gitlab/connect", s.handleGitLabConnect)
api.HandleFunc("DELETE /api/gitlab/connect", s.handleGitLabDisconnect)
@@ -257,7 +270,14 @@ func (s *Server) handleAbort(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteContainer(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.spawn.RemoveSession(r.Context(), id); err != nil {
var err error
if id == opsSessionID {
// the ops container has no containers-table row; remove by name
err = s.spawn.RemoveOps(r.Context())
} else {
err = s.spawn.RemoveSession(r.Context(), id)
}
if err != nil {
if errors.Is(err, errNoContainer) {
writeError(w, http.StatusNotFound, "no container for session")
return
@@ -296,6 +316,63 @@ func (s *Server) handleSpawnStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.spawn.JobsSnapshot())
}
// repoFromImagePath extracts the repo path from /api/repos/<repo>/image.
func repoFromImagePath(path string) (string, bool) {
rest, ok := strings.CutSuffix(strings.TrimPrefix(path, reposPathPrefix), reposPathSuffix)
if !ok || !validRepoPath(rest) {
return "", false
}
return rest, true
}
func (s *Server) handleRepoImages(w http.ResponseWriter, r *http.Request) {
rows, err := s.store.ListRepoImages()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if rows == nil {
rows = []RepoImageRow{}
}
writeJSON(w, http.StatusOK, rows)
}
func (s *Server) handleSetRepoImage(w http.ResponseWriter, r *http.Request) {
repo, ok := repoFromImagePath(r.URL.Path)
if !ok {
writeError(w, http.StatusBadRequest, "repo must look like group/project")
return
}
var body struct {
Image string `json:"image"`
}
if !decodeBody(w, r, &body) {
return
}
if !imageNameRe.MatchString(body.Image) {
writeError(w, http.StatusBadRequest, "invalid image name")
return
}
if err := s.store.SetRepoImage(repo, body.Image); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handleDeleteRepoImage(w http.ResponseWriter, r *http.Request) {
repo, ok := repoFromImagePath(r.URL.Path)
if !ok {
writeError(w, http.StatusBadRequest, "repo must look like group/project")
return
}
if err := s.store.DeleteRepoImage(repo); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handleGitLabStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.gitlab.Status())
}