| 1 | package console |
| 2 | |
| 3 | // claim_invariant_moves_test.go covers what the Phase 1 moves switch |
| 4 | // (CARLOS_MOVES_PHASE1, Config.MovesEnabled) adds to the console, on a console |
| 5 | // built with the switch on: |
| 6 | // |
| 7 | // - one route, the operator's resolve door, registered only with the switch |
| 8 | // (registerMovesPhase1Routes). It is intentionally unavailable: with the |
| 9 | // switch on its body is not built, so a valid operator request for a real |
| 10 | // instance is answered the exact sentence below and writes nothing. That is |
| 11 | // a refused fixture, not a probe: only an exact status and message from |
| 12 | // claimUnsupported may stand for a route that does not work yet. |
| 13 | // - one worker, PublishMovesConsole (control/moves-console.json), registered |
| 14 | // in every console and a no-op with the switch off. Its probe is in |
| 15 | // claim_invariant_probes_worker_test.go, with the switch turned on. |
| 16 | |
| 17 | import ( |
| 18 | "net/http" |
| 19 | "os" |
| 20 | "slices" |
| 21 | "strings" |
| 22 | "testing" |
| 23 | |
| 24 | "github.com/carlosframework/platform/internal/moves" |
| 25 | ) |
| 26 | |
| 27 | // claimMoveResolveUnavailableMsg is what the resolve door answers an operator |
| 28 | // until Plan 4 Task 10 and Plan 6 Task 2 give it its body. |
| 29 | const claimMoveResolveUnavailableMsg = "nothing to resolve yet" |
| 30 | |
| 31 | const claimMoveResolveKey = "route POST /api/cli/apps/{account}/{app}/instances/{host}/move/resolve" |
| 32 | |
| 33 | // claimMovesOn builds the console the way cmd/console builds it with the |
| 34 | // switch on (applyMovesSwitch runs before NewServer). |
| 35 | func claimMovesOn(c *Config) { c.MovesEnabled = true } |
| 36 | |
| 37 | // claimMovesFixtures are the entries that exist only on a moves-on console. |
| 38 | var claimMovesFixtures = map[string]claimFixture{ |
| 39 | claimMoveResolveKey: {refused: &claimRefusalFixture{ |
| 40 | apps: claimRefs("bab/titogo"), |
| 41 | setup: func(t *testing.T, cx *claimCase) { cx.movableInstance(t) }, |
| 42 | do: claimRefusedAPI(http.MethodPost, "/api/cli/apps/bab/titogo/instances/acme.go.tito.io/move/resolve", `{}`), |
| 43 | status: http.StatusConflict, |
| 44 | msg: claimMoveResolveUnavailableMsg, |
| 45 | }}, |
| 46 | } |
| 47 | |
| 48 | // claimMovesMerged is the fixture table a moves-on console must match exactly. |
| 49 | func claimMovesMerged() map[string]claimFixture { |
| 50 | merged := map[string]claimFixture{} |
| 51 | for k, v := range claimFixtures { |
| 52 | merged[k] = v |
| 53 | } |
| 54 | for k, v := range claimMovesFixtures { |
| 55 | merged[k] = v |
| 56 | } |
| 57 | return merged |
| 58 | } |
| 59 | |
| 60 | // TestClaimMovesConsoleRouteCensus: a moves-on console serves exactly what an |
| 61 | // enabled console serves plus the entries in testdata/claim-routes-moves.txt, |
| 62 | // and every one has a fixture. |
| 63 | func TestClaimMovesConsoleRouteCensus(t *testing.T) { |
| 64 | base := claimInventoryKeys(newClaimCase(t).srv.claimEntries.snapshot()) |
| 65 | if slices.Contains(base, claimMoveResolveKey) { |
| 66 | t.Fatalf("%s is registered with the moves switch off", claimMoveResolveKey) |
| 67 | } |
| 68 | cx := newClaimCaseWith(t, claimMovesOn) |
| 69 | if !cx.srv.cfg.MovesEnabled || !cx.srv.cfg.HostClaim { |
| 70 | t.Fatal("the moves console is not built with both switches on") |
| 71 | } |
| 72 | b, err := os.ReadFile("testdata/claim-routes-moves.txt") |
| 73 | if err != nil { |
| 74 | t.Fatal(err) |
| 75 | } |
| 76 | want := append(slices.Clone(base), strings.Split(strings.TrimSpace(string(b)), "\n")...) |
| 77 | slices.Sort(want) |
| 78 | entries := cx.srv.claimEntries.snapshot() |
| 79 | got := claimInventoryKeys(entries) |
| 80 | if !slices.Equal(got, want) { |
| 81 | for _, k := range got { |
| 82 | if !slices.Contains(want, k) { |
| 83 | t.Errorf("new with the moves switch: %s", k) |
| 84 | } |
| 85 | } |
| 86 | for _, k := range want { |
| 87 | if !slices.Contains(got, k) { |
| 88 | t.Errorf("missing with the moves switch: %s", k) |
| 89 | } |
| 90 | } |
| 91 | t.FailNow() |
| 92 | } |
| 93 | for _, p := range claimFixtureCensus(entries, claimMovesMerged()) { |
| 94 | t.Error(p) |
| 95 | } |
| 96 | for key, f := range claimMovesFixtures { |
| 97 | if f.door == nil && f.refused == nil { |
| 98 | t.Errorf("%s: a moves-only entry is a door or an exact refusal; a probe table is the base console's", key) |
| 99 | } |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | // TestClaimMovesEntriesAreCovered drives every moves-only entry on a moves-on |
| 104 | // console. |
| 105 | func TestClaimMovesEntriesAreCovered(t *testing.T) { |
| 106 | for key, f := range claimMovesFixtures { |
| 107 | t.Run(key, func(t *testing.T) { |
| 108 | if f.refused == nil { |
| 109 | t.Skip("a door is driven by TestClaimConsoleMutationInvariant") |
| 110 | } |
| 111 | for _, p := range runClaimRefusal(t, f.refused, claimMovesOn) { |
| 112 | t.Error(p) |
| 113 | } |
| 114 | }) |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | // With the switch off the worker publishes nothing: the entry is registered in |
| 119 | // every console, and only the switch makes it write. |
| 120 | func TestClaimPublishMovesConsoleIsInertWithTheSwitchOff(t *testing.T) { |
| 121 | cx := newClaimCase(t) |
| 122 | if cx.srv.cfg.MovesEnabled { |
| 123 | t.Fatal("the base console has the moves switch on") |
| 124 | } |
| 125 | m := cx.rec.mark() |
| 126 | claimWorkerReply("PublishMovesConsole")(t, cx) |
| 127 | cx.quiesce() |
| 128 | if cx.bucketHas(moves.ConsoleKey) { |
| 129 | t.Fatalf("%s published with the moves switch off", moves.ConsoleKey) |
| 130 | } |
| 131 | if events := cx.rec.since(m); len(events) != 0 { |
| 132 | t.Fatalf("the worker wrote with the moves switch off: %v", events) |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | // TestClaimMovesConsoleBindsTheMovePass (moves Plan 5 Task 7): a moves-on |
| 137 | // claim console binds its move pass as claim Plan 10's Recovery.Moves hook, |
| 138 | // with the lost target's stamp; it adds no route and no worker (the pass runs |
| 139 | // inside StartClaimRecoveryPass). A moves-off console binds neither, so the |
| 140 | // recovery pass skips move claims as main does. |
| 141 | func TestClaimMovesConsoleBindsTheMovePass(t *testing.T) { |
| 142 | off := newClaimCase(t).srv |
| 143 | if off.cfg.ClaimMoveRecovery != nil || off.cfg.HostClaimRecovery == nil || off.cfg.HostClaimRecovery.Moves != nil { |
| 144 | t.Fatal("a moves-off console bound the move pass") |
| 145 | } |
| 146 | on := newClaimCaseWith(t, claimMovesOn).srv |
| 147 | if on.cfg.ClaimMoveRecovery == nil || on.cfg.HostClaimRecovery == nil || on.cfg.HostClaimRecovery.Moves == nil { |
| 148 | t.Fatal("a moves-on console did not bind the move pass") |
| 149 | } |
| 150 | if on.cfg.HostClaimRecovery.MoveFenceAuthority == nil { |
| 151 | t.Fatal("the move pass has no fence authority for its conversions") |
| 152 | } |
| 153 | } |
| 154 | |