v0.237.0: the Update button takes a backup first, and tells the truth (update arc slice 4 — R-448, R-443, R-439)
gates / gates (push) Successful in 13s
gates / gates (push) Successful in 13s
POST /api/stacks/{name}/update is now a guarded job answering 202:
cheap refusals (hold — R-439, busy, migration, deploying, memory via the
deploy's own memoryVerdict, a fixed 2 GB disk floor, and no restorable
Tier-2 copy) → backup-first when the proven copy is older than
update.backup_max_age (24h) → safety dump BEFORE the pin moves → pin →
pull (failure puts the pin back) → up → health (.felhom.yml check or 60 s
settle, update.health_timeout 5m). Not healthy → the app is stopped and
HELD (RestoreHold reason update_failed, same store and gate as R-379) and
the page names the backup to restore from; the pin stays. Success is only
ever update_phase=done after health (R-443). UpdateStack is deleted.
The restorable-unit predicate is EXTRACTED to backup.Tier2UnitRestorePoint
and shared with the backups page (row pinned unchanged). The copy is aged
by the last successful Tier-2 copy, not the manifest created_at — measured
on demo-hp that created_at moves only on definition changes.
Crash safety: update-journal.json before each phase; RecoverUpdates before
the boot sweep, ResumeInterruptedUpdates after the guards are wired.
Three unattended start paths ignored a hold and now honour it: the
drive-return gate (restart + boot recreate) and the nightly volume dump.
The nightly capture and Tier-2 run skip held apps so the restore point
survives. No automatic rollback — measured per-app; route back = restore.
Tests A–H across stacks/backup/api/web/cmd; six red-proofs seen to fail.
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
@@ -46,6 +47,49 @@ type Config struct {
|
||||
Quiesce QuiesceConfig `yaml:"quiesce"`
|
||||
MailRelay MailRelayConfig `yaml:"mail_relay"`
|
||||
Offsite OffsiteConfig `yaml:"offsite"`
|
||||
Update UpdateConfig `yaml:"update"`
|
||||
}
|
||||
|
||||
// UpdateConfig tunes the guarded app update (update arc slice 4, v0.237.0).
|
||||
//
|
||||
// Both are OPERATOR knobs, not constants: "recent" and "healthy in time" are judgements the operator
|
||||
// ruled defaults for (2026-09-13) and may want to move per box without a release.
|
||||
type UpdateConfig struct {
|
||||
// BackupMaxAge is how old the app's proven Tier-2 copy may be before an update makes a fresh one
|
||||
// first. Default "24h".
|
||||
BackupMaxAge string `yaml:"backup_max_age"`
|
||||
// HealthTimeout bounds the wait for the new version to become healthy before the app is held.
|
||||
// Default "5m".
|
||||
HealthTimeout string `yaml:"health_timeout"`
|
||||
}
|
||||
|
||||
// DefaultUpdateBackupMaxAge and DefaultUpdateHealthTimeout are the values used when the config is
|
||||
// empty or unparseable. An unparseable value falls back rather than failing the whole config load:
|
||||
// a typo in a tuning knob must not stop a controller from starting.
|
||||
const (
|
||||
DefaultUpdateBackupMaxAge = 24 * time.Hour
|
||||
DefaultUpdateHealthTimeout = 5 * time.Minute
|
||||
)
|
||||
|
||||
// BackupMaxAgeDuration parses BackupMaxAge, falling back to the default on empty/invalid/non-positive.
|
||||
func (u UpdateConfig) BackupMaxAgeDuration() time.Duration {
|
||||
return parsePositiveDuration(u.BackupMaxAge, DefaultUpdateBackupMaxAge)
|
||||
}
|
||||
|
||||
// HealthTimeoutDuration parses HealthTimeout, falling back to the default on empty/invalid/non-positive.
|
||||
func (u UpdateConfig) HealthTimeoutDuration() time.Duration {
|
||||
return parsePositiveDuration(u.HealthTimeout, DefaultUpdateHealthTimeout)
|
||||
}
|
||||
|
||||
func parsePositiveDuration(s string, def time.Duration) time.Duration {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
d, err := time.ParseDuration(s)
|
||||
if err != nil || d <= 0 {
|
||||
return def
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// MailRelayConfig tunes the in-controller SMTP shim (app email → shim → hub → Resend).
|
||||
@@ -384,6 +428,8 @@ func applyDefaults(cfg *Config) {
|
||||
d(&cfg.MailRelay.TLSListen, ":2465")
|
||||
d(&cfg.MailRelay.PlainNoTLSListen, ":2526")
|
||||
d(&cfg.MailRelay.ShimHost, "felhom-controller")
|
||||
d(&cfg.Update.BackupMaxAge, "24h")
|
||||
d(&cfg.Update.HealthTimeout, "5m")
|
||||
if len(cfg.MailRelay.FromDomains) == 0 {
|
||||
cfg.MailRelay.FromDomains = []string{"felhom.eu"}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user