| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "net/http" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/release" |
| 12 | "github.com/carlosframework/platform/internal/status" |
| 13 | ) |
| 14 | |
| 15 | // layoutDowngradeBlocked judges observed state: a target at or above the |
| 16 | // floor is always allowed; below it, every box must be fresh, carry a |
| 17 | // layout report, and report no nested, fenced or conflict row. |
| 18 | func TestLayoutDowngradeBlockedJudgesEveryBoxsReport(t *testing.T) { |
| 19 | fx := newConsoleFixture(t) |
| 20 | ctx := context.Background() |
| 21 | now := time.Now() |
| 22 | flat := &status.LayoutReport{Mode: "flat", Rows: map[string]status.RowLayout{"a.example.com": {Layout: "flat"}, "x.example.com": {Layout: "external"}}} |
| 23 | |
| 24 | seedLayoutBox(t, fx.rel, "ie", now, flat) |
| 25 | if got := fx.srv.layoutDowngradeBlocked(ctx, "v0.19.0"); got != "" { |
| 26 | t.Fatalf("all flat: %q, want allowed", got) |
| 27 | } |
| 28 | |
| 29 | seedLayoutBox(t, fx.rel, "uk", now, &status.LayoutReport{Mode: "nested", Rows: map[string]status.RowLayout{ |
| 30 | "b.example.com": {Layout: "nested"}, "c.example.com": {Layout: "flat"}, "d.example.com": {Layout: "fenced", Reason: "mid-move"}, |
| 31 | }}) |
| 32 | got := fx.srv.layoutDowngradeBlocked(ctx, "v0.19.0") |
| 33 | for _, want := range []string{"v0.19.0 predates the instance layout", "uk: b.example.com is nested", "uk: d.example.com is fenced", "carlos system layout revert"} { |
| 34 | if !strings.Contains(got, want) { |
| 35 | t.Errorf("reason %q lacks %q", got, want) |
| 36 | } |
| 37 | } |
| 38 | if strings.Contains(got, "c.example.com") || strings.Contains(got, "ie") { |
| 39 | t.Errorf("reason %q names a flat row or a flat box", got) |
| 40 | } |
| 41 | // At or above the floor nothing is judged. |
| 42 | for _, v := range []string{"v0.25.0", "v0.26.1"} { |
| 43 | if got := fx.srv.layoutDowngradeBlocked(ctx, v); got != "" { |
| 44 | t.Errorf("%s: %q, want allowed", v, got) |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | // A box that reports no layout at all is unknown and blocks, unless |
| 49 | // the release it reports running parses below the floor: that build |
| 50 | // has no layout pass, so its silence is flat. Today's fleet, on |
| 51 | // v0.21.0 to v0.24.2, keeps its rollback (and its named update). |
| 52 | for _, v := range []string{"v0.18.0", "v0.20.0", "v0.21.0", "v0.24.2"} { |
| 53 | seedBox(t, fx, "au", "linux-arm64", "stable", v) |
| 54 | if got := fx.srv.layoutDowngradeBlocked(ctx, "v0.19.0"); strings.Contains(got, "au ") { |
| 55 | t.Errorf("a %s box with no layout field blocked: %q", v, got) |
| 56 | } |
| 57 | } |
| 58 | // Silence at or above the floor is unknown: that build reports a |
| 59 | // layout when it has an activator, and says "unmanaged" when it has |
| 60 | // none, so no report at all means a box the console cannot vouch for. |
| 61 | seedBox(t, fx, "au", "linux-arm64", "stable", "v0.25.0") |
| 62 | if got := fx.srv.layoutDowngradeBlocked(ctx, "v0.19.0"); !strings.Contains(got, "au reports no layout field") { |
| 63 | t.Errorf("no layout field on the floor release: %q", got) |
| 64 | } |
| 65 | seedBox(t, fx, "au", "linux-arm64", "stable", "dev") |
| 66 | if got := fx.srv.layoutDowngradeBlocked(ctx, "v0.19.0"); !strings.Contains(got, "au reports no layout field") { |
| 67 | t.Errorf("no layout field on an unplaceable release: %q", got) |
| 68 | } |
| 69 | // So does a stale one, whatever its last report said. |
| 70 | seedLayoutBox(t, fx.rel, "au", now.Add(-20*time.Minute), flat) |
| 71 | if got := fx.srv.layoutDowngradeBlocked(ctx, "v0.19.0"); !strings.Contains(got, "au is stale") { |
| 72 | t.Errorf("stale: %q", got) |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | // Both system doors call the guard before a pointer moves: the rollback |
| 77 | // door against the release the pointer would go back to, the update door |
| 78 | // against a named version. A non-nested fleet rolls back as before. |
| 79 | func TestSystemDoorsRefuseAPreLayoutMoveWhileABoxIsNested(t *testing.T) { |
| 80 | fx := newConsoleFixture(t) |
| 81 | owner := cliToken(t, fx, "paul@keymail.dev", "code-layout-guard") |
| 82 | ctx := context.Background() |
| 83 | sg := release.RootSigner(fx.priv) |
| 84 | rel := func(v string) release.SystemRelease { |
| 85 | return release.SystemRelease{Version: v, Artifacts: map[string]release.SystemArtifact{"linux-arm64": {Key: release.SystemArtifactKey(v, "linux-arm64"), SHA256: strings.Repeat("a", 64)}}} |
| 86 | } |
| 87 | if _, err := release.PromoteSystem(ctx, fx.rel, sg, "router", "canary", rel("v0.19.0"), release.SystemPromoteOptions{}); err != nil { |
| 88 | t.Fatal(err) |
| 89 | } |
| 90 | time.Sleep(1100 * time.Millisecond) |
| 91 | if _, err := release.PromoteSystem(ctx, fx.rel, sg, "router", "canary", rel("v0.25.0"), release.SystemPromoteOptions{}); err != nil { |
| 92 | t.Fatal(err) |
| 93 | } |
| 94 | seedLayoutBox(t, fx.rel, "ie", time.Now(), &status.LayoutReport{Mode: "nested", Rows: map[string]status.RowLayout{"a.example.com": {Layout: "nested"}}}) |
| 95 | |
| 96 | st, out := postJSON(t, fx.ts.URL+"/api/cli/system/rollback", map[string]string{"channel": "canary"}, owner) |
| 97 | if st != http.StatusConflict || !strings.Contains(str(t, out, "error"), "ie: a.example.com is nested") { |
| 98 | t.Fatalf("rollback to v0.19.0 with a nested row = %d %v, want 409 naming the row", st, out) |
| 99 | } |
| 100 | p, _, err := release.GetSystemPointer(ctx, fx.rel, fx.pub, "router", "canary") |
| 101 | if err != nil || p.Current.Version != "v0.25.0" { |
| 102 | t.Fatalf("pointer moved anyway: %+v %v", p, err) |
| 103 | } |
| 104 | st, out = postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary", "version": "v0.19.0"}, owner) |
| 105 | if st != http.StatusConflict { |
| 106 | t.Fatalf("update to v0.19.0 with a nested row = %d %v, want 409", st, out) |
| 107 | } |
| 108 | // A version at the floor or above is not the guard's business; the |
| 109 | // request goes on to the job (and fails there, which is not what this |
| 110 | // test is about). The fixture has no SystemFeed, so the job asks the |
| 111 | // vendor feed: the version must be one that can never be published, or |
| 112 | // the job succeeds once it is (v0.26.0 was, on 2026-10-13), moves the |
| 113 | // pointer, and the rollback below lands on the wrong version. |
| 114 | if st, _ := postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary", "version": "v99.0.0"}, owner); st == http.StatusConflict { |
| 115 | t.Error("update to v99.0.0 was refused by the layout guard") |
| 116 | } |
| 117 | |
| 118 | // Every row flat: the rollback goes through. |
| 119 | seedLayoutBox(t, fx.rel, "ie", time.Now(), &status.LayoutReport{Mode: "flat", Rows: map[string]status.RowLayout{"a.example.com": {Layout: "flat"}}}) |
| 120 | time.Sleep(1100 * time.Millisecond) |
| 121 | if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/rollback", map[string]string{"channel": "canary"}, owner); st != http.StatusOK || str(t, out, "version") != "v0.19.0" { |
| 122 | t.Fatalf("rollback with every row flat = %d %v", st, out) |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | // `layout migrate` is refused while any box in scope runs below the floor, |
| 127 | // is stale, reports no release, or (for a named box) has never reported; |
| 128 | // it goes through once every box in scope runs the floor release. A |
| 129 | // revert is never judged this way. |
| 130 | func TestSystemLayoutMigrateRefusedBelowTheFloor(t *testing.T) { |
| 131 | fx := newConsoleFixture(t) |
| 132 | owner := cliToken(t, fx, "paul@keymail.dev", "code-layout-floor") |
| 133 | seedBox(t, fx, "ie", "linux-arm64", "stable", "v0.24.2") |
| 134 | seedBox(t, fx, "uk", "linux-arm64", "stable", "v0.25.0") |
| 135 | st, out := postJSON(t, fx.ts.URL+"/api/cli/system/layout", map[string]string{"mode": "nested"}, owner) |
| 136 | if st != http.StatusConflict || !strings.Contains(str(t, out, "error"), "ie runs v0.24.2") || strings.Contains(str(t, out, "error"), "uk") { |
| 137 | t.Fatalf("fleet migrate with a box below the floor = %d %v, want 409 naming ie only", st, out) |
| 138 | } |
| 139 | if _, found, _ := release.LoadLayoutIntents(context.Background(), fx.rel); found { |
| 140 | t.Fatal("a refused migrate recorded an intent") |
| 141 | } |
| 142 | if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/layout", map[string]string{"mode": "nested", "box": "ie"}, owner); st != http.StatusConflict { |
| 143 | t.Errorf("box migrate on the low box = %d %v, want 409", st, out) |
| 144 | } |
| 145 | if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/layout", map[string]string{"mode": "nested", "box": "au"}, owner); st != http.StatusConflict || !strings.Contains(str(t, out, "error"), "box au has not reported") { |
| 146 | t.Errorf("box migrate on an unknown box = %d %v, want 409 saying it has not reported", st, out) |
| 147 | } |
| 148 | if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/layout", map[string]string{"mode": "nested", "box": "uk"}, owner); st != http.StatusOK { |
| 149 | t.Errorf("box migrate on the floor-release box = %d %v, want 200", st, out) |
| 150 | } |
| 151 | // A revert is not judged by the floor: the low box can always go flat. |
| 152 | if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/layout", map[string]string{"mode": "flat", "box": "ie"}, owner); st != http.StatusOK { |
| 153 | t.Errorf("revert on the low box = %d %v, want 200", st, out) |
| 154 | } |
| 155 | seedBox(t, fx, "ie", "linux-arm64", "stable", "v0.25.0") |
| 156 | if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/layout", map[string]string{"mode": "nested"}, owner); st != http.StatusOK { |
| 157 | t.Errorf("fleet migrate with every box on the floor release = %d %v, want 200", st, out) |
| 158 | } |
| 159 | // An unplaceable release (a dev box on a sha) blocks while it reports |
| 160 | // no layout, and passes once its layout report is there: the report |
| 161 | // is proof the build carries the pass. |
| 162 | seedBox(t, fx, "ie", "linux-arm64", "stable", "abc1234") |
| 163 | if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/layout", map[string]string{"mode": "nested"}, owner); st != http.StatusConflict || !strings.Contains(str(t, out, "error"), "ie runs abc1234, which cannot be placed against v0.25.0, and reports no layout") { |
| 164 | t.Errorf("fleet migrate with an unplaceable release and no layout = %d %v, want 409", st, out) |
| 165 | } |
| 166 | b := status.CollectBox(nil, status.Probes{Release: "abc1234", Platform: "linux-arm64", SystemChannel: "stable", |
| 167 | Layout: &status.LayoutReport{Mode: "none", Rows: map[string]status.RowLayout{"a.example.com": {Layout: "flat"}}}}, time.Now()) |
| 168 | b.Label = "ie" |
| 169 | if err := status.WriteBox(context.Background(), fx.rel, b); err != nil { |
| 170 | t.Fatal(err) |
| 171 | } |
| 172 | if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/layout", map[string]string{"mode": "nested"}, owner); st != http.StatusOK { |
| 173 | t.Errorf("fleet migrate with a sha box that reports its layout = %d %v, want 200", st, out) |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | // Today's fleet: every box on v0.21.0 to v0.24.2, none reporting a layout |
| 178 | // field (those builds have no pass). Rollback and a named update to a |
| 179 | // pre-floor release both go through, because nothing on such a box can be |
| 180 | // nested. A box on a sha is a build that might carry the pass, so its |
| 181 | // silence still blocks. |
| 182 | func TestTodaysFleetWithNoLayoutFieldKeepsRollbackAndUpdate(t *testing.T) { |
| 183 | fx := newConsoleFixture(t) |
| 184 | owner := cliToken(t, fx, "paul@keymail.dev", "code-layout-todays-fleet") |
| 185 | ctx := context.Background() |
| 186 | for i, v := range []string{"v0.21.0", "v0.22.2", "v0.24.2", "v0.24.2"} { |
| 187 | seedBox(t, fx, fmt.Sprintf("box-%d", i), "linux-arm64", "stable", v) |
| 188 | } |
| 189 | sg := release.RootSigner(fx.priv) |
| 190 | rel := func(v string) release.SystemRelease { |
| 191 | return release.SystemRelease{Version: v, Artifacts: map[string]release.SystemArtifact{"linux-arm64": {Key: release.SystemArtifactKey(v, "linux-arm64"), SHA256: strings.Repeat("a", 64)}}} |
| 192 | } |
| 193 | if _, err := release.PromoteSystem(ctx, fx.rel, sg, "router", "stable", rel("v0.23.0"), release.SystemPromoteOptions{}); err != nil { |
| 194 | t.Fatal(err) |
| 195 | } |
| 196 | time.Sleep(1100 * time.Millisecond) |
| 197 | if _, err := release.PromoteSystem(ctx, fx.rel, sg, "router", "stable", rel("v0.24.2"), release.SystemPromoteOptions{}); err != nil { |
| 198 | t.Fatal(err) |
| 199 | } |
| 200 | if got := fx.srv.layoutDowngradeBlocked(ctx, "v0.23.0"); got != "" { |
| 201 | t.Fatalf("today's fleet blocked: %q", got) |
| 202 | } |
| 203 | time.Sleep(1100 * time.Millisecond) |
| 204 | if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/rollback", map[string]string{"channel": "stable"}, owner); st != http.StatusOK || str(t, out, "version") != "v0.23.0" { |
| 205 | t.Fatalf("rollback on today's fleet = %d %v, want 200 onto v0.23.0", st, out) |
| 206 | } |
| 207 | // A named pre-floor update passes the guard (and goes on to the job, |
| 208 | // which fails on this fixture's absent feed; not what this is about). |
| 209 | if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "stable", "version": "v0.24.2"}, owner); st == http.StatusConflict { |
| 210 | t.Fatalf("named update on today's fleet refused by the layout guard: %v", out) |
| 211 | } |
| 212 | // A box whose release is a sha is a build that might carry the pass, |
| 213 | // so its silence blocks. |
| 214 | seedBox(t, fx, "box-9", "linux-arm64", "stable", "abc1234") |
| 215 | if got := fx.srv.layoutDowngradeBlocked(ctx, "v0.23.0"); !strings.Contains(got, "box-9 reports no layout field") { |
| 216 | t.Fatalf("a sha box with no layout field: %q", got) |
| 217 | } |
| 218 | } |
| 219 | |
| 220 | // A box that is nested by default (no recorded intent) is judged exactly |
| 221 | // like one under an explicit intent: its nested rows block a pre-floor |
| 222 | // pointer move, and a box the default has not reached (flat rows, a |
| 223 | // blocked reason) does not. The guards never read the intent, so the |
| 224 | // default needs no rule of its own; this pins that. |
| 225 | func TestLayoutGuardsTreatNestedByDefaultLikeAnIntent(t *testing.T) { |
| 226 | fx := newConsoleFixture(t) |
| 227 | ctx := context.Background() |
| 228 | now := time.Now() |
| 229 | seedLayoutBox(t, fx.rel, "ie", now, &status.LayoutReport{Mode: "nested", Default: true, |
| 230 | Rows: map[string]status.RowLayout{"a.example.com": {Layout: "nested"}}}) |
| 231 | if got := fx.srv.layoutDowngradeBlocked(ctx, "v0.19.0"); !strings.Contains(got, "ie: a.example.com is nested") { |
| 232 | t.Fatalf("nested by default did not block: %q", got) |
| 233 | } |
| 234 | seedLayoutBox(t, fx.rel, "ie", now, &status.LayoutReport{Mode: "none", DefaultBlocked: "isolation not ready: probe fixture absent", |
| 235 | Rows: map[string]status.RowLayout{"a.example.com": {Layout: "flat"}}}) |
| 236 | if got := fx.srv.layoutDowngradeBlocked(ctx, "v0.19.0"); got != "" { |
| 237 | t.Fatalf("a box the default has not reached blocked: %q", got) |
| 238 | } |
| 239 | } |
| 240 | |