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 } // copy, not rename: bakeDir (data volume) and bakeCtx (tmp) are // different mounts — rename(2) fails with EXDEV if err := copyTree(bakeDir, filepath.Join(bakeCtx, "docker", "pi-agent")); err != nil { writeError(w, http.StatusInternalServerError, "copy bake dir: "+err.Error()) return } if err := os.RemoveAll(bakeDir); err != nil { log.Printf("pi-config: remove bake dir after copy: %v", err) } 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 }