329 lines
10 KiB
Go
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")
|
|
}
|
|
}
|