504eae018b
gates / gates (push) Successful in 24s
Five red-proofs. MinAgent 0.131.0 unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
101 lines
4.3 KiB
Go
101 lines
4.3 KiB
Go
package stacks
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
)
|
|
|
|
// ── An install cut off by a controller restart is finished, not forgotten (R-681, v0.270.0) ─────────
|
|
//
|
|
// MEASURED 2026-09-24 (night, chaos round 2): `systemctl restart docker` 20 s into n8n's install took the
|
|
// controller down with it. After the restart the box logged NOTHING about n8n, the app read not_deployed
|
|
// and the household's page showed it as never installed — the install had simply vanished, while its
|
|
// half-written files stayed in the stack dir. An update journals itself and resumes after the same
|
|
// accident; an install did not.
|
|
//
|
|
// THE MECHANISM: DeployStack writes installPendingFile before the compose-up goroutine starts;
|
|
// runComposeDeploy removes it when the install ENDS either way (the failure path already reports itself,
|
|
// R-536/R-649). A pending marker found at start therefore means the process died mid-install, and
|
|
// RecoverInterruptedInstalls finishes it through the SAME failure path a failed install takes:
|
|
// - the durable record says Deployed:true → the install had finished; only the marker goes;
|
|
// - otherwise → `compose down` (volumes kept, R-649), the stale pin records cleared, the app reads
|
|
// not-installed with installInterruptedFile set (the page's sentence, surviving further restarts
|
|
// until the next install), and the deploy-done hook fires `app_deploy_failed` with the reason.
|
|
// Pinned by r681_install_interrupted_test.go.
|
|
|
|
const (
|
|
installPendingFile = ".felhom-install-pending"
|
|
installInterruptedFile = ".felhom-install-interrupted"
|
|
)
|
|
|
|
// installInterruptedReason is the detail the event and the deploy page carry.
|
|
const installInterruptedReason = "interrupted by a controller restart before it finished — install it again"
|
|
|
|
func markInstallPending(stackDir string) error {
|
|
_ = os.Remove(filepath.Join(stackDir, installInterruptedFile)) // a new install supersedes the old sentence
|
|
return os.WriteFile(filepath.Join(stackDir, installPendingFile), []byte(time.Now().UTC().Format(time.RFC3339)+"\n"), 0o644)
|
|
}
|
|
|
|
func clearInstallPending(stackDir string) {
|
|
_ = os.Remove(filepath.Join(stackDir, installPendingFile))
|
|
}
|
|
|
|
func installInterrupted(stackDir string) bool {
|
|
_, err := os.Stat(filepath.Join(stackDir, installInterruptedFile))
|
|
return err == nil
|
|
}
|
|
|
|
// RecoverInterruptedInstalls runs once at start, after the deploy-done hook is wired. It returns the
|
|
// names it resolved as interrupted.
|
|
func (m *Manager) RecoverInterruptedInstalls() []string {
|
|
m.mu.RLock()
|
|
type cand struct{ name, dir string }
|
|
var cands []cand
|
|
for name, st := range m.stacks {
|
|
dir := filepath.Dir(st.ComposePath)
|
|
if _, err := os.Stat(filepath.Join(dir, installPendingFile)); err == nil {
|
|
cands = append(cands, cand{name, dir})
|
|
}
|
|
}
|
|
m.mu.RUnlock()
|
|
|
|
var out []string
|
|
for _, c := range cands {
|
|
if cfg := LoadAppConfig(c.dir); cfg != nil && cfg.Deployed {
|
|
clearInstallPending(c.dir)
|
|
m.logger.Printf("[INFO] [stacks] install %s: its record says deployed — the install had finished before the restart; marker cleared (R-681)", c.name)
|
|
continue
|
|
}
|
|
m.logger.Printf("[WARN] [stacks] install %s was INTERRUPTED by a controller restart — removing what it started (volumes kept) and reporting it (R-681)", c.name)
|
|
down := m.composeDownFn
|
|
if down == nil {
|
|
down = func(dir string) error { _, err := m.composeExecCustomEnv(dir, m.stackEnv(dir), "down"); return err }
|
|
}
|
|
if err := down(c.dir); err != nil {
|
|
m.logger.Printf("[ERROR] [stacks] install %s: removing what the interrupted install started failed: %v — Remove clears it", c.name, err)
|
|
}
|
|
for _, p := range []string{AppliedComposePath(c.dir), filepath.Join(c.dir, appliedMetaDir)} {
|
|
_ = os.RemoveAll(p) // the pin of a version that never became real
|
|
}
|
|
if err := os.WriteFile(filepath.Join(c.dir, installInterruptedFile), []byte(time.Now().UTC().Format(time.RFC3339)+"\n"), 0o644); err != nil {
|
|
m.logger.Printf("[WARN] [stacks] install %s: cannot record the interruption for the page: %v", c.name, err)
|
|
}
|
|
clearInstallPending(c.dir)
|
|
m.mu.Lock()
|
|
if s, ok := m.stacks[c.name]; ok {
|
|
s.Deployed = false
|
|
s.Deploying = false
|
|
s.AppConfig = nil
|
|
s.DeployError = installInterruptedReason
|
|
s.InstallInterrupted = true
|
|
}
|
|
m.mu.Unlock()
|
|
if m.deployDoneHook != nil {
|
|
m.deployDoneHook(c.name, false, installInterruptedReason)
|
|
}
|
|
out = append(out, c.name)
|
|
}
|
|
return out
|
|
}
|