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:
+78
-1
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user