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) } } } }