v0.279.0: after_install (decision 45), known default logins on the page, Part D empty-backup alarm, night chain (R-705), R-706
gates / gates (push) Successful in 27s
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"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 []string
|
||||
out := make([]string, len(cmd))
|
||||
for i, a := range cmd {
|
||||
out[i] = os.Expand(a, func(k string) string {
|
||||
if !ok[k] || env[k] == "" {
|
||||
missing = append(missing, k)
|
||||
return ""
|
||||
}
|
||||
return env[k]
|
||||
})
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return nil, fmt.Errorf("after_install: %v not declared in env or has no value — not run", missing)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// 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, "")
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// v0.279.0 (decision 45) — after_install: the one command after a FRESH install that replaces a known default
|
||||
// login with the generated one. The exec is the seam; nothing reaches Docker.
|
||||
// COMPANION RED-PROOFS: (1) drop the success-marker check in runAfterInstallNow → a failing command is
|
||||
// recorded ok and the second case fails; (2) make expandAfterInstall allow an undeclared/empty name → the
|
||||
// command runs with a hole and the third case fails.
|
||||
func TestAfterInstall_ReplacesTheDefaultLoginOnceAndRecordsIt(t *testing.T) {
|
||||
m, _ := keptManager(t)
|
||||
var logBuf bytes.Buffer
|
||||
m.logger = log.New(&logBuf, "", 0)
|
||||
afterInstallTries, afterInstallGap = 3, 0
|
||||
t.Cleanup(func() { afterInstallTries, afterInstallGap = 6, 20*time.Second })
|
||||
dir := filepath.Dir(m.stacks["cloudapp"].ComposePath)
|
||||
must(t, SaveAppConfig(dir, &AppConfig{Deployed: true, Env: map[string]string{"ADMIN_PASSWORD": "Gen3r4tedValue"}}, m.encKey, nil))
|
||||
ai := &AfterInstallCommand{Service: "cloudapp", Env: []string{"ADMIN_PASSWORD"},
|
||||
Command: []string{"/app/bin/tool", "set-admin", "${ADMIN_PASSWORD}"}, Success: "FELHOM_OK"}
|
||||
m.mu.Lock()
|
||||
m.stacks["cloudapp"].Meta.AfterInstall = ai
|
||||
m.mu.Unlock()
|
||||
record := func(ok bool, d string) {
|
||||
m.mutateAppConfig("cloudapp", dir, "after_install", func(c *AppConfig) bool {
|
||||
c.AfterInstall = &AfterInstallRecord{At: "t", OK: ok, Detail: d}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// 1. Success: the value is filled in, the command runs ONCE, the record says ok, the log never carries it.
|
||||
var calls [][]string
|
||||
m.afterLoadFn = func(_ string, args ...string) (string, error) { calls = append(calls, args); return "... FELHOM_OK", nil }
|
||||
cmd, err := expandAfterInstall(ai.Command, ai.Env, map[string]string{"ADMIN_PASSWORD": "Gen3r4tedValue"})
|
||||
must(t, err)
|
||||
if err := m.runAfterInstallNow("cloudapp", ai, cmd, record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(calls) != 1 || strings.Join(calls[0], " ") != "exec -T cloudapp /app/bin/tool set-admin Gen3r4tedValue" {
|
||||
t.Fatalf("ran %v", calls)
|
||||
}
|
||||
if c := LoadAppConfig(dir); c.AfterInstall == nil || !c.AfterInstall.OK {
|
||||
t.Fatalf("record: %+v", c.AfterInstall)
|
||||
}
|
||||
if strings.Contains(logBuf.String(), "Gen3r4tedValue") {
|
||||
t.Fatal("the generated password reached the log")
|
||||
}
|
||||
|
||||
// 2. No success marker: retried, then recorded as FAILED — never recorded ok.
|
||||
calls = nil
|
||||
m.afterLoadFn = func(_ string, args ...string) (string, error) { calls = append(calls, args); return "boom", errors.New("exit 1") }
|
||||
if err := m.runAfterInstallNow("cloudapp", ai, cmd, record); err == nil {
|
||||
t.Fatal("a failing command reported success")
|
||||
}
|
||||
if len(calls) != 3 {
|
||||
t.Fatalf("tries %d, want 3", len(calls))
|
||||
}
|
||||
if c := LoadAppConfig(dir); c.AfterInstall == nil || c.AfterInstall.OK {
|
||||
t.Fatalf("a failure was recorded as ok: %+v", c.AfterInstall)
|
||||
}
|
||||
|
||||
// 2b. Exit 0 WITHOUT the marker (claper's `rpc` exits 0 on an Elixir error): still a failure.
|
||||
calls = nil
|
||||
m.afterLoadFn = func(_ string, args ...string) (string, error) {
|
||||
calls = append(calls, args)
|
||||
return "FELHOM_AFTER_INSTALL_FAILED {:error, changeset}", nil
|
||||
}
|
||||
if err := m.runAfterInstallNow("cloudapp", ai, cmd, record); err == nil {
|
||||
t.Fatal("an exit-0 command without the success marker reported success")
|
||||
}
|
||||
if c := LoadAppConfig(dir); c.AfterInstall == nil || c.AfterInstall.OK {
|
||||
t.Fatalf("an exit-0 failure was recorded as ok: %+v", c.AfterInstall)
|
||||
}
|
||||
|
||||
// 3. A value that is not declared, or empty, refuses BEFORE anything runs (never an empty password).
|
||||
for _, env := range []map[string]string{{}, {"ADMIN_PASSWORD": ""}} {
|
||||
if _, err := expandAfterInstall(ai.Command, ai.Env, env); err == nil {
|
||||
t.Fatalf("expanded with env %v", env)
|
||||
}
|
||||
}
|
||||
if _, err := expandAfterInstall([]string{"x ${OTHER}"}, ai.Env, map[string]string{"OTHER": "v"}); err == nil {
|
||||
t.Fatal("an undeclared name was filled in")
|
||||
}
|
||||
}
|
||||
@@ -179,6 +179,8 @@ type AppConfig struct {
|
||||
// removed app, a rebuilt guest) while the app's own login came back with its data. The page then shows
|
||||
// no value for them and says the old password is the one that works (restoredLoginFields).
|
||||
RestoredLogins []string `yaml:"restored_logins,omitempty" json:"restored_logins,omitempty"`
|
||||
// AfterInstall (v0.279.0, decision 45) is what the template's one-time after_install command did.
|
||||
AfterInstall *AfterInstallRecord `yaml:"after_install,omitempty" json:"after_install,omitempty"`
|
||||
}
|
||||
|
||||
// InstalledImage is one compose service's observed image. See AppConfig.InstalledImages.
|
||||
|
||||
@@ -31,6 +31,7 @@ func carryLifeRecords(logger *log.Logger, name string, prior, cfg *AppConfig) {
|
||||
cfg.FailedStep = prior.FailedStep
|
||||
cfg.LastUpdateUndone = prior.LastUpdateUndone
|
||||
cfg.LastAutoUpdate = prior.LastAutoUpdate
|
||||
cfg.AfterInstall = prior.AfterInstall // v0.279.0: what the install's one-time command did stays true after a restore
|
||||
if n := len(prior.EarlierConversionCopies); prior.ConversionCopy != nil || n > 0 {
|
||||
cur := ""
|
||||
if prior.ConversionCopy != nil {
|
||||
|
||||
@@ -52,8 +52,11 @@ type Metadata struct {
|
||||
// app's kept data (a database from a backup under files kept on the drive) — for an app whose own
|
||||
// index of its files must be rebuilt. Measured on nextcloud: a file written after the backup is on
|
||||
// the drive and invisible until `occ files:scan --all`. Optional; absent = nothing runs.
|
||||
AfterLoad *AfterLoadCommand `yaml:"after_load,omitempty" json:"after_load,omitempty"`
|
||||
Integrations []IntegrationDef `yaml:"integrations,omitempty" json:"integrations,omitempty"`
|
||||
AfterLoad *AfterLoadCommand `yaml:"after_load,omitempty" json:"after_load,omitempty"`
|
||||
// AfterInstall (v0.279.0, `09` §3 decision 45) is ONE command the box runs once after a FRESH install —
|
||||
// to replace a known default login with the generated one. See after_install.go.
|
||||
AfterInstall *AfterInstallCommand `yaml:"after_install,omitempty" json:"after_install,omitempty"`
|
||||
Integrations []IntegrationDef `yaml:"integrations,omitempty" json:"integrations,omitempty"`
|
||||
// InitialCreds: for apps that auto-generate a first-login credential into a file inside the
|
||||
// container (e.g. Crafty's default-creds.txt). The controller reads + parses that file live and
|
||||
// surfaces it on the app page, so the customer never has to dig through logs. Optional.
|
||||
|
||||
@@ -217,6 +217,17 @@ func LegDeadline(now time.Time, window string, loc *time.Location) time.Time {
|
||||
// RunUpdateLeg runs one night's leg and returns its summary (nil when another leg is running or the leg
|
||||
// is not wired). trigger names the caller for the log ("after-offsite").
|
||||
func (m *Manager) RunUpdateLeg(ctx context.Context, trigger string) *UpdateLegSummary {
|
||||
return m.runUpdateLeg(ctx, trigger, false)
|
||||
}
|
||||
|
||||
// RunUpdateLegNow is the leg for a MANUAL run of the night's chain (R-705, the debug action): the same
|
||||
// leg, but its step deadline is its start + the leg's normal length (backupwindow.UpdateLegLengthMin),
|
||||
// not W+5h of the last window — which, by day, has passed and would make the leg start nothing.
|
||||
func (m *Manager) RunUpdateLegNow(ctx context.Context, trigger string) *UpdateLegSummary {
|
||||
return m.runUpdateLeg(ctx, trigger, true)
|
||||
}
|
||||
|
||||
func (m *Manager) runUpdateLeg(ctx context.Context, trigger string, manual bool) *UpdateLegSummary {
|
||||
if !m.leg.run.TryLock() {
|
||||
m.logger.Printf("[WARN] [update-leg] a leg is already running — this call (%s) does nothing", trigger)
|
||||
return nil
|
||||
@@ -249,6 +260,9 @@ func (m *Manager) RunUpdateLeg(ctx context.Context, trigger string) *UpdateLegSu
|
||||
window = o.WindowStart()
|
||||
}
|
||||
sum.Deadline = LegDeadline(sum.StartedAt, window, o.Location)
|
||||
if manual {
|
||||
sum.Deadline = sum.StartedAt.Add(time.Duration(backupwindow.UpdateLegLengthMin) * time.Minute)
|
||||
}
|
||||
m.setLegFlags(true, false)
|
||||
defer m.setLegFlags(false, false)
|
||||
m.logger.Printf("[INFO] [update-leg] started (%s): window %s, no step starts at or after %s", trigger, window, sum.Deadline.In(o.Location).Format("15:04"))
|
||||
|
||||
@@ -473,3 +473,23 @@ func TestR687_EmptyLegReportsEmptySteps(t *testing.T) {
|
||||
t.Fatalf("an empty leg must report steps as [], got %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
// R-705 (v0.279.0) — the MANUAL chain's leg runs by day: at 18:00 the night's W+5h (07:30) has passed, so the
|
||||
// night leg starts nothing (window_end), while RunUpdateLegNow gives the leg its normal length from its own
|
||||
// start and presses the step. COMPANION RED-PROOF: drop the `if manual` deadline in runUpdateLeg → the manual
|
||||
// run also reports window_end and starts nothing.
|
||||
func TestR705_TheManualLegRunsByDay(t *testing.T) {
|
||||
day := time.Date(2026, 9, 25, 18, 0, 0, 0, time.UTC)
|
||||
m, _, _, ups, _ := legManager(t)
|
||||
legOpts(m, func(o *UpdateLegOptions) { o.Now = func() time.Time { return day } })
|
||||
if s := m.RunUpdateLeg(context.Background(), "night"); s == nil || s.Stopped != LegSkipWindowEnd || len(*ups) != 0 {
|
||||
t.Fatalf("by day the NIGHT leg must start nothing: ups=%v summary=%+v", *ups, s)
|
||||
}
|
||||
s := m.RunUpdateLegNow(context.Background(), "manual-chain")
|
||||
if s == nil || !s.Deadline.Equal(day.Add(195*time.Minute)) {
|
||||
t.Fatalf("the manual leg's deadline %v, want %v", s.Deadline, day.Add(195*time.Minute))
|
||||
}
|
||||
if len(*ups) == 0 || legStepFor(s, "nextcloud").Reason == LegSkipWindowEnd {
|
||||
t.Fatalf("the manual leg pressed nothing by day: ups=%v summary=%+v", *ups, s)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user