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
3// The sidecar API (apisidecars.go): the console's provisioning verbs for a
4// member that nothing can dial, and the app-held token that may speak to
5// them. The properties that matter, in the order they'd hurt if lost: every
6// refusal before the principal is proven is the instance family's
7// byte-identical 404 (a probe learns nothing — not whether an account
8// exists, not whether an app does, not whether a SIDECAR does), an app token
9// is contained to its own account+app and can enable nothing, every created
10// record carries a non-empty channel (a channel-less record is protected and
11// never minted by the reconcile pass — silently inert, with no error
12// anywhere), no name reaches a bucket key without passing the label check,
13// and the rate limiter is consulted only AFTER the gate so a 429 can never
14// confirm what a 404 hides.
15
16import (
17 "context"
18 "encoding/json"
19 "errors"
20 "fmt"
21 "net/http"
22 "path/filepath"
23 "strings"
24 "testing"
25 "time"
26
27 "github.com/carlosframework/platform/internal/provision"
28 "github.com/carlosframework/platform/internal/registry"
29 "github.com/carlosframework/platform/internal/release"
30 "github.com/carlosframework/platform/internal/status"
31)
32
33// sidecarsURL and sidecarEnableURL are the two prefixes this family answers
34// on: the collection, and the literal manifest sibling beside it.
35func sidecarsURL(fx *consoleFixture, acct, app string) string {
36 return fx.ts.URL + "/api/cli/apps/" + acct + "/" + app + "/sidecars"
37}
38
39func sidecarEnableURL(fx *consoleFixture, acct, app string) string {
40 return fx.ts.URL + "/api/cli/apps/" + acct + "/" + app + "/sidecars-manifest"
41}
42
43// enableSidecars writes the app's sidecar manifest directly — the operator
44// half of `carlos sidecars enable`, whose ABSENCE is what makes the feature
45// not exist for an app.
46func enableSidecars(t *testing.T, fx *consoleFixture, app string, m provision.SidecarManifest) {
47 t.Helper()
48 if err := provision.PutSidecarManifest(context.Background(), fx.rel, release.OpsAccount, app, m); err != nil {
49 t.Fatal(err)
50 }
51}
52
53func titoSidecarManifest() provision.SidecarManifest {
54 return provision.SidecarManifest{Schema: 1, App: "titogo", Pool: "tito",
55 Channel: "stable", Regions: []string{"ie"}, Args: []string{"sidecar", "run"}}
56}
57
58// botHost is the synthetic host the door derives for a sidecar of the
59// fixture's app. Spelled out rather than computed so a change to
60// SidecarHost's shape fails a test rather than silently agreeing with itself.
61const botHost = "bot.titogo.bab.sidecar.internal"
62
63// TestAPISidecarRefusalMatrix: every way of not being allowed here — a
64// stranger's token, an app token pointed at a neighbouring account or a
65// neighbouring app, an account that does not exist, an app that was never
66// claimed — answers with the SAME bytes http.NotFound writes, on every verb
67// including wake. Asserted against the stdlib's own output.
68func TestAPISidecarRefusalMatrix(t *testing.T) {
69 fx, member := instanceFixture(t, "code-side-refusal")
70 enableSidecars(t, fx, "titogo", titoSidecarManifest())
71 want := canonicalNotFound(t)
72
73 now := time.Now()
74 if err := fx.st.CreateAccount(42, "cdef", "widgets", "bob@keymail.dev", now); err != nil {
75 t.Fatal(err)
76 }
77 if _, err := fx.st.CreateAppIn(42, "titogo", "bob@keymail.dev", now); err != nil {
78 t.Fatal(err)
79 }
80 // A real SIBLING app in the token's own account, with sidecars enabled on
81 // it. The foreign-app refusal must rest on the token's app binding, not on
82 // the app being absent or the feature being off: if app scoping broke,
83 // every gate past this point would let the call through to a create.
84 if _, err := fx.st.CreateAppIn(OpsAccountID, "sibling", "alice@keymail.dev", now); err != nil {
85 t.Fatal(err)
86 }
87 enableSidecars(t, fx, "sibling", titoSidecarManifest())
88 appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", now)
89 if err != nil {
90 t.Fatal(err)
91 }
92 eve := cliToken(t, fx, "eve@keymail.dev", "code-side-eve")
93
94 body := `{"name":"bot","region":"ie"}`
95 cases := []struct {
96 what string
97 method string
98 url string
99 token string
100 body string
101 }{
102 {"non-member list", http.MethodGet, sidecarsURL(fx, "bab", "titogo"), eve, ""},
103 {"non-member create", http.MethodPost, sidecarsURL(fx, "bab", "titogo"), eve, body},
104 {"non-member get", http.MethodGet, sidecarsURL(fx, "bab", "titogo") + "/bot", eve, ""},
105 {"non-member delete", http.MethodDelete, sidecarsURL(fx, "bab", "titogo") + "/bot", eve, ""},
106 {"non-member wake", http.MethodPost, sidecarsURL(fx, "bab", "titogo") + "/bot/wake", eve, ""},
107 {"app token, foreign account", http.MethodGet, sidecarsURL(fx, "cdef", "titogo"), appTok, ""},
108 {"app token, foreign account create", http.MethodPost, sidecarsURL(fx, "cdef", "titogo"), appTok, body},
109 {"app token, foreign account wake", http.MethodPost, sidecarsURL(fx, "cdef", "titogo") + "/bot/wake", appTok, ""},
110 {"app token, unclaimed app", http.MethodGet, sidecarsURL(fx, "bab", "other"), appTok, ""},
111 {"app token, unclaimed app create", http.MethodPost, sidecarsURL(fx, "bab", "other"), appTok, body},
112 // The rigorous half: a REAL sibling app in the token's own account,
113 // with sidecars enabled on it, so nothing but the binding can be
114 // doing the refusing.
115 {"app token, sibling app in its own account", http.MethodGet, sidecarsURL(fx, "bab", "sibling"), appTok, ""},
116 {"app token, sibling app create", http.MethodPost, sidecarsURL(fx, "bab", "sibling"), appTok, body},
117 {"app token, sibling app get", http.MethodGet, sidecarsURL(fx, "bab", "sibling") + "/bot", appTok, ""},
118 {"app token, sibling app delete", http.MethodDelete, sidecarsURL(fx, "bab", "sibling") + "/bot", appTok, ""},
119 {"app token, sibling app wake", http.MethodPost, sidecarsURL(fx, "bab", "sibling") + "/bot/wake", appTok, ""},
120 // The account segment resolves by NAME for a user token; an app
121 // token holds no memberships to resolve one against, so its own
122 // account's name is not a way in either.
123 {"app token, own account by name", http.MethodGet, sidecarsURL(fx, "ops", "titogo"), appTok, ""},
124 {"unknown account", http.MethodGet, sidecarsURL(fx, "zzzz", "titogo"), member, ""},
125 {"unclaimed app", http.MethodGet, sidecarsURL(fx, "bab", "ghost-app"), member, ""},
126 {"unclaimed app create", http.MethodPost, sidecarsURL(fx, "bab", "ghost-app"), member, body},
127 {"unclaimed app wake", http.MethodPost, sidecarsURL(fx, "bab", "ghost-app") + "/bot/wake", member, ""},
128 }
129 for _, c := range cases {
130 status, h, out := rawReq(t, c.method, c.url, c.token, c.body)
131 assertNotFound(t, c.what, want, status, h, out)
132 }
133
134 // A sidecar's EXISTENCE is not a thing to probe for either: a member of
135 // the account asking for a name that was never created gets the same
136 // bytes a stranger gets for the whole app.
137 for _, c := range []struct{ what, method, url string }{
138 {"member, unknown sidecar get", http.MethodGet, sidecarsURL(fx, "bab", "titogo") + "/ghost"},
139 {"member, unknown sidecar wake", http.MethodPost, sidecarsURL(fx, "bab", "titogo") + "/ghost/wake"},
140 {"member, traversal name get", http.MethodGet, sidecarsURL(fx, "bab", "titogo") + "/..%2f..%2fsidecars.json"},
141 {"member, traversal name wake", http.MethodPost, sidecarsURL(fx, "bab", "titogo") + "/..%2f..%2fsidecars.json/wake"},
142 } {
143 status, h, out := rawReq(t, c.method, c.url, member, "")
144 assertNotFound(t, c.what, want, status, h, out)
145 }
146
147 // None of the refused calls wrote anything.
148 if _, _, err := fx.rel.Get(context.Background(),
149 provision.SidecarRecordKey("bab", "titogo", "bot")); err == nil {
150 t.Fatalf("a record exists after only refused creates")
151 }
152 if _, _, err := fx.rel.Get(context.Background(),
153 provision.SidecarRecordKey("cdef", "titogo", "bot")); err == nil {
154 t.Fatalf("a record exists in the neighbouring account after only refused creates")
155 }
156 // And nothing landed in the sibling app, whose sidecars ARE enabled: the
157 // app token reached neither the manifest nor the write.
158 if _, _, err := fx.rel.Get(context.Background(),
159 provision.SidecarRecordKey("bab", "sibling", "bot")); err == nil {
160 t.Fatalf("an app token wrote into a SIBLING app in its own account — the binding is not scoping by app")
161 }
162 // And no wake object was minted by a refused wake — the wake write is
163 // strictly behind the gate and behind the record's own existence.
164 if _, _, err := fx.rel.Get(context.Background(),
165 release.SidecarWakeKey("bab", "titogo", "bot")); err == nil {
166 t.Fatalf("a wake object exists after only refused wakes")
167 }
168 if _, _, err := fx.rel.Get(context.Background(),
169 release.SidecarWakeKey("bab", "titogo", "ghost")); err == nil {
170 t.Fatalf("a wake object exists for a sidecar that was never created")
171 }
172}
173
174// TestAPISidecarEnableRefusalMatrix: the enable door is apiAccountApp's, so
175// its refusals are that gate's — the byte-identical 404 for a stranger, and
176// a plain 401 for an app token, which CLITokenAddress does not answer for at
177// all. Enabling is a human act.
178func TestAPISidecarEnableRefusalMatrix(t *testing.T) {
179 fx, member := instanceFixture(t, "code-side-enable-ref")
180 want := canonicalNotFound(t)
181 eve := cliToken(t, fx, "eve@keymail.dev", "code-side-enable-eve")
182 appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now())
183 if err != nil {
184 t.Fatal(err)
185 }
186 for _, c := range []struct{ what, acct, app, token string }{
187 {"non-member", "bab", "titogo", eve},
188 {"unknown account", "zzzz", "titogo", member},
189 {"unclaimed app", "bab", "ghost-app", member},
190 } {
191 status, h, out := rawReq(t, http.MethodPut, sidecarEnableURL(fx, c.acct, c.app), c.token, `{}`)
192 assertNotFound(t, "sidecars enable: "+c.what, want, status, h, out)
193 }
194 // 403, not 401: the enable door carries the ZERO role Set (the escalation
195 // cut — see closedDoors), so the role gate refuses a service credential
196 // at the registration, before the handler's own apiAccountApp ever runs.
197 // The refusal is the same one this door always made; only the answer a
198 // credential gets for it moved.
199 if status, _, out := rawReq(t, http.MethodPut, sidecarEnableURL(fx, "bab", "titogo"), appTok, `{}`); status != http.StatusForbidden {
200 t.Fatalf("app token enabling sidecars = %d %s, want 403", status, out)
201 }
202 if _, _, err := fx.rel.Get(context.Background(),
203 provision.SidecarManifestKey("bab", "titogo")); err == nil {
204 t.Fatalf("a manifest exists after only refused enables")
205 }
206}
207
208// TestAPISidecarNoToken: no credential at all is the 401 every CLI door
209// gives, on all six routes.
210func TestAPISidecarNoToken(t *testing.T) {
211 fx, _ := instanceFixture(t, "code-side-notoken")
212 u := sidecarsURL(fx, "bab", "titogo")
213 for _, c := range []struct{ method, url, body string }{
214 {http.MethodPut, sidecarEnableURL(fx, "bab", "titogo"), `{}`},
215 {http.MethodGet, u, ""},
216 {http.MethodPost, u, `{"name":"bot"}`},
217 {http.MethodGet, u + "/bot", ""},
218 {http.MethodDelete, u + "/bot", ""},
219 {http.MethodPost, u + "/bot/wake", ""},
220 } {
221 if status, _, out := rawReq(t, c.method, c.url, "", c.body); status != http.StatusUnauthorized {
222 t.Errorf("%s %s with no token = %d %s, want 401", c.method, c.url, status, out)
223 }
224 }
225}
226
227// TestAPISidecarAppTokenContainment: the app token is contained in both
228// directions. It cannot speak to the verbs a person's token speaks to
229// (promote, minting, ENABLING), and it CAN do exactly the five record verbs
230// on its own app — which is the whole "apps provision sidecars easily"
231// requirement: Eleven's process, holding no AWS credentials and no box
232// access, creates a bot per enrolled member.
233func TestAPISidecarAppTokenContainment(t *testing.T) {
234 fx, _ := instanceFixture(t, "code-side-contain")
235 enableSidecars(t, fx, "titogo", titoSidecarManifest())
236 appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now())
237 if err != nil {
238 t.Fatal(err)
239 }
240 u := sidecarsURL(fx, "bab", "titogo")
241
242 // The human-only doors.
243 if status, _, out := rawReq(t, http.MethodPost,
244 fx.ts.URL+"/api/cli/apps/bab/titogo/promote", appTok,
245 `{"version":"1.0.0","channel":"canary/a"}`); status != http.StatusForbidden {
246 t.Fatalf("app token on promote = %d %s, want 403", status, out)
247 }
248 if status, _, out := rawReq(t, http.MethodPost,
249 fx.ts.URL+"/api/cli/apps/bab/titogo/instance-token", appTok, `{}`); status != http.StatusForbidden {
250 t.Fatalf("app token minting a token = %d %s, want 403", status, out)
251 }
252 if status, _, out := rawReq(t, http.MethodPut,
253 sidecarEnableURL(fx, "bab", "titogo"), appTok, `{"channel":"beta"}`); status != http.StatusForbidden {
254 t.Fatalf("app token enabling sidecars = %d %s, want 403", status, out)
255 }
256
257 // Its own app's verbs work, all five.
258 status, _, out := rawReq(t, http.MethodPost, u, appTok, `{"name":"bot","region":"ie"}`)
259 if status != http.StatusCreated {
260 t.Fatalf("app token create on its own app = %d %s, want 201", status, out)
261 }
262 var rec provision.SidecarRecord
263 if err := json.Unmarshal([]byte(out), &rec); err != nil {
264 t.Fatalf("create body %s: %v", out, err)
265 }
266 if rec.CreatedBy != "app:bab/titogo" {
267 t.Fatalf("created_by = %q, want the app principal app:bab/titogo", rec.CreatedBy)
268 }
269 if status, _, out := rawReq(t, http.MethodGet, u, appTok, ""); status != http.StatusOK {
270 t.Fatalf("app token list = %d %s, want 200", status, out)
271 }
272 if status, _, out := rawReq(t, http.MethodGet, u+"/bot", appTok, ""); status != http.StatusOK {
273 t.Fatalf("app token get = %d %s, want 200", status, out)
274 }
275 if status, _, out := rawReq(t, http.MethodPost, u+"/bot/wake", appTok, ""); status != http.StatusOK {
276 t.Fatalf("app token wake = %d %s, want 200", status, out)
277 }
278 if status, _, out := rawReq(t, http.MethodDelete, u+"/bot", appTok, ""); status != http.StatusOK {
279 t.Fatalf("app token delete = %d %s, want 200", status, out)
280 }
281}
282
283// TestAPISidecarCreateWithoutManifestNamesTheEnableVerb: membership is
284// already proven at this point, so the refusal may name its cause and the
285// act that fixes it — and it must name SIDECARS' own verb, never the
286// instance family's.
287func TestAPISidecarCreateWithoutManifestNamesTheEnableVerb(t *testing.T) {
288 fx, token := instanceFixture(t, "code-side-nomanifest")
289 status, _, out := rawReq(t, http.MethodPost, sidecarsURL(fx, "bab", "titogo"), token, `{"name":"bot"}`)
290 if status != http.StatusConflict {
291 t.Fatalf("create with no manifest = %d %s, want 409", status, out)
292 }
293 if !strings.Contains(out, "carlos sidecars enable") {
294 t.Fatalf("no-manifest refusal = %s, want it to name `carlos sidecars enable`", out)
295 }
296 if strings.Contains(out, "instances enable") {
297 t.Fatalf("no-manifest refusal names another family's verb: %s", out)
298 }
299 // An instances manifest is not a sidecars manifest: enabling one must
300 // not enable the other.
301 enableInstances(t, fx, "titogo", titoManifest())
302 if status, _, out := rawReq(t, http.MethodPost, sidecarsURL(fx, "bab", "titogo"), token,
303 `{"name":"bot"}`); status != http.StatusConflict {
304 t.Fatalf("create with only an INSTANCES manifest = %d %s, want 409", status, out)
305 }
306}
307
308// TestAPISidecarCreateRefusalOrder walks the family's order with a member
309// token: membership is already proven, so from here on refusals may name
310// their cause — and each one must be the RIGHT cause, in the right order.
311func TestAPISidecarCreateRefusalOrder(t *testing.T) {
312 fx, token := instanceFixture(t, "code-side-create")
313 u := sidecarsURL(fx, "bab", "titogo")
314 enableSidecars(t, fx, "titogo", titoSidecarManifest())
315
316 for _, c := range []struct{ what, body string }{
317 {"empty name", `{"name":"","region":"ie"}`},
318 {"bad name shape", `{"name":"-nope-","region":"ie"}`},
319 {"dotted name", `{"name":"bot.eleven","region":"ie"}`},
320 {"traversal name", `{"name":"../../etc/passwd","region":"ie"}`},
321 {"uppercase name", `{"name":"Bot","region":"ie"}`},
322 {"region outside the allow-list", `{"name":"bot","region":"us"}`},
323 {"malformed region", `{"name":"bot","region":"IE/../x"}`},
324 {"bad channel", `{"name":"bot","region":"ie","channel":"../etc"}`},
325 } {
326 status, _, out := rawReq(t, http.MethodPost, u, token, c.body)
327 if status != http.StatusBadRequest {
328 t.Errorf("%s = %d %s, want 400", c.what, status, out)
329 }
330 }
331 // Nothing above reached the bucket.
332 keys, err := fx.rel.List(context.Background(), provision.SidecarsPrefix("bab", "titogo"))
333 if err != nil {
334 t.Fatal(err)
335 }
336 if len(keys) != 0 {
337 t.Fatalf("refused creates left %v in the bucket", keys)
338 }
339
340 // The happy path: the host is DERIVED (nothing the body says can name
341 // it), pool comes from the manifest, channel defaults from it, and the
342 // record comes back whole.
343 status, _, out := rawReq(t, http.MethodPost, u, token, `{"name":"bot","region":"ie","pool":"attacker","host":"evil.example"}`)
344 if status != http.StatusCreated {
345 t.Fatalf("create = %d %s, want 201", status, out)
346 }
347 var rec provision.SidecarRecord
348 if err := json.Unmarshal([]byte(out), &rec); err != nil {
349 t.Fatalf("create body %s: %v", out, err)
350 }
351 if rec.Schema != 1 || rec.Name != "bot" || rec.Host != botHost ||
352 rec.App != "titogo" || rec.Account != "bab" || rec.Pool != "tito" ||
353 rec.Region != "ie" || rec.Channel != "stable" {
354 t.Fatalf("record = %+v", rec)
355 }
356 if rec.CreatedBy != "alice@keymail.dev" {
357 t.Fatalf("created_by = %q, want the member's address", rec.CreatedBy)
358 }
359 // Placement stays EMPTY on the record: it is app-wide manifest policy,
360 // and a record that restated it would pin itself against a later
361 // manifest change (the reconcile pass prefers the record's spelling, so
362 // stamping it here would break placement evacuation).
363 if rec.Placement != "" {
364 t.Fatalf("placement = %q, want empty — placement is the manifest's to state", rec.Placement)
365 }
366
367 // It is in the bucket at exactly the key the reconciler will look for.
368 stored, err := provision.GetSidecarRecord(context.Background(), fx.rel, "bab", "titogo", "bot")
369 if err != nil {
370 t.Fatalf("stored record: %v", err)
371 }
372 if stored.CreatedBy != "alice@keymail.dev" || stored.Host != botHost {
373 t.Fatalf("stored record = %+v", stored)
374 }
375 if stored.CreatedAt.IsZero() {
376 t.Fatalf("stored record has no created_at: %+v", stored)
377 }
378
379 // An explicit channel is honoured over the manifest's default.
380 status, _, out = rawReq(t, http.MethodPost, u, token, `{"name":"beta","region":"ie","channel":"canary/beta"}`)
381 if status != http.StatusCreated {
382 t.Fatalf("create with channel = %d %s, want 201", status, out)
383 }
384 if err := json.Unmarshal([]byte(out), &rec); err != nil {
385 t.Fatal(err)
386 }
387 if rec.Channel != "canary/beta" {
388 t.Fatalf("channel = %q, want the requested canary/beta", rec.Channel)
389 }
390}
391
392// TestAPISidecarCreateAlwaysStampsAChannel is the load-bearing one. Task 9's
393// reconcile pass PROTECTS a channel-less sidecar record — it is deliberately
394// never minted, because a route with no channel cannot resolve a release and
395// so has no build to exec. A record created without a channel would be
396// silently inert: minted by nothing, with no error anywhere. This door is
397// therefore the thing that has to stamp it, from the request if valid, else
398// the manifest, else the documented default.
399func TestAPISidecarCreateAlwaysStampsAChannel(t *testing.T) {
400 fx, token := instanceFixture(t, "code-side-channel")
401 u := sidecarsURL(fx, "bab", "titogo")
402
403 // A manifest carrying NO channel at all — the shape that would leave a
404 // record channel-less if the door only copied the manifest through.
405 m := titoSidecarManifest()
406 m.Channel = ""
407 m.Regions = nil
408 enableSidecars(t, fx, "titogo", m)
409
410 for _, c := range []struct{ what, body, want string }{
411 {"no channel anywhere", `{"name":"bot"}`, "stable"},
412 {"explicit channel", `{"name":"beta","channel":"canary/beta"}`, "canary/beta"},
413 {"blank channel falls through to the default", `{"name":"blank","channel":" "}`, "stable"},
414 } {
415 status, _, out := rawReq(t, http.MethodPost, u, token, c.body)
416 if status != http.StatusCreated {
417 t.Fatalf("%s: create = %d %s, want 201", c.what, status, out)
418 }
419 var rec provision.SidecarRecord
420 if err := json.Unmarshal([]byte(out), &rec); err != nil {
421 t.Fatalf("%s: create body %s: %v", c.what, out, err)
422 }
423 if rec.Channel != c.want {
424 t.Errorf("%s: channel = %q, want %q", c.what, rec.Channel, c.want)
425 }
426 }
427 // The manifest's channel is used when it has one.
428 m.Channel = "canary/tito"
429 enableSidecars(t, fx, "titogo", m)
430 status, _, out := rawReq(t, http.MethodPost, u, token, `{"name":"inherit"}`)
431 if status != http.StatusCreated {
432 t.Fatalf("create = %d %s, want 201", status, out)
433 }
434 var rec provision.SidecarRecord
435 if err := json.Unmarshal([]byte(out), &rec); err != nil {
436 t.Fatal(err)
437 }
438 if rec.Channel != "canary/tito" {
439 t.Errorf("channel = %q, want the manifest's canary/tito", rec.Channel)
440 }
441
442 // EVERY record this door wrote carries a non-empty channel, read back
443 // from the bucket — the reconcile pass reads these bytes, not the
444 // response body.
445 recs, _, err := provision.ListSidecarRecords(context.Background(), fx.rel, "bab", "titogo", 100)
446 if err != nil {
447 t.Fatal(err)
448 }
449 if len(recs) != 4 {
450 t.Fatalf("stored records = %d, want 4", len(recs))
451 }
452 for _, r := range recs {
453 if r.Channel == "" {
454 t.Errorf("record %s was stored with no channel — the reconcile pass protects it and never mints it", r.Name)
455 }
456 }
457}
458
459// TestAPISidecarCreateIsCreateOnly: the same name twice is a lost race, not
460// a silent overwrite — the winner's record stands, byte for byte.
461func TestAPISidecarCreateIsCreateOnly(t *testing.T) {
462 fx, token := instanceFixture(t, "code-side-createonly")
463 enableSidecars(t, fx, "titogo", titoSidecarManifest())
464 u := sidecarsURL(fx, "bab", "titogo")
465 if status, _, out := rawReq(t, http.MethodPost, u, token, `{"name":"bot","region":"ie"}`); status != http.StatusCreated {
466 t.Fatalf("create = %d %s", status, out)
467 }
468 first, err := provision.GetSidecarRecord(context.Background(), fx.rel, "bab", "titogo", "bot")
469 if err != nil {
470 t.Fatal(err)
471 }
472
473 status, _, out := rawReq(t, http.MethodPost, u, token, `{"name":"bot","region":"ie","channel":"canary/other"}`)
474 if status != http.StatusConflict {
475 t.Fatalf("duplicate create = %d %s, want 409", status, out)
476 }
477 if !strings.Contains(out, "already exists") {
478 t.Fatalf("duplicate refusal = %s, want it to say the sidecar already exists", out)
479 }
480 after, err := provision.GetSidecarRecord(context.Background(), fx.rel, "bab", "titogo", "bot")
481 if err != nil {
482 t.Fatal(err)
483 }
484 if after.Channel != first.Channel || !after.CreatedAt.Equal(first.CreatedAt) {
485 t.Fatalf("the losing create changed the winner's record: %+v then %+v", first, after)
486 }
487}
488
489// TestAPISidecarDeleteIsIdempotent: the caller's goal is "not there", and a
490// second delete has reached it. Deletion also outlives POLICY — an app that
491// retired its sidecar manifest must still be able to clean up the records it
492// left behind, or provisioned rows are stranded with nothing able to retire
493// them.
494func TestAPISidecarDeleteIsIdempotent(t *testing.T) {
495 fx, token := instanceFixture(t, "code-side-delete")
496 enableSidecars(t, fx, "titogo", titoSidecarManifest())
497 u := sidecarsURL(fx, "bab", "titogo")
498
499 // A name that never existed: still 200.
500 if status, _, out := rawReq(t, http.MethodDelete, u+"/ghost", token, ""); status != http.StatusOK {
501 t.Fatalf("delete of an absent name = %d %s, want an idempotent 200", status, out)
502 }
503 // A hostile name is refused before the key builder, and deletes nothing.
504 if status, _, out := rawReq(t, http.MethodDelete, u+"/..%2f..%2fsidecars.json", token, ""); status != http.StatusBadRequest {
505 t.Fatalf("delete of a traversal name = %d %s, want 400", status, out)
506 }
507 if _, _, err := fx.rel.Get(context.Background(), provision.SidecarManifestKey("bab", "titogo")); err != nil {
508 t.Fatalf("the manifest was destroyed by a traversal delete: %v", err)
509 }
510
511 if status, _, out := rawReq(t, http.MethodPost, u, token, `{"name":"bot","region":"ie"}`); status != http.StatusCreated {
512 t.Fatalf("create = %d %s", status, out)
513 }
514 // Give it a wake object, so the delete's cleanup has something to do.
515 if status, _, out := rawReq(t, http.MethodPost, u+"/bot/wake", token, ""); status != http.StatusOK {
516 t.Fatalf("wake = %d %s", status, out)
517 }
518 status, _, out := rawReq(t, http.MethodDelete, u+"/bot", token, "")
519 if status != http.StatusOK || !strings.Contains(out, "deleted") {
520 t.Fatalf("delete = %d %s, want 200 deleted", status, out)
521 }
522 if _, _, err := fx.rel.Get(context.Background(),
523 provision.SidecarRecordKey("bab", "titogo", "bot")); err == nil {
524 t.Fatalf("the record survived its delete")
525 }
526 // The wake object goes with it: a per-member sidecar fleet would
527 // otherwise leave one object per retired member in the bucket forever.
528 if _, _, err := fx.rel.Get(context.Background(),
529 release.SidecarWakeKey("bab", "titogo", "bot")); err == nil {
530 t.Fatalf("the wake object outlived the record it belongs to")
531 }
532 // Again: still 200.
533 if status, _, out := rawReq(t, http.MethodDelete, u+"/bot", token, ""); status != http.StatusOK {
534 t.Fatalf("second delete = %d %s, want an idempotent 200", status, out)
535 }
536
537 // Deletion outlives the manifest.
538 if status, _, out := rawReq(t, http.MethodPost, u, token, `{"name":"beta","region":"ie"}`); status != http.StatusCreated {
539 t.Fatalf("create = %d %s", status, out)
540 }
541 if err := fx.rel.Delete(context.Background(), provision.SidecarManifestKey("bab", "titogo")); err != nil {
542 t.Fatal(err)
543 }
544 if status, _, out := rawReq(t, http.MethodDelete, u+"/beta", token, ""); status != http.StatusOK {
545 t.Fatalf("delete with the manifest retired = %d %s, want 200", status, out)
546 }
547 // And the shape check still applies with no manifest to read.
548 if status, _, out := rawReq(t, http.MethodDelete, u+"/..%2f..%2fchannels", token, ""); status != http.StatusBadRequest {
549 t.Fatalf("traversal delete with no manifest = %d %s, want 400", status, out)
550 }
551}
552
553// TestAPISidecarWakeBumpsTheGeneration: two wakes move the generation 1 then
554// 2, the serial moves both times (that is what makes a box act within
555// seconds), and the write goes through release.TouchSidecarWake — the one
556// writer for this object — rather than a second hand-rolled counter.
557func TestAPISidecarWakeBumpsTheGeneration(t *testing.T) {
558 fx, token := instanceFixture(t, "code-side-wake")
559 enableSidecars(t, fx, "titogo", titoSidecarManifest())
560 u := sidecarsURL(fx, "bab", "titogo")
561 if status, _, out := rawReq(t, http.MethodPost, u, token, `{"name":"bot","region":"ie"}`); status != http.StatusCreated {
562 t.Fatalf("create = %d %s", status, out)
563 }
564
565 readGen := func() release.SidecarWake {
566 t.Helper()
567 raw, _, err := fx.rel.Get(context.Background(), release.SidecarWakeKey("bab", "titogo", "bot"))
568 if err != nil {
569 t.Fatalf("wake object: %v", err)
570 }
571 var w release.SidecarWake
572 if err := json.Unmarshal(raw, &w); err != nil {
573 t.Fatal(err)
574 }
575 return w
576 }
577 serial := func() int64 {
578 t.Helper()
579 doc, _, err := release.ReadSerial(context.Background(), fx.rel)
580 if err != nil {
581 t.Fatal(err)
582 }
583 return doc.Serial
584 }
585
586 before := serial()
587 if status, _, out := rawReq(t, http.MethodPost, u+"/bot/wake", token, ""); status != http.StatusOK {
588 t.Fatalf("first wake = %d %s, want 200", status, out)
589 }
590 w := readGen()
591 if w.Schema != 1 || w.Generation != 1 {
592 t.Fatalf("after one wake = %+v, want schema 1 generation 1", w)
593 }
594 if w.By != "alice@keymail.dev" {
595 t.Fatalf("wake by = %q, want the principal that asked", w.By)
596 }
597 if w.At == "" || w.Reason == "" {
598 t.Fatalf("wake object carries no at/reason for a human debugging it: %+v", w)
599 }
600 afterFirst := serial()
601 if afterFirst <= before {
602 t.Fatalf("serial %d -> %d: the first wake did not announce itself", before, afterFirst)
603 }
604
605 if status, _, out := rawReq(t, http.MethodPost, u+"/bot/wake", token, ""); status != http.StatusOK {
606 t.Fatalf("second wake = %d %s, want 200", status, out)
607 }
608 if w := readGen(); w.Generation != 2 {
609 t.Fatalf("after two wakes generation = %d, want 2", w.Generation)
610 }
611 if s := serial(); s <= afterFirst {
612 t.Fatalf("serial %d -> %d: the second wake did not announce itself", afterFirst, s)
613 }
614}
615
616// TestAPISidecarEnableChain: the member-facing half of `carlos sidecars
617// enable`. It writes the app's policy, updates preserve the operator-only
618// fields (pool and regions), and its refusals are cheap and named.
619func TestAPISidecarEnableChain(t *testing.T) {
620 fx, token := instanceFixture(t, "code-side-enable")
621 e := sidecarEnableURL(fx, "bab", "titogo")
622
623 status, _, out := rawReq(t, http.MethodPut, e, token,
624 `{"channel":"beta","args":["sidecar","run"],"placement":"no-inbound","idle_seconds":300,"max_run_seconds":3600,"hibernate":false}`)
625 if status != http.StatusOK {
626 t.Fatalf("enable = %d %s, want 200", status, out)
627 }
628 m, err := provision.GetSidecarManifest(context.Background(), fx.rel, "bab", "titogo")
629 if err != nil {
630 t.Fatalf("manifest after enable: %v", err)
631 }
632 if m.Schema != 1 || m.App != "titogo" || m.Channel != "beta" ||
633 m.Placement != provision.PlacementNoInbound || m.IdleSeconds != 300 || m.MaxRunSeconds != 3600 {
634 t.Fatalf("manifest = %+v", m)
635 }
636 if len(m.Args) != 2 || m.Args[0] != "sidecar" || m.Args[1] != "run" {
637 t.Fatalf("manifest args = %v", m.Args)
638 }
639 if m.Hibernate == nil || *m.Hibernate {
640 t.Fatalf("hibernate = %v, want an explicit false (absent and false are different facts)", m.Hibernate)
641 }
642
643 // Operator policy rides through an update untouched, and an omitted
644 // field preserves what the manifest already carries.
645 m.Pool, m.Regions = "tito", []string{"ie"}
646 enableSidecars(t, fx, "titogo", *m)
647 if status, _, out := rawReq(t, http.MethodPut, e, token, `{"channel":"stable"}`); status != http.StatusOK {
648 t.Fatalf("update = %d %s, want 200", status, out)
649 }
650 m2, err := provision.GetSidecarManifest(context.Background(), fx.rel, "bab", "titogo")
651 if err != nil {
652 t.Fatal(err)
653 }
654 if m2.Pool != "tito" || len(m2.Regions) != 1 || m2.Regions[0] != "ie" {
655 t.Fatalf("operator policy was rewritten by a member update: %+v", m2)
656 }
657 if m2.Channel != "stable" || m2.Placement != provision.PlacementNoInbound ||
658 m2.IdleSeconds != 300 || len(m2.Args) != 2 || m2.Hibernate == nil || *m2.Hibernate {
659 t.Fatalf("an omitted field did not survive the update: %+v", m2)
660 }
661
662 // The create door can now be used, and the enable announced itself.
663 if status, _, out := rawReq(t, http.MethodPost, sidecarsURL(fx, "bab", "titogo"), token,
664 `{"name":"bot","region":"ie"}`); status != http.StatusCreated {
665 t.Fatalf("create after enable = %d %s, want 201", status, out)
666 }
667
668 // Cheap validation refusals, after the gate.
669 for _, c := range []struct{ what, body string }{
670 {"bad channel", `{"channel":"NOT a channel"}`},
671 {"unknown placement", `{"placement":"anywhere"}`},
672 {"too many args", `{"args":[` + strings.Repeat(`"a",`, 16) + `"a"]}`},
673 {"empty arg", `{"args":[""]}`},
674 {"oversized arg", `{"args":["` + strings.Repeat("a", 257) + `"]}`},
675 {"negative idle", `{"idle_seconds":-1}`},
676 {"negative max run", `{"max_run_seconds":-1}`},
677 {"absurd idle", `{"idle_seconds":999999999}`},
678 } {
679 if status, _, out := rawReq(t, http.MethodPut, e, token, c.body); status != http.StatusBadRequest {
680 t.Errorf("enable %s = %d %s, want 400", c.what, status, out)
681 }
682 }
683}
684
685// TestAPISidecarListAndGet: the list is name-sorted, capped honestly, and
686// carries the best-effort running version joined from the fleet's own box
687// snapshots — a sidecar that has never reported reads empty, never the
688// desired version, because a stamp that overclaims is worse than an absent
689// one.
690func TestAPISidecarListAndGet(t *testing.T) {
691 fx, token := instanceFixture(t, "code-side-list")
692 enableSidecars(t, fx, "titogo", titoSidecarManifest())
693 u := sidecarsURL(fx, "bab", "titogo")
694 for _, n := range []string{"gamma", "alpha", "beta"} {
695 if status, _, out := rawReq(t, http.MethodPost, u, token,
696 fmt.Sprintf(`{"name":%q,"region":"ie"}`, n)); status != http.StatusCreated {
697 t.Fatalf("create %s = %d %s", n, status, out)
698 }
699 }
700
701 // A fresh box reporting alpha's serving version and gamma's installed
702 // version, and a stale box reporting beta's serving version.
703 fresh := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now())
704 fresh.Metrics = &status.BoxMetrics{
705 RouteServing: map[string]string{"alpha.titogo.bab.sidecar.internal": "abc1234"},
706 // gamma is installed but not started: root's stamp is not a running build.
707 RouteVersions: map[string]string{"gamma.titogo.bab.sidecar.internal": "f00d123"},
708 }
709 if err := status.WriteBox(context.Background(), fx.rel, fresh); err != nil {
710 t.Fatal(err)
711 }
712 stale := status.CollectBox(nil, status.Probes{MachineID: "box2"}, time.Now().Add(-time.Hour))
713 stale.Metrics = &status.BoxMetrics{RouteServing: map[string]string{
714 "beta.titogo.bab.sidecar.internal": "deadbeef"}}
715 if err := status.WriteBox(context.Background(), fx.rel, stale); err != nil {
716 t.Fatal(err)
717 }
718
719 code, _, out := rawReq(t, http.MethodGet, u, token, "")
720 if code != http.StatusOK {
721 t.Fatalf("list = %d %s, want 200", code, out)
722 }
723 var list struct {
724 Sidecars []struct {
725 Name string `json:"name"`
726 Host string `json:"host"`
727 Channel string `json:"channel"`
728 RunningVersion string `json:"running_version"`
729 } `json:"sidecars"`
730 Truncated bool `json:"truncated"`
731 }
732 if err := json.Unmarshal([]byte(out), &list); err != nil {
733 t.Fatalf("list body %s: %v", out, err)
734 }
735 if list.Truncated {
736 t.Fatalf("three records reported truncated")
737 }
738 if len(list.Sidecars) != 3 {
739 t.Fatalf("list = %+v, want three records", list.Sidecars)
740 }
741 if list.Sidecars[0].Name != "alpha" || list.Sidecars[2].Name != "gamma" {
742 t.Fatalf("list is not name-sorted: %+v", list.Sidecars)
743 }
744 got := map[string]string{}
745 for _, r := range list.Sidecars {
746 if r.Channel == "" {
747 t.Errorf("%s listed with no channel", r.Name)
748 }
749 got[r.Name] = r.RunningVersion
750 }
751 if got["alpha"] != "abc1234" {
752 t.Errorf("alpha running_version = %q, want abc1234 (its box is fresh)", got["alpha"])
753 }
754 if got["beta"] != "" {
755 t.Errorf("beta running_version = %q, want empty — its only reporting box is stale", got["beta"])
756 }
757 if got["gamma"] != "" {
758 t.Errorf("gamma running_version = %q, want empty — an install stamp is not a running build", got["gamma"])
759 }
760
761 // GET one.
762 code, _, out = rawReq(t, http.MethodGet, u+"/alpha", token, "")
763 if code != http.StatusOK {
764 t.Fatalf("get = %d %s, want 200", code, out)
765 }
766 var rec provision.SidecarRecord
767 if err := json.Unmarshal([]byte(out), &rec); err != nil {
768 t.Fatal(err)
769 }
770 if rec.Name != "alpha" || rec.Host != "alpha.titogo.bab.sidecar.internal" {
771 t.Fatalf("get = %+v", rec)
772 }
773}
774
775// TestAPISidecarNoStore: a console running without a deployment bucket
776// answers 503 on every verb rather than panicking on a nil store.
777func TestAPISidecarNoStore(t *testing.T) {
778 fx, token := instanceFixture(t, "code-side-nostore")
779 fx.srv.cfg.Releases = nil
780 u := sidecarsURL(fx, "bab", "titogo")
781 for _, c := range []struct{ what, method, url, body string }{
782 {"enable", http.MethodPut, sidecarEnableURL(fx, "bab", "titogo"), `{}`},
783 {"list", http.MethodGet, u, ""},
784 {"create", http.MethodPost, u, `{"name":"bot","region":"ie"}`},
785 {"get", http.MethodGet, u + "/bot", ""},
786 {"delete", http.MethodDelete, u + "/bot", ""},
787 {"wake", http.MethodPost, u + "/bot/wake", ""},
788 } {
789 if status, _, out := rawReq(t, c.method, c.url, token, c.body); status != http.StatusServiceUnavailable {
790 t.Errorf("%s with no bucket = %d %s, want 503", c.what, status, out)
791 }
792 }
793}
794
795// unlistableStore is a bucket whose prefix listing fails — the one store
796// call the list door makes on its own behalf.
797type unlistableStore struct{ release.Store }
798
799func (u unlistableStore) List(context.Context, string) ([]string, error) {
800 return nil, errors.New(brokenStoreErr)
801}
802
803// TestAPISidecarStoreFailure: a bucket that cannot be read or written is a
804// 502, and its error text — bucket names, request ids — never reaches the
805// caller. A read failure is NOT reported as "sidecars are not enabled".
806func TestAPISidecarStoreFailure(t *testing.T) {
807 fx, token := instanceFixture(t, "code-side-broken")
808 enableSidecars(t, fx, "titogo", titoSidecarManifest())
809 u := sidecarsURL(fx, "bab", "titogo")
810
811 fx.srv.cfg.Releases = brokenStore{fx.rel}
812 status, _, out := rawReq(t, http.MethodPost, u, token, `{"name":"bot","region":"ie"}`)
813 if status != http.StatusBadGateway {
814 t.Fatalf("create against an unwritable bucket = %d %s, want 502", status, out)
815 }
816 if strings.Contains(out, "carlos-prod-secret-bucket") || strings.Contains(out, "ABC123") {
817 t.Fatalf("refusal leaks the store's error text: %s", out)
818 }
819
820 fx.srv.cfg.Releases = unreadableStore{fx.rel}
821 status, _, out = rawReq(t, http.MethodPost, u, token, `{"name":"bot","region":"ie"}`)
822 if status != http.StatusBadGateway {
823 t.Fatalf("create against an unreadable bucket = %d %s, want 502", status, out)
824 }
825 if strings.Contains(out, "sidecars enable") {
826 t.Fatalf("a read failure was reported as the feature being disabled: %s", out)
827 }
828 // A bucket that cannot even be LISTED is the list's own 502. (A bucket
829 // that lists but whose bodies cannot be read answers an empty list, not
830 // an error: provision.ListSidecarRecords skips an unreadable record
831 // rather than failing the page, the same rule ListRecords applies for
832 // instances. Recorded here so the difference is deliberate.)
833 fx.srv.cfg.Releases = unlistableStore{fx.rel}
834 status, _, out = rawReq(t, http.MethodGet, u, token, "")
835 if status != http.StatusBadGateway {
836 t.Fatalf("list against an unlistable bucket = %d %s, want 502", status, out)
837 }
838 if strings.Contains(out, "carlos-prod-secret-bucket") {
839 t.Fatalf("list refusal leaks the store's error text: %s", out)
840 }
841
842 fx.srv.cfg.Releases = undeletableStore{fx.rel}
843 status, _, out = rawReq(t, http.MethodDelete, u+"/bot", token, "")
844 if status != http.StatusBadGateway {
845 t.Fatalf("delete against a bucket that cannot delete = %d %s, want 502", status, out)
846 }
847 if strings.Contains(out, "carlos-prod-secret-bucket") {
848 t.Fatalf("delete refusal leaks the store's error text: %s", out)
849 }
850}
851
852// TestAPISidecarRateLimit: the budget is spent AFTER the gate, so a
853// stranger's refusal never changes shape however many times they ask —
854// which is the gate-before-budget ordering asserted from outside. A limiter
855// consulted first would start answering 429 where the 404 belongs, and that
856// is a difference a prober can measure.
857func TestAPISidecarRateLimit(t *testing.T) {
858 fx, token := instanceFixture(t, "code-side-ratelimit")
859 enableSidecars(t, fx, "titogo", titoSidecarManifest())
860 u := sidecarsURL(fx, "bab", "titogo")
861
862 for i := 0; i < cliInstanceBurst; i++ {
863 body := fmt.Sprintf(`{"name":"bot%d","region":"ie"}`, i)
864 if status, _, out := rawReq(t, http.MethodPost, u, token, body); status != http.StatusCreated {
865 t.Fatalf("create %d = %d %s, want 201 inside the budget", i, status, out)
866 }
867 }
868 if status, _, out := rawReq(t, http.MethodPost, u, token,
869 `{"name":"over","region":"ie"}`); status != http.StatusTooManyRequests {
870 t.Fatalf("create past the budget = %d %s, want 429", status, out)
871 }
872
873 eve := cliToken(t, fx, "eve@keymail.dev", "code-side-rl-eve")
874 want := canonicalNotFound(t)
875 for i := 0; i < cliInstanceBurst+2; i++ {
876 status, h, out := rawReq(t, http.MethodPost, u, eve, `{"name":"x","region":"ie"}`)
877 assertNotFound(t, fmt.Sprintf("non-member probe %d", i), want, status, h, out)
878 if t.Failed() {
879 break
880 }
881 }
882 // The write budget the ENABLE door uses is a different one: the member
883 // above spent the record budget, not this.
884 if status, _, out := rawReq(t, http.MethodPut, sidecarEnableURL(fx, "bab", "titogo"),
885 token, `{"channel":"stable"}`); status != http.StatusOK {
886 t.Fatalf("enable after spending the record budget = %d %s, want 200", status, out)
887 }
888}
889
890// TestAPISidecarCreateInheritsFleetPlacement: a fleet-placed app's sidecars
891// inherit the app's placement, exactly as its instances do — the request
892// body's region is ignored entirely, and even a region the manifest's
893// allow-list would otherwise refuse succeeds, because that validation is
894// never consulted once the app is placed. This is not a nicety: Eleven, the
895// customer this whole family exists for, places its app onto a fleet, and
896// "fleet/<name>" can never pass provision.ValidRegion — so without the
897// override there is no such thing as a usable Eleven sidecar record.
898//
899// A route is seeded first for appPlacement's route-derived fallback path,
900// the realistic shape of an app already running before it was placed (see
901// TestAPIInstanceCreateInheritsFleetPlacement's own doc comment).
902func TestAPISidecarCreateInheritsFleetPlacement(t *testing.T) {
903 fx, token := instanceFixture(t, "code-side-placed")
904 enableSidecars(t, fx, "titogo", titoSidecarManifest()) // Regions: []string{"ie"}
905
906 reg, err := registry.Open(fx.regPath)
907 if err != nil {
908 t.Fatal(err)
909 }
910 if err := reg.Upsert(registry.Route{Host: "www.go.tito.io", Kind: "instance", App: "titogo", Account: "bab"}); err != nil {
911 t.Fatal(err)
912 }
913 if status, body := postJSON(t, fx.ts.URL+"/api/cli/fleets/bab/new",
914 map[string]string{"name": "eleven", "bucket": "eleven-fleet-data"}, token); status != http.StatusOK {
915 t.Fatalf("create fleet = %d %v, want 200", status, body)
916 }
917 if status, body := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/place",
918 map[string]string{"target": "fleet/eleven"}, token); status != http.StatusOK {
919 t.Fatalf("place = %d %v, want 200", status, body)
920 }
921
922 // "us" is outside titoSidecarManifest's ["ie"] allow-list — refused on an
923 // unplaced app (TestAPISidecarCreateUnplacedHonorsBodyRegion below), but
924 // accepted and overridden here, which is the whole point of the ordering.
925 status, _, out := rawReq(t, http.MethodPost, sidecarsURL(fx, "bab", "titogo"), token,
926 `{"name":"bot","region":"us"}`)
927 if status != http.StatusCreated {
928 t.Fatalf("create on a placed app = %d %s, want 201", status, out)
929 }
930 var rec provision.SidecarRecord
931 if err := json.Unmarshal([]byte(out), &rec); err != nil {
932 t.Fatalf("create body %s: %v", out, err)
933 }
934 if rec.Region != "fleet/eleven" {
935 t.Fatalf("record Region = %q, want the inherited fleet/eleven (body region ignored)", rec.Region)
936 }
937 // The stored record agrees, and — the half that would otherwise make this
938 // inert — it passes the shape rail the reconcile pass re-checks it at, so
939 // a fleet-placed sidecar is mintable rather than permanently protected.
940 stored, err := provision.GetSidecarRecord(context.Background(), fx.rel, "bab", "titogo", "bot")
941 if err != nil {
942 t.Fatal(err)
943 }
944 if stored.Region != "fleet/eleven" {
945 t.Fatalf("stored record Region = %q, want fleet/eleven", stored.Region)
946 }
947 if err := provision.ValidateSidecarShape(*stored); err != nil {
948 t.Fatalf("the record this door wrote fails its own shape rail: %v", err)
949 }
950}
951
952// TestAPISidecarCreateUnplacedHonorsBodyRegion is the regression guard for
953// the inheritance above: with no placement, the ordinary contract is
954// unchanged — the body's region is shape-checked and matched against the
955// manifest's allow-list exactly as before.
956func TestAPISidecarCreateUnplacedHonorsBodyRegion(t *testing.T) {
957 fx, token := instanceFixture(t, "code-side-unplaced")
958 enableSidecars(t, fx, "titogo", titoSidecarManifest())
959 u := sidecarsURL(fx, "bab", "titogo")
960
961 status, _, out := rawReq(t, http.MethodPost, u, token, `{"name":"bot","region":"ie"}`)
962 if status != http.StatusCreated {
963 t.Fatalf("create = %d %s, want 201", status, out)
964 }
965 var rec provision.SidecarRecord
966 if err := json.Unmarshal([]byte(out), &rec); err != nil {
967 t.Fatal(err)
968 }
969 if rec.Region != "ie" {
970 t.Fatalf("Region = %q, want the body's ie (no placement in play)", rec.Region)
971 }
972 if status, _, out := rawReq(t, http.MethodPost, u, token,
973 `{"name":"beta","region":"us"}`); status != http.StatusBadRequest {
974 t.Fatalf("create outside the allow-list on an unplaced app = %d %s, want 400", status, out)
975 }
976 // And a fleet target is NOT a way to smuggle one past the allow-list: an
977 // unplaced app's region is user input, and user input takes ValidRegion.
978 if status, _, out := rawReq(t, http.MethodPost, u, token,
979 `{"name":"beta","region":"fleet/eleven"}`); status != http.StatusBadRequest {
980 t.Fatalf("create naming a fleet region on an UNPLACED app = %d %s, want 400", status, out)
981 }
982}
983
984// TestAPISidecarCreatePlacementLookupFailure: a registry the console cannot
985// open is the instance door's error shape — 502 "could not check this app's
986// placement" — and never a record written against an unknown placement.
987func TestAPISidecarCreatePlacementLookupFailure(t *testing.T) {
988 fx, token := instanceFixture(t, "code-side-placefail")
989 enableSidecars(t, fx, "titogo", titoSidecarManifest())
990 fx.srv.cfg.Registry = filepath.Join(t.TempDir(), "no-such-dir", "registry.db")
991
992 status, _, out := rawReq(t, http.MethodPost, sidecarsURL(fx, "bab", "titogo"), token,
993 `{"name":"bot","region":"ie"}`)
994 if status != http.StatusBadGateway {
995 t.Fatalf("create with an unopenable registry = %d %s, want 502", status, out)
996 }
997 if !strings.Contains(out, "placement") {
998 t.Fatalf("refusal = %s, want it to name the check that failed", out)
999 }
1000 if _, _, err := fx.rel.Get(context.Background(),
1001 provision.SidecarRecordKey("bab", "titogo", "bot")); err == nil {
1002 t.Fatalf("a record was written despite an unknown placement")
1003 }
1004}
1005
1006// TestAPISidecarRateLimitNamesTheVerbFamily: the budget is shared with the
1007// instance verbs deliberately (one credential, one kind of work), but a
1008// member who spends it creating sidecars is told about sidecars.
1009func TestAPISidecarRateLimitNamesTheVerbFamily(t *testing.T) {
1010 fx, token := instanceFixture(t, "code-side-429text")
1011 enableSidecars(t, fx, "titogo", titoSidecarManifest())
1012 u := sidecarsURL(fx, "bab", "titogo")
1013 for i := 0; i < cliInstanceBurst; i++ {
1014 if status, _, out := rawReq(t, http.MethodPost, u, token,
1015 fmt.Sprintf(`{"name":"bot%d","region":"ie"}`, i)); status != http.StatusCreated {
1016 t.Fatalf("create %d = %d %s", i, status, out)
1017 }
1018 }
1019 status, _, out := rawReq(t, http.MethodPost, u, token, `{"name":"over","region":"ie"}`)
1020 if status != http.StatusTooManyRequests {
1021 t.Fatalf("create past the budget = %d %s, want 429", status, out)
1022 }
1023 if !strings.Contains(out, "sidecar requests") {
1024 t.Fatalf("429 body = %s, want it to name the verb family the caller was using", out)
1025 }
1026}
1027