v0.280.0: the setup gate (decision 46); R-710 'I changed it' + absent-record window; R-709 password fields off the page; password:N:special generator
gates / gates (push) Successful in 25s
gates / gates (push) Successful in 25s
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,332 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
)
|
||||
|
||||
// v0.280.0 (`09` §3 decision 46) — the setup gate. Nothing here reaches Docker: the deploy's compose call is
|
||||
// the composeExecFn seam, every other docker call hits a stub on PATH, and the probe is setupGateProbeGet.
|
||||
|
||||
const gateCompose = "services:\n" +
|
||||
" gapp:\n image: busybox\n labels:\n" +
|
||||
" - \"traefik.enable=true\"\n" +
|
||||
" - \"traefik.http.routers.gapp.rule=Host(`${SUBDOMAIN}.${DOMAIN}`)\"\n" +
|
||||
" - \"traefik.http.routers.gapp.tls.certresolver=letsencrypt\"\n" +
|
||||
" - \"traefik.http.services.gapp.loadbalancer.server.port=80\"\n" +
|
||||
" - \"traefik.http.routers.gapp-api.rule=Host(`${SUBDOMAIN}.${DOMAIN}`) && PathPrefix(`/api`)\"\n" +
|
||||
" gapp-db:\n image: busybox\n"
|
||||
|
||||
func gateManager(t *testing.T, felhomYml string) *Manager {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
// A docker STUB on PATH (R-650's sanctioned seam): every docker call answers "nothing", none reaches this host.
|
||||
bin := filepath.Join(dir, "bin")
|
||||
must(t, os.MkdirAll(bin, 0o755))
|
||||
must(t, os.WriteFile(filepath.Join(bin, "docker"), []byte("#!/bin/sh\nexit 0\n"), 0o755))
|
||||
t.Setenv("PATH", bin)
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
|
||||
cfg.Paths.SystemDataPath = filepath.Join(dir, "system")
|
||||
cfg.Stacks.ComposeCommand = "docker compose"
|
||||
cfg.Customer.Domain = "example.hu"
|
||||
app := filepath.Join(cfg.Paths.StacksDir, "gapp")
|
||||
must(t, os.MkdirAll(app, 0o755))
|
||||
must(t, os.WriteFile(filepath.Join(app, "docker-compose.yml"), []byte(gateCompose), 0o644))
|
||||
must(t, os.WriteFile(filepath.Join(app, ".felhom.yml"), []byte(felhomYml), 0o644))
|
||||
m, err := NewManager(cfg, log.New(io.Discard, "", 0))
|
||||
must(t, err)
|
||||
must(t, m.ScanStacks())
|
||||
return m
|
||||
}
|
||||
|
||||
const gatedYml = "display_name: Gated App\nsetup_gate: true\n" +
|
||||
"setup_done_probe:\n url: http://gapp:80/api/status\n field: data.initialized\n done: \"true\"\n" +
|
||||
"deploy_fields:\n - env_var: DOMAIN\n type: domain\n - env_var: SUBDOMAIN\n type: subdomain\n default: gapp\n"
|
||||
|
||||
// The gate stands BEFORE the app's first start (spike F2: written after, the app is open until it lands).
|
||||
// COMPANION RED-PROOF: move the prepareSetupGate block in DeployStack below the compose call (or drop it) →
|
||||
// "the gate file did not exist when the app was first started" fails.
|
||||
func TestSetupGate_WrittenBeforeTheFirstStartAndRecordedClosed(t *testing.T) {
|
||||
m := gateManager(t, gatedYml)
|
||||
gatePath := m.setupGatePath("gapp")
|
||||
var atUp string
|
||||
existedAtUp := false
|
||||
m.composeExecFn = func(_ string, _ map[string]string, args ...string) (string, error) {
|
||||
if len(args) > 0 && args[0] == "up" {
|
||||
b, err := os.ReadFile(gatePath)
|
||||
existedAtUp, atUp = err == nil, string(b)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
done := make(chan bool, 1)
|
||||
m.SetDeployDoneHook(func(_ string, ok bool, _ string) { done <- ok })
|
||||
if _, err := m.DeployStack(DeployRequest{StackName: "gapp"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(20 * time.Second):
|
||||
t.Fatal("the deploy never ended")
|
||||
}
|
||||
if !existedAtUp {
|
||||
t.Fatal("the gate file did not exist when the app was first started — a stranger could reach its first-setup screen")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Host(`gapp.example.hu`)", `service: "gapp@docker"`, "http://felhom-controller:8080/__felhom_gate/auth",
|
||||
"certResolver: letsencrypt", "PathPrefix(`/api`)", "felhom-setup-gate-gapp@file",
|
||||
} {
|
||||
if !strings.Contains(atUp, want) {
|
||||
t.Errorf("the gate file lacks %q:\n%s", want, atUp)
|
||||
}
|
||||
}
|
||||
// The path router must still win over the host-only one, as it does without the gate.
|
||||
hostRule, apiRule := "Host(`gapp.example.hu`)", "Host(`gapp.example.hu`) && PathPrefix(`/api`)"
|
||||
if !strings.Contains(atUp, fmt.Sprintf("priority: %d", setupGatePriority+len(apiRule))) ||
|
||||
!strings.Contains(atUp, fmt.Sprintf("priority: %d", setupGatePriority+len(hostRule))) || len(apiRule) <= len(hostRule) {
|
||||
t.Errorf("priorities do not keep the path router above the host router:\n%s", atUp)
|
||||
}
|
||||
cfg := LoadAppConfig(filepath.Join(m.cfg.Paths.StacksDir, "gapp"))
|
||||
if cfg == nil || !cfg.SetupGate.Closed() || strings.Join(cfg.SetupGate.Hosts, ",") != "gapp.example.hu" {
|
||||
t.Fatalf("app.yaml gate record: %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// A gate that cannot be written refuses the install: never published open.
|
||||
// COMPANION RED-PROOF: ignore prepareSetupGate's error in DeployStack → the deploy is accepted and this fails.
|
||||
func TestSetupGate_AnUnwritableGateRefusesTheInstall(t *testing.T) {
|
||||
m := gateManager(t, gatedYml)
|
||||
// a FILE where the dynamic directory must be
|
||||
must(t, os.MkdirAll(filepath.Join(m.cfg.Paths.StacksDir, "traefik"), 0o755))
|
||||
must(t, os.WriteFile(m.setupGateDir(), []byte("x"), 0o644))
|
||||
called := false
|
||||
m.composeExecFn = func(string, map[string]string, ...string) (string, error) { called = true; return "", nil }
|
||||
if _, err := m.DeployStack(DeployRequest{StackName: "gapp"}); err == nil {
|
||||
t.Fatal("an install whose gate could not be written was accepted")
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if called {
|
||||
t.Fatal("compose ran for a refused install")
|
||||
}
|
||||
if st, _ := m.GetStack("gapp"); st.Deployed || st.Deploying {
|
||||
t.Fatalf("left Deployed=%v Deploying=%v", st.Deployed, st.Deploying)
|
||||
}
|
||||
}
|
||||
|
||||
// An ungated template is untouched: no file, no record.
|
||||
func TestSetupGate_AnUngatedTemplateGetsNoGate(t *testing.T) {
|
||||
m := gateManager(t, strings.Replace(gatedYml, "setup_gate: true\n", "", 1))
|
||||
m.composeExecFn = func(string, map[string]string, ...string) (string, error) { return "", nil }
|
||||
done := make(chan bool, 1)
|
||||
m.SetDeployDoneHook(func(string, bool, string) { done <- true })
|
||||
_, err := m.DeployStack(DeployRequest{StackName: "gapp"})
|
||||
must(t, err)
|
||||
<-done
|
||||
if _, err := os.Stat(m.setupGatePath("gapp")); !os.IsNotExist(err) {
|
||||
t.Fatal("an ungated template got a gate file")
|
||||
}
|
||||
if c := LoadAppConfig(filepath.Join(m.cfg.Paths.StacksDir, "gapp")); c.SetupGate != nil {
|
||||
t.Fatalf("an ungated template got a gate record: %+v", c.SetupGate)
|
||||
}
|
||||
}
|
||||
|
||||
// closedGate puts gapp in the state a gated install leaves: deployed, running, record closed, file written.
|
||||
func closedGate(t *testing.T, m *Manager) string {
|
||||
t.Helper()
|
||||
dir := filepath.Join(m.cfg.Paths.StacksDir, "gapp")
|
||||
env := map[string]string{"DOMAIN": "example.hu", "SUBDOMAIN": "gapp"}
|
||||
rec, err := m.prepareSetupGate("gapp", filepath.Join(dir, "docker-compose.yml"), env)
|
||||
must(t, err)
|
||||
cfg := &AppConfig{Deployed: true, Env: env, SetupGate: rec}
|
||||
must(t, SaveAppConfig(dir, cfg, m.encKey, nil))
|
||||
m.mu.Lock()
|
||||
m.stacks["gapp"].Deployed, m.stacks["gapp"].State, m.stacks["gapp"].AppConfig = true, StateRunning, cfg
|
||||
m.mu.Unlock()
|
||||
return dir
|
||||
}
|
||||
|
||||
// The probe opens the gate — the record first, then the file — and only when the app says it is set up.
|
||||
// COMPANION RED-PROOF: make probeSaysDone return true for any readable body → "not yet set up" opens the gate
|
||||
// and this fails; drop the removeSetupGateFile call in OpenSetupGate → "file still there" fails.
|
||||
func TestSetupGate_TheProbeOpensItOnlyWhenTheAppSaysSetUp(t *testing.T) {
|
||||
m := gateManager(t, gatedYml)
|
||||
dir := closedGate(t, m)
|
||||
answer := `{"data":{"initialized":false}}`
|
||||
var probeErr error
|
||||
old := setupGateProbeGet
|
||||
setupGateProbeGet = func(url string) ([]byte, error) {
|
||||
if url != "http://gapp:80/api/status" {
|
||||
t.Errorf("probed %q", url)
|
||||
}
|
||||
return []byte(answer), probeErr
|
||||
}
|
||||
t.Cleanup(func() { setupGateProbeGet = old })
|
||||
|
||||
m.SetupGateTick()
|
||||
if c := LoadAppConfig(dir); !c.SetupGate.Closed() {
|
||||
t.Fatal("not yet set up, but the gate opened")
|
||||
}
|
||||
probeErr = errors.New("connection refused")
|
||||
answer = `{"data":{"initialized":true}}`
|
||||
m.SetupGateTick()
|
||||
if c := LoadAppConfig(dir); !c.SetupGate.Closed() {
|
||||
t.Fatal("an unreadable probe opened the gate (must fail closed)")
|
||||
}
|
||||
probeErr = nil
|
||||
m.SetupGateTick()
|
||||
c := LoadAppConfig(dir)
|
||||
if c.SetupGate.Closed() || c.SetupGate.OpenedBy != SetupGateByProbe || c.SetupGate.OpenedAt == "" {
|
||||
t.Fatalf("the app says it is set up, but the gate record is %+v", c.SetupGate)
|
||||
}
|
||||
if _, err := os.Stat(m.setupGatePath("gapp")); !os.IsNotExist(err) {
|
||||
t.Fatal("the gate opened but its traefik file is still there — the app is still gated")
|
||||
}
|
||||
// A later tick does not re-gate it.
|
||||
m.SetupGateTick()
|
||||
if _, err := os.Stat(m.setupGatePath("gapp")); !os.IsNotExist(err) {
|
||||
t.Fatal("a tick re-gated an opened app")
|
||||
}
|
||||
}
|
||||
|
||||
// The household's button opens it; a second press says it is already open.
|
||||
// COMPANION RED-PROOF: skip the Closed() check at the top of OpenSetupGate → the second press succeeds.
|
||||
func TestSetupGate_TheButtonOpensItOnce(t *testing.T) {
|
||||
m := gateManager(t, strings.Replace(gatedYml, "setup_done_probe:\n url: http://gapp:80/api/status\n field: data.initialized\n done: \"true\"\n", "", 1))
|
||||
dir := closedGate(t, m)
|
||||
must(t, m.OpenSetupGate("gapp", SetupGateByHousehold))
|
||||
if c := LoadAppConfig(dir); c.SetupGate.Closed() || c.SetupGate.OpenedBy != SetupGateByHousehold {
|
||||
t.Fatalf("record %+v", c.SetupGate)
|
||||
}
|
||||
if _, err := os.Stat(m.setupGatePath("gapp")); !os.IsNotExist(err) {
|
||||
t.Fatal("file still there after the press")
|
||||
}
|
||||
if err := m.OpenSetupGate("gapp", SetupGateByHousehold); !errors.Is(err, ErrSetupGateNotClosed) {
|
||||
t.Fatalf("second press: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A controller restart keeps the gate: the record is on disk, and the loop rewrites a missing file. A file
|
||||
// whose app is not gated any more (removed app) is removed.
|
||||
// COMPANION RED-PROOF: drop the writeSetupGate call in SetupGateTick → "the restart left the app open" fails.
|
||||
func TestSetupGate_ARestartKeepsItAndStaleFilesGo(t *testing.T) {
|
||||
m := gateManager(t, gatedYml)
|
||||
closedGate(t, m)
|
||||
must(t, os.Remove(m.setupGatePath("gapp"))) // e.g. lost with the traefik dir
|
||||
must(t, os.WriteFile(filepath.Join(m.setupGateDir(), "setup-gate-gone.yml"), []byte("x"), 0o644)) // a removed app's
|
||||
// "restart": a fresh manager reads the same disk
|
||||
m2, err := NewManager(m.cfg, log.New(io.Discard, "", 0))
|
||||
must(t, err)
|
||||
must(t, m2.ScanStacks())
|
||||
m2.mu.Lock()
|
||||
m2.stacks["gapp"].State = StateStopped // not running: no probe, but the file must still come back
|
||||
m2.mu.Unlock()
|
||||
m2.SetupGateTick()
|
||||
if _, err := os.Stat(m2.setupGatePath("gapp")); err != nil {
|
||||
t.Fatal("the restart left the app open: the gate file was not rewritten from the record")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(m2.setupGateDir(), "setup-gate-gone.yml")); !os.IsNotExist(err) {
|
||||
t.Fatal("a gate file nobody owns was kept")
|
||||
}
|
||||
if _, closed, found := m2.SetupGateHost("GAPP.example.hu"); !found || !closed {
|
||||
t.Fatalf("after the restart the host is found=%v closed=%v", found, closed)
|
||||
}
|
||||
}
|
||||
|
||||
// A restore keeps the gate as it was (never re-gates an app that is set up); no prior record = no gate.
|
||||
// COMPANION RED-PROOF: drop `cfg.SetupGate = prior.SetupGate` from carryLifeRecords → the closed case fails.
|
||||
func TestSetupGate_ARestoreKeepsTheRecord(t *testing.T) {
|
||||
lg := log.New(io.Discard, "", 0)
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
prior *AppConfig
|
||||
want string
|
||||
}{
|
||||
{"opened before", &AppConfig{SetupGate: &SetupGateRecord{State: SetupGateOpen}}, SetupGateOpen},
|
||||
{"still closed", &AppConfig{SetupGate: &SetupGateRecord{State: SetupGateClosed}}, SetupGateClosed},
|
||||
{"never gated / kept data / removed app", nil, ""},
|
||||
} {
|
||||
cfg := &AppConfig{}
|
||||
carryLifeRecords(lg, "gapp", c.prior, cfg)
|
||||
got := ""
|
||||
if cfg.SetupGate != nil {
|
||||
got = cfg.SetupGate.State
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("%s: after the restore %q, want %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The probe reads a dotted path and compares its text; anything unreadable is "not done".
|
||||
func TestSetupGate_ProbeSaysDone(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
body, field, done string
|
||||
want bool
|
||||
}{
|
||||
{`{"data":{"userManagement":{"showSetupOnFirstLoad":false}}}`, "data.userManagement.showSetupOnFirstLoad", "false", true},
|
||||
{`{"data":{"userManagement":{"showSetupOnFirstLoad":true}}}`, "data.userManagement.showSetupOnFirstLoad", "false", false},
|
||||
{`{"isInitialized":true}`, "isInitialized", "true", true},
|
||||
{`{"isInitialized":false}`, "isInitialized", "true", false},
|
||||
{`{}`, "isInitialized", "true", false},
|
||||
{`not json`, "isInitialized", "true", false},
|
||||
{`{"a":"x"}`, "a.b", "x", false},
|
||||
} {
|
||||
if got, _ := probeSaysDone([]byte(c.body), c.field, c.done); got != c.want {
|
||||
t.Errorf("%s @ %s: %v, want %v", c.body, c.field, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A router with no service label and a container with two services is refused (the gate would not know where
|
||||
// to send the household).
|
||||
func TestSetupGate_RoutersNeedAKnownService(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "c.yml")
|
||||
must(t, os.WriteFile(p, []byte("services:\n a:\n labels:\n traefik.enable: \"true\"\n"+
|
||||
" traefik.http.routers.a.rule: Host(`a.x.hu`)\n"+
|
||||
" traefik.http.services.s1.loadbalancer.server.port: \"1\"\n"+
|
||||
" traefik.http.services.s2.loadbalancer.server.port: \"2\"\n"), 0o644))
|
||||
if _, err := gateRoutersFromCompose(p, nil); err == nil {
|
||||
t.Fatal("an ambiguous service was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// "password:N:special" (v0.280.0) meets a policy that demands a special character (calibre-web): a lower, an
|
||||
// upper, a digit, one of passwordSpecials, a letter or digit first, and nothing that breaks a compose value, a
|
||||
// shell's double quotes or "user:password". COMPANION RED-PROOF: return randomAlphanumeric for the special form
|
||||
// → "no special character" fails.
|
||||
func TestGenerateValue_PasswordWithASpecialCharacter(t *testing.T) {
|
||||
for i := 0; i < 300; i++ {
|
||||
p, err := generateValue("password:24:special")
|
||||
must(t, err)
|
||||
if len(p) != 24 {
|
||||
t.Fatalf("length %d", len(p))
|
||||
}
|
||||
if !strings.ContainsAny(p, passwordSpecials) {
|
||||
t.Fatal("no special character")
|
||||
}
|
||||
if !strings.ContainsAny(p, "abcdefghijklmnopqrstuvwxyz") || !strings.ContainsAny(p, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") || !strings.ContainsAny(p, "0123456789") {
|
||||
t.Fatal("a character class is missing")
|
||||
}
|
||||
if strings.ContainsAny(p, "'\"$\\: `") || strings.ContainsAny(p[:1], passwordSpecials) {
|
||||
t.Fatal("an unsafe character, or a special first")
|
||||
}
|
||||
}
|
||||
if p, err := generateValue("password:16"); err != nil || strings.ContainsAny(p, passwordSpecials) || len(p) != 16 {
|
||||
t.Fatalf("the plain form changed: %q %v", p, err)
|
||||
}
|
||||
for _, bad := range []string{"password:24:weird", "password:4:special"} {
|
||||
if _, err := generateValue(bad); err == nil {
|
||||
t.Fatalf("%q accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user