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:
@@ -84,6 +84,7 @@ func (s *Server) Routes(webdist string) http.Handler {
|
|||||||
api.HandleFunc("GET /api/spawn/status", s.handleSpawnStatus)
|
api.HandleFunc("GET /api/spawn/status", s.handleSpawnStatus)
|
||||||
api.HandleFunc("GET /api/repos", s.handleRepoImages)
|
api.HandleFunc("GET /api/repos", s.handleRepoImages)
|
||||||
api.HandleFunc("POST /api/repos/", s.handlePrepareRepo)
|
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
|
// repo paths contain "/" (group/project), which a single {repo} wildcard
|
||||||
// segment cannot match — subtree routes with manual path parsing instead.
|
// segment cannot match — subtree routes with manual path parsing instead.
|
||||||
api.HandleFunc("PUT /api/repos/", s.handleSetRepoImage)
|
api.HandleFunc("PUT /api/repos/", s.handleSetRepoImage)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
-17
@@ -4,7 +4,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SRC="${LVMH_PI_AGENT_DIR:-$HOME/.dotfiles/pi/agent}"
|
SRC="${LVMH_PI_AGENT_DIR:-$HOME/.dotfiles/pi/agent}"
|
||||||
DEST="$(dirname "$0")/../docker/pi-agent"
|
DEST="${LVMH_PI_BAKE_DIR:-$(dirname "$0")/../docker/pi-agent}"
|
||||||
|
|
||||||
# DEST must exist even without dotfiles: the worker Dockerfile COPYs it
|
# DEST must exist even without dotfiles: the worker Dockerfile COPYs it
|
||||||
# unconditionally (a missing dir would fail the build).
|
# unconditionally (a missing dir would fail the build).
|
||||||
@@ -31,20 +31,41 @@ rsync -a --delete \
|
|||||||
--exclude '.pi/' \
|
--exclude '.pi/' \
|
||||||
"$SRC/" "$DEST/"
|
"$SRC/" "$DEST/"
|
||||||
|
|
||||||
# Container sessions cannot run git: package postinstalls (e.g. husky) — keep
|
# Pre-seed the git: package cache: clone each pinned git: package into
|
||||||
# registry (npm:) packages only in the baked settings.json.
|
# docker/pi-agent/git/... where pi's package manager installs them, with
|
||||||
python3 - "$DEST/settings.json" <<'PY'
|
# lifecycle scripts ignored (postinstalls like husky crash headless).
|
||||||
import json, sys
|
# Keeps subagents/todo/async tooling present with no runtime network need.
|
||||||
p = sys.argv[1]
|
# Requires network access to github.com at bake time.
|
||||||
with open(p) as f:
|
python3 - "$DEST" <<'PY'
|
||||||
s = json.load(f)
|
import json, re, shutil, subprocess, sys
|
||||||
pkgs = s.get("packages") or []
|
from pathlib import Path
|
||||||
kept = [x for x in pkgs if x.startswith("npm:")]
|
|
||||||
if pkgs != kept:
|
dest = Path(sys.argv[1])
|
||||||
s["packages"] = kept
|
settings = json.loads((dest / "settings.json").read_text())
|
||||||
with open(p, "w") as f:
|
git_root = dest / "git"
|
||||||
json.dump(s, f, indent=2)
|
if git_root.exists():
|
||||||
f.write("\n")
|
shutil.rmtree(git_root)
|
||||||
print(f"rsync-pi-agent: dropped {len(pkgs) - len(kept)} git: packages from baked settings")
|
|
||||||
|
for pkg in settings.get("packages") or []:
|
||||||
|
if not pkg.startswith("git:"):
|
||||||
|
continue
|
||||||
|
rest = pkg[4:]
|
||||||
|
m = re.match(r"^(.+)@([0-9a-f]{7,40})$", rest)
|
||||||
|
repo, ref = (m.group(1), m.group(2)) if m else (rest, None)
|
||||||
|
target = git_root / repo
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
url = f"https://{repo}" if not repo.startswith("https://") else repo
|
||||||
|
print(f"rsync-pi-agent: cloning {repo}@{ref or 'HEAD'}")
|
||||||
|
subprocess.run(["git", "clone", "--quiet", url, str(target)], check=True)
|
||||||
|
if ref:
|
||||||
|
subprocess.run(["git", "checkout", "--quiet", ref], cwd=target, check=True)
|
||||||
|
shutil.rmtree(target / ".git", ignore_errors=True)
|
||||||
|
if (target / "package.json").exists():
|
||||||
|
npm = "/usr/bin/npm" if Path("/usr/bin/npm").exists() else "npm"
|
||||||
|
subprocess.run(
|
||||||
|
[npm, "install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"],
|
||||||
|
cwd=target, check=True, capture_output=True,
|
||||||
|
)
|
||||||
PY
|
PY
|
||||||
echo "rsync-pi-agent: synced $(find "$DEST" -type f | wc -l) files from $SRC"
|
|
||||||
|
echo "rsync-pi-agent: synced $(find "$DEST" -type f | wc -l) files from $SRC (git: packages preserved)"
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ services:
|
|||||||
- .:/app/build:ro # repo checkout (rsynced by deploy.sh)
|
- .:/app/build:ro # repo checkout (rsynced by deploy.sh)
|
||||||
# daemon rebuilds lvmh-worker from /app/build/docker/worker.Dockerfile
|
# daemon rebuilds lvmh-worker from /app/build/docker/worker.Dockerfile
|
||||||
- ./web/dist:/app/web-dist:ro # web UI served from repo checkout
|
- ./web/dist:/app/web-dist:ro # web UI served from repo checkout
|
||||||
|
- ${LVMH_PI_AGENT_DIR:-/home/raph/.dotfiles/pi/agent}:/pi-dotfiles:ro
|
||||||
|
# read-only user pi config; POST /api/pi-config/resync bakes it into
|
||||||
|
# fresh worker+ops images
|
||||||
networks:
|
networks:
|
||||||
lvmh-net:
|
lvmh-net:
|
||||||
aliases: [lvmh]
|
aliases: [lvmh]
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ RUN apt-get update \
|
|||||||
|
|
||||||
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
||||||
|
|
||||||
|
# Same npm shim as the worker image (see worker.Dockerfile).
|
||||||
|
RUN mv /usr/local/bin/npm /usr/local/bin/npm-real
|
||||||
|
COPY docker/npm-shim /usr/local/bin/npm
|
||||||
|
RUN chmod +x /usr/local/bin/npm
|
||||||
|
|
||||||
# Same pi config as workers (dotfiles, filtered; git: packages stripped).
|
# Same pi config as workers (dotfiles, filtered; git: packages stripped).
|
||||||
COPY docker/pi-agent/ /root/.pi/agent/
|
COPY docker/pi-agent/ /root/.pi/agent/
|
||||||
COPY plugin/lvmh-agent.ts /root/.pi/agent/extensions/lvmh-agent.ts
|
COPY plugin/lvmh-agent.ts /root/.pi/agent/extensions/lvmh-agent.ts
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# npm shim for headless pi containers: skip lifecycle scripts during package
|
||||||
|
# installs. pi installs git: packages by running `npm install --omit=dev` in
|
||||||
|
# each clone; some upstream packages (e.g. husky in rpiv-mono) have
|
||||||
|
# postinstall scripts that assume a dev machine and fail/crash headless
|
||||||
|
# installs. --ignore-scripts makes installs safe; runtime code (pure TS/JS
|
||||||
|
# extensions) does not need lifecycle scripts. Everything else passes through
|
||||||
|
# to the real npm transparently.
|
||||||
|
case "$1" in
|
||||||
|
install|i)
|
||||||
|
exec /usr/bin/npm-real "$@" --ignore-scripts
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
exec /usr/bin/npm-real "$@"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -13,6 +13,14 @@ RUN GOBIN=/usr/local/bin go install golang.org/x/tools/gopls@latest || true
|
|||||||
|
|
||||||
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
||||||
|
|
||||||
|
# npm shim: pi installs git: packages with `npm install`; postinstall
|
||||||
|
# scripts (husky etc.) crash headless installs. Route installs through
|
||||||
|
# --ignore-scripts so ALL dotfiles packages (pi-subagents, todo tooling,
|
||||||
|
# async agents, every extension) survive the bake.
|
||||||
|
RUN mv /usr/local/bin/npm /usr/local/bin/npm-real
|
||||||
|
COPY docker/npm-shim /usr/local/bin/npm
|
||||||
|
RUN chmod +x /usr/local/bin/npm
|
||||||
|
|
||||||
# User's pi config from dotfiles (settings, skills, agents, extensions,
|
# User's pi config from dotfiles (settings, skills, agents, extensions,
|
||||||
# APPEND_SYSTEM.md) — synced by deploy.sh from ~/.dotfiles/pi/agent (filtered:
|
# APPEND_SYSTEM.md) — synced by deploy.sh from ~/.dotfiles/pi/agent (filtered:
|
||||||
# no auth.json/sessions/cache/npm). If docker/pi-agent/ is absent this layer
|
# no auth.json/sessions/cache/npm). If docker/pi-agent/ is absent this layer
|
||||||
|
|||||||
Reference in New Issue
Block a user