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:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user