Files
felhom-controller/controller/internal/stacks/after_install.go
T
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

199 lines
8.0 KiB
Go

package stacks
import (
"encoding/base64"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// ── after_install (v0.279.0, `09` §3 decision 45): no app is published with a login a stranger knows ──
//
// Some apps start with a known, shared admin password (claper seeds admin@claper.co / claper at every first
// start — measured on 9202 2026-09-28, R-702). The box publishes every app on the household's domain. So
// where the app's OWN CLI or API can change it, the template declares ONE command the box runs once, in the
// app's own container, right after a FRESH install:
//
// after_install:
// service: claper
// env: [ADMIN_PASSWORD] # deploy values filled into the command; nothing else is
// command: ["/app/bin/claper", "rpc", "... ${ADMIN_PASSWORD} ..."]
// success: FELHOM_AFTER_INSTALL_OK # the command's output must carry this, or it failed
//
// The value is a generated `type: password` field, so the household sees it on the app page as the first
// password (the grafana/code-server pattern).
//
// ONLY after a fresh install (the deploy-done hook, ok=true). NEVER after a restore or "use my kept data":
// there the login comes back with the data, and changing it would lock the household out (R-694). A failed
// command is retried while the app boots, then recorded (`after_install` in app.yaml) and shown on the app
// page; the app stays installed and running — never half-installed. The expanded command is never logged
// (it carries the password); the log names the template.
// Pinned by internal/stacks/after_install_test.go.
// AfterInstallCommand is `.felhom.yml`'s `after_install:`.
type AfterInstallCommand struct {
Service string `yaml:"service" json:"service"`
User string `yaml:"user,omitempty" json:"user,omitempty"`
Env []string `yaml:"env,omitempty" json:"env,omitempty"`
Command []string `yaml:"command" json:"command"`
Success string `yaml:"success" json:"success"`
}
// AfterInstallRecord is app.yaml's `after_install:` — what the one-time command did.
type AfterInstallRecord struct {
At string `yaml:"at" json:"at"`
OK bool `yaml:"ok" json:"ok"`
Detail string `yaml:"detail,omitempty" json:"detail,omitempty"`
}
// afterInstallTries / afterInstallGap: the app may still be booting when the deploy reports done.
var (
afterInstallTries = 6
afterInstallGap = 20 * time.Second
)
// expandAfterInstall fills ${NAME} for the declared env names only, from the app's env. An undeclared or
// empty name refuses — a command with a hole must never run (it could set an EMPTY password).
func expandAfterInstall(cmd []string, allowed []string, env map[string]string) ([]string, error) {
ok := map[string]bool{}
for _, n := range allowed {
ok[n] = true
}
var missing, unsafe []string
out := make([]string, len(cmd))
for i, a := range cmd {
// R-713 (v0.281.0): where does the value land? Its OWN argument ("${X}") or a plain argument
// ("--password=${X}", "admin:${X}") cannot be read as code — any value. Text with spaces, quotes or
// brackets around it ('… "${X}" …' in an Elixir or Python string) can: there the raw value must not hold a
// quote, a backslash, $, {, }, a backtick or a line break. `${X|base64}` is always safe (letters, digits,
// +, /, =): the template decodes it in its own code (claper).
codeShaped := !argumentShaped(a)
out[i] = os.Expand(a, func(k string) string {
name, enc, _ := strings.Cut(k, "|")
if !ok[name] || env[name] == "" {
missing = append(missing, name)
return ""
}
v := env[name]
switch enc {
case "":
case "base64":
return base64.StdEncoding.EncodeToString([]byte(v))
default:
missing = append(missing, k)
return ""
}
if codeShaped && strings.ContainsAny(v, codeUnsafeChars) {
unsafe = append(unsafe, name)
}
return v
})
}
if len(missing) > 0 {
return nil, fmt.Errorf("after_install: %v not declared in env or has no value — not run", missing)
}
if len(unsafe) > 0 {
return nil, fmt.Errorf("after_install: the value of %v would be read as code (it holds a quote, a backslash, $, {, }, a backtick or a line break) — not run; the template should pass it as its own argument or as ${NAME|base64}", unsafe)
}
return out, nil
}
// codeUnsafeChars can end a string or start an interpolation in the code a command carries.
const codeUnsafeChars = "'\"\\$`{}\n\r\x00"
// argumentShaped: with every ${…} removed, the element is empty or plain argument text (a flag, a name, a "user:"
// prefix) — nothing that can open or close a string in code.
func argumentShaped(a string) bool {
rest := os.Expand(a, func(string) string { return "" })
for _, r := range rest {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
case strings.ContainsRune("-_.:=/@+,", r):
default:
return false
}
}
return true
}
// RunAfterInstall runs the app's after_install once, after a FRESH install (main.go's deploy-done hook).
// Returns (ran, error). Records the outcome in app.yaml either way.
func (m *Manager) RunAfterInstall(name string, wait time.Duration) (bool, error) {
st, ok := m.GetStack(name)
if !ok {
return false, fmt.Errorf("stack %q not found", name)
}
ai := st.Meta.AfterInstall
if ai == nil || ai.Service == "" || len(ai.Command) == 0 || ai.Success == "" {
return false, nil
}
dir := filepath.Dir(st.ComposePath)
record := func(ok bool, detail string) {
rec := &AfterInstallRecord{At: m.now().UTC().Format(time.RFC3339), OK: ok, Detail: truncateStr(detail, 300)}
m.mutateAppConfig(name, dir, "after_install", func(cfg *AppConfig) bool { cfg.AfterInstall = rec; return true })
}
cfg := LoadAppConfigDecrypted(dir, m.encKey)
if cfg == nil {
record(false, "the app's settings could not be read")
return true, fmt.Errorf("after_install %s: app.yaml unreadable", name)
}
cmd, err := expandAfterInstall(ai.Command, ai.Env, cfg.Env)
if err != nil {
m.logger.Printf("[ERROR] [stacks] %s: %v", name, err)
record(false, err.Error())
return true, err
}
deadline := time.Now().Add(wait)
for {
_ = m.RefreshStatus()
if s, ok := m.GetStack(name); ok && (s.State == StateRunning || s.State == StateUnhealthy) {
break
}
if time.Now().After(deadline) {
record(false, "the app did not start in time")
return true, fmt.Errorf("after_install %s: the app did not start within %s — not run", name, wait)
}
time.Sleep(5 * time.Second)
}
return true, m.runAfterInstallNow(name, ai, cmd, record)
}
// runAfterInstallNow runs the expanded command (RunAfterInstall has waited for the app): retries while the
// output lacks the success marker, then records the outcome.
func (m *Manager) runAfterInstallNow(name string, ai *AfterInstallCommand, cmd []string, record func(ok bool, detail string)) error {
dir := ""
if st, ok := m.GetStack(name); ok {
dir = filepath.Dir(st.ComposePath)
}
args := []string{"exec", "-T"}
if ai.User != "" {
args = append(args, "-u", ai.User)
}
args = append(args, ai.Service)
args = append(args, cmd...)
var last string
for try := 1; try <= afterInstallTries; try++ {
t0 := time.Now()
out, err := m.afterLoadExec(dir, args...)
if err == nil && strings.Contains(out, ai.Success) {
m.logger.Printf("[INFO] [stacks] after_install %s: %s %v done in %s (try %d) — the app's known first login is replaced by its generated one",
name, ai.Service, ai.Command, time.Since(t0).Round(time.Millisecond), try)
record(true, "")
if err := m.OpenInstallHold(name, InstallHoldByAfterInstall); err != nil {
m.logger.Printf("[ERROR] [stacks] %s: %v — the loop retries", name, err)
}
return nil
}
last = fmt.Sprintf("try %d: err=%v, no %q in the output", try, err, ai.Success)
m.logger.Printf("[WARN] [stacks] after_install %s: %s %v %s", name, ai.Service, ai.Command, last)
if try < afterInstallTries {
time.Sleep(afterInstallGap)
}
}
m.logger.Printf("[ERROR] [stacks] after_install %s FAILED after %d tries — the app runs with its KNOWN default login; the app page says so", name, afterInstallTries)
record(false, last)
return fmt.Errorf("after_install %s failed: %s", name, last)
}