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
540 lines
19 KiB
Go
540 lines
19 KiB
Go
package stacks
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// ── The setup gate (v0.280.0, `09` §3 decision 46) ─────────────────────────────────────────────────────
|
|
//
|
|
// 34 catalog apps let the FIRST VISITOR create the admin, and every app is on the internet from its first
|
|
// minute. So an app whose template says `setup_gate: true` is installed CLOSED: traefik sends each request to
|
|
// the controller first (forwardAuth), and the controller lets it through only for the household — a browser
|
|
// that holds a valid dashboard session (the handshake lives in internal/web/setup_gate.go). The gate OPENS
|
|
// when the app's own status says the first admin exists (`setup_done_probe:`), or when the household presses
|
|
// "Done, I set it up". Opening REMOVES the gate's traefik file: the app's own docker-label router is then the
|
|
// only one, exactly as if it had never been gated.
|
|
//
|
|
// setup_gate: true
|
|
// setup_done_probe: # optional; without it, the household's button opens it
|
|
// url: http://n8n:5678/rest/settings # read on the docker network, never through traefik
|
|
// field: data.userManagement.showSetupOnFirstLoad # dotted JSON path
|
|
// done: "false" # the field's value once the setup is done, as text
|
|
//
|
|
// Order is load-bearing (spike F2, audits/login-gate-2026-09-29/B): the gate file is written BEFORE the app's
|
|
// first start. traefik holds a file router whose service does not exist yet and enables it the moment the
|
|
// app's service appears — measured: 0 app answers to a stranger across ~530 polls during two installs.
|
|
// A gate that cannot be written refuses the install; a gated app is never published open.
|
|
//
|
|
// The record (`setup_gate:` in app.yaml) is a life record: it survives a controller restart (the file on disk
|
|
// is reconciled from it) and a restore (carryLifeRecords). An install that LOADS kept data never gates — the
|
|
// data comes back with its admin.
|
|
// Pinned by internal/stacks/setup_gate_test.go.
|
|
|
|
// SetupDoneProbe is `.felhom.yml`'s `setup_done_probe:`.
|
|
type SetupDoneProbe struct {
|
|
URL string `yaml:"url" json:"url"`
|
|
Field string `yaml:"field" json:"field"`
|
|
Done string `yaml:"done" json:"done"`
|
|
// DoneStatus (v0.282.0, R-715): a non-200 HTTP status that itself means "set up" (gramps-web answers 405).
|
|
DoneStatus int `yaml:"done_status,omitempty" json:"done_status,omitempty"`
|
|
}
|
|
|
|
// Setup gate states.
|
|
const (
|
|
SetupGateClosed = "closed"
|
|
SetupGateOpen = "open"
|
|
// Who opened it.
|
|
SetupGateByProbe = "probe"
|
|
SetupGateByHousehold = "household"
|
|
)
|
|
|
|
// SetupGateRecord is app.yaml's `setup_gate:`.
|
|
type SetupGateRecord struct {
|
|
State string `yaml:"state" json:"state"`
|
|
Since string `yaml:"since" json:"since"`
|
|
Hosts []string `yaml:"hosts,omitempty" json:"hosts,omitempty"`
|
|
OpenedAt string `yaml:"opened_at,omitempty" json:"opened_at,omitempty"`
|
|
OpenedBy string `yaml:"opened_by,omitempty" json:"opened_by,omitempty"`
|
|
// SignupOpenUntil (v0.281.0, decision 47): the household opened sign-up until this time (signup_block.go).
|
|
SignupOpenUntil string `yaml:"signup_open_until,omitempty" json:"signup_open_until,omitempty"`
|
|
// NativeLock (v0.282.0): the app's own sign-up switch — "applied", "lifted" (the window), or "" (never set).
|
|
NativeLock string `yaml:"native_lock,omitempty" json:"native_lock,omitempty"`
|
|
}
|
|
|
|
// Closed reports whether the gate stands.
|
|
func (r *SetupGateRecord) Closed() bool { return r != nil && r.State == SetupGateClosed }
|
|
|
|
// setupGateAuthURL is where traefik asks. The controller sits on traefik-public as felhom-controller (wireController).
|
|
const setupGateAuthURL = "http://felhom-controller:8080/__felhom_gate/auth"
|
|
|
|
// setupGatePriority beats every docker-label router (traefik's default priority is the rule's length). The rule's
|
|
// length is ADDED so an app's path-scoped router (adventurelog's backend) still wins over its host-only one, as
|
|
// it does without the gate.
|
|
const setupGatePriority = 100000
|
|
|
|
// gateRouter is one traefik router an app publishes, as its compose labels define it.
|
|
type gateRouter struct {
|
|
Name string
|
|
Rule string
|
|
Service string
|
|
CertResolver string
|
|
}
|
|
|
|
var hostInRule = regexp.MustCompile("Host\\(`([^`]+)`\\)")
|
|
|
|
// expandComposeVars fills ${NAME} and ${NAME:-default} from env, as compose does for a label value.
|
|
func expandComposeVars(s string, env map[string]string) string {
|
|
return os.Expand(s, func(v string) string {
|
|
if name, def, ok := strings.Cut(v, ":-"); ok {
|
|
if val := env[name]; val != "" {
|
|
return val
|
|
}
|
|
return def
|
|
}
|
|
return env[v]
|
|
})
|
|
}
|
|
|
|
// gateRoutersFromCompose reads the routers an app publishes from its compose labels, filled with the app's
|
|
// env. A router with no `service` label takes its container's only service; two services and no label is
|
|
// refused (the gate would not know where to send the household).
|
|
func gateRoutersFromCompose(composePath string, env map[string]string) ([]gateRouter, error) {
|
|
data, err := os.ReadFile(composePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var doc struct {
|
|
Services map[string]struct {
|
|
Labels interface{} `yaml:"labels"`
|
|
} `yaml:"services"`
|
|
}
|
|
if err := yaml.Unmarshal(data, &doc); err != nil {
|
|
return nil, fmt.Errorf("compose: %w", err)
|
|
}
|
|
var out []gateRouter
|
|
names := make([]string, 0, len(doc.Services))
|
|
for n := range doc.Services {
|
|
names = append(names, n)
|
|
}
|
|
sort.Strings(names)
|
|
for _, svc := range names {
|
|
labels := map[string]string{}
|
|
switch l := doc.Services[svc].Labels.(type) {
|
|
case []interface{}:
|
|
for _, e := range l {
|
|
k, v, _ := strings.Cut(fmt.Sprint(e), "=")
|
|
labels[strings.TrimSpace(k)] = strings.TrimSpace(v)
|
|
}
|
|
case map[string]interface{}:
|
|
for k, v := range l {
|
|
labels[k] = fmt.Sprint(v)
|
|
}
|
|
}
|
|
if strings.ToLower(labels["traefik.enable"]) != "true" {
|
|
continue
|
|
}
|
|
var services []string
|
|
routers := map[string]*gateRouter{}
|
|
for k, v := range labels {
|
|
parts := strings.Split(k, ".")
|
|
if len(parts) < 5 || parts[0] != "traefik" || parts[1] != "http" {
|
|
continue
|
|
}
|
|
switch parts[2] {
|
|
case "services":
|
|
if !containsStr(services, parts[3]) {
|
|
services = append(services, parts[3])
|
|
}
|
|
case "routers":
|
|
r := routers[parts[3]]
|
|
if r == nil {
|
|
r = &gateRouter{Name: parts[3]}
|
|
routers[parts[3]] = r
|
|
}
|
|
switch strings.Join(parts[4:], ".") {
|
|
case "rule":
|
|
r.Rule = expandComposeVars(v, env)
|
|
case "service":
|
|
r.Service = expandComposeVars(v, env)
|
|
case "tls.certresolver":
|
|
r.CertResolver = v
|
|
}
|
|
}
|
|
}
|
|
rnames := make([]string, 0, len(routers))
|
|
for n := range routers {
|
|
rnames = append(rnames, n)
|
|
}
|
|
sort.Strings(rnames)
|
|
for _, n := range rnames {
|
|
r := routers[n]
|
|
if r.Rule == "" {
|
|
continue
|
|
}
|
|
if r.Service == "" {
|
|
if len(services) != 1 {
|
|
return nil, fmt.Errorf("router %s (service %s) names no traefik service and its container has %d", n, svc, len(services))
|
|
}
|
|
r.Service = services[0]
|
|
}
|
|
out = append(out, *r)
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil, fmt.Errorf("the compose file publishes no traefik router")
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// gateHosts is every host the routers match.
|
|
func gateHosts(rs []gateRouter) []string {
|
|
var hosts []string
|
|
for _, r := range rs {
|
|
for _, m := range hostInRule.FindAllStringSubmatch(r.Rule, -1) {
|
|
h := strings.ToLower(m[1])
|
|
if !containsStr(hosts, h) {
|
|
hosts = append(hosts, h)
|
|
}
|
|
}
|
|
}
|
|
sort.Strings(hosts)
|
|
return hosts
|
|
}
|
|
|
|
// renderSetupGate is the traefik file-provider config that puts the gate in front of every router the app
|
|
// publishes: same rule, higher priority, forwardAuth, then the app's own docker service.
|
|
func renderSetupGate(name string, rs []gateRouter) string {
|
|
var b strings.Builder
|
|
mw := "felhom-setup-gate-" + name
|
|
fmt.Fprintf(&b, "# Setup gate for %s — managed by felhom-controller (`09` §3 decision 46).\n", name)
|
|
b.WriteString("# The app is closed to everyone but the household until its first setup is done; 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", 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) setupGateDir() string {
|
|
return filepath.Join(m.cfg.Paths.StacksDir, "traefik", "dynamic")
|
|
}
|
|
|
|
func (m *Manager) setupGatePath(name string) string {
|
|
return filepath.Join(m.setupGateDir(), "setup-gate-"+name+".yml")
|
|
}
|
|
|
|
// writeSetupGate writes (or refreshes) the app's gate file. Returns the hosts it covers.
|
|
func (m *Manager) writeSetupGate(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 := renderSetupGate(name, rs)
|
|
p := m.setupGatePath(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) removeSetupGateFile(name string) error {
|
|
err := os.Remove(m.setupGatePath(name))
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// prepareSetupGate is DeployStack's step for a `setup_gate: true` template on a FRESH install: the file first,
|
|
// then the record the caller saves with the app.
|
|
func (m *Manager) prepareSetupGate(name, composePath string, env map[string]string) (*SetupGateRecord, error) {
|
|
hosts, err := m.writeSetupGate(name, composePath, env)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
m.logger.Printf("[INFO] [stacks] %s: setup gate CLOSED before the first start — only the household reaches %v until the first setup is done", name, hosts)
|
|
return &SetupGateRecord{State: SetupGateClosed, Since: m.now().UTC().Format(time.RFC3339), Hosts: hosts}, nil
|
|
}
|
|
|
|
// OpenSetupGate opens an app's gate: the record first, then the file (a failed removal is retried by the
|
|
// reconcile; the reverse order could re-gate an opened app). by = SetupGateByProbe | SetupGateByHousehold.
|
|
func (m *Manager) OpenSetupGate(name, by string) error {
|
|
st, ok := m.GetStack(name)
|
|
if !ok {
|
|
return fmt.Errorf("stack %q not found", name)
|
|
}
|
|
if st.AppConfig == nil || !st.AppConfig.SetupGate.Closed() {
|
|
return ErrSetupGateNotClosed
|
|
}
|
|
dir := filepath.Dir(st.ComposePath)
|
|
now := m.now().UTC().Format(time.RFC3339)
|
|
// Decision 47: the sign-up block goes up BEFORE the gate comes down, so there is no moment where a stranger
|
|
// can sign up. Cannot write it → the gate stays closed (the loop or the next press tries again).
|
|
block := strings.TrimSpace(st.Meta.SignupBlock)
|
|
if block != "" {
|
|
if err := m.writeSignupBlock(name, st.AppConfig.SetupGate.Hosts, block); err != nil {
|
|
return fmt.Errorf("setup gate %s: the sign-up block could not be written, so the gate stays closed: %w", name, err)
|
|
}
|
|
}
|
|
opened := false
|
|
m.mutateAppConfig(name, dir, "setup_gate", func(cfg *AppConfig) bool {
|
|
if !cfg.SetupGate.Closed() {
|
|
return false
|
|
}
|
|
cfg.SetupGate.State, cfg.SetupGate.OpenedAt, cfg.SetupGate.OpenedBy = SetupGateOpen, now, by
|
|
opened = true
|
|
return true
|
|
})
|
|
if !opened {
|
|
if block != "" {
|
|
_ = m.removeSignupBlockFile(name) // still gated: the household may still need the sign-up address
|
|
}
|
|
return fmt.Errorf("setup gate %s: the record could not be written", name)
|
|
}
|
|
if err := m.removeSetupGateFile(name); err != nil {
|
|
m.logger.Printf("[ERROR] [stacks] %s: setup gate opened but its traefik file could not be removed (%v) — the reconcile retries", name, err)
|
|
}
|
|
m.logger.Printf("[INFO] [stacks] %s: setup gate OPENED by %s — the app is reached as without a gate", name, by)
|
|
// v0.282.0 (decision 47): the app's own sign-up switch, after the block is up and the gate is down. One restart.
|
|
if st.Meta.AfterSetup != nil {
|
|
m.goNativeLock(name, true, "the gate opened ("+by+")")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ErrSetupGateNotClosed: the app has no closed gate (never gated, or already open).
|
|
var ErrSetupGateNotClosed = fmt.Errorf("the app has no closed setup gate")
|
|
|
|
// SetupGateHost maps a host to the app that owns it and whether that app's gate is closed.
|
|
func (m *Manager) SetupGateHost(host string) (name string, closed bool, found bool) {
|
|
host = strings.ToLower(host)
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
// R-741: a closed install hold answers first (its routers outrank the gate's); an app with both is closed while
|
|
// either is.
|
|
for n, st := range m.stacks {
|
|
if st.AppConfig == nil || !st.AppConfig.InstallHold.Closed() {
|
|
continue
|
|
}
|
|
if containsStr(st.AppConfig.InstallHold.Hosts, host) {
|
|
return n, true, true
|
|
}
|
|
}
|
|
for n, st := range m.stacks {
|
|
if st.AppConfig == nil || st.AppConfig.SetupGate == nil {
|
|
continue
|
|
}
|
|
if containsStr(st.AppConfig.SetupGate.Hosts, host) {
|
|
return n, st.AppConfig.SetupGate.Closed(), true
|
|
}
|
|
}
|
|
return "", false, false
|
|
}
|
|
|
|
// setupGateProbeFetch reads a probe URL: the HTTP status and the body (a seam: tests never reach a network).
|
|
var setupGateProbeFetch = func(url string) (int, []byte, error) {
|
|
c := &http.Client{Timeout: 5 * time.Second}
|
|
resp, err := c.Get(url)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
b, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
return resp.StatusCode, b, err
|
|
}
|
|
|
|
// probeOnce asks the app's own status once. v0.282.0 (R-715): `done_status:` — an app that answers a fixed non-200
|
|
// status once it is set up (gramps-web: 405 "Users already exist") — counts as done on that status alone; any other
|
|
// non-200 is "cannot read" (fail closed). A 200 is read as JSON: `field` is a dotted path whose parts may be list
|
|
// indexes (`setup.0.status` — ghost; `0.done` — home-assistant), compared as text with `done`.
|
|
func probeOnce(p *SetupDoneProbe) (done bool, got string, err error) {
|
|
status, body, err := setupGateProbeFetch(p.URL)
|
|
if err != nil {
|
|
return false, "", err
|
|
}
|
|
if p.DoneStatus != 0 && status == p.DoneStatus {
|
|
return true, fmt.Sprintf("HTTP %d", status), nil
|
|
}
|
|
if status != http.StatusOK {
|
|
return false, fmt.Sprintf("HTTP %d", status), fmt.Errorf("HTTP %d", status)
|
|
}
|
|
if p.Field == "" {
|
|
return false, "no field", fmt.Errorf("the probe names no field")
|
|
}
|
|
done, got = probeSaysDone(body, p.Field, p.Done)
|
|
return done, got, nil
|
|
}
|
|
|
|
// probeSaysDone reads the field at the dotted path (a numeric part indexes a list) and compares its text form with
|
|
// done. Anything it cannot read is "not done" — the gate stays closed (fail closed).
|
|
func probeSaysDone(body []byte, field, done string) (bool, string) {
|
|
var v interface{}
|
|
if err := json.Unmarshal(body, &v); err != nil {
|
|
return false, "not JSON"
|
|
}
|
|
for _, k := range strings.Split(field, ".") {
|
|
switch cur := v.(type) {
|
|
case map[string]interface{}:
|
|
nv, ok := cur[k]
|
|
if !ok {
|
|
return false, "no field " + field
|
|
}
|
|
v = nv
|
|
case []interface{}:
|
|
n, err := strconv.Atoi(k)
|
|
if err != nil || n < 0 || n >= len(cur) {
|
|
return false, "no field " + field
|
|
}
|
|
v = cur[n]
|
|
default:
|
|
return false, "no field " + field
|
|
}
|
|
}
|
|
got := fmt.Sprint(v)
|
|
return got == done, got
|
|
}
|
|
|
|
// SetupGateTick is one pass of the gate's loop: every closed gate's file exists; every app whose gate is not
|
|
// closed has none (a removed app, an opened gate whose removal failed); a closed gate whose app is running and
|
|
// whose probe says done opens.
|
|
func (m *Manager) SetupGateTick() {
|
|
type item struct {
|
|
name, dir, compose string
|
|
rec *SetupGateRecord
|
|
probe *SetupDoneProbe
|
|
running bool
|
|
}
|
|
var items []item
|
|
keep := map[string]bool{}
|
|
m.mu.RLock()
|
|
for n, st := range m.stacks {
|
|
if !st.Deployed || st.AppConfig == nil || !st.AppConfig.SetupGate.Closed() {
|
|
continue
|
|
}
|
|
rec := *st.AppConfig.SetupGate
|
|
items = append(items, item{name: n, dir: filepath.Dir(st.ComposePath), compose: st.ComposePath, rec: &rec,
|
|
probe: st.Meta.SetupDoneProbe, running: st.State == StateRunning || st.State == StateUnhealthy})
|
|
keep[n] = true
|
|
}
|
|
m.mu.RUnlock()
|
|
|
|
// Stale files: a gate file whose app is not closed-gated any more.
|
|
if ents, err := os.ReadDir(m.setupGateDir()); err == nil {
|
|
for _, e := range ents {
|
|
n := e.Name()
|
|
if !strings.HasPrefix(n, "setup-gate-") || !strings.HasSuffix(n, ".yml") {
|
|
continue
|
|
}
|
|
app := strings.TrimSuffix(strings.TrimPrefix(n, "setup-gate-"), ".yml")
|
|
if !keep[app] {
|
|
if err := m.removeSetupGateFile(app); err == nil {
|
|
m.logger.Printf("[INFO] [stacks] %s: removed a setup-gate file for an app whose gate is not closed", app)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, it := range items {
|
|
cfg := LoadAppConfigDecrypted(it.dir, m.encKey)
|
|
if cfg != nil {
|
|
if _, err := m.writeSetupGate(it.name, it.compose, cfg.Env); err != nil {
|
|
m.logger.Printf("[ERROR] [stacks] %s: the setup gate's traefik file could not be (re)written: %v", it.name, err)
|
|
}
|
|
}
|
|
if it.probe == nil || it.probe.URL == "" || !it.running {
|
|
continue
|
|
}
|
|
done, got, err := probeOnce(it.probe)
|
|
if err != nil {
|
|
if m.isDebug() {
|
|
m.logger.Printf("[DEBUG] [stacks] %s: setup probe unreadable (%v) — gate stays closed", it.name, err)
|
|
}
|
|
continue
|
|
}
|
|
if m.isDebug() {
|
|
m.logger.Printf("[DEBUG] [stacks] %s: setup probe %s = %q (done when %q)", it.name, it.probe.Field, got, it.probe.Done)
|
|
}
|
|
if done {
|
|
if err := m.OpenSetupGate(it.name, SetupGateByProbe); err != nil {
|
|
m.logger.Printf("[ERROR] [stacks] %s: the probe says the setup is done but the gate did not open: %v", it.name, err)
|
|
}
|
|
}
|
|
}
|
|
m.reconcileSignupBlocks()
|
|
m.installHoldTick()
|
|
}
|
|
|
|
// RunSetupGateLoop runs SetupGateTick every interval until ctx ends.
|
|
func (m *Manager) RunSetupGateLoop(ctx context.Context, interval time.Duration) {
|
|
t := time.NewTicker(interval)
|
|
defer t.Stop()
|
|
m.SetupGateTick()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
m.SetupGateTick()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MarkDefaultLoginChanged records the household's word that it changed the template's known default login by
|
|
// hand (R-710). The page then stops naming the default. It changes nothing in the app.
|
|
func (m *Manager) MarkDefaultLoginChanged(name, by string) error {
|
|
st, ok := m.GetStack(name)
|
|
if !ok || !st.Deployed {
|
|
return fmt.Errorf("stack %q is not installed", name)
|
|
}
|
|
rec := &DefaultLoginRecord{ChangedAt: m.now().UTC().Format(time.RFC3339), By: by}
|
|
done := false
|
|
m.mutateAppConfig(name, filepath.Dir(st.ComposePath), "default_login", func(cfg *AppConfig) bool {
|
|
cfg.DefaultLogin = rec
|
|
done = true
|
|
return true
|
|
})
|
|
if !done {
|
|
return fmt.Errorf("%s: app.yaml could not be read", name)
|
|
}
|
|
m.logger.Printf("[INFO] [stacks] %s: the household says it changed the default login by hand — the page stops naming it", name)
|
|
if err := m.OpenInstallHold(name, InstallHoldByHousehold); err != nil { // R-741
|
|
m.logger.Printf("[ERROR] [stacks] %s: %v — the loop retries", name, err)
|
|
}
|
|
return nil
|
|
}
|