carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "testing"
8 "time"
9
10 "github.com/carlosframework/platform/internal/activator"
11 "github.com/carlosframework/platform/internal/metrics"
12 "github.com/carlosframework/platform/internal/registry"
13 "github.com/carlosframework/platform/internal/release"
14)
15
16// TestAPIStatusInstancesGate: operator only, with the same posture every
17// operator door here has — anyone else sees the byte-identical 404 an unknown
18// route gets. It matters more than usual on this door: the answer names other
19// people's accounts and what they are running, so the refusal must give away
20// neither the rows nor whether the caller holds the operator bit. It does not
21// hide the PATH — a wrong-method request gets the mux's 405 before any of
22// this — and the handler's comment says so; what is asserted here is what the
23// 404 actually buys.
24func TestAPIStatusInstancesGate(t *testing.T) {
25 fx := newConsoleFixture(t)
26 token := cliToken(t, fx, "paul@keymail.dev", "code-apistatusinstances")
27 ghostStatus, ghostBody := getBearer(t, fx.ts.URL+"/this-api-route-does-not-exist", token)
28
29 if status, _ := getBearer(t, fx.ts.URL+"/api/cli/status/instances", ""); status != http.StatusUnauthorized {
30 t.Fatalf("unauthenticated = %d; want the CLI door's 401", status)
31 }
32 if status, body := getBearer(t, fx.ts.URL+"/api/cli/status/instances", token); status != ghostStatus || string(body) != string(ghostBody) {
33 t.Fatalf("non-operator = %d %q; want the ghost 404 (%d %q)", status, body, ghostStatus, ghostBody)
34 }
35
36 if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
37 t.Fatal(err)
38 }
39 status, body := getBearer(t, fx.ts.URL+"/api/cli/status/instances", token)
40 if status != http.StatusOK {
41 t.Fatalf("operator = %d %q", status, body)
42 }
43 var out struct {
44 Instances []apiStatusInstance `json:"instances"`
45 }
46 if err := json.Unmarshal(body, &out); err != nil {
47 t.Fatalf("unreadable: %v: %s", err, body)
48 }
49 // Never null: a client that decodes null gets a nil slice and prints
50 // nothing, which reads the same as "no instances" for the wrong reason.
51 if out.Instances == nil {
52 t.Error("instances decoded as null; want an empty array")
53 }
54}
55
56func TestAPIStatusInstancesKeepsSharedRegistryOpen(t *testing.T) {
57 fx := newConsoleFixture(t)
58 token := cliToken(t, fx, "paul@keymail.dev", "code-status-registry-lifetime")
59 fx.srv.cfg.AppsDomain = "oncarlos.test"
60 if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
61 t.Fatal(err)
62 }
63 tenancyFixture(t, fx)
64 statusInstanceRows(t, fx, token, "")
65
66 reg, err := openRegistry(fx.regPath)
67 if err != nil {
68 t.Fatal(err)
69 }
70 _, err = reg.ListPaths()
71 if err != nil {
72 t.Fatalf("registry read after status request: %v", err)
73 }
74 if rows := statusInstanceRows(t, fx, token, ""); len(rows) != 3 {
75 t.Fatalf("second status request = %d rows, want 3", len(rows))
76 }
77 status, body := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new", map[string]string{"name": "afterstatus"}, token)
78 if status != http.StatusOK {
79 t.Fatalf("app creation after status request = %d %v", status, body)
80 }
81}
82
83// tenancyFixture puts real readings behind the door: one box, three sampled
84// units, two of them owned by routes belonging to DIFFERENT accounts and one
85// owned by no route at all.
86//
87// It exists because without it the tests below assert nothing. No other test
88// in this package writes anything under metrics/boxes/, so LoadMetrics finds
89// no boxes, the handler's loop over box units never runs, every answer is an
90// empty array, and every loop a test writes over that answer runs zero times.
91// The --account filter test in particular stayed green with the server-side
92// filter deleted. A fixture is the difference between a test that reads like
93// a pin and one that is one.
94//
95// The unit names are built the way the handler matches them — through
96// activator.UnitName, which is what routeForUnit compares against — so what
97// is under test is the real matching rule rather than a string this file
98// agreed with itself about.
99func tenancyFixture(t *testing.T, fx *consoleFixture) {
100 t.Helper()
101 // fx.sqid is the built-in ops account; "cdef" is somebody else. Two
102 // tenants on one box is the whole point: a filter can only be shown to
103 // narrow if there is something for it to leave behind.
104 fx.addRoute(t, registry.Route{Host: "ours.example.com", Kind: "instance", Status: "ready",
105 App: "kass", Account: fx.sqid, Channel: "stable"})
106 fx.addRoute(t, registry.Route{Host: "theirs.example.com", Kind: "instance", Status: "ready",
107 App: "widgets", Account: "cdef", Channel: "stable"})
108 at := time.Now().UTC().Add(-time.Minute)
109 cpu, mem := 0.25, int64(1<<20)
110 if err := metrics.WriteRaw(context.Background(), fx.rel, metrics.RawChunk{
111 V: 1, Box: "box-tenancy", Hour: metrics.HourOf(at),
112 Samples: []metrics.Sample{{At: at, Units: map[string]metrics.UnitSample{
113 // A full reading, a partial one (CPU but no memory — the reason
114 // those fields are pointers), and a unit matching no route at
115 // all: the console's own, which is platform overhead.
116 activator.UnitName("ours.example.com"): {CPUFrac: &cpu, MemBytes: &mem},
117 activator.UnitName("theirs.example.com"): {CPUFrac: &cpu},
118 "console.service": {},
119 }}},
120 }); err != nil {
121 t.Fatal(err)
122 }
123}
124
125// statusInstanceRows asks the door and decodes it, failing the test on any
126// answer that is not a 200 carrying an array.
127func statusInstanceRows(t *testing.T, fx *consoleFixture, token, query string) []apiStatusInstance {
128 t.Helper()
129 status, body := getBearer(t, fx.ts.URL+"/api/cli/status/instances"+query, token)
130 if status != http.StatusOK {
131 t.Fatalf("GET /api/cli/status/instances%s = %d %q", query, status, body)
132 }
133 var out struct {
134 Instances []apiStatusInstance `json:"instances"`
135 }
136 if err := json.Unmarshal(body, &out); err != nil {
137 t.Fatalf("unreadable: %v: %s", err, body)
138 }
139 return out.Instances
140}
141
142// TestAPIStatusInstancesAttributesUnitsToAccounts pins the join itself: a
143// sampled unit is carried to the account that owns the route it belongs to,
144// and a unit belonging to no route stays in the answer marked as platform
145// overhead rather than being dropped. Dropping it would be the quieter bug of
146// the two — an operator asking what is on a box would get a list that omits
147// the box's own load and never says it did.
148func TestAPIStatusInstancesAttributesUnitsToAccounts(t *testing.T) {
149 fx := newConsoleFixture(t)
150 token := cliToken(t, fx, "paul@keymail.dev", "code-apistatusjoin")
151 if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
152 t.Fatal(err)
153 }
154 tenancyFixture(t, fx)
155
156 rows := statusInstanceRows(t, fx, token, "")
157 byUnit := map[string]apiStatusInstance{}
158 for _, r := range rows {
159 byUnit[r.Unit] = r
160 }
161 if len(rows) != 3 || len(byUnit) != 3 {
162 t.Fatalf("unfiltered = %d rows %+v, want one per sampled unit", len(rows), rows)
163 }
164
165 ours := byUnit[activator.UnitName("ours.example.com")]
166 if ours.Account != fx.sqid || ours.App != "kass" || ours.Host != "ours.example.com" || ours.Platform {
167 t.Errorf("ours = %+v, want the ops account's kass route rather than platform", ours)
168 }
169 if ours.Box != "box-tenancy" || ours.CPU == nil || *ours.CPU != 0.25 || ours.Mem == nil || *ours.Mem != 1<<20 {
170 t.Errorf("ours = %+v, want box-tenancy carrying its own readings", ours)
171 }
172
173 theirs := byUnit[activator.UnitName("theirs.example.com")]
174 if theirs.Account != "cdef" || theirs.App != "widgets" || theirs.Host != "theirs.example.com" || theirs.Platform {
175 t.Errorf("theirs = %+v, want cdef's widgets route", theirs)
176 }
177 // A sampled unit with no memory reading must arrive as nil rather than 0:
178 // a zero is a number an operator would act on.
179 if theirs.Mem != nil {
180 t.Errorf("theirs mem = %d, want nil — the sample carried no reading", *theirs.Mem)
181 }
182
183 plat := byUnit["console.service"]
184 if !plat.Platform || plat.Account != release.OpsAccount || plat.App != "" || plat.Host != "" {
185 t.Errorf("console.service = %+v, want platform overhead charged to ops", plat)
186 }
187}
188
189// TestAPIStatusInstancesAccountFilterNarrows: --account is the "is THIS member
190// hogging the box?" form of the question, which is the one that prompted the
191// door (discussion #11). The filter is applied on the SERVER, and it has to
192// be: a filter that silently ignores itself is how an operator reads another
193// account's rows as the one they asked about, and a client-side one would put
194// every account's rows on the wire to get there.
195//
196// Asserted against a populated box, so "narrows" means fewer rows than the
197// unfiltered answer rather than an empty list being trivially consistent with
198// every claim anyone could make about it.
199func TestAPIStatusInstancesAccountFilterNarrows(t *testing.T) {
200 fx := newConsoleFixture(t)
201 token := cliToken(t, fx, "paul@keymail.dev", "code-apistatusfilter")
202 if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
203 t.Fatal(err)
204 }
205 tenancyFixture(t, fx)
206
207 if all := statusInstanceRows(t, fx, token, ""); len(all) != 3 {
208 t.Fatalf("unfiltered = %d rows %+v, want three — the fixture is what makes the rest of this test mean anything", len(all), all)
209 }
210
211 // A known account: exactly its own row, and nobody else's.
212 only := statusInstanceRows(t, fx, token, "?account=cdef")
213 if len(only) != 1 {
214 t.Fatalf("?account=cdef = %d rows %+v, want just the one", len(only), only)
215 }
216 if got, want := only[0].Unit, activator.UnitName("theirs.example.com"); got != want {
217 t.Errorf("?account=cdef returned %q, want %q", got, want)
218 }
219
220 // An unknown account narrows to nothing rather than falling back to
221 // everything.
222 if rows := statusInstanceRows(t, fx, token, "?account=nosuchacct"); len(rows) != 0 {
223 t.Errorf("?account=nosuchacct = %+v, want no rows at all", rows)
224 }
225}
226