1a68b53b06
The golden's version IS the controller it bakes (build-golden.sh:345 defaults GOLDEN_VERSION to the controller tag), so a golden behind the newest deployed controller means every FRESH install lands on stale application code. On the R-120 occurrence that stale code shipped a customer-facing falsehood: a box from the 0.185.1 golden told a customer whose backup drive had fallen out that the backup was on the same disk as the system -- false, the drive was gone -- and offered a different drive as the remedy. WHY A GATE, NOT A REMINDER. The gap has opened three times: R-111 (golden's agent 17 releases behind), R-115 (agent built and deployed, never published), R-120 (this). The first two were closed by re-baking and remembering; remembering then failed again. R-29 is the standing proof that a check nobody runs is worse than none because it reads as coverage -- hostinstall_gates.py sat RED and uninvoked across three version bumps and hub_confirm_gate.py has never run at all. So the property that matters is not whether a check exists but whether it BLOCKS. - Wired into handleSetArtifacts (internal/web/configs.go), immediately before the only write, on the sole UI path to SetArtifactManifest -- it runs on every vouch without anyone choosing to. A script in scripts/ would have been a fourth orphan. - It REFUSES (operator ruling, 2026-07-30), with a flash naming the remedy. - Signal: store.NewestReportedControllerVersion() over reports.controller_version, SEMVER-compared in Go -- MAX() in SQL ranks 0.99.0 above 0.186.0, a pair this fleet has shipped. No outbound call, no new credential. - Fail-open in exactly two deliberate cases: an empty golden field (clearing the manifest is legitimate) and an unknown fleet version (a new hub must vouch its first golden). NEAR-MISS RECORDED: the first draft read guests.controller_version, a column that exists in the schema and that NOTHING writes -- it would always have seen "" and failed open, i.e. inert, this gate's own failure shape. Caught by grepping for a writer before trusting the column. Blind spot stated rather than papered over: a controller no box has ever run is invisible to this signal. Not the failure that has bitten -- all three instances were deployed-newer-than-baked. 4 tests through the PRODUCTION handler over httptest, never an injected seam. The refusal asserts both the flash and that the manifest was NOT written, because a gate that redirects and saves anyway reads as enforcement while providing none. Red-proof: deleting the block makes the stale golden vouchable and both assertions fail. ROADMAP R-29's audit list now records this as the FIRST enforced gate, so the contrast with its three orphans is kept rather than lost. The orphans are unchanged. Suite rc=0 read separately from this commit.
253 lines
11 KiB
Go
253 lines
11 KiB
Go
package web
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
|
)
|
|
|
|
// seedReport writes a latest report carrying a controller version for customerID.
|
|
func seedReport(t *testing.T, st *store.Store, customerID, ctrlVer string) {
|
|
t.Helper()
|
|
if err := st.SaveReport(customerID, []byte(`{"controller_version":"`+ctrlVer+`"}`)); err != nil {
|
|
t.Fatalf("SaveReport(%s): %v", customerID, err)
|
|
}
|
|
}
|
|
|
|
// TestManifestSave_DoesNotTouchFloor is Part C's central guarantee: saving the Day-0 artifact
|
|
// manifest must NOT write hub_settings.min_controller_version (the publish-train footgun). Companion
|
|
// red-proof: add a SetGlobalMinControllerVersion call into handleSetArtifacts → the floor changes →
|
|
// this fails.
|
|
func TestManifestSave_DoesNotTouchFloor(t *testing.T) {
|
|
s, st := newTestServer(t)
|
|
st.SetDefaultMinControllerVersion("0.87.0")
|
|
if err := st.SetGlobalMinControllerVersion("0.113.0"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before := st.ResolveGlobalFloor()
|
|
|
|
form := url.Values{"agent_version": {"0.82.0"}, "golden_version": {"0.115.0"}}
|
|
// No Gitea client in the test → resolveArtifactSHA takes the manual path; provide valid shas.
|
|
form.Set("agent_sha256", strings.Repeat("a", 64))
|
|
form.Set("golden_sha256", strings.Repeat("b", 64))
|
|
r := httptest.NewRequest(http.MethodPost, "/configuration/artifacts", strings.NewReader(form.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
w := httptest.NewRecorder()
|
|
s.handleSetArtifacts(w, r)
|
|
|
|
if m := st.GetArtifactManifest(); m.AgentVersion != "0.82.0" || m.GoldenVersion != "0.115.0" {
|
|
t.Fatalf("manifest not saved: %+v", m)
|
|
}
|
|
after := st.ResolveGlobalFloor()
|
|
if after.Effective != before.Effective || after.Source != before.Source || after.DBValue != before.DBValue {
|
|
t.Errorf("manifest save MUST NOT change the floor: before=%+v after=%+v", before, after)
|
|
}
|
|
}
|
|
|
|
// TestGlobalFloorImpact_Count: the confirm-dialog probe counts boxes below a proposed floor,
|
|
// honoring per-customer overrides (an overridden box is governed by its own floor, not the proposed
|
|
// global). Companion red-proof: drop the override exclusion → the overridden box is miscounted.
|
|
func TestGlobalFloorImpact_Count(t *testing.T) {
|
|
s, st := newTestServer(t)
|
|
// Three reporting boxes.
|
|
seedReport(t, st, "below-a", "0.110.0") // below a 0.113 proposal
|
|
seedReport(t, st, "below-b", "0.112.0") // below
|
|
seedReport(t, st, "current", "0.113.0") // at the proposal (not below)
|
|
seedReport(t, st, "overridden", "0.90.0") // below the global BUT has its own override at 0.90.0
|
|
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "overridden", RetrievalPassword: "x", APIKey: "y"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.SetMinControllerVersion("overridden", "0.90.0"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/configuration/global-floor/impact?v=0.113.0", nil)
|
|
w := httptest.NewRecorder()
|
|
s.handleGlobalFloorImpact(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("impact: %d", w.Code)
|
|
}
|
|
var resp struct {
|
|
Version string `json:"version"`
|
|
Valid bool `json:"valid"`
|
|
Below int `json:"below"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !resp.Valid || resp.Version != "0.113.0" {
|
|
t.Fatalf("resp = %+v", resp)
|
|
}
|
|
// below-a + below-b = 2; current is AT the floor; overridden is governed by its own 0.90.0.
|
|
if resp.Below != 2 {
|
|
t.Errorf("below-floor count = %d, want 2 (overridden box excluded, at-floor excluded)", resp.Below)
|
|
}
|
|
|
|
// Invalid version → valid:false, below:0.
|
|
req2 := httptest.NewRequest(http.MethodGet, "/configuration/global-floor/impact?v=notaversion", nil)
|
|
w2 := httptest.NewRecorder()
|
|
s.handleGlobalFloorImpact(w2, req2)
|
|
var resp2 struct {
|
|
Valid bool `json:"valid"`
|
|
Below int `json:"below"`
|
|
}
|
|
_ = json.Unmarshal(w2.Body.Bytes(), &resp2)
|
|
if resp2.Valid || resp2.Below != 0 {
|
|
t.Errorf("invalid version → valid=false below=0, got %+v", resp2)
|
|
}
|
|
}
|
|
|
|
// TestConfigurationPage_RendersFloorSource: the effective-floor source line renders both the
|
|
// DB-wins and env-fallback states through the production template.
|
|
func TestConfigurationPage_RendersFloorSource(t *testing.T) {
|
|
render := func(setup func(st *store.Store)) string {
|
|
t.Helper()
|
|
s, st := newTestServer(t)
|
|
setup(st)
|
|
req := httptest.NewRequest(http.MethodGet, "/configuration", nil)
|
|
w := httptest.NewRecorder()
|
|
s.handleConfiguration(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("configuration page: %d", w.Code)
|
|
}
|
|
return w.Body.String()
|
|
}
|
|
|
|
dbWins := render(func(st *store.Store) {
|
|
st.SetDefaultMinControllerVersion("0.87.0")
|
|
_ = st.SetGlobalMinControllerVersion("0.113.0")
|
|
})
|
|
if !strings.Contains(dbWins, "DB (hub_settings)") || !strings.Contains(dbWins, "v0.113.0") {
|
|
t.Errorf("db-wins source line missing:\n%s", excerpt(dbWins))
|
|
}
|
|
if !strings.Contains(dbWins, "env fallback would be") || !strings.Contains(dbWins, "v0.87.0") {
|
|
t.Errorf("db-wins must also surface the env fallback value:\n%s", excerpt(dbWins))
|
|
}
|
|
|
|
envOnly := render(func(st *store.Store) { st.SetDefaultMinControllerVersion("0.87.0") })
|
|
if !strings.Contains(envOnly, "env fallback (DEFAULT_MIN_CONTROLLER_VERSION)") {
|
|
t.Errorf("env-fallback source line missing:\n%s", excerpt(envOnly))
|
|
}
|
|
// The type-to-confirm wiring must be present (no bare submit button).
|
|
if !strings.Contains(envOnly, "confirmGlobalFloor()") || !strings.Contains(envOnly, "global-floor/impact") {
|
|
t.Errorf("type-to-confirm dialog wiring missing")
|
|
}
|
|
}
|
|
|
|
func excerpt(s string) string {
|
|
if i := strings.Index(s, "Managed updates"); i >= 0 {
|
|
end := i + 900
|
|
if end > len(s) {
|
|
end = len(s)
|
|
}
|
|
return s[i:end]
|
|
}
|
|
if len(s) > 600 {
|
|
return s[:600]
|
|
}
|
|
return s
|
|
}
|
|
|
|
// ── R-120: the vouch path REFUSES a golden older than the controller the fleet already runs ──────
|
|
//
|
|
// These drive handleSetArtifacts — THE production vouch path, the only UI writer of
|
|
// SetArtifactManifest — over httptest. Deliberately NOT through an injected seam: the whole point of
|
|
// this gate is that it cannot be inert, and three shipped defects in this project were fully green
|
|
// with the seam disconnected. The assertions check BOTH the operator-visible outcome and that the
|
|
// manifest was not written, because a gate that redirects but still saves is worse than none.
|
|
|
|
// RED-PROOF: delete the `goldenVer != ""` gate block in handleSetArtifacts → this fails, because the
|
|
// stale golden is accepted and the manifest is overwritten.
|
|
func TestVouchRefusesGoldenBehindFleet(t *testing.T) {
|
|
s, st := newTestServer(t)
|
|
// The R-120 situation exactly: a box is running 0.186.0 while the operator vouches a 0.185.1 golden.
|
|
seedReport(t, st, "demo", "0.186.0")
|
|
if err := st.SetArtifactManifest(store.ArtifactManifest{
|
|
AgentVersion: "0.116.0", GoldenVersion: "0.186.0",
|
|
AgentSHA256: strings.Repeat("a", 64), GoldenSHA256: strings.Repeat("b", 64),
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
form := url.Values{"agent_version": {"0.116.0"}, "golden_version": {"0.185.1"}}
|
|
form.Set("agent_sha256", strings.Repeat("c", 64))
|
|
form.Set("golden_sha256", strings.Repeat("d", 64))
|
|
r := httptest.NewRequest(http.MethodPost, "/configuration/artifacts", strings.NewReader(form.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
w := httptest.NewRecorder()
|
|
s.handleSetArtifacts(w, r)
|
|
|
|
if got := w.Header().Get("Location"); !strings.Contains(got, "golden_behind_fleet") {
|
|
t.Errorf("expected the refusal flash, got Location=%q — the operator would not know it was refused", got)
|
|
}
|
|
// The load-bearing half: the manifest must be UNCHANGED.
|
|
if m := st.GetArtifactManifest(); m.GoldenVersion != "0.186.0" {
|
|
t.Fatalf("REFUSED but still wrote the manifest: golden=%q, want 0.186.0 untouched. A gate that "+
|
|
"redirects and saves anyway is worse than no gate — it reads as enforcement.", m.GoldenVersion)
|
|
}
|
|
}
|
|
|
|
// The gate must not block legitimate vouches, or it gets disabled and becomes another R-29 orphan.
|
|
func TestVouchAllowsGoldenAtOrAheadOfFleet(t *testing.T) {
|
|
s, st := newTestServer(t)
|
|
seedReport(t, st, "demo", "0.186.0")
|
|
for _, golden := range []string{"0.186.0", "0.187.0"} {
|
|
form := url.Values{"agent_version": {"0.116.0"}, "golden_version": {golden}}
|
|
form.Set("agent_sha256", strings.Repeat("a", 64))
|
|
form.Set("golden_sha256", strings.Repeat("b", 64))
|
|
r := httptest.NewRequest(http.MethodPost, "/configuration/artifacts", strings.NewReader(form.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
w := httptest.NewRecorder()
|
|
s.handleSetArtifacts(w, r)
|
|
if m := st.GetArtifactManifest(); m.GoldenVersion != golden {
|
|
t.Errorf("golden %s (>= fleet 0.186.0) should be vouchable, manifest holds %q", golden, m.GoldenVersion)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fail-open case 1: a hub whose fleet has reported nothing must be able to vouch its first golden.
|
|
func TestVouchAllowedWhenFleetVersionUnknown(t *testing.T) {
|
|
s, st := newTestServer(t)
|
|
form := url.Values{"agent_version": {"0.116.0"}, "golden_version": {"0.185.1"}}
|
|
form.Set("agent_sha256", strings.Repeat("a", 64))
|
|
form.Set("golden_sha256", strings.Repeat("b", 64))
|
|
r := httptest.NewRequest(http.MethodPost, "/configuration/artifacts", strings.NewReader(form.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
w := httptest.NewRecorder()
|
|
s.handleSetArtifacts(w, r)
|
|
if m := st.GetArtifactManifest(); m.GoldenVersion != "0.185.1" {
|
|
t.Errorf("with no reported fleet version the gate must fail OPEN; manifest holds %q", m.GoldenVersion)
|
|
}
|
|
}
|
|
|
|
// Fail-open case 2: clearing the golden field is a legitimate operator act, not drift.
|
|
func TestVouchAllowsClearingGolden(t *testing.T) {
|
|
s, st := newTestServer(t)
|
|
seedReport(t, st, "demo", "0.186.0")
|
|
form := url.Values{"agent_version": {"0.116.0"}, "golden_version": {""}}
|
|
form.Set("agent_sha256", strings.Repeat("a", 64))
|
|
r := httptest.NewRequest(http.MethodPost, "/configuration/artifacts", strings.NewReader(form.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
w := httptest.NewRecorder()
|
|
s.handleSetArtifacts(w, r)
|
|
if got := w.Header().Get("Location"); strings.Contains(got, "golden_behind_fleet") {
|
|
t.Error("clearing the golden must not trip the gate")
|
|
}
|
|
}
|
|
|
|
// The comparison must be SEMVER, not lexical — 0.99.0 vs 0.186.0 is the pair that breaks string order,
|
|
// and it is not hypothetical: the fleet has shipped both 0.9x and 0.18x controllers.
|
|
func TestNewestReportedControllerIsSemverOrdered(t *testing.T) {
|
|
_, st := newTestServer(t)
|
|
seedReport(t, st, "a", "0.99.0")
|
|
seedReport(t, st, "b", "0.186.0")
|
|
if got := st.NewestReportedControllerVersion(); got != "0.186.0" {
|
|
t.Errorf("NewestReportedControllerVersion() = %q, want 0.186.0 — a lexical MAX() would return 0.99.0", got)
|
|
}
|
|
}
|