Files
lvmh/daemon/ops_test.go
T

329 lines
10 KiB
Go

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