From 37f7b807ab7ffc7b435a40fa4f92e9a462ebcc40 Mon Sep 17 00:00:00 2001 From: hshinosa Date: Tue, 15 Sep 2026 10:06:08 +0700 Subject: [PATCH 1/2] feat(config): default the dashboard bind to loopback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard used to seed server.host with 0.0.0.0, so a fresh install exposed the web UI on every network interface (Wi-Fi, ethernet, VPN, hotspot). ValidateListen then refused the first setup submit until the user configured an auth token or a dashboard password, blocking the wizard on the very first click. The README already told users the production binary "binds 127.0.0.1 by default" — this brings the code into line with what was already documented. Defaults now route through a resolver: 1. ANTARES_HOST wins when non-empty, so installers, systemd units, and one-off invocations can override without editing config. 2. Container heuristic returns 0.0.0.0 when /.dockerenv, /run/.containerenv, or /proc/1/cgroup marks Docker, Podman, containerd, or Kubernetes — otherwise `docker run -p 8787:8787` cannot reach the process. 3. Loopback fallback everywhere else. Existing configs with an explicit server.host keep their value untouched; this only changes what a fresh install writes and what a config with no server.host resolves to. ValidateListen still refuses non-loopback binds without auth, so opting in to LAN exposure still requires the guard's consent. Tests cover the three branches through a swappable containerProbe seam so no container runtime is needed at test time. --- README.md | 20 +++++---- internal/config/defaults.go | 64 +++++++++++++++++++++++++++-- internal/config/defaults_test.go | 69 ++++++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 11 deletions(-) create mode 100644 internal/config/defaults_test.go diff --git a/README.md b/README.md index 15d1585..c8070f6 100644 --- a/README.md +++ b/README.md @@ -129,21 +129,25 @@ are written to `~/.antares/logs/daemon.log`. ### Accessing it from another machine -The production binary binds `127.0.0.1` by default. Change `server.host` to -`0.0.0.0` (or a specific interface) to reach it from elsewhere; a non-loopback -bind requires `server.auth_token`, a dashboard password, or an explicit -`server.auth_disabled: true`. Vite also binds loopback in dev; set `HOST=0.0.0.0` +The production binary binds `127.0.0.1` by default so the dashboard stays on +this machine. Inside a container Antares detects the environment and switches +to `0.0.0.0` automatically so `docker run -p 8787:8787` (or a Kubernetes +port-forward) reaches it without extra config. To expose it anywhere else, +set `server.host` in `config.yaml`, or export `ANTARES_HOST` before starting +the process — the environment variable wins over both the container heuristic +and the loopback fallback. Vite also binds loopback in dev; set `HOST=0.0.0.0` to expose it on the network. ``` http://:8787 # production binary, after setting server.host +ANTARES_HOST=0.0.0.0 antares # one-off exposure via env var HOST=0.0.0.0 make dev-web # dev, exposed on the LAN ``` -Antares refuses to bind a non-loopback address unless `server.auth_token` is -set, a dashboard password is configured, or `server.auth_disabled: true` is -set explicitly — the default `127.0.0.1` binding leaves the dashboard open, -which is right on your own machine and safe behind a private network. +A non-loopback bind still requires `server.auth_token`, a dashboard password, +or an explicit `server.auth_disabled: true` — Antares refuses to start +otherwise. The loopback default leaves the dashboard open, which is right on +your own machine and safe behind a private network. --- diff --git a/internal/config/defaults.go b/internal/config/defaults.go index cbb9686..7b2c76d 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -1,6 +1,10 @@ package config -import "path/filepath" +import ( + "bytes" + "os" + "path/filepath" +) // Default returns a fully populated configuration. Every field the dashboard // can edit has a sensible value here so the UI never renders a blank form. @@ -50,8 +54,12 @@ func Default() *Config { MaxConns: 8, Busy: 5000, WAL: true, }, Server: Server{ - Host: "0.0.0.0", Port: 8787, - // Same-origin is the safe default. Cross-origin access must be + Host: defaultHost(), Port: 8787, + // defaultHost() picks a bind address safe for where Antares is + // running: loopback on a laptop, wildcard inside a container so + // docker port-forwarding still works, or the ANTARES_HOST override + // when an installer / systemd unit / operator set one. Same-origin + // is the safe default for CORS; cross-origin access must be // explicitly configured by the operator. CORSOrigins: []string{}, }, @@ -143,3 +151,53 @@ func Default() *Config { }, } } + +// defaultHost picks the address the dashboard should bind to when the config +// leaves server.host empty. Precedence, high to low: +// +// 1. ANTARES_HOST — installers, systemd units, and one-off overrides win +// over every heuristic. An empty value is ignored so a stray `export +// ANTARES_HOST=` in a shell profile does not break the default. +// 2. Container heuristic — inside Docker / Podman / Kubernetes the process +// must bind the wildcard address for host port forwarding to reach it. +// 3. Loopback fallback — everything else. Fresh installs on a laptop stay +// on this machine only; ValidateListen still refuses a non-loopback +// bind without an auth token or dashboard password if the user opts in +// via config or ANTARES_HOST. +func defaultHost() string { + if h := os.Getenv("ANTARES_HOST"); h != "" { + return h + } + if inContainer() { + return "0.0.0.0" + } + return "127.0.0.1" +} + +// containerProbe is the seam tests replace to simulate running inside a +// container without needing Docker. Production code calls the real probe. +var containerProbe = detectContainer + +// inContainer reports whether Antares is running inside a container. It goes +// through the swappable containerProbe so tests can stub the answer. +func inContainer() bool { return containerProbe() } + +// detectContainer looks at filesystem markers Docker, Podman, and +// Kubernetes leave in the container image. Any failure means "not detected" +// — the loopback fallback is the safe answer if we cannot tell. +func detectContainer() bool { + if _, err := os.Stat("/.dockerenv"); err == nil { + return true + } + if _, err := os.Stat("/run/.containerenv"); err == nil { + return true + } + if b, err := os.ReadFile("/proc/1/cgroup"); err == nil { + if bytes.Contains(b, []byte("docker")) || + bytes.Contains(b, []byte("kubepods")) || + bytes.Contains(b, []byte("containerd")) { + return true + } + } + return false +} diff --git a/internal/config/defaults_test.go b/internal/config/defaults_test.go new file mode 100644 index 0000000..6ddba98 --- /dev/null +++ b/internal/config/defaults_test.go @@ -0,0 +1,69 @@ +package config + +import "testing" + +// TestDefaultHost pins the precedence: ANTARES_HOST wins over the container +// heuristic, the container heuristic wins over the bare-metal fallback, and an +// empty ANTARES_HOST is treated as "unset" so a stray shell export cannot +// silently break the default. Stubs containerProbe so the tests do not need +// Docker / Podman / Kubernetes to be present. +func TestDefaultHost(t *testing.T) { + origProbe := containerProbe + t.Cleanup(func() { containerProbe = origProbe }) + + tests := []struct { + name string + envValue string + envSet bool + container bool + want string + }{ + {name: "bare metal loopback", envSet: false, container: false, want: "127.0.0.1"}, + {name: "container wildcard", envSet: false, container: true, want: "0.0.0.0"}, + {name: "env override on laptop", envSet: true, envValue: "192.168.1.10", container: false, want: "192.168.1.10"}, + {name: "env override beats container heuristic", envSet: true, envValue: "127.0.0.1", container: true, want: "127.0.0.1"}, + // An empty ANTARES_HOST must be ignored, not accepted verbatim — + // otherwise a user who exports the variable without a value gets a + // server that binds nowhere useful. + {name: "empty env falls through to loopback", envSet: true, envValue: "", container: false, want: "127.0.0.1"}, + {name: "empty env falls through to container wildcard", envSet: true, envValue: "", container: true, want: "0.0.0.0"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.envSet { + t.Setenv("ANTARES_HOST", tc.envValue) + } else { + // t.Setenv restores on cleanup; use it with "" plus Unsetenv + // via a nested subtest is overkill. Setting to "" already + // covers the "empty" cases; for "unset", ensure the parent + // process has not exported it. + t.Setenv("ANTARES_HOST", "") + // Setenv leaves the variable set-to-empty, which is the + // "empty env" case above. To simulate a truly unset variable + // we rely on the fact that defaultHost() treats "" as unset. + } + containerProbe = func() bool { return tc.container } + + if got := defaultHost(); got != tc.want { + t.Fatalf("defaultHost() = %q, want %q", got, tc.want) + } + }) + } +} + +// TestDefaultServerHostUsesResolver guards against a future refactor that +// bypasses defaultHost() and reintroduces the literal 0.0.0.0. Default() must +// route the seed value through the resolver so the container / env-var logic +// keeps applying. +func TestDefaultServerHostUsesResolver(t *testing.T) { + origProbe := containerProbe + t.Cleanup(func() { containerProbe = origProbe }) + containerProbe = func() bool { return false } + t.Setenv("ANTARES_HOST", "10.9.8.7") + + cfg := Default() + if cfg.Server.Host != "10.9.8.7" { + t.Fatalf("Default().Server.Host = %q, want %q — the seed value is not going through defaultHost()", cfg.Server.Host, "10.9.8.7") + } +} From 700c5cdb10df8970e9ffca0cd3d72e08fdbd98de Mon Sep 17 00:00:00 2001 From: review Date: Tue, 15 Sep 2026 22:04:37 +0700 Subject: [PATCH 2/2] fix(config): keep first-run host environment overrides transient --- README.md | 21 +-- internal/config/defaults.go | 28 +--- internal/config/defaults_test.go | 53 +++----- internal/config/load_test.go | 213 +++++++++++++++++++++++++++++++ 4 files changed, 246 insertions(+), 69 deletions(-) create mode 100644 internal/config/load_test.go diff --git a/README.md b/README.md index c8070f6..fcd5271 100644 --- a/README.md +++ b/README.md @@ -129,18 +129,19 @@ are written to `~/.antares/logs/daemon.log`. ### Accessing it from another machine -The production binary binds `127.0.0.1` by default so the dashboard stays on -this machine. Inside a container Antares detects the environment and switches -to `0.0.0.0` automatically so `docker run -p 8787:8787` (or a Kubernetes -port-forward) reaches it without extra config. To expose it anywhere else, -set `server.host` in `config.yaml`, or export `ANTARES_HOST` before starting -the process — the environment variable wins over both the container heuristic -and the loopback fallback. Vite also binds loopback in dev; set `HOST=0.0.0.0` -to expose it on the network. +The production binary binds `127.0.0.1` by default. Inside a container the +first boot seeds `server.host: 0.0.0.0` into `config.yaml` once so `docker +run -p 8787:8787` and Kubernetes port-forwards reach it out of the box; +later boots read the stored value verbatim so your edits stick. To expose +the binary elsewhere, edit `server.host` in `config.yaml` or export +`ANTARES_HOST` before starting. `ANTARES_HOST` is a per-process override +applied on every load — it wins for the current run but is **not** written +to disk, so unsetting it restores the stored value on the next boot. Vite +also binds loopback in dev; set `HOST=0.0.0.0` to expose it on the LAN. ``` -http://:8787 # production binary, after setting server.host -ANTARES_HOST=0.0.0.0 antares # one-off exposure via env var +http://:8787 # production binary, after editing server.host +ANTARES_HOST=0.0.0.0 antares # one-off exposure via env var; config.yaml is not changed HOST=0.0.0.0 make dev-web # dev, exposed on the LAN ``` diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 7b2c76d..aa1b15b 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -55,12 +55,10 @@ func Default() *Config { }, Server: Server{ Host: defaultHost(), Port: 8787, - // defaultHost() picks a bind address safe for where Antares is - // running: loopback on a laptop, wildcard inside a container so - // docker port-forwarding still works, or the ANTARES_HOST override - // when an installer / systemd unit / operator set one. Same-origin - // is the safe default for CORS; cross-origin access must be - // explicitly configured by the operator. + // defaultHost() seeds loopback on bare metal, wildcard in a + // container. ANTARES_HOST is a per-load runtime override + // applied in applyEnv; it never touches the persisted seed. + // CORS starts same-origin only. CORSOrigins: []string{}, }, Agent: Agent{ @@ -152,22 +150,10 @@ func Default() *Config { } } -// defaultHost picks the address the dashboard should bind to when the config -// leaves server.host empty. Precedence, high to low: -// -// 1. ANTARES_HOST — installers, systemd units, and one-off overrides win -// over every heuristic. An empty value is ignored so a stray `export -// ANTARES_HOST=` in a shell profile does not break the default. -// 2. Container heuristic — inside Docker / Podman / Kubernetes the process -// must bind the wildcard address for host port forwarding to reach it. -// 3. Loopback fallback — everything else. Fresh installs on a laptop stay -// on this machine only; ValidateListen still refuses a non-loopback -// bind without an auth token or dashboard password if the user opts in -// via config or ANTARES_HOST. +// defaultHost picks the seed for a fresh config.yaml. It does not read +// ANTARES_HOST — that env var is a runtime override applied by applyEnv, so +// a transient export never gets baked into the persisted file. func defaultHost() string { - if h := os.Getenv("ANTARES_HOST"); h != "" { - return h - } if inContainer() { return "0.0.0.0" } diff --git a/internal/config/defaults_test.go b/internal/config/defaults_test.go index 6ddba98..15b4b6e 100644 --- a/internal/config/defaults_test.go +++ b/internal/config/defaults_test.go @@ -2,68 +2,45 @@ package config import "testing" -// TestDefaultHost pins the precedence: ANTARES_HOST wins over the container -// heuristic, the container heuristic wins over the bare-metal fallback, and an -// empty ANTARES_HOST is treated as "unset" so a stray shell export cannot -// silently break the default. Stubs containerProbe so the tests do not need -// Docker / Podman / Kubernetes to be present. +// TestDefaultHost pins the seed: loopback on bare metal, wildcard in a +// container. ANTARES_HOST must NOT influence the seed — that is applyEnv's +// job, and mixing the two is what let a transient env value persist into +// config.yaml. func TestDefaultHost(t *testing.T) { origProbe := containerProbe t.Cleanup(func() { containerProbe = origProbe }) + t.Setenv("ANTARES_HOST", "203.0.113.9") // must be ignored tests := []struct { name string - envValue string - envSet bool container bool want string }{ - {name: "bare metal loopback", envSet: false, container: false, want: "127.0.0.1"}, - {name: "container wildcard", envSet: false, container: true, want: "0.0.0.0"}, - {name: "env override on laptop", envSet: true, envValue: "192.168.1.10", container: false, want: "192.168.1.10"}, - {name: "env override beats container heuristic", envSet: true, envValue: "127.0.0.1", container: true, want: "127.0.0.1"}, - // An empty ANTARES_HOST must be ignored, not accepted verbatim — - // otherwise a user who exports the variable without a value gets a - // server that binds nowhere useful. - {name: "empty env falls through to loopback", envSet: true, envValue: "", container: false, want: "127.0.0.1"}, - {name: "empty env falls through to container wildcard", envSet: true, envValue: "", container: true, want: "0.0.0.0"}, + {name: "bare metal loopback", container: false, want: "127.0.0.1"}, + {name: "container wildcard", container: true, want: "0.0.0.0"}, } - for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - if tc.envSet { - t.Setenv("ANTARES_HOST", tc.envValue) - } else { - // t.Setenv restores on cleanup; use it with "" plus Unsetenv - // via a nested subtest is overkill. Setting to "" already - // covers the "empty" cases; for "unset", ensure the parent - // process has not exported it. - t.Setenv("ANTARES_HOST", "") - // Setenv leaves the variable set-to-empty, which is the - // "empty env" case above. To simulate a truly unset variable - // we rely on the fact that defaultHost() treats "" as unset. - } containerProbe = func() bool { return tc.container } - if got := defaultHost(); got != tc.want { - t.Fatalf("defaultHost() = %q, want %q", got, tc.want) + t.Fatalf("defaultHost() = %q, want %q — env leaked into the seed", got, tc.want) } }) } } -// TestDefaultServerHostUsesResolver guards against a future refactor that -// bypasses defaultHost() and reintroduces the literal 0.0.0.0. Default() must -// route the seed value through the resolver so the container / env-var logic -// keeps applying. -func TestDefaultServerHostUsesResolver(t *testing.T) { +// TestDefaultServerHostIgnoresEnv is the direct regression: Default() must +// not consult ANTARES_HOST, otherwise the value gets written to config.yaml +// on first boot and every later boot binds that address even after the env +// export is gone. +func TestDefaultServerHostIgnoresEnv(t *testing.T) { origProbe := containerProbe t.Cleanup(func() { containerProbe = origProbe }) containerProbe = func() bool { return false } t.Setenv("ANTARES_HOST", "10.9.8.7") cfg := Default() - if cfg.Server.Host != "10.9.8.7" { - t.Fatalf("Default().Server.Host = %q, want %q — the seed value is not going through defaultHost()", cfg.Server.Host, "10.9.8.7") + if cfg.Server.Host != "127.0.0.1" { + t.Fatalf("Default().Server.Host = %q, want %q — ANTARES_HOST leaked into the seed", cfg.Server.Host, "127.0.0.1") } } diff --git a/internal/config/load_test.go b/internal/config/load_test.go new file mode 100644 index 0000000..00837b1 --- /dev/null +++ b/internal/config/load_test.go @@ -0,0 +1,213 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +// resetLoadedForTest wipes the package cache so each Reload goes through the +// disk seed path again. +func resetLoadedForTest(t *testing.T) { + t.Helper() + mu.Lock() + loaded = nil + mu.Unlock() +} + +// isolateConfigHome points ANTARES_HOME at a fresh temp dir and clears the +// cache. Returns the home path. +func isolateConfigHome(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("ANTARES_HOME", dir) + t.Setenv("ANTARES_PROFILE", "default") + t.Setenv("ANTARES_CONFIG", "") + resetLoadedForTest(t) + return dir +} + +// TestReloadDoesNotPersistTransientHostEnv is the regression for the +// ANTARES_HOST-persisting bug. First boot with env=0.0.0.0 on bare metal: +// runtime wins the env value, on-disk seed stays loopback. Second boot with +// the env cleared: runtime falls back to the persisted loopback, not the +// previous env value. +func TestReloadDoesNotPersistTransientHostEnv(t *testing.T) { + origProbe := containerProbe + t.Cleanup(func() { containerProbe = origProbe }) + containerProbe = func() bool { return false } + + home := isolateConfigHome(t) + cfgPath := filepath.Join(home, "config.yaml") + + // Boot 1: env=0.0.0.0. Env wins in memory; disk is the loopback seed. + t.Setenv("ANTARES_HOST", "0.0.0.0") + cfg, err := Reload() + if err != nil { + t.Fatalf("first Reload: %v", err) + } + if cfg.Server.Host != "0.0.0.0" { + t.Fatalf("boot 1 runtime host = %q, want %q", cfg.Server.Host, "0.0.0.0") + } + if got := readHostFromDisk(t, cfgPath); got != "127.0.0.1" { + t.Fatalf("boot 1 persisted host = %q, want %q — ANTARES_HOST leaked into config.yaml", got, "127.0.0.1") + } + + // Boot 2: env cleared. Runtime falls back to the persisted loopback. + os.Unsetenv("ANTARES_HOST") + resetLoadedForTest(t) + cfg, err = Reload() + if err != nil { + t.Fatalf("second Reload: %v", err) + } + if cfg.Server.Host != "127.0.0.1" { + t.Fatalf("boot 2 runtime host = %q, want %q — env value persisted across boots", cfg.Server.Host, "127.0.0.1") + } + if got := readHostFromDisk(t, cfgPath); got != "127.0.0.1" { + t.Fatalf("boot 2 persisted host = %q, want %q", got, "127.0.0.1") + } +} + +// TestReloadPreservesYAMLHost proves an operator-authored server.host wins +// across boots and is not overwritten by defaultHost() or by an unset env. +func TestReloadPreservesYAMLHost(t *testing.T) { + origProbe := containerProbe + t.Cleanup(func() { containerProbe = origProbe }) + containerProbe = func() bool { return false } + + home := isolateConfigHome(t) + cfgPath := filepath.Join(home, "config.yaml") + writeYAMLHost(t, cfgPath, "192.168.42.10") + + os.Unsetenv("ANTARES_HOST") + cfg, err := Reload() + if err != nil { + t.Fatalf("Reload: %v", err) + } + if cfg.Server.Host != "192.168.42.10" { + t.Fatalf("runtime host = %q, want the pinned YAML value", cfg.Server.Host) + } + if got := readHostFromDisk(t, cfgPath); got != "192.168.42.10" { + t.Fatalf("persisted host = %q, want %q — operator edit was overwritten", got, "192.168.42.10") + } +} + +// TestReloadEnvOverridesYAMLHost pins the documented precedence: ANTARES_HOST +// beats the stored server.host for the current process, without rewriting it. +func TestReloadEnvOverridesYAMLHost(t *testing.T) { + origProbe := containerProbe + t.Cleanup(func() { containerProbe = origProbe }) + containerProbe = func() bool { return false } + + home := isolateConfigHome(t) + cfgPath := filepath.Join(home, "config.yaml") + writeYAMLHost(t, cfgPath, "192.168.42.10") + + t.Setenv("ANTARES_HOST", "10.0.0.1") + cfg, err := Reload() + if err != nil { + t.Fatalf("Reload: %v", err) + } + if cfg.Server.Host != "10.0.0.1" { + t.Fatalf("runtime host = %q, want the env override", cfg.Server.Host) + } + if got := readHostFromDisk(t, cfgPath); got != "192.168.42.10" { + t.Fatalf("persisted host = %q, want %q — env override rewrote the YAML", got, "192.168.42.10") + } +} + +// TestReloadWhitespaceHostEnvIgnored: applyEnv treats whitespace-only exports +// as unset. A stray `export ANTARES_HOST=" "` must not blank the host. +func TestReloadWhitespaceHostEnvIgnored(t *testing.T) { + origProbe := containerProbe + t.Cleanup(func() { containerProbe = origProbe }) + containerProbe = func() bool { return false } + + home := isolateConfigHome(t) + cfgPath := filepath.Join(home, "config.yaml") + writeYAMLHost(t, cfgPath, "192.168.42.10") + + t.Setenv("ANTARES_HOST", " ") + cfg, err := Reload() + if err != nil { + t.Fatalf("Reload: %v", err) + } + if cfg.Server.Host != "192.168.42.10" { + t.Fatalf("runtime host = %q, want the pinned YAML value — whitespace env bypassed the trim check", cfg.Server.Host) + } + if got := readHostFromDisk(t, cfgPath); got != "192.168.42.10" { + t.Fatalf("persisted host = %q, want %q", got, "192.168.42.10") + } +} + +// TestReloadContainerSeedPersists: the container heuristic runs once, its +// wildcard is written to disk, and a later boot where the heuristic no +// longer trips still reads that wildcard verbatim. Reload never re-probes. +func TestReloadContainerSeedPersists(t *testing.T) { + origProbe := containerProbe + t.Cleanup(func() { containerProbe = origProbe }) + + home := isolateConfigHome(t) + cfgPath := filepath.Join(home, "config.yaml") + + containerProbe = func() bool { return true } + os.Unsetenv("ANTARES_HOST") + cfg, err := Reload() + if err != nil { + t.Fatalf("first Reload: %v", err) + } + if cfg.Server.Host != "0.0.0.0" { + t.Fatalf("boot 1 runtime host = %q, want %q", cfg.Server.Host, "0.0.0.0") + } + if got := readHostFromDisk(t, cfgPath); got != "0.0.0.0" { + t.Fatalf("boot 1 persisted host = %q, want %q", got, "0.0.0.0") + } + + containerProbe = func() bool { return false } + resetLoadedForTest(t) + cfg, err = Reload() + if err != nil { + t.Fatalf("second Reload: %v", err) + } + if cfg.Server.Host != "0.0.0.0" { + t.Fatalf("boot 2 runtime host = %q, want %q — persisted seed lost", cfg.Server.Host, "0.0.0.0") + } + if got := readHostFromDisk(t, cfgPath); got != "0.0.0.0" { + t.Fatalf("boot 2 persisted host = %q, want %q — seed rewritten on re-probe", got, "0.0.0.0") + } +} + +// readHostFromDisk decodes config.yaml directly so a bug that only shows up +// in the persisted value cannot hide behind applyEnv. +func readHostFromDisk(t *testing.T, path string) string { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + var doc struct { + Server struct { + Host string `yaml:"host"` + } `yaml:"server"` + } + if err := yaml.Unmarshal(raw, &doc); err != nil { + t.Fatalf("parse %s: %v", path, err) + } + return strings.TrimSpace(doc.Server.Host) +} + +// writeYAMLHost stages a minimal config.yaml with a pinned server.host, +// mirroring an operator's direct edit. +func writeYAMLHost(t *testing.T, path string, host string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + body := "server:\n host: " + host + "\n" + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("write %s: %v", path, err) + } +}