| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "net/http" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/release" |
| 12 | ) |
| 13 | |
| 14 | // The layout door is the update door's gate, byte for byte: a member of |
| 15 | // the ops account gets the bare 404 an unknown path gives, and a system |
| 16 | // operator who is not an owner gets in, as they do at update and rollback. |
| 17 | func TestSystemLayoutDoorIsTheSystemGate(t *testing.T) { |
| 18 | fx := newConsoleFixture(t) |
| 19 | if err := fx.st.AddMember(OpsAccountID, "member@keymail.dev", RoleMember, time.Now()); err != nil { |
| 20 | t.Fatal(err) |
| 21 | } |
| 22 | member := cliToken(t, fx, "member@keymail.dev", "code-layout-member") |
| 23 | _, want := getRaw(t, fx.ts.URL+"/api/cli/system/no-such-door", member) |
| 24 | if st, body := getRaw(t, fx.ts.URL+"/api/cli/system/layout", member); st != http.StatusNotFound || body != want { |
| 25 | t.Errorf("GET as a member = %d %q, want 404 %q", st, body, want) |
| 26 | } |
| 27 | if st, body := postRaw(t, fx.ts.URL+"/api/cli/system/layout", map[string]string{"mode": "nested"}, member); st != http.StatusNotFound || body != want { |
| 28 | t.Errorf("POST as a member = %d %q, want 404 %q", st, body, want) |
| 29 | } |
| 30 | // A member that nothing was recorded for: the object stays absent. |
| 31 | if _, found, _ := release.LoadLayoutIntents(context.Background(), fx.rel); found { |
| 32 | t.Error("a refused POST recorded an intent") |
| 33 | } |
| 34 | |
| 35 | fx.srv.cfg.SystemOperators = []string{"Roller@keymail.dev"} |
| 36 | if err := fx.st.AddMember(OpsAccountID, "roller@keymail.dev", RoleMember, time.Now()); err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | roller := cliToken(t, fx, "roller@keymail.dev", "code-layout-roller") |
| 40 | if st, out := getJSON(t, fx.ts.URL+"/api/cli/system/layout", roller); st != http.StatusOK || out["mode"] != "none" { |
| 41 | t.Errorf("GET as a system operator = %d %v, want 200 mode none", st, out) |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | func TestSystemLayoutDoorRecordsTheIntentAndBumpsTheSerial(t *testing.T) { |
| 46 | fx := newConsoleFixture(t) |
| 47 | owner := cliToken(t, fx, "paul@keymail.dev", "code-layout-owner") |
| 48 | // Every box in scope runs the floor release, so the migrate's floor |
| 49 | // check (TestSystemLayoutMigrateRefusedBelowTheFloor) is not what this |
| 50 | // test is about. |
| 51 | seedBox(t, fx, "kass-1", "linux-arm64", "stable", "v0.25.0") |
| 52 | |
| 53 | for _, body := range []map[string]any{{"mode": "sideways"}, {"mode": ""}, {"mode": "nested", "box": "Not A Label!"}, {"mode": "nested", "bogus": 1}} { |
| 54 | if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/layout", body, owner); st != http.StatusBadRequest { |
| 55 | t.Errorf("POST %v = %d %v, want 400", body, st, out) |
| 56 | } |
| 57 | } |
| 58 | if serial(t, fx) != 0 { |
| 59 | t.Fatal("a refused request bumped the serial") |
| 60 | } |
| 61 | |
| 62 | st, out := postJSON(t, fx.ts.URL+"/api/cli/system/layout", map[string]any{"mode": "nested", "box": "kass-1", "force": true}, owner) |
| 63 | if st != http.StatusOK || out["mode"] != "nested" || out["box"] != "kass-1" || out["force"] != true || str(t, out, "id") == "" { |
| 64 | t.Fatalf("record = %d %v", st, out) |
| 65 | } |
| 66 | in, found, err := release.LoadLayoutIntent(context.Background(), fx.rel, "kass-1") |
| 67 | if err != nil || !found || in.Mode != "nested" || in.Scope.Box != "kass-1" || !in.Force || in.ID != str(t, out, "id") { |
| 68 | t.Fatalf("object = %+v %v %v", in, found, err) |
| 69 | } |
| 70 | if _, found, _ := release.LoadLayoutIntent(context.Background(), fx.rel, "kass-2"); found { |
| 71 | t.Error("a box-scoped intent applies to another box") |
| 72 | } |
| 73 | if got := serial(t, fx); got != 1 { |
| 74 | t.Fatalf("serial = %d, want 1: boxes converge on the serial move", got) |
| 75 | } |
| 76 | |
| 77 | st, out = getJSON(t, fx.ts.URL+"/api/cli/system/layout", owner) |
| 78 | if st != http.StatusOK || out["mode"] != "nested" || out["box"] != "kass-1" || out["force"] != true || out["requests"] != float64(1) { |
| 79 | t.Fatalf("get = %d %v", st, out) |
| 80 | } |
| 81 | |
| 82 | // A fleet-wide revert after it is the newest request everywhere. |
| 83 | st, out = postJSON(t, fx.ts.URL+"/api/cli/system/layout", map[string]any{"mode": "flat"}, owner) |
| 84 | if st != http.StatusOK || out["box"] != "" || out["force"] != false { |
| 85 | t.Fatalf("revert = %d %v", st, out) |
| 86 | } |
| 87 | in, found, _ = release.LoadLayoutIntent(context.Background(), fx.rel, "kass-1") |
| 88 | if !found || in.Mode != "flat" { |
| 89 | t.Fatalf("kass-1 after a fleet-wide revert = %+v", in) |
| 90 | } |
| 91 | if got := serial(t, fx); got != 2 { |
| 92 | t.Fatalf("serial = %d, want 2", got) |
| 93 | } |
| 94 | // The audit row names the act. |
| 95 | events, err := fx.st.Events(OpsAccountID, "platform", 10) |
| 96 | if err != nil { |
| 97 | t.Fatal(err) |
| 98 | } |
| 99 | var saw bool |
| 100 | for _, e := range events { |
| 101 | if e.Action == "system-layout" && strings.Contains(e.Detail, "nested for box kass-1") && strings.Contains(e.Detail, "force") { |
| 102 | saw = true |
| 103 | } |
| 104 | } |
| 105 | if !saw { |
| 106 | t.Errorf("audit rows = %+v, want a system-layout row naming nested, box kass-1 and force", events) |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | func TestSystemLayoutDoorWithoutABucketRefuses(t *testing.T) { |
| 111 | fx := newConsoleFixture(t) |
| 112 | owner := cliToken(t, fx, "paul@keymail.dev", "code-layout-nobucket") |
| 113 | fx.srv.cfg.Releases = nil |
| 114 | if st, out := getJSON(t, fx.ts.URL+"/api/cli/system/layout", owner); st != http.StatusServiceUnavailable { |
| 115 | t.Errorf("GET without a bucket = %d %v, want 503", st, out) |
| 116 | } |
| 117 | if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/layout", map[string]string{"mode": "nested"}, owner); st != http.StatusServiceUnavailable { |
| 118 | t.Errorf("POST without a bucket = %d %v, want 503", st, out) |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | // #132's interlock: a revert is refused for a box with an active or |
| 123 | // revoking maintenance worker head, in scope. A detached head, or a head |
| 124 | // on a box outside a box-scoped revert, does not block. Migrating is never |
| 125 | // blocked by a worker. A reader that fails blocks: unknown is not "none". |
| 126 | func TestSystemLayoutRevertRefusedWhileAWorkerHeadIsAttached(t *testing.T) { |
| 127 | fx := newConsoleFixture(t) |
| 128 | owner := cliToken(t, fx, "paul@keymail.dev", "code-layout-worker") |
| 129 | heads := []release.WorkerHead{ |
| 130 | {Box: "kass-1", Status: "active"}, |
| 131 | {Box: "kass-2", Status: "detached"}, |
| 132 | {Box: "kass-3", Status: "revoking"}, |
| 133 | } |
| 134 | var readErr error |
| 135 | fx.srv.cfg.WorkerHeads = func(context.Context) ([]release.WorkerHead, error) { return heads, readErr } |
| 136 | for _, b := range []string{"kass-1", "kass-2", "kass-3"} { |
| 137 | seedBox(t, fx, b, "linux-arm64", "stable", "v0.25.0") |
| 138 | } |
| 139 | |
| 140 | st, out := postJSON(t, fx.ts.URL+"/api/cli/system/layout", map[string]string{"mode": "flat"}, owner) |
| 141 | if st != http.StatusConflict { |
| 142 | t.Fatalf("fleet revert with workers attached = %d %v, want 409", st, out) |
| 143 | } |
| 144 | if msg := str(t, out, "error"); !strings.Contains(msg, "kass-1 has a maintenance worker (active)") || !strings.Contains(msg, "kass-3 has a maintenance worker (revoking)") || strings.Contains(msg, "kass-2") { |
| 145 | t.Errorf("reason = %q, want kass-1 and kass-3 named and kass-2 not", msg) |
| 146 | } |
| 147 | if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/layout", map[string]string{"mode": "flat", "box": "kass-1"}, owner); st != http.StatusConflict { |
| 148 | t.Errorf("box revert on the worker's box = %d %v, want 409", st, out) |
| 149 | } |
| 150 | if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/layout", map[string]string{"mode": "flat", "box": "kass-2"}, owner); st != http.StatusOK { |
| 151 | t.Errorf("box revert beside a detached head = %d %v, want 200", st, out) |
| 152 | } |
| 153 | if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/layout", map[string]string{"mode": "nested"}, owner); st != http.StatusOK { |
| 154 | t.Errorf("migrate with workers attached = %d %v, want 200", st, out) |
| 155 | } |
| 156 | readErr = errors.New("store down") |
| 157 | if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/layout", map[string]string{"mode": "flat", "box": "kass-2"}, owner); st != http.StatusConflict { |
| 158 | t.Errorf("revert with an unreadable head store = %d %v, want 409", st, out) |
| 159 | } |
| 160 | } |
| 161 | |