v0.270.0: no update for a current app (R-679); an interrupted install is reported (R-681); a restore brings back the pinned version's health check (R-669); R-674
gates / gates (push) Successful in 24s
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
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user