feat: full pi config in containers — git: packages vendored at bake (ignore-scripts), npm shim for runtime installs, dotfiles mount + POST /api/pi-config/resync rebuilds worker+ops images (95% cover, race clean)
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
package main
|
||||
|
||||
// pi_config_test.go — resync endpoint wiring: auth, conflict-while-running,
|
||||
// missing dotfiles mount, missing script.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAPIPiConfigResync(t *testing.T) {
|
||||
ts, _ := newTestServer(t)
|
||||
auth := testToken
|
||||
|
||||
if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/pi-config/resync", "", ""); code != http.StatusUnauthorized {
|
||||
t.Fatalf("resync without token = %d, want 401", code)
|
||||
}
|
||||
|
||||
// script missing in test env (path is /app/build/...): 500 before any work
|
||||
t.Setenv(envPiDotfilesMount, t.TempDir())
|
||||
code, body := apiReq(t, http.MethodPost, ts.URL+"/api/pi-config/resync", auth, "")
|
||||
if code != http.StatusInternalServerError && code != http.StatusBadRequest {
|
||||
t.Fatalf("resync with no settings/script = %d %s", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPiSyncStateMutex(t *testing.T) {
|
||||
piSyncState.mu.Lock()
|
||||
if piSyncState.running {
|
||||
piSyncState.mu.Unlock()
|
||||
t.Fatal("running must start false")
|
||||
}
|
||||
piSyncState.running = true
|
||||
piSyncState.mu.Unlock()
|
||||
// emulate handler guard
|
||||
piSyncState.mu.Lock()
|
||||
again := piSyncState.running
|
||||
piSyncState.mu.Unlock()
|
||||
if !again {
|
||||
t.Fatal("guard must observe running")
|
||||
}
|
||||
piSyncState.mu.Lock()
|
||||
piSyncState.running = false
|
||||
piSyncState.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestCopyTreeSmoke(t *testing.T) {
|
||||
src := t.TempDir()
|
||||
dst := t.TempDir() + "/out"
|
||||
if err := os.WriteFile(src+"/f.txt", []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := copyTree(src, dst); err != nil {
|
||||
t.Fatalf("copyTree: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(dst + "/f.txt"); err != nil {
|
||||
t.Fatalf("copied file missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPiConfigResyncHappyPathStub(t *testing.T) {
|
||||
// full resync against a stub script + fake docker + writable dirs
|
||||
useFakeGit(t, fakeGitModeOK)
|
||||
f := newFakeDocker()
|
||||
ts := f.server(t)
|
||||
t.Setenv("DOCKER_HOST", "tcp://"+ts.Listener.Addr().String())
|
||||
|
||||
root := t.TempDir()
|
||||
src := filepath.Join(root, "dotfiles")
|
||||
bake := filepath.Join(root, "bake")
|
||||
repo := filepath.Join(root, "repo")
|
||||
for _, d := range []string{src, bake, filepath.Join(repo, "docker")} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(src, "settings.json"), []byte(`{"packages":[]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(repo, "docker", "worker.Dockerfile"), []byte("FROM scratch\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(repo, "docker", "control.Dockerfile"), []byte("FROM scratch\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
script := filepath.Join(root, "sync.sh")
|
||||
if err := os.WriteFile(script, []byte("#!/bin/sh\nmkdir -p \"$LVMH_PI_BAKE_DIR\"\ncp \"$LVMH_PI_AGENT_DIR/settings.json\" \"$LVMH_PI_BAKE_DIR/\"\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv(envPiDotfilesMount, src)
|
||||
t.Setenv("LVMH_PI_BAKE_DIR", bake)
|
||||
daemonToken = testToken
|
||||
store := openTestStore(t)
|
||||
hub := NewHub(store)
|
||||
sp, err := NewSpawner(context.Background(), store, hub, "https://gitlab.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := &Server{store: store, hub: hub, spawn: sp, gitlab: NewGitLab(store, "https://gitlab.example")}
|
||||
api := httptest.NewServer(srv.Routes(""))
|
||||
t.Cleanup(api.Close)
|
||||
|
||||
// point the handler at the stub script + repo layout
|
||||
t.Setenv("LVMH_TEST_SYNC_SCRIPT", script)
|
||||
t.Setenv("LVMH_TEST_BUILD_CTX", filepath.Join(root, "bake-ctx"))
|
||||
t.Setenv("LVMH_TEST_BUILD_REPO", repo)
|
||||
if code, body := apiReq(t, http.MethodPost, api.URL+"/api/pi-config/resync", testToken, ""); code != http.StatusOK {
|
||||
t.Fatalf("resync = %d %s", code, body)
|
||||
}
|
||||
if !f.hasCall(http.MethodPost, "/build") {
|
||||
t.Fatal("image builds not issued")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPiConfigResyncConflictWhileRunning(t *testing.T) {
|
||||
ts, _ := newTestServer(t)
|
||||
piSyncState.mu.Lock()
|
||||
piSyncState.running = true
|
||||
piSyncState.mu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
piSyncState.mu.Lock()
|
||||
piSyncState.running = false
|
||||
piSyncState.mu.Unlock()
|
||||
})
|
||||
if code, body := apiReq(t, http.MethodPost, ts.URL+"/api/pi-config/resync", testToken, ""); code != http.StatusConflict {
|
||||
t.Fatalf("resync while running = %d %s, want 409", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyTreeError(t *testing.T) {
|
||||
if err := copyTree(t.TempDir()+"/nope", t.TempDir()+"/dst"); err == nil {
|
||||
t.Fatal("copyTree of missing src must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPiConfigResyncFailurePaths(t *testing.T) {
|
||||
ts, _ := newTestServer(t)
|
||||
|
||||
// 1: dotfiles mount without settings.json -> 400
|
||||
empty := t.TempDir()
|
||||
t.Setenv(envPiDotfilesMount, empty)
|
||||
t.Setenv("LVMH_TEST_SYNC_SCRIPT", "/nonexistent-sync.sh")
|
||||
if code, body := apiReq(t, http.MethodPost, ts.URL+"/api/pi-config/resync", testToken, ""); code != http.StatusBadRequest {
|
||||
t.Fatalf("no settings = %d %s, want 400", code, body)
|
||||
}
|
||||
|
||||
// 2: settings present, script missing -> 500
|
||||
if err := os.WriteFile(filepath.Join(empty, "settings.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code, body := apiReq(t, http.MethodPost, ts.URL+"/api/pi-config/resync", testToken, ""); code != http.StatusInternalServerError {
|
||||
t.Fatalf("missing script = %d %s, want 500", code, body)
|
||||
}
|
||||
|
||||
// 3: script fails -> 500 sync failed
|
||||
root := t.TempDir()
|
||||
src := filepath.Join(root, "src")
|
||||
if err := os.MkdirAll(src, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(src, "settings.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failScript := filepath.Join(root, "fail.sh")
|
||||
if err := os.WriteFile(failScript, []byte("#!/bin/sh\nexit 3\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(envPiDotfilesMount, src)
|
||||
t.Setenv("LVMH_TEST_SYNC_SCRIPT", failScript)
|
||||
if code, body := apiReq(t, http.MethodPost, ts.URL+"/api/pi-config/resync", testToken, ""); code != http.StatusInternalServerError {
|
||||
t.Fatalf("failing script = %d %s, want 500", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPiConfigResyncBuildFailure(t *testing.T) {
|
||||
useFakeGit(t, fakeGitModeOK)
|
||||
f := newFakeDocker()
|
||||
f.failBuildHTTP = true
|
||||
ts := f.server(t)
|
||||
t.Setenv("DOCKER_HOST", "tcp://"+ts.Listener.Addr().String())
|
||||
|
||||
root := t.TempDir()
|
||||
src := filepath.Join(root, "src")
|
||||
repo := filepath.Join(root, "repo")
|
||||
bake := filepath.Join(root, "bake")
|
||||
for _, d := range []string{src, bake, filepath.Join(repo, "docker")} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(src, "settings.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(repo, "docker", "worker.Dockerfile"), []byte("FROM scratch\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(repo, "docker", "control.Dockerfile"), []byte("FROM scratch\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
script := filepath.Join(root, "ok.sh")
|
||||
if err := os.WriteFile(script, []byte("#!/bin/sh\nmkdir -p \"$LVMH_PI_BAKE_DIR\"\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(envPiDotfilesMount, src)
|
||||
t.Setenv("LVMH_TEST_SYNC_SCRIPT", script)
|
||||
t.Setenv("LVMH_PI_BAKE_DIR", bake)
|
||||
t.Setenv("LVMH_TEST_BUILD_REPO", repo)
|
||||
t.Setenv("LVMH_TEST_BUILD_CTX", filepath.Join(root, "ctx"))
|
||||
|
||||
daemonToken = testToken
|
||||
store := openTestStore(t)
|
||||
hub := NewHub(store)
|
||||
sp, err := NewSpawner(context.Background(), store, hub, "https://gitlab.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := &Server{store: store, hub: hub, spawn: sp, gitlab: NewGitLab(store, "https://gitlab.example")}
|
||||
api := httptest.NewServer(srv.Routes(""))
|
||||
t.Cleanup(api.Close)
|
||||
|
||||
if code, body := apiReq(t, http.MethodPost, api.URL+"/api/pi-config/resync", testToken, ""); code != http.StatusInternalServerError {
|
||||
t.Fatalf("build failure = %d %s, want 500", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func mustWrite(t *testing.T, path, content string, mode os.FileMode) string {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestPiConfigResyncContextFailureArms(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
src := filepath.Join(root, "src")
|
||||
if err := os.MkdirAll(src, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(src, "settings.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
okScript := filepath.Join(root, "ok.sh")
|
||||
if err := os.WriteFile(okScript, []byte("#!/bin/sh\nmkdir -p \"$LVMH_PI_BAKE_DIR\"\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// repo missing control.Dockerfile only → copyTree ok, rename ok, worker ok (scratch), ops dockerfile missing → buildImage fails
|
||||
useFakeGit(t, fakeGitModeOK)
|
||||
f := newFakeDocker()
|
||||
ts := f.server(t)
|
||||
t.Setenv("DOCKER_HOST", "tcp://"+ts.Listener.Addr().String())
|
||||
daemonToken = testToken
|
||||
store := openTestStore(t)
|
||||
hub := NewHub(store)
|
||||
sp, err := NewSpawner(context.Background(), store, hub, "https://gitlab.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := &Server{store: store, hub: hub, spawn: sp, gitlab: NewGitLab(store, "https://gitlab.example")}
|
||||
api := httptest.NewServer(srv.Routes(""))
|
||||
t.Cleanup(api.Close)
|
||||
|
||||
repo := filepath.Join(root, "repo")
|
||||
bake := filepath.Join(root, "bake")
|
||||
for _, d := range []string{bake, filepath.Join(repo, "docker")} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(repo, "docker", "worker.Dockerfile"), []byte("FROM scratch\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(repo, "docker", "control.Dockerfile"), []byte("FROM scratch\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// rename arm: bake dir absent → os.Rename fails
|
||||
t.Setenv("LVMH_TEST_SYNC_SCRIPT", mustWrite(t, filepath.Join(root, "noop.sh"), "#!/bin/sh\nexit 0\n", 0o755))
|
||||
t.Setenv(envPiDotfilesMount, src)
|
||||
t.Setenv("LVMH_PI_BAKE_DIR", filepath.Join(root, "no-such-bake"))
|
||||
t.Setenv("LVMH_TEST_BUILD_REPO", repo)
|
||||
t.Setenv("LVMH_TEST_BUILD_CTX", filepath.Join(root, "ctx"))
|
||||
|
||||
if code, body := apiReq(t, http.MethodPost, api.URL+"/api/pi-config/resync", testToken, ""); code != http.StatusInternalServerError {
|
||||
t.Fatalf("ops missing = %d %s, want 500", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPiConfigDefaultScriptPathBranch(t *testing.T) {
|
||||
ts, _ := newTestServer(t)
|
||||
root := t.TempDir()
|
||||
src := filepath.Join(root, "src")
|
||||
if err := os.MkdirAll(src, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(src, "settings.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(envPiDotfilesMount, src)
|
||||
t.Setenv("LVMH_TEST_SYNC_SCRIPT", "") // default branch → missing real script path → 500
|
||||
if code, body := apiReq(t, http.MethodPost, ts.URL+"/api/pi-config/resync", testToken, ""); code != http.StatusInternalServerError {
|
||||
t.Fatalf("default script missing = %d %s, want 500", code, body)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user