v0.282.0: after_setup (the app's own sign-up switch), close sign-up now (decision 49), probes read lists + done_status (R-715)
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:
2026-09-29 16:19:12 +02:00
parent 6fc3059167
commit 4110da50e9
25 changed files with 1864 additions and 36 deletions
+53 -16
View File
@@ -10,6 +10,7 @@ import (
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
@@ -47,6 +48,8 @@ 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.
@@ -67,6 +70,8 @@ type SetupGateRecord struct {
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.
@@ -325,6 +330,10 @@ func (m *Manager) OpenSetupGate(name, by string) error {
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
}
@@ -347,33 +356,62 @@ func (m *Manager) SetupGateHost(host string) (name string, closed bool, found bo
return "", false, false
}
// setupGateProbeGet reads a probe URL (a seam: tests never reach a network).
var setupGateProbeGet = func(url string) ([]byte, error) {
// 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 nil, err
return 0, nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
return io.ReadAll(io.LimitReader(resp.Body, 1<<20))
b, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
return resp.StatusCode, b, err
}
// probeSaysDone reads the field at the dotted path and compares its text form with done. Anything it cannot
// read is "not done" — the gate stays closed (fail closed).
// 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, ".") {
obj, ok := v.(map[string]interface{})
if !ok {
return false, "no field " + field
}
if v, ok = obj[k]; !ok {
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
}
}
@@ -431,14 +469,13 @@ func (m *Manager) SetupGateTick() {
if it.probe == nil || it.probe.URL == "" || !it.running {
continue
}
body, err := setupGateProbeGet(it.probe.URL)
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
}
done, got := probeSaysDone(body, it.probe.Field, it.probe.Done)
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)
}