Files
admin 5a3437669f
gates / gates (push) Successful in 27s
v0.284.0 — a box deletes old app images (decision 53, R-736); an after_install app is held until its known login is replaced (R-741)
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
2026-09-30 22:29:33 +02:00

212 lines
8.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}
}