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 }