| 1 | package 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 | |
| 16 | import ( |
| 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. |
| 35 | func sidecarsURL(fx *consoleFixture, acct, app string) string { |
| 36 | return fx.ts.URL + "/api/cli/apps/" + acct + "/" + app + "/sidecars" |
| 37 | } |
| 38 | |
| 39 | func 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. |
| 46 | func 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 | |
| 53 | func 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. |
| 61 | const 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. |
| 68 | func 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. |
| 178 | func 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. |
| 210 | func 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. |
| 233 | func 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. |
| 287 | func 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. |
| 311 | func 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. |
| 399 | func 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. |
| 461 | func 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. |
| 494 | func 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. |
| 557 | func 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. |
| 619 | func 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. |
| 690 | func 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. |
| 777 | func 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. |
| 797 | type unlistableStore struct{ release.Store } |
| 798 | |
| 799 | func (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". |
| 806 | func 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. |
| 857 | func 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). |
| 902 | func 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. |
| 956 | func 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. |
| 987 | func 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. |
| 1009 | func 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 | |