5a3437669f
gates / gates (push) Successful in 27s
Image retention: after a done/undone guarded Update and at remove, an app's images older than its running and previous one are deleted — never an image any container, installed compose or installed/previous record names (box-wide keep set read at delete time); exact id, never forced or pruned; paused while any update runs; a one-time sweep of catalog app images at the first start. Install hold: an after_install app is installed behind the setup gate's door and opens when after_install succeeds or the household says it changed the login. Tests TestImageRetention_* and TestInstallHold_* with red-proofs; parity fixture for the held card. 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
212 lines
8.8 KiB
Go
212 lines
8.8 KiB
Go
package stacks
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// ── The install hold (R-741, `09` §3 decision 45) ─────────────────────────────────────────────────────
|
||
//
|
||
// Measured 2026-09-30 on 9202 (calibre-web): an app whose template carries `after_install:` answered its PUBLIC
|
||
// default login through traefik for 1–18 s — the app was published at its first start, and the box replaced the
|
||
// login only after the app was up. So such an app is installed HELD: before its first start a traefik file puts
|
||
// the setup gate's door (forwardAuth, internal/web/setup_gate.go) in front of every router it publishes. A
|
||
// stranger is refused; the household (a dashboard session) still passes — so a failed after_install leaves the
|
||
// household able to change the login by hand and say so ("I changed it"). The hold OPENS when after_install
|
||
// succeeds (runAfterInstallNow) or when the household says it changed the login (MarkDefaultLoginChanged); opening
|
||
// removes the file. The record (`install_hold:` in app.yaml, the gate's record shape) is reconciled by the gate's
|
||
// loop: a closed hold keeps its file; a hold whose after_install already succeeded, or whose login the household
|
||
// changed, opens; an absent after_install record (a controller restart cut the hook off) is run again once per
|
||
// process. Its priority beats the setup gate and the sign-up block, so a gated app is held first.
|
||
// Pinned by internal/stacks/install_hold_test.go.
|
||
|
||
const (
|
||
InstallHoldByAfterInstall = "after_install"
|
||
InstallHoldByHousehold = "household"
|
||
)
|
||
|
||
func (m *Manager) installHoldPath(name string) string {
|
||
return filepath.Join(m.setupGateDir(), "install-hold-"+name+".yml")
|
||
}
|
||
|
||
// renderInstallHold is the traefik file: every router the app publishes, same rule, a priority above the gate's
|
||
// and the sign-up block's, the gate's forwardAuth door, then the app's own docker service.
|
||
func renderInstallHold(name string, rs []gateRouter) string {
|
||
var b strings.Builder
|
||
mw := "felhom-install-hold-" + name
|
||
fmt.Fprintf(&b, "# Install hold for %s — managed by felhom-controller (R-741, `09` §3 decision 45).\n", name)
|
||
b.WriteString("# Only the household reaches the app until its known first login has been replaced; then this file is removed.\n")
|
||
b.WriteString("http:\n middlewares:\n")
|
||
fmt.Fprintf(&b, " %s:\n forwardAuth:\n address: %q\n", mw, setupGateAuthURL)
|
||
b.WriteString(" routers:\n")
|
||
for _, r := range rs {
|
||
fmt.Fprintf(&b, " %s-%s:\n", mw, r.Name)
|
||
fmt.Fprintf(&b, " rule: %q\n", r.Rule)
|
||
fmt.Fprintf(&b, " priority: %d\n", 3*setupGatePriority+len(r.Rule))
|
||
b.WriteString(" entryPoints:\n - websecure\n")
|
||
if r.CertResolver != "" {
|
||
fmt.Fprintf(&b, " tls:\n certResolver: %s\n", r.CertResolver)
|
||
} else {
|
||
b.WriteString(" tls: {}\n")
|
||
}
|
||
fmt.Fprintf(&b, " middlewares:\n - %s@file\n", mw)
|
||
fmt.Fprintf(&b, " service: %q\n", r.Service+"@docker")
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func (m *Manager) writeInstallHold(name, composePath string, env map[string]string) ([]string, error) {
|
||
rs, err := gateRoutersFromCompose(composePath, env)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := os.MkdirAll(m.setupGateDir(), 0o755); err != nil {
|
||
return nil, err
|
||
}
|
||
want := renderInstallHold(name, rs)
|
||
p := m.installHoldPath(name)
|
||
if cur, err := os.ReadFile(p); err == nil && string(cur) == want {
|
||
return gateHosts(rs), nil
|
||
}
|
||
tmp := p + ".tmp"
|
||
if err := os.WriteFile(tmp, []byte(want), 0o644); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := os.Rename(tmp, p); err != nil {
|
||
return nil, err
|
||
}
|
||
return gateHosts(rs), nil
|
||
}
|
||
|
||
func (m *Manager) removeInstallHoldFile(name string) error {
|
||
err := os.Remove(m.installHoldPath(name))
|
||
if err != nil && !os.IsNotExist(err) {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// wantsInstallHold: the template replaces a known first login after the install.
|
||
func wantsInstallHold(meta *Metadata) bool {
|
||
ai := meta.AfterInstall
|
||
return ai != nil && ai.Service != "" && len(ai.Command) > 0 && ai.Success != ""
|
||
}
|
||
|
||
// prepareInstallHold is DeployStack's step for an after_install template on a FRESH install: the file first (it
|
||
// must stand before the first start), then the record the caller saves with the app.
|
||
func (m *Manager) prepareInstallHold(name, composePath string, env map[string]string) (*SetupGateRecord, error) {
|
||
hosts, err := m.writeInstallHold(name, composePath, env)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
m.logger.Printf("[INFO] [stacks] %s: install HOLD before the first start — only the household reaches %v until the known first login is replaced", name, hosts)
|
||
return &SetupGateRecord{State: SetupGateClosed, Since: m.now().UTC().Format(time.RFC3339), Hosts: hosts}, nil
|
||
}
|
||
|
||
// OpenInstallHold opens an app's hold: the record first, then the file (a failed removal is retried by the loop).
|
||
// A hold that is not closed is not an error — after_install succeeding on an app never held (installed before
|
||
// this release) opens nothing.
|
||
func (m *Manager) OpenInstallHold(name, by string) error {
|
||
st, ok := m.GetStack(name)
|
||
if !ok || st.AppConfig == nil || !st.AppConfig.InstallHold.Closed() {
|
||
return nil
|
||
}
|
||
dir := filepath.Dir(st.ComposePath)
|
||
now := m.now().UTC().Format(time.RFC3339)
|
||
opened := false
|
||
m.mutateAppConfig(name, dir, "install_hold", func(cfg *AppConfig) bool {
|
||
if !cfg.InstallHold.Closed() {
|
||
return false
|
||
}
|
||
cfg.InstallHold.State, cfg.InstallHold.OpenedAt, cfg.InstallHold.OpenedBy = SetupGateOpen, now, by
|
||
opened = true
|
||
return true
|
||
})
|
||
if !opened {
|
||
return fmt.Errorf("install hold %s: the record could not be written", name)
|
||
}
|
||
if err := m.removeInstallHoldFile(name); err != nil {
|
||
m.logger.Printf("[ERROR] [stacks] %s: install hold opened but its traefik file could not be removed (%v) — the loop retries", name, err)
|
||
}
|
||
m.logger.Printf("[INFO] [stacks] %s: install hold OPENED by %s — the app is reached as without a hold", name, by)
|
||
return nil
|
||
}
|
||
|
||
// installHoldProcessStart: only an install made BEFORE this process started can have lost its hook (the hook runs
|
||
// after_install in this process's own goroutine right after an install made now).
|
||
var installHoldProcessStart = time.Now()
|
||
|
||
// installHoldRetried: apps whose absent after_install record this process already re-ran (once per process).
|
||
var installHoldRetried sync.Map
|
||
|
||
// installHoldAfterInstall is RunAfterInstall, a seam for the tests.
|
||
var installHoldAfterInstall = func(m *Manager, name string) { _, _ = m.RunAfterInstall(name, 10*time.Minute) }
|
||
|
||
// installHoldTick is the hold's part of SetupGateTick: stale files go, closed holds keep their file, and a hold
|
||
// whose login is already replaced opens.
|
||
func (m *Manager) installHoldTick() {
|
||
type item struct {
|
||
name, dir, compose string
|
||
opened, rerun string
|
||
}
|
||
var items []item
|
||
keep := map[string]bool{}
|
||
m.mu.RLock()
|
||
for n, st := range m.stacks {
|
||
if !st.Deployed || st.AppConfig == nil || !st.AppConfig.InstallHold.Closed() {
|
||
continue
|
||
}
|
||
it := item{name: n, dir: filepath.Dir(st.ComposePath), compose: st.ComposePath}
|
||
switch {
|
||
case st.AppConfig.AfterInstall != nil && st.AppConfig.AfterInstall.OK:
|
||
it.opened = InstallHoldByAfterInstall
|
||
case st.AppConfig.DefaultLogin != nil:
|
||
it.opened = InstallHoldByHousehold
|
||
case st.AppConfig.AfterInstall == nil && (st.State == StateRunning || st.State == StateUnhealthy) && !st.Deploying:
|
||
if at, err := time.Parse(time.RFC3339, st.AppConfig.DeployedAt); err == nil && at.Before(installHoldProcessStart.Truncate(time.Second)) { // DeployedAt has whole seconds
|
||
it.rerun = "yes"
|
||
}
|
||
}
|
||
items = append(items, it)
|
||
keep[n] = true
|
||
}
|
||
m.mu.RUnlock()
|
||
if ents, err := os.ReadDir(m.setupGateDir()); err == nil {
|
||
for _, e := range ents {
|
||
n := e.Name()
|
||
if !strings.HasPrefix(n, "install-hold-") || !strings.HasSuffix(n, ".yml") {
|
||
continue
|
||
}
|
||
app := strings.TrimSuffix(strings.TrimPrefix(n, "install-hold-"), ".yml")
|
||
if !keep[app] {
|
||
if err := m.removeInstallHoldFile(app); err == nil {
|
||
m.logger.Printf("[INFO] [stacks] %s: removed an install-hold file for an app that is not held", app)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
for _, it := range items {
|
||
if it.opened != "" {
|
||
if err := m.OpenInstallHold(it.name, it.opened); err != nil {
|
||
m.logger.Printf("[ERROR] [stacks] %s: %v", it.name, err)
|
||
}
|
||
continue
|
||
}
|
||
if cfg := LoadAppConfigDecrypted(it.dir, m.encKey); cfg != nil {
|
||
if _, err := m.writeInstallHold(it.name, it.compose, cfg.Env); err != nil {
|
||
m.logger.Printf("[ERROR] [stacks] %s: the install hold's traefik file could not be (re)written: %v", it.name, err)
|
||
}
|
||
}
|
||
if it.rerun != "" {
|
||
if _, done := installHoldRetried.LoadOrStore(it.name, true); !done {
|
||
m.logger.Printf("[WARN] [stacks] %s: held, and its after_install never ran (a restart cut the install hook off) — running it now", it.name)
|
||
go installHoldAfterInstall(m, it.name)
|
||
}
|
||
}
|
||
}
|
||
}
|