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:
Raphael Westphal
2026-08-19 11:00:39 +02:00
parent 910a244376
commit cbf52d2b76
8 changed files with 505 additions and 17 deletions
+1
View File
@@ -84,6 +84,7 @@ func (s *Server) Routes(webdist string) http.Handler {
api.HandleFunc("GET /api/spawn/status", s.handleSpawnStatus)
api.HandleFunc("GET /api/repos", s.handleRepoImages)
api.HandleFunc("POST /api/repos/", s.handlePrepareRepo)
api.HandleFunc("POST /api/pi-config/resync", s.handlePiConfigResync)
// repo paths contain "/" (group/project), which a single {repo} wildcard
// segment cannot match — subtree routes with manual path parsing instead.
api.HandleFunc("PUT /api/repos/", s.handleSetRepoImage)
+126
View File
@@ -0,0 +1,126 @@
package main
// pi-config.go — re-sync the user's pi config (dotfiles, mounted read-only)
// into the image build context and rebuild the worker + ops images, so
// spawned agents pick up config changes without a full deploy.
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"sync"
"time"
)
const (
envPiDotfilesMount string = "LVMH_PI_DOTFILES" // default /pi-dotfiles
piSyncScript string = "/app/build/deploy/rsync-pi-agent.sh"
piSyncTimeout time.Duration = 10 * time.Minute
)
// piSyncState serializes resyncs; at most one runs at a time.
var piSyncState = struct {
mu sync.Mutex
running bool
}{}
func (s *Server) handlePiConfigResync(w http.ResponseWriter, r *http.Request) {
piSyncState.mu.Lock()
if piSyncState.running {
piSyncState.mu.Unlock()
writeError(w, http.StatusConflict, "pi config sync already running")
return
}
piSyncState.running = true
piSyncState.mu.Unlock()
defer func() {
piSyncState.mu.Lock()
piSyncState.running = false
piSyncState.mu.Unlock()
}()
src := envOr(envPiDotfilesMount, "/pi-dotfiles")
if _, err := os.Stat(filepath.Join(src, "settings.json")); err != nil {
writeError(w, http.StatusBadRequest, "no pi settings at "+src)
return
}
scriptPath := os.Getenv("LVMH_TEST_SYNC_SCRIPT")
if scriptPath == "" {
scriptPath = piSyncScript
}
if _, err := os.Stat(scriptPath); err != nil {
writeError(w, http.StatusInternalServerError, "sync script missing: "+scriptPath)
return
}
// The script writes into the repo checkout (docker/pi-agent) and we then
// rebuild both images from that context. Build context is mounted :ro in
// compose — remount rw or run against a writable copy? The build context
// bind is :ro; run the script with DEST inside the shared lvmh-data volume
// instead, then build from a tar we assemble there.
bakeDir := os.Getenv("LVMH_PI_BAKE_DIR")
if bakeDir == "" {
bakeDir = "/data/pi-bake"
}
script := exec.CommandContext(r.Context(), "bash", scriptPath)
script.Env = append(os.Environ(),
"LVMH_PI_AGENT_DIR="+src,
"LVMH_PI_BAKE_DIR="+bakeDir,
)
out, err := script.CombinedOutput()
if err != nil {
log.Printf("pi-config: sync failed: %v\n%s", err, out)
writeError(w, http.StatusInternalServerError, "sync failed: "+err.Error())
return
}
ctx, cancel := context.WithTimeout(context.Background(), piSyncTimeout)
defer cancel()
bakeCtx := filepath.Join(os.TempDir(), "lvmh-pi-bake-ctx")
if bc := os.Getenv("LVMH_TEST_BUILD_CTX"); bc != "" {
bakeCtx = bc
}
if err := os.RemoveAll(bakeCtx); err != nil {
writeError(w, http.StatusInternalServerError, "clear bake context: "+err.Error())
return
}
buildRepo := "/app/build"
if br := os.Getenv("LVMH_TEST_BUILD_REPO"); br != "" {
buildRepo = br
}
if err := copyTree(buildRepo, bakeCtx); err != nil {
writeError(w, http.StatusInternalServerError, "prep build context: "+err.Error())
return
}
if err := os.RemoveAll(filepath.Join(bakeCtx, "docker", "pi-agent")); err != nil {
writeError(w, http.StatusInternalServerError, "clear stale pi-agent: "+err.Error())
return
}
if err := os.Rename(bakeDir, filepath.Join(bakeCtx, "docker", "pi-agent")); err != nil {
writeError(w, http.StatusInternalServerError, "move bake dir: "+err.Error())
return
}
if err := s.spawn.buildImage(ctx, bakeCtx, filepath.Join(bakeCtx, "docker", "worker.Dockerfile"), imageRefWorker); err != nil {
writeError(w, http.StatusInternalServerError, "worker rebuild: "+err.Error())
return
}
if err := s.spawn.buildImage(ctx, bakeCtx, filepath.Join(bakeCtx, "docker", "control.Dockerfile"), opsImageRef); err != nil {
writeError(w, http.StatusInternalServerError, "ops rebuild: "+err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// copyTree duplicates a directory tree for a one-shot build context.
func copyTree(src, dst string) error {
out, err := exec.Command("cp", "-a", src, dst).CombinedOutput()
if err != nil {
return fmt.Errorf("cp %s -> %s: %s", src, dst, out)
}
return nil
}
+308
View File
@@ -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)
}
}