Files
felhom-controller/controller/internal/stacks/install_interrupted.go
T
2026-09-24 16:49:43 +02:00

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
}