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())
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+50
-23
@@ -132,11 +132,12 @@ type Spawner struct {
|
||||
cli *client.Client
|
||||
baseURL string // gitlab base, for clone URLs
|
||||
|
||||
reposDir string
|
||||
dockerfile string
|
||||
buildContext string
|
||||
containerURL string
|
||||
network string
|
||||
reposDir string
|
||||
dockerfile string
|
||||
controlDockerfile string
|
||||
buildContext string
|
||||
containerURL string
|
||||
network string
|
||||
|
||||
mu sync.Mutex
|
||||
jobs map[string]*SpawnJob // keyed by sessionId
|
||||
@@ -151,18 +152,19 @@ func NewSpawner(ctx context.Context, store *Store, hub *Hub, baseURL string) (*S
|
||||
}
|
||||
dockerfile := envOr(envWorkerDockerfile, defaultDockerfile)
|
||||
return &Spawner{
|
||||
ctx: ctx,
|
||||
store: store,
|
||||
hub: hub,
|
||||
cli: cli,
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
reposDir: envOr(envRepoDir, defaultRepoDir),
|
||||
dockerfile: dockerfile,
|
||||
buildContext: envOr(envWorkerContext, filepath.Dir(filepath.Dir(dockerfile))),
|
||||
containerURL: envOr(envContainerLVMHURL, defaultContainerURL),
|
||||
network: envOr(envContainerNetwork, defaultNetwork),
|
||||
jobs: make(map[string]*SpawnJob),
|
||||
slugLocks: make(map[string]*sync.Mutex),
|
||||
ctx: ctx,
|
||||
store: store,
|
||||
hub: hub,
|
||||
cli: cli,
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
reposDir: envOr(envRepoDir, defaultRepoDir),
|
||||
dockerfile: dockerfile,
|
||||
controlDockerfile: envOr(envControlDockerfile, defaultControlDockerfile),
|
||||
buildContext: envOr(envWorkerContext, filepath.Dir(filepath.Dir(dockerfile))),
|
||||
containerURL: envOr(envContainerLVMHURL, defaultContainerURL),
|
||||
network: envOr(envContainerNetwork, defaultNetwork),
|
||||
jobs: make(map[string]*SpawnJob),
|
||||
slugLocks: make(map[string]*sync.Mutex),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -244,7 +246,12 @@ func (s *Spawner) Start(ctx context.Context, repo, branch string) (SpawnResult,
|
||||
}
|
||||
|
||||
func (s *Spawner) imageExists(ctx context.Context) (bool, error) {
|
||||
args := filters.NewArgs(filters.Arg("reference", imageRefWorker))
|
||||
return s.imageRefExists(ctx, imageRefWorker)
|
||||
}
|
||||
|
||||
// imageRefExists reports whether ref is present in the local docker store.
|
||||
func (s *Spawner) imageRefExists(ctx context.Context, ref string) (bool, error) {
|
||||
args := filters.NewArgs(filters.Arg("reference", ref))
|
||||
summaries, err := s.cli.ImageList(ctx, image.ListOptions{Filters: args})
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -391,19 +398,26 @@ func (s *Spawner) ensureImage(ctx context.Context) error {
|
||||
if _, err := os.Stat(s.dockerfile); err != nil {
|
||||
return fmt.Errorf("worker Dockerfile missing at %s: %w", s.dockerfile, err)
|
||||
}
|
||||
relDockerfile, err := filepath.Rel(s.buildContext, s.dockerfile)
|
||||
return s.buildImage(ctx, s.buildContext, s.dockerfile, imageRefWorker)
|
||||
}
|
||||
|
||||
// buildImage streams a tar of buildContext into docker ImageBuild, building
|
||||
// tag from dockerfile (relative to buildContext). Shared by the worker and
|
||||
// ops image paths.
|
||||
func (s *Spawner) buildImage(ctx context.Context, buildContext, dockerfile, tag string) error {
|
||||
relDockerfile, err := filepath.Rel(buildContext, dockerfile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pr, pw := io.Pipe()
|
||||
tarDone := make(chan error, 1)
|
||||
go func() {
|
||||
err := tarDir(pw, s.buildContext)
|
||||
err := tarDir(pw, buildContext)
|
||||
_ = pw.CloseWithError(err)
|
||||
tarDone <- err
|
||||
}()
|
||||
resp, buildErr := s.cli.ImageBuild(ctx, pr, build.ImageBuildOptions{
|
||||
Tags: []string{imageRefWorker},
|
||||
Tags: []string{tag},
|
||||
Dockerfile: relDockerfile,
|
||||
Remove: true,
|
||||
})
|
||||
@@ -414,7 +428,7 @@ func (s *Spawner) ensureImage(ctx context.Context) error {
|
||||
if resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
return fmt.Errorf("build context %s: %w", s.buildContext, tarErr)
|
||||
return fmt.Errorf("build context %s: %w", buildContext, tarErr)
|
||||
}
|
||||
if buildErr != nil {
|
||||
return fmt.Errorf("docker build: %w", buildErr)
|
||||
@@ -450,7 +464,20 @@ func extractBuildError(body []byte) string {
|
||||
}
|
||||
|
||||
// createAndStart provisions volumes, creates and starts the worker container.
|
||||
// A repo-registered custom image (see /api/repos) overrides the default
|
||||
// worker image; the ops agent builds and registers those.
|
||||
func (s *Spawner) createAndStart(ctx context.Context, repo, slug, sessionID string) (string, error) {
|
||||
image := imageRefWorker
|
||||
if custom, ok, _ := s.store.GetRepoImage(repo); ok && custom != "" {
|
||||
exists, err := s.imageRefExists(ctx, custom)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("docker unavailable: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return "", fmt.Errorf("custom image %s not built (ask the ops agent to build it)", custom)
|
||||
}
|
||||
image = custom
|
||||
}
|
||||
repoVolume := volumeRepoPrefix + slug
|
||||
fresh, err := s.ensureRepoVolume(ctx, repoVolume)
|
||||
if err != nil {
|
||||
@@ -479,7 +506,7 @@ func (s *Spawner) createAndStart(ctx context.Context, repo, slug, sessionID stri
|
||||
volumePiCache + ":" + cacheMount,
|
||||
}
|
||||
cfg := &container.Config{
|
||||
Image: imageRefWorker,
|
||||
Image: image,
|
||||
Env: []string{
|
||||
envProviderAPIKey + "=" + os.Getenv(envProviderAPIKey),
|
||||
envToken + "=" + daemonToken,
|
||||
|
||||
+102
-12
@@ -36,6 +36,7 @@ type recordedCreate struct {
|
||||
Env []string `json:"Env"`
|
||||
Labels map[string]string `json:"Labels"`
|
||||
Cmd []string `json:"Cmd"`
|
||||
WorkingDir string `json:"WorkingDir"`
|
||||
HostConfig struct {
|
||||
Binds []string `json:"Binds"`
|
||||
NetworkMode string `json:"NetworkMode"`
|
||||
@@ -46,12 +47,16 @@ type recordedCreate struct {
|
||||
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
|
||||
calls []dockerCall
|
||||
images int // legacy: >0 serves the default worker image
|
||||
imageTags map[string]bool // when set, /images/json serves exactly these
|
||||
volume map[string]bool // existing volumes
|
||||
nextID int
|
||||
create []recordedCreate
|
||||
archives []int // sizes of accepted CopyToContainer tar streams
|
||||
|
||||
containers map[string]string // name-or-id → container id
|
||||
running map[string]bool // container id → running
|
||||
|
||||
failBuild bool
|
||||
failBuildHTTP bool
|
||||
@@ -70,7 +75,23 @@ type fakeDocker struct {
|
||||
}
|
||||
|
||||
func newFakeDocker() *fakeDocker {
|
||||
return &fakeDocker{images: 1, volume: map[string]bool{}}
|
||||
return &fakeDocker{images: 1, volume: map[string]bool{},
|
||||
containers: map[string]string{}, running: map[string]bool{}}
|
||||
}
|
||||
|
||||
// containerID resolves a name-or-id path token to the tracked container id.
|
||||
func (f *fakeDocker) containerID(token string) (string, bool) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
id, ok := f.containers[token]
|
||||
return id, ok
|
||||
}
|
||||
|
||||
// isRunning reports the running flag of a tracked container id.
|
||||
func (f *fakeDocker) isRunning(id string) bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.running[id]
|
||||
}
|
||||
|
||||
func (f *fakeDocker) server(t *testing.T) *httptest.Server {
|
||||
@@ -82,6 +103,43 @@ func (f *fakeDocker) server(t *testing.T) *httptest.Server {
|
||||
|
||||
// record appends a call; the /vX.Y version prefix negotiated by the SDK is
|
||||
// stripped so assertions are version-agnostic.
|
||||
// imageListJSON builds the GET /images/json entry list (joined with commas)
|
||||
// honoring the reference filter. Legacy mode (imageTags nil): images>0 means
|
||||
// exactly the default worker image is present.
|
||||
func (f *fakeDocker) imageListJSON(filtersJSON string) string {
|
||||
matches := func(ref string) bool {
|
||||
if f.imageTags == nil {
|
||||
return f.images > 0 && ref == imageRefWorker
|
||||
}
|
||||
return f.imageTags[ref]
|
||||
}
|
||||
var refs []string
|
||||
if filtersJSON != "" {
|
||||
var flt struct {
|
||||
Reference map[string]bool `json:"reference"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(filtersJSON), &flt); err != nil {
|
||||
return ""
|
||||
}
|
||||
for ref, want := range flt.Reference {
|
||||
if want {
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
}
|
||||
} else if f.imageTags != nil {
|
||||
for ref := range f.imageTags {
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
}
|
||||
entries := make([]string, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
if matches(ref) {
|
||||
entries = append(entries, fmt.Sprintf(`{"Id":"sha256:abc","RepoTags":[%q]}`, ref))
|
||||
}
|
||||
}
|
||||
return strings.Join(entries, ",")
|
||||
}
|
||||
|
||||
func (f *fakeDocker) record(r *http.Request, body string) dockerCall {
|
||||
path := r.URL.Path
|
||||
if strings.HasPrefix(path, "/v") {
|
||||
@@ -171,11 +229,7 @@ func (f *fakeDocker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
writeJSONNow(w, http.StatusOK, "["+f.imageListJSON(r.URL.Query().Get("filters"))+"]")
|
||||
case call.Method == http.MethodPost && call.Path == "/build":
|
||||
if f.failBuildHTTP {
|
||||
writeJSONNow(w, http.StatusInternalServerError, `{"message":"build endpoint broken"}`)
|
||||
@@ -238,19 +292,47 @@ func (f *fakeDocker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
f.nextID++
|
||||
id := fmt.Sprintf("cid-%d", f.nextID)
|
||||
f.create = append(f.create, rc)
|
||||
f.containers[id] = id
|
||||
if rc.Name != "" {
|
||||
f.containers[rc.Name] = id
|
||||
}
|
||||
f.running[id] = false
|
||||
f.mu.Unlock()
|
||||
writeJSONNow(w, http.StatusCreated, fmt.Sprintf(`{"Id":%q,"Warnings":null}`, id))
|
||||
case call.Method == http.MethodGet && strings.HasPrefix(call.Path, "/containers/") && strings.HasSuffix(call.Path, "/json"):
|
||||
token := strings.TrimSuffix(strings.TrimPrefix(call.Path, "/containers/"), "/json")
|
||||
f.mu.Lock()
|
||||
id, ok := f.containers[token]
|
||||
running := ok && f.running[id]
|
||||
f.mu.Unlock()
|
||||
if !ok {
|
||||
writeJSONNow(w, http.StatusNotFound, fmt.Sprintf(`{"message":"No such container: %s"}`, token))
|
||||
return
|
||||
}
|
||||
writeJSONNow(w, http.StatusOK, fmt.Sprintf(`{"Id":%q,"State":{"Running":%t}}`, id, running))
|
||||
case call.Method == http.MethodPost && strings.HasSuffix(call.Path, "/start"):
|
||||
if f.failStart {
|
||||
writeJSONNow(w, http.StatusInternalServerError, `{"message":"start failed"}`)
|
||||
return
|
||||
}
|
||||
token := strings.TrimSuffix(strings.TrimPrefix(call.Path, "/containers/"), "/start")
|
||||
f.mu.Lock()
|
||||
if id, ok := f.containers[token]; ok {
|
||||
f.running[id] = true
|
||||
}
|
||||
f.mu.Unlock()
|
||||
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
|
||||
}
|
||||
token := strings.TrimSuffix(strings.TrimPrefix(call.Path, "/containers/"), "/stop")
|
||||
f.mu.Lock()
|
||||
if id, ok := f.containers[token]; ok {
|
||||
f.running[id] = false
|
||||
}
|
||||
f.mu.Unlock()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
case call.Method == http.MethodPost && strings.HasSuffix(call.Path, "/wait"):
|
||||
if f.failWait {
|
||||
@@ -277,6 +359,14 @@ func (f *fakeDocker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case call.Method == http.MethodDelete && strings.HasPrefix(call.Path, "/containers/"):
|
||||
token := strings.TrimPrefix(call.Path, "/containers/")
|
||||
f.mu.Lock()
|
||||
if id, ok := f.containers[token]; ok {
|
||||
delete(f.containers, token)
|
||||
delete(f.containers, id)
|
||||
delete(f.running, id)
|
||||
}
|
||||
f.mu.Unlock()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
f.mu.Lock()
|
||||
|
||||
@@ -73,6 +73,7 @@ func run(args []string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("docker client: %w", err)
|
||||
}
|
||||
spawner.StartOpsLoop(spawnerCtx)
|
||||
|
||||
srv := NewServer(store, hub, spawner, gitlab)
|
||||
httpServer := &http.Server{
|
||||
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
package main
|
||||
|
||||
// ops.go — the lvmh-ops control container: an agent pi with docker access
|
||||
// (docker.sock bind) that prepares repos, builds per-repo worker images and
|
||||
// registers them via /api/repos. Kept alive by StartOpsLoop.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/volume"
|
||||
)
|
||||
|
||||
// Ops container wiring constants.
|
||||
const (
|
||||
opsImageRef string = "lvmh-ops:latest"
|
||||
opsContainerName string = "lvmh-ops"
|
||||
labelOps string = "lvmh.ops"
|
||||
opsSessionID string = "lvmh-ops-control"
|
||||
envControlDockerfile string = "LVMH_CONTROL_DOCKERFILE"
|
||||
defaultControlDockerfile string = "/app/build/docker/control.Dockerfile"
|
||||
opsRecheckInterval time.Duration = 30 * time.Second
|
||||
|
||||
volumeOpsWork string = "lvmh-ops-work"
|
||||
opsMount string = "/ops"
|
||||
opsAPIBase string = "http://lvmh:8686"
|
||||
dockerSock string = "/var/run/docker.sock"
|
||||
)
|
||||
|
||||
// EnsureOps guarantees the ops control container is running: a running
|
||||
// container is a no-op, a stopped one is started, a missing one is built
|
||||
// (image) + created + started. Errors are returned, never fatal — the ops
|
||||
// loop logs them and retries on the next tick (docker may be down at boot).
|
||||
func (s *Spawner) EnsureOps(ctx context.Context) error {
|
||||
inspect, err := s.cli.ContainerInspect(ctx, opsContainerName)
|
||||
if err == nil {
|
||||
if inspect.State != nil && inspect.State.Running {
|
||||
return nil
|
||||
}
|
||||
return s.cli.ContainerStart(ctx, opsContainerName, container.StartOptions{})
|
||||
}
|
||||
if !cerrdefs.IsNotFound(err) {
|
||||
return fmt.Errorf("inspect %s: %w", opsContainerName, err)
|
||||
}
|
||||
if err := s.ensureOpsImage(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.createOps(ctx)
|
||||
}
|
||||
|
||||
// ensureOpsImage builds the ops image when missing, from the control
|
||||
// Dockerfile (build context derived like the worker's: dir above the
|
||||
// dockerfile's dir).
|
||||
func (s *Spawner) ensureOpsImage(ctx context.Context) error {
|
||||
exists, err := s.imageRefExists(ctx, opsImageRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(s.controlDockerfile); err != nil {
|
||||
return fmt.Errorf("control Dockerfile missing at %s: %w", s.controlDockerfile, err)
|
||||
}
|
||||
return s.buildImage(ctx, filepath.Dir(filepath.Dir(s.controlDockerfile)), s.controlDockerfile, opsImageRef)
|
||||
}
|
||||
|
||||
// createOps creates and starts the ops container with its own workspace
|
||||
// volume, the shared session/pi caches, and the docker socket. Never
|
||||
// auto-removed: the loop restarts it in place.
|
||||
func (s *Spawner) createOps(ctx context.Context) error {
|
||||
for _, vol := range []string{volumeOpsWork, volumeSessions, volumePiCache} {
|
||||
if _, err := s.cli.VolumeCreate(ctx, volume.CreateOptions{Name: vol}); err != nil {
|
||||
return fmt.Errorf("volume %s: %w", vol, err)
|
||||
}
|
||||
}
|
||||
env := []string{
|
||||
envProviderAPIKey + "=" + os.Getenv(envProviderAPIKey),
|
||||
envToken + "=" + daemonToken,
|
||||
"LVMH_URL=" + s.containerURL,
|
||||
envLVMHSessionID + "=" + opsSessionID,
|
||||
envLVMHAgent + "=1",
|
||||
"LVMH_API=" + opsAPIBase,
|
||||
}
|
||||
if pat, ok, _ := s.store.GetSetting(settingGitLabToken); ok && pat != "" {
|
||||
env = append(env, "LVMH_GITEA_TOKEN="+pat)
|
||||
}
|
||||
cfg := &container.Config{
|
||||
Image: opsImageRef,
|
||||
Env: env,
|
||||
WorkingDir: opsMount,
|
||||
Labels: map[string]string{labelOps: "true", labelSession: opsSessionID},
|
||||
}
|
||||
hostCfg := &container.HostConfig{
|
||||
Binds: []string{
|
||||
volumeOpsWork + ":" + opsMount,
|
||||
volumeSessions + ":" + sessionsMount,
|
||||
volumePiCache + ":" + cacheMount,
|
||||
dockerSock + ":" + dockerSock,
|
||||
},
|
||||
NetworkMode: container.NetworkMode(s.network),
|
||||
AutoRemove: false,
|
||||
Init: &[]bool{true}[0],
|
||||
}
|
||||
if _, err := s.cli.ContainerCreate(ctx, cfg, hostCfg, nil, nil, opsContainerName); err != nil {
|
||||
return fmt.Errorf("docker create: %w", err)
|
||||
}
|
||||
if err := s.cli.ContainerStart(ctx, opsContainerName, container.StartOptions{}); err != nil {
|
||||
s.removeContainer(context.Background(), opsContainerName)
|
||||
return fmt.Errorf("docker start: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartOpsLoop keeps the ops container alive, re-checking every
|
||||
// opsRecheckInterval. Ensure failures are logged, never fatal.
|
||||
func (s *Spawner) StartOpsLoop(ctx context.Context) {
|
||||
go func() {
|
||||
if err := s.EnsureOps(ctx); err != nil {
|
||||
log.Printf("ops: ensure: %v", err)
|
||||
}
|
||||
ticker := time.NewTicker(opsRecheckInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := s.EnsureOps(ctx); err != nil {
|
||||
log.Printf("ops: ensure: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// RemoveOps stops and removes the ops container by NAME (it has no
|
||||
// containers-table row, so RemoveSession cannot handle it). The ops loop
|
||||
// recreates it on the next tick.
|
||||
func (s *Spawner) RemoveOps(_ context.Context) error {
|
||||
stopCtx, cancel := context.WithTimeout(context.Background(), time.Duration(stopTimeoutSeconds)*time.Second)
|
||||
if err := s.cli.ContainerStop(stopCtx, opsContainerName, container.StopOptions{}); err != nil && !cerrdefs.IsNotFound(err) {
|
||||
cancel()
|
||||
return fmt.Errorf("docker stop: %w", err)
|
||||
}
|
||||
cancel()
|
||||
s.removeContainer(context.Background(), opsContainerName)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package main
|
||||
|
||||
// ops_test.go — lvmh-ops control container: ensure/start/create paths and
|
||||
// the delete-container special case, against the fake docker API.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
)
|
||||
|
||||
// newOpsSpawner wires a spawner whose control Dockerfile exists on disk.
|
||||
func newOpsSpawner(t *testing.T, f *fakeDocker) (*Spawner, *Store) {
|
||||
t.Helper()
|
||||
buildCtx := t.TempDir()
|
||||
control := filepath.Join(buildCtx, "docker", "control.Dockerfile")
|
||||
if err := os.MkdirAll(filepath.Dir(control), 0o755); err != nil {
|
||||
t.Fatalf("mkdir control dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(control, []byte("FROM node:24\n"), 0o644); err != nil {
|
||||
t.Fatalf("write control dockerfile: %v", err)
|
||||
}
|
||||
t.Setenv(envControlDockerfile, control)
|
||||
return newTestSpawner(t, f)
|
||||
}
|
||||
|
||||
func TestEnsureOpsCreatesAndStarts(t *testing.T) {
|
||||
f := newFakeDocker()
|
||||
f.imageTags = map[string]bool{imageRefWorker: true, opsImageRef: true} // no build
|
||||
sp, _ := newOpsSpawner(t, f)
|
||||
|
||||
if err := sp.EnsureOps(context.Background()); err != nil {
|
||||
t.Fatalf("EnsureOps: %v", err)
|
||||
}
|
||||
creates := f.createsByName(opsContainerName)
|
||||
if len(creates) != 1 {
|
||||
t.Fatalf("ops creates = %+v, want 1", creates)
|
||||
}
|
||||
c := creates[0]
|
||||
if c.Image != opsImageRef {
|
||||
t.Fatalf("image = %q, want %q", c.Image, opsImageRef)
|
||||
}
|
||||
if c.WorkingDir != opsMount {
|
||||
t.Fatalf("workdir = %q, want %q", c.WorkingDir, opsMount)
|
||||
}
|
||||
wantLabels := map[string]string{labelOps: "true", labelSession: opsSessionID}
|
||||
if !reflect.DeepEqual(c.Labels, wantLabels) {
|
||||
t.Fatalf("labels = %v, want %v", c.Labels, wantLabels)
|
||||
}
|
||||
wantEnv := map[string]string{
|
||||
envProviderAPIKey: "key-123",
|
||||
envToken: testToken,
|
||||
"LVMH_URL": defaultContainerURL,
|
||||
envLVMHSessionID: opsSessionID,
|
||||
envLVMHAgent: "1",
|
||||
"LVMH_API": opsAPIBase,
|
||||
}
|
||||
for _, e := range c.Env {
|
||||
for k, want := range wantEnv {
|
||||
if e == k+"="+want {
|
||||
delete(wantEnv, k)
|
||||
}
|
||||
}
|
||||
if e == "LVMH_GITEA_TOKEN=" {
|
||||
t.Fatal("LVMH_GITEA_TOKEN present without stored PAT")
|
||||
}
|
||||
}
|
||||
if len(wantEnv) != 0 {
|
||||
t.Fatalf("missing env %v in %v", wantEnv, c.Env)
|
||||
}
|
||||
wantBinds := []string{
|
||||
volumeOpsWork + ":" + opsMount,
|
||||
volumeSessions + ":" + sessionsMount,
|
||||
volumePiCache + ":" + cacheMount,
|
||||
dockerSock + ":" + dockerSock,
|
||||
}
|
||||
if !reflect.DeepEqual(c.HostConfig.Binds, wantBinds) {
|
||||
t.Fatalf("binds = %v, want %v", c.HostConfig.Binds, wantBinds)
|
||||
}
|
||||
if c.HostConfig.NetworkMode != defaultNetwork {
|
||||
t.Fatalf("network = %q", c.HostConfig.NetworkMode)
|
||||
}
|
||||
if c.HostConfig.Init == nil || !*c.HostConfig.Init {
|
||||
t.Fatalf("Init = %v, want true", c.HostConfig.Init)
|
||||
}
|
||||
if !f.hasCallSuffix(http.MethodPost, "/containers/"+opsContainerName+"/start") {
|
||||
t.Fatal("ops container not started")
|
||||
}
|
||||
for _, vol := range []string{volumeOpsWork, volumeSessions, volumePiCache} {
|
||||
if !f.volumeExists(vol) {
|
||||
t.Fatalf("volume %q missing", vol)
|
||||
}
|
||||
}
|
||||
f.assertNoUnknown(t)
|
||||
}
|
||||
|
||||
func TestEnsureOpsEnvCarriesPAT(t *testing.T) {
|
||||
f := newFakeDocker()
|
||||
f.imageTags = map[string]bool{opsImageRef: true}
|
||||
sp, store := newOpsSpawner(t, f)
|
||||
if err := store.SetSetting(settingGitLabToken, "pat-ops"); err != nil {
|
||||
t.Fatalf("set PAT: %v", err)
|
||||
}
|
||||
if err := sp.EnsureOps(context.Background()); err != nil {
|
||||
t.Fatalf("EnsureOps: %v", err)
|
||||
}
|
||||
creates := f.createsByName(opsContainerName)
|
||||
found := false
|
||||
for _, e := range creates[0].Env {
|
||||
if e == "LVMH_GITEA_TOKEN=pat-ops" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("LVMH_GITEA_TOKEN missing from %v", creates[0].Env)
|
||||
}
|
||||
f.assertNoUnknown(t)
|
||||
}
|
||||
|
||||
func TestEnsureOpsSkipsWhenRunning(t *testing.T) {
|
||||
f := newFakeDocker()
|
||||
f.imageTags = map[string]bool{opsImageRef: true}
|
||||
sp, _ := newOpsSpawner(t, f)
|
||||
ctx := context.Background()
|
||||
if err := sp.EnsureOps(ctx); err != nil {
|
||||
t.Fatalf("first EnsureOps: %v", err)
|
||||
}
|
||||
if err := sp.EnsureOps(ctx); err != nil {
|
||||
t.Fatalf("second EnsureOps: %v", err)
|
||||
}
|
||||
if got := len(f.createsByName(opsContainerName)); got != 1 {
|
||||
t.Fatalf("ops creates = %d, want 1 (running is a no-op)", got)
|
||||
}
|
||||
f.assertNoUnknown(t)
|
||||
}
|
||||
|
||||
func TestEnsureOpsStartsStopped(t *testing.T) {
|
||||
f := newFakeDocker()
|
||||
f.imageTags = map[string]bool{opsImageRef: true}
|
||||
sp, _ := newOpsSpawner(t, f)
|
||||
ctx := context.Background()
|
||||
if err := sp.EnsureOps(ctx); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
stopCtx, cancel := context.WithTimeout(ctx, time.Second)
|
||||
defer cancel()
|
||||
if err := sp.cli.ContainerStop(stopCtx, opsContainerName, container.StopOptions{}); err != nil {
|
||||
t.Fatalf("stop: %v", err)
|
||||
}
|
||||
if err := sp.EnsureOps(ctx); err != nil {
|
||||
t.Fatalf("EnsureOps stopped: %v", err)
|
||||
}
|
||||
if got := len(f.createsByName(opsContainerName)); got != 1 {
|
||||
t.Fatalf("ops creates = %d, want 1 (start only)", got)
|
||||
}
|
||||
if !f.isRunning(f.containers[opsContainerName]) {
|
||||
t.Fatal("ops container must be running again")
|
||||
}
|
||||
f.assertNoUnknown(t)
|
||||
}
|
||||
|
||||
func TestEnsureOpsBuildsImageWhenMissing(t *testing.T) {
|
||||
f := newFakeDocker() // legacy mode: only lvmh-worker present → ops missing
|
||||
sp, _ := newOpsSpawner(t, f)
|
||||
if err := sp.EnsureOps(context.Background()); err != nil {
|
||||
t.Fatalf("EnsureOps: %v", err)
|
||||
}
|
||||
if n := f.countCalls(http.MethodPost, "/build"); n != 1 {
|
||||
t.Fatalf("build calls = %d, want 1", n)
|
||||
}
|
||||
if got := len(f.createsByName(opsContainerName)); got != 1 {
|
||||
t.Fatalf("ops creates = %d, want 1", got)
|
||||
}
|
||||
f.assertNoUnknown(t)
|
||||
}
|
||||
|
||||
func TestEnsureOpsErrors(t *testing.T) {
|
||||
// control Dockerfile missing → clear error
|
||||
f := newFakeDocker()
|
||||
sp, _ := newOpsSpawner(t, f)
|
||||
sp.controlDockerfile = filepath.Join(t.TempDir(), "gone.Dockerfile")
|
||||
if err := sp.EnsureOps(context.Background()); err == nil || !strings.Contains(err.Error(), "control Dockerfile missing") {
|
||||
t.Fatalf("EnsureOps without dockerfile = %v", err)
|
||||
}
|
||||
|
||||
// docker dead → error returned (caller logs; daemon keeps running)
|
||||
sp2, _ := newOpsSpawner(t, newFakeDocker())
|
||||
sp2.images0AndDead(t)
|
||||
if err := sp2.EnsureOps(context.Background()); err == nil {
|
||||
t.Fatal("EnsureOps with dead docker must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureOpsStartFailureCleansUp(t *testing.T) {
|
||||
f := newFakeDocker()
|
||||
f.imageTags = map[string]bool{opsImageRef: true}
|
||||
f.failStart = true
|
||||
sp, _ := newOpsSpawner(t, f)
|
||||
if err := sp.EnsureOps(context.Background()); err == nil || !strings.Contains(err.Error(), "docker start") {
|
||||
t.Fatalf("EnsureOps with failing start = %v", err)
|
||||
}
|
||||
if !f.hasCall(http.MethodDelete, "/containers/"+opsContainerName) {
|
||||
t.Fatal("failed ops start must remove the created container")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartOpsLoopCreatesAndExits(t *testing.T) {
|
||||
f := newFakeDocker()
|
||||
f.imageTags = map[string]bool{opsImageRef: true}
|
||||
sp, _ := newOpsSpawner(t, f)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
sp.StartOpsLoop(ctx)
|
||||
waitFor(t, 5*time.Second, func() bool { return len(f.createsByName(opsContainerName)) == 1 })
|
||||
cancel()
|
||||
}
|
||||
|
||||
// newOpsAPIServer builds a full Server over a spawner with a control
|
||||
// Dockerfile on disk; returns the spawner for EnsureOps calls.
|
||||
func newOpsAPIServer(t *testing.T) (*httptest.Server, *fakeDocker, *Spawner) {
|
||||
t.Helper()
|
||||
useFakeGit(t, fakeGitModeOK)
|
||||
f := newFakeDocker()
|
||||
ts := f.server(t)
|
||||
t.Setenv("DOCKER_HOST", "tcp://"+ts.Listener.Addr().String())
|
||||
buildCtx := t.TempDir()
|
||||
worker := filepath.Join(buildCtx, "docker", "worker.Dockerfile")
|
||||
if err := os.MkdirAll(filepath.Dir(worker), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(worker, []byte("FROM scratch\n"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
t.Setenv(envWorkerDockerfile, worker)
|
||||
t.Setenv(envWorkerContext, buildCtx)
|
||||
t.Setenv(envRepoDir, t.TempDir())
|
||||
control := filepath.Join(buildCtx, "docker", "control.Dockerfile")
|
||||
if err := os.WriteFile(control, []byte("FROM node:24\n"), 0o644); err != nil {
|
||||
t.Fatalf("write control: %v", err)
|
||||
}
|
||||
t.Setenv(envControlDockerfile, control)
|
||||
|
||||
daemonToken = testToken
|
||||
store := openTestStore(t)
|
||||
hub := NewHub(store)
|
||||
sp, err := NewSpawner(context.Background(), store, hub, "https://gitlab.example")
|
||||
if err != nil {
|
||||
t.Fatalf("NewSpawner: %v", err)
|
||||
}
|
||||
api := httptest.NewServer(NewServer(store, hub, sp, NewGitLab(store, "https://gitlab.example")).Routes(""))
|
||||
t.Cleanup(api.Close)
|
||||
return api, f, sp
|
||||
}
|
||||
|
||||
// TestAPIDeleteOpsContainer: DELETE /api/sessions/lvmh-ops-control/container
|
||||
// stops+removes the ops container by NAME (it has no containers-table row)
|
||||
// and returns {ok:true}.
|
||||
func TestAPIDeleteOpsContainer(t *testing.T) {
|
||||
ts, f, sp := newOpsAPIServer(t)
|
||||
f.imageTags = map[string]bool{opsImageRef: true}
|
||||
|
||||
if err := sp.EnsureOps(context.Background()); err != nil {
|
||||
t.Fatalf("EnsureOps: %v", err)
|
||||
}
|
||||
if len(f.createsByName(opsContainerName)) != 1 {
|
||||
t.Fatal("ops container missing before delete")
|
||||
}
|
||||
|
||||
code, body := apiReq(t, http.MethodDelete, ts.URL+"/api/sessions/"+opsSessionID+"/container", testToken, "")
|
||||
if code != http.StatusOK || !strings.Contains(body, `"ok":true`) {
|
||||
t.Fatalf("delete ops = %d %s", code, body)
|
||||
}
|
||||
if !f.hasCall(http.MethodPost, "/containers/"+opsContainerName+"/stop") {
|
||||
t.Fatal("ops must be stopped by name")
|
||||
}
|
||||
if !f.hasCall(http.MethodDelete, "/containers/"+opsContainerName) {
|
||||
t.Fatal("ops must be removed by name")
|
||||
}
|
||||
if _, ok := f.containerID(opsContainerName); ok {
|
||||
t.Fatal("ops container must be gone from the fake registry")
|
||||
}
|
||||
|
||||
// the loop recreates it on the next tick (manual call here)
|
||||
if err := sp.EnsureOps(context.Background()); err != nil {
|
||||
t.Fatalf("recreate: %v", err)
|
||||
}
|
||||
if got := len(f.createsByName(opsContainerName)); got != 2 {
|
||||
t.Fatalf("ops creates = %d, want 2 after recreate", got)
|
||||
}
|
||||
f.assertNoUnknown(t)
|
||||
}
|
||||
|
||||
func TestRemoveOpsStopFailureAndAbsence(t *testing.T) {
|
||||
f := newFakeDocker()
|
||||
sp, _ := newOpsSpawner(t, f)
|
||||
|
||||
// absent container: stop+remove are tolerated, still ok
|
||||
if err := sp.RemoveOps(context.Background()); err != nil {
|
||||
t.Fatalf("RemoveOps absent = %v, want nil", err)
|
||||
}
|
||||
|
||||
// stop failure surfaces
|
||||
f.failStop = true
|
||||
if err := sp.RemoveOps(context.Background()); err == nil || !strings.Contains(err.Error(), "docker stop") {
|
||||
t.Fatalf("RemoveOps with stop failure = %v, want docker stop error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureOpsVolumeCreateFailure(t *testing.T) {
|
||||
f := newFakeDocker()
|
||||
f.imageTags = map[string]bool{opsImageRef: true}
|
||||
f.failVolumeCreate = true
|
||||
sp, _ := newOpsSpawner(t, f)
|
||||
if err := sp.EnsureOps(context.Background()); err == nil || !strings.Contains(err.Error(), volumeOpsWork) {
|
||||
t.Fatalf("EnsureOps with volume failure = %v, want %q error", err, volumeOpsWork)
|
||||
}
|
||||
if len(f.createsByName(opsContainerName)) != 0 {
|
||||
t.Fatal("no ops container may be created when volumes fail")
|
||||
}
|
||||
}
|
||||
@@ -1187,3 +1187,99 @@ func TestSpawnerStartDedupesImageList(t *testing.T) {
|
||||
}
|
||||
f.assertNoUnknown(t)
|
||||
}
|
||||
|
||||
// TestSpawnerCustomImageUsed: a repo-registered image replaces the default
|
||||
// worker image in the created container.
|
||||
func TestSpawnerCustomImageUsed(t *testing.T) {
|
||||
useFakeGit(t, fakeGitModeOK)
|
||||
f := newFakeDocker()
|
||||
custom := "lvmh-worker-group--project-ab12cd"
|
||||
f.imageTags = map[string]bool{imageRefWorker: true, custom: true}
|
||||
sp, store := newTestSpawner(t, f)
|
||||
if err := store.SetRepoImage("group/project", custom); err != nil {
|
||||
t.Fatalf("set repo image: %v", err)
|
||||
}
|
||||
|
||||
res, err := sp.Start(context.Background(), "group/project", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
waitJobState(t, sp, res.SessionID, stateRunning)
|
||||
creates := f.createsByName("lvmh-agent-")
|
||||
if len(creates) != 1 || creates[0].Image != custom {
|
||||
t.Fatalf("agent create image = %+v, want %q", creates[0], custom)
|
||||
}
|
||||
// seed container still uses the default worker image
|
||||
seed := f.createsByName("lvmh-seed-")
|
||||
if len(seed) != 1 || seed[0].Image != imageRefWorker {
|
||||
t.Fatalf("seed create image = %+v, want default %q", seed[0], imageRefWorker)
|
||||
}
|
||||
f.assertNoUnknown(t)
|
||||
}
|
||||
|
||||
// TestSpawnerCustomImageMissing: registering an image that was never built
|
||||
// fails the job with the ask-ops message instead of a raw docker error.
|
||||
func TestSpawnerCustomImageMissing(t *testing.T) {
|
||||
useFakeGit(t, fakeGitModeOK)
|
||||
f := newFakeDocker()
|
||||
f.imageTags = map[string]bool{imageRefWorker: true} // custom absent
|
||||
sp, store := newTestSpawner(t, f)
|
||||
custom := "lvmh-worker-group--project-ab12cd"
|
||||
if err := store.SetRepoImage("group/project", custom); err != nil {
|
||||
t.Fatalf("set repo image: %v", err)
|
||||
}
|
||||
|
||||
res, err := sp.Start(context.Background(), "group/project", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
job := waitJobState(t, sp, res.SessionID, stateError)
|
||||
want := "custom image " + custom + " not built (ask the ops agent to build it)"
|
||||
if job.Message != want {
|
||||
t.Fatalf("job message = %q, want %q", job.Message, want)
|
||||
}
|
||||
if len(f.createsByName("lvmh-agent-")) != 0 {
|
||||
t.Fatal("no agent container may be created for a missing image")
|
||||
}
|
||||
f.assertNoUnknown(t)
|
||||
}
|
||||
|
||||
// TestSpawnerCustomImageDeleteFallsBack: deleting the registration returns
|
||||
// the repo to the default worker image.
|
||||
func TestSpawnerCustomImageDeleteFallsBack(t *testing.T) {
|
||||
useFakeGit(t, fakeGitModeOK)
|
||||
f := newFakeDocker()
|
||||
sp, store := newTestSpawner(t, f)
|
||||
if err := store.SetRepoImage("group/project", "lvmh-worker-x"); err != nil {
|
||||
t.Fatalf("set repo image: %v", err)
|
||||
}
|
||||
if err := store.DeleteRepoImage("group/project"); err != nil {
|
||||
t.Fatalf("delete repo image: %v", err)
|
||||
}
|
||||
|
||||
res, err := sp.Start(context.Background(), "group/project", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
waitJobState(t, sp, res.SessionID, stateRunning)
|
||||
creates := f.createsByName("lvmh-agent-")
|
||||
if len(creates) != 1 || creates[0].Image != imageRefWorker {
|
||||
t.Fatalf("create after deregister = %+v, want default %q", creates[0], imageRefWorker)
|
||||
}
|
||||
f.assertNoUnknown(t)
|
||||
}
|
||||
|
||||
// TestSpawnerCustomImageCheckDockerDead: the custom-image presence check
|
||||
// wraps docker unavailability like Start does.
|
||||
func TestSpawnerCustomImageCheckDockerDead(t *testing.T) {
|
||||
f := newFakeDocker()
|
||||
sp, store := newTestSpawner(t, f)
|
||||
if err := store.SetRepoImage("group/project", "lvmh-worker-x"); err != nil {
|
||||
t.Fatalf("set repo image: %v", err)
|
||||
}
|
||||
sp.images0AndDead(t)
|
||||
_, err := sp.createAndStart(context.Background(), "group/project", repoSlug("group/project"), "s1")
|
||||
if err == nil || !strings.Contains(err.Error(), "docker unavailable") {
|
||||
t.Fatalf("createAndStart with dead docker = %v, want docker unavailable", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,12 @@ type ContainerRow struct {
|
||||
Repo string
|
||||
}
|
||||
|
||||
// RepoImageRow maps a repo path to its registered custom worker image.
|
||||
type RepoImageRow struct {
|
||||
Repo string `json:"repo"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
|
||||
const dsnParams string = "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)"
|
||||
|
||||
const schema string = `
|
||||
@@ -59,6 +65,8 @@ CREATE TABLE IF NOT EXISTS sessions(
|
||||
CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS containers(
|
||||
sessionId TEXT PRIMARY KEY, containerId TEXT NOT NULL, repo TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS repo_images(
|
||||
repo TEXT PRIMARY KEY, image TEXT NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_session ON events(sessionId, seq);
|
||||
`
|
||||
|
||||
@@ -278,3 +286,47 @@ func (s *Store) DeleteContainer(sessionID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM containers WHERE sessionId=?`, sessionID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetRepoImage returns the custom image registered for a repo, if any.
|
||||
func (s *Store) GetRepoImage(repo string) (string, bool, error) {
|
||||
var image string
|
||||
err := s.db.QueryRow(`SELECT image FROM repo_images WHERE repo=?`, repo).Scan(&image)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return image, true, nil
|
||||
}
|
||||
|
||||
// SetRepoImage upserts the custom image for a repo.
|
||||
func (s *Store) SetRepoImage(repo, image string) error {
|
||||
_, err := s.db.Exec(`INSERT INTO repo_images(repo, image) VALUES(?,?)
|
||||
ON CONFLICT(repo) DO UPDATE SET image=excluded.image`, repo, image)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteRepoImage removes a repo's custom image registration (idempotent).
|
||||
func (s *Store) DeleteRepoImage(repo string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM repo_images WHERE repo=?`, repo)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListRepoImages returns every repo→image registration, sorted by repo.
|
||||
func (s *Store) ListRepoImages() ([]RepoImageRow, error) {
|
||||
rows, err := s.db.Query(`SELECT repo, image FROM repo_images ORDER BY repo`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []RepoImageRow
|
||||
for rows.Next() {
|
||||
var row RepoImageRow
|
||||
if err := rows.Scan(&row.Repo, &row.Image); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
@@ -64,6 +64,18 @@ func TestStoreClosedHandleErrors(t *testing.T) {
|
||||
if err := store.DeleteContainer("s"); errStr(err) == "" {
|
||||
t.Fatal("DeleteContainer on closed store must error")
|
||||
}
|
||||
if _, _, err := store.GetRepoImage("r"); errStr(err) == "" {
|
||||
t.Fatal("GetRepoImage on closed store must error")
|
||||
}
|
||||
if err := store.SetRepoImage("r", "lvmh-worker-r"); errStr(err) == "" {
|
||||
t.Fatal("SetRepoImage on closed store must error")
|
||||
}
|
||||
if err := store.DeleteRepoImage("r"); errStr(err) == "" {
|
||||
t.Fatal("DeleteRepoImage on closed store must error")
|
||||
}
|
||||
if _, err := store.ListRepoImages(); errStr(err) == "" {
|
||||
t.Fatal("ListRepoImages on closed store must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreCorruptRows(t *testing.T) {
|
||||
|
||||
@@ -4,6 +4,7 @@ package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -132,3 +133,55 @@ func TestStoreSettingsAndContainers(t *testing.T) {
|
||||
t.Fatal("container row should be gone")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRepoImages(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
|
||||
if _, ok, err := store.GetRepoImage("group/proj"); err != nil || ok {
|
||||
t.Fatalf("missing repo image = %v %v, want absent no-error", ok, err)
|
||||
}
|
||||
if rows, err := store.ListRepoImages(); err != nil || rows != nil {
|
||||
t.Fatalf("empty list = %v %v, want nil no-error", rows, err)
|
||||
}
|
||||
|
||||
if err := store.SetRepoImage("group/proj", "lvmh-worker-g--p-ab12cd"); err != nil {
|
||||
t.Fatalf("set: %v", err)
|
||||
}
|
||||
if img, ok, _ := store.GetRepoImage("group/proj"); !ok || img != "lvmh-worker-g--p-ab12cd" {
|
||||
t.Fatalf("get = %q %v", img, ok)
|
||||
}
|
||||
|
||||
// upsert replaces
|
||||
if err := store.SetRepoImage("group/proj", "lvmh-worker-g--p-ff99ee"); err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
if img, _, _ := store.GetRepoImage("group/proj"); img != "lvmh-worker-g--p-ff99ee" {
|
||||
t.Fatalf("after upsert = %q", img)
|
||||
}
|
||||
|
||||
if err := store.SetRepoImage("other/repo", "lvmh-worker-o--r-11aabb"); err != nil {
|
||||
t.Fatalf("set second: %v", err)
|
||||
}
|
||||
rows, err := store.ListRepoImages()
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
want := []RepoImageRow{
|
||||
{Repo: "group/proj", Image: "lvmh-worker-g--p-ff99ee"},
|
||||
{Repo: "other/repo", Image: "lvmh-worker-o--r-11aabb"},
|
||||
}
|
||||
if !reflect.DeepEqual(rows, want) {
|
||||
t.Fatalf("list = %+v, want %+v (sorted by repo)", rows, want)
|
||||
}
|
||||
|
||||
if err := store.DeleteRepoImage("group/proj"); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if _, ok, _ := store.GetRepoImage("group/proj"); ok {
|
||||
t.Fatal("repo image should be gone")
|
||||
}
|
||||
// idempotent delete
|
||||
if err := store.DeleteRepoImage("group/proj"); err != nil {
|
||||
t.Fatalf("delete absent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user