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") } }