| new file mode 100644 |
| index 000000000..476ebc7ba |
| --- /dev/null |
| +++ b/internal/console/apisidecars_test.go |
| @@ -0,0 +1,856 @@ |
| +package console |
| + |
| +// The sidecar API (apisidecars.go): the console's provisioning verbs for a |
| +// member that nothing can dial, and the app-held token that may speak to |
| +// them. The properties that matter, in the order they'd hurt if lost: every |
| +// refusal before the principal is proven is the instance family's |
| +// byte-identical 404 (a probe learns nothing — not whether an account |
| +// exists, not whether an app does, not whether a SIDECAR does), an app token |
| +// is contained to its own account+app and can enable nothing, every created |
| +// record carries a non-empty channel (a channel-less record is protected and |
| +// never minted by the reconcile pass — silently inert, with no error |
| +// anywhere), no name reaches a bucket key without passing the label check, |
| +// and the rate limiter is consulted only AFTER the gate so a 429 can never |
| +// confirm what a 404 hides. |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +// sidecarsURL and sidecarEnableURL are the two prefixes this family answers |
| +// on: the collection, and the literal manifest sibling beside it. |
| +func sidecarsURL(fx *consoleFixture, acct, app string) string { |
| + return fx.ts.URL + "/api/cli/apps/" + acct + "/" + app + "/sidecars" |
| +} |
| + |
| +func sidecarEnableURL(fx *consoleFixture, acct, app string) string { |
| + return fx.ts.URL + "/api/cli/apps/" + acct + "/" + app + "/sidecars-manifest" |
| +} |
| + |
| +// enableSidecars writes the app's sidecar manifest directly — the operator |
| +// half of `carlos sidecars enable`, whose ABSENCE is what makes the feature |
| +// not exist for an app. |
| +func enableSidecars(t *testing.T, fx *consoleFixture, app string, m provision.SidecarManifest) { |
| + t.Helper() |
| + if err := provision.PutSidecarManifest(context.Background(), fx.rel, release.OpsAccount, app, m); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func titoSidecarManifest() provision.SidecarManifest { |
| + return provision.SidecarManifest{Schema: 1, App: "titogo", Pool: "tito", |
| + Channel: "stable", Regions: []string{"ie"}, Args: []string{"sidecar", "run"}} |
| +} |
| + |
| +// botHost is the synthetic host the door derives for a sidecar of the |
| +// fixture's app. Spelled out rather than computed so a change to |
| +// SidecarHost's shape fails a test rather than silently agreeing with itself. |
| +const botHost = "bot.titogo.bab.sidecar.internal" |
| + |
| +// TestAPISidecarRefusalMatrix: every way of not being allowed here — a |
| +// stranger's token, an app token pointed at a neighbouring account or a |
| +// neighbouring app, an account that does not exist, an app that was never |
| +// claimed — answers with the SAME bytes http.NotFound writes, on every verb |
| +// including wake. Asserted against the stdlib's own output. |
| +func TestAPISidecarRefusalMatrix(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-side-refusal") |
| + enableSidecars(t, fx, "titogo", titoSidecarManifest()) |
| + want := canonicalNotFound(t) |
| + |
| + now := time.Now() |
| + if err := fx.st.CreateAccount(42, "cdef", "widgets", "bob@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.st.CreateAppIn(42, "titogo", "bob@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + eve := cliToken(t, fx, "eve@keymail.dev", "code-side-eve") |
| + |
| + body := `{"name":"bot","region":"ie"}` |
| + cases := []struct { |
| + what string |
| + method string |
| + url string |
| + token string |
| + body string |
| + }{ |
| + {"non-member list", http.MethodGet, sidecarsURL(fx, "bab", "titogo"), eve, ""}, |
| + {"non-member create", http.MethodPost, sidecarsURL(fx, "bab", "titogo"), eve, body}, |
| + {"non-member get", http.MethodGet, sidecarsURL(fx, "bab", "titogo") + "/bot", eve, ""}, |
| + {"non-member delete", http.MethodDelete, sidecarsURL(fx, "bab", "titogo") + "/bot", eve, ""}, |
| + {"non-member wake", http.MethodPost, sidecarsURL(fx, "bab", "titogo") + "/bot/wake", eve, ""}, |
| + {"app token, foreign account", http.MethodGet, sidecarsURL(fx, "cdef", "titogo"), appTok, ""}, |
| + {"app token, foreign account create", http.MethodPost, sidecarsURL(fx, "cdef", "titogo"), appTok, body}, |
| + {"app token, foreign account wake", http.MethodPost, sidecarsURL(fx, "cdef", "titogo") + "/bot/wake", appTok, ""}, |
| + {"app token, foreign app", http.MethodGet, sidecarsURL(fx, "bab", "other"), appTok, ""}, |
| + {"app token, foreign app create", http.MethodPost, sidecarsURL(fx, "bab", "other"), appTok, body}, |
| + // The account segment resolves by NAME for a user token; an app |
| + // token holds no memberships to resolve one against, so its own |
| + // account's name is not a way in either. |
| + {"app token, own account by name", http.MethodGet, sidecarsURL(fx, "ops", "titogo"), appTok, ""}, |
| + {"unknown account", http.MethodGet, sidecarsURL(fx, "zzzz", "titogo"), member, ""}, |
| + {"unclaimed app", http.MethodGet, sidecarsURL(fx, "bab", "ghost-app"), member, ""}, |
| + {"unclaimed app create", http.MethodPost, sidecarsURL(fx, "bab", "ghost-app"), member, body}, |
| + {"unclaimed app wake", http.MethodPost, sidecarsURL(fx, "bab", "ghost-app") + "/bot/wake", member, ""}, |
| + } |
| + for _, c := range cases { |
| + status, h, out := rawReq(t, c.method, c.url, c.token, c.body) |
| + assertNotFound(t, c.what, want, status, h, out) |
| + } |
| + |
| + // A sidecar's EXISTENCE is not a thing to probe for either: a member of |
| + // the account asking for a name that was never created gets the same |
| + // bytes a stranger gets for the whole app. |
| + for _, c := range []struct{ what, method, url string }{ |
| + {"member, unknown sidecar get", http.MethodGet, sidecarsURL(fx, "bab", "titogo") + "/ghost"}, |
| + {"member, unknown sidecar wake", http.MethodPost, sidecarsURL(fx, "bab", "titogo") + "/ghost/wake"}, |
| + {"member, traversal name get", http.MethodGet, sidecarsURL(fx, "bab", "titogo") + "/..%2f..%2fsidecars.json"}, |
| + {"member, traversal name wake", http.MethodPost, sidecarsURL(fx, "bab", "titogo") + "/..%2f..%2fsidecars.json/wake"}, |
| + } { |
| + status, h, out := rawReq(t, c.method, c.url, member, "") |
| + assertNotFound(t, c.what, want, status, h, out) |
| + } |
| + |
| + // None of the refused calls wrote anything. |
| + if _, _, err := fx.rel.Get(context.Background(), |
| + provision.SidecarRecordKey("bab", "titogo", "bot")); err == nil { |
| + t.Fatalf("a record exists after only refused creates") |
| + } |
| + if _, _, err := fx.rel.Get(context.Background(), |
| + provision.SidecarRecordKey("cdef", "titogo", "bot")); err == nil { |
| + t.Fatalf("a record exists in the neighbouring account after only refused creates") |
| + } |
| + // And no wake object was minted by a refused wake — the wake write is |
| + // strictly behind the gate and behind the record's own existence. |
| + if _, _, err := fx.rel.Get(context.Background(), |
| + release.SidecarWakeKey("bab", "titogo", "bot")); err == nil { |
| + t.Fatalf("a wake object exists after only refused wakes") |
| + } |
| + if _, _, err := fx.rel.Get(context.Background(), |
| + release.SidecarWakeKey("bab", "titogo", "ghost")); err == nil { |
| + t.Fatalf("a wake object exists for a sidecar that was never created") |
| + } |
| +} |
| + |
| +// TestAPISidecarEnableRefusalMatrix: the enable door is apiAccountApp's, so |
| +// its refusals are that gate's — the byte-identical 404 for a stranger, and |
| +// a plain 401 for an app token, which CLITokenAddress does not answer for at |
| +// all. Enabling is a human act. |
| +func TestAPISidecarEnableRefusalMatrix(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-side-enable-ref") |
| + want := canonicalNotFound(t) |
| + eve := cliToken(t, fx, "eve@keymail.dev", "code-side-enable-eve") |
| + appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, c := range []struct{ what, acct, app, token string }{ |
| + {"non-member", "bab", "titogo", eve}, |
| + {"unknown account", "zzzz", "titogo", member}, |
| + {"unclaimed app", "bab", "ghost-app", member}, |
| + } { |
| + status, h, out := rawReq(t, http.MethodPut, sidecarEnableURL(fx, c.acct, c.app), c.token, `{}`) |
| + assertNotFound(t, "sidecars enable: "+c.what, want, status, h, out) |
| + } |
| + if status, _, out := rawReq(t, http.MethodPut, sidecarEnableURL(fx, "bab", "titogo"), appTok, `{}`); status != http.StatusUnauthorized { |
| + t.Fatalf("app token enabling sidecars = %d %s, want 401", status, out) |
| + } |
| + if _, _, err := fx.rel.Get(context.Background(), |
| + provision.SidecarManifestKey("bab", "titogo")); err == nil { |
| + t.Fatalf("a manifest exists after only refused enables") |
| + } |
| +} |
| + |
| +// TestAPISidecarNoToken: no credential at all is the 401 every CLI door |
| +// gives, on all six routes. |
| +func TestAPISidecarNoToken(t *testing.T) { |
| + fx, _ := instanceFixture(t, "code-side-notoken") |
| + u := sidecarsURL(fx, "bab", "titogo") |
| + for _, c := range []struct{ method, url, body string }{ |
| + {http.MethodPut, sidecarEnableURL(fx, "bab", "titogo"), `{}`}, |
| + {http.MethodGet, u, ""}, |
| + {http.MethodPost, u, `{"name":"bot"}`}, |
| + {http.MethodGet, u + "/bot", ""}, |
| + {http.MethodDelete, u + "/bot", ""}, |
| + {http.MethodPost, u + "/bot/wake", ""}, |
| + } { |
| + if status, _, out := rawReq(t, c.method, c.url, "", c.body); status != http.StatusUnauthorized { |
| + t.Errorf("%s %s with no token = %d %s, want 401", c.method, c.url, status, out) |
| + } |
| + } |
| +} |
| + |
| +// TestAPISidecarAppTokenContainment: the app token is contained in both |
| +// directions. It cannot speak to the verbs a person's token speaks to |
| +// (promote, minting, ENABLING), and it CAN do exactly the five record verbs |
| +// on its own app — which is the whole "apps provision sidecars easily" |
| +// requirement: Eleven's process, holding no AWS credentials and no box |
| +// access, creates a bot per enrolled member. |
| +func TestAPISidecarAppTokenContainment(t *testing.T) { |
| + fx, _ := instanceFixture(t, "code-side-contain") |
| + enableSidecars(t, fx, "titogo", titoSidecarManifest()) |
| + appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + u := sidecarsURL(fx, "bab", "titogo") |
| + |
| + // The human-only doors. |
| + if status, _, out := rawReq(t, http.MethodPost, |
| + fx.ts.URL+"/api/cli/apps/bab/titogo/promote", appTok, |
| + `{"version":"1.0.0","channel":"canary/a"}`); status != http.StatusUnauthorized { |
| + t.Fatalf("app token on promote = %d %s, want 401", status, out) |
| + } |
| + if status, _, out := rawReq(t, http.MethodPost, |
| + fx.ts.URL+"/api/cli/apps/bab/titogo/instance-token", appTok, `{}`); status != http.StatusUnauthorized { |
| + t.Fatalf("app token minting a token = %d %s, want 401", status, out) |
| + } |
| + if status, _, out := rawReq(t, http.MethodPut, |
| + sidecarEnableURL(fx, "bab", "titogo"), appTok, `{"channel":"beta"}`); status != http.StatusUnauthorized { |
| + t.Fatalf("app token enabling sidecars = %d %s, want 401", status, out) |
| + } |
| + |
| + // Its own app's verbs work, all five. |
| + status, _, out := rawReq(t, http.MethodPost, u, appTok, `{"name":"bot","region":"ie"}`) |
| + if status != http.StatusCreated { |
| + t.Fatalf("app token create on its own app = %d %s, want 201", status, out) |
| + } |
| + var rec provision.SidecarRecord |
| + if err := json.Unmarshal([]byte(out), &rec); err != nil { |
| + t.Fatalf("create body %s: %v", out, err) |
| + } |
| + if rec.CreatedBy != "app:bab/titogo" { |
| + t.Fatalf("created_by = %q, want the app principal app:bab/titogo", rec.CreatedBy) |
| + } |
| + if status, _, out := rawReq(t, http.MethodGet, u, appTok, ""); status != http.StatusOK { |
| + t.Fatalf("app token list = %d %s, want 200", status, out) |
| + } |
| + if status, _, out := rawReq(t, http.MethodGet, u+"/bot", appTok, ""); status != http.StatusOK { |
| + t.Fatalf("app token get = %d %s, want 200", status, out) |
| + } |
| + if status, _, out := rawReq(t, http.MethodPost, u+"/bot/wake", appTok, ""); status != http.StatusOK { |
| + t.Fatalf("app token wake = %d %s, want 200", status, out) |
| + } |
| + if status, _, out := rawReq(t, http.MethodDelete, u+"/bot", appTok, ""); status != http.StatusOK { |
| + t.Fatalf("app token delete = %d %s, want 200", status, out) |
| + } |
| +} |
| + |
| +// TestAPISidecarCreateWithoutManifestNamesTheEnableVerb: membership is |
| +// already proven at this point, so the refusal may name its cause and the |
| +// act that fixes it — and it must name SIDECARS' own verb, never the |
| +// instance family's. |
| +func TestAPISidecarCreateWithoutManifestNamesTheEnableVerb(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-side-nomanifest") |
| + status, _, out := rawReq(t, http.MethodPost, sidecarsURL(fx, "bab", "titogo"), token, `{"name":"bot"}`) |
| + if status != http.StatusConflict { |
| + t.Fatalf("create with no manifest = %d %s, want 409", status, out) |
| + } |
| + if !strings.Contains(out, "carlos sidecars enable") { |
| + t.Fatalf("no-manifest refusal = %s, want it to name `carlos sidecars enable`", out) |
| + } |
| + if strings.Contains(out, "instances enable") { |
| + t.Fatalf("no-manifest refusal names another family's verb: %s", out) |
| + } |
| + // An instances manifest is not a sidecars manifest: enabling one must |
| + // not enable the other. |
| + enableInstances(t, fx, "titogo", titoManifest()) |
| + if status, _, out := rawReq(t, http.MethodPost, sidecarsURL(fx, "bab", "titogo"), token, |
| + `{"name":"bot"}`); status != http.StatusConflict { |
| + t.Fatalf("create with only an INSTANCES manifest = %d %s, want 409", status, out) |
| + } |
| +} |
| + |
| +// TestAPISidecarCreateRefusalOrder walks the family's order with a member |
| +// token: membership is already proven, so from here on refusals may name |
| +// their cause — and each one must be the RIGHT cause, in the right order. |
| +func TestAPISidecarCreateRefusalOrder(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-side-create") |
| + u := sidecarsURL(fx, "bab", "titogo") |
| + enableSidecars(t, fx, "titogo", titoSidecarManifest()) |
| + |
| + for _, c := range []struct{ what, body string }{ |
| + {"empty name", `{"name":"","region":"ie"}`}, |
| + {"bad name shape", `{"name":"-nope-","region":"ie"}`}, |
| + {"dotted name", `{"name":"bot.eleven","region":"ie"}`}, |
| + {"traversal name", `{"name":"../../etc/passwd","region":"ie"}`}, |
| + {"uppercase name", `{"name":"Bot","region":"ie"}`}, |
| + {"region outside the allow-list", `{"name":"bot","region":"us"}`}, |
| + {"malformed region", `{"name":"bot","region":"IE/../x"}`}, |
| + {"bad channel", `{"name":"bot","region":"ie","channel":"../etc"}`}, |
| + } { |
| + status, _, out := rawReq(t, http.MethodPost, u, token, c.body) |
| + if status != http.StatusBadRequest { |
| + t.Errorf("%s = %d %s, want 400", c.what, status, out) |
| + } |
| + } |
| + // Nothing above reached the bucket. |
| + keys, err := fx.rel.List(context.Background(), provision.SidecarsPrefix("bab", "titogo")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(keys) != 0 { |
| + t.Fatalf("refused creates left %v in the bucket", keys) |
| + } |
| + |
| + // The happy path: the host is DERIVED (nothing the body says can name |
| + // it), pool comes from the manifest, channel defaults from it, and the |
| + // record comes back whole. |
| + status, _, out := rawReq(t, http.MethodPost, u, token, `{"name":"bot","region":"ie","pool":"attacker","host":"evil.example"}`) |
| + if status != http.StatusCreated { |
| + t.Fatalf("create = %d %s, want 201", status, out) |
| + } |
| + var rec provision.SidecarRecord |
| + if err := json.Unmarshal([]byte(out), &rec); err != nil { |
| + t.Fatalf("create body %s: %v", out, err) |
| + } |
| + if rec.Schema != 1 || rec.Name != "bot" || rec.Host != botHost || |
| + rec.App != "titogo" || rec.Account != "bab" || rec.Pool != "tito" || |
| + rec.Region != "ie" || rec.Channel != "stable" { |
| + t.Fatalf("record = %+v", rec) |
| + } |
| + if rec.CreatedBy != "alice@keymail.dev" { |
| + t.Fatalf("created_by = %q, want the member's address", rec.CreatedBy) |
| + } |
| + // Placement stays EMPTY on the record: it is app-wide manifest policy, |
| + // and a record that restated it would pin itself against a later |
| + // manifest change (the reconcile pass prefers the record's spelling, so |
| + // stamping it here would break placement evacuation). |
| + if rec.Placement != "" { |
| + t.Fatalf("placement = %q, want empty — placement is the manifest's to state", rec.Placement) |
| + } |
| + |
| + // It is in the bucket at exactly the key the reconciler will look for. |
| + stored, err := provision.GetSidecarRecord(context.Background(), fx.rel, "bab", "titogo", "bot") |
| + if err != nil { |
| + t.Fatalf("stored record: %v", err) |
| + } |
| + if stored.CreatedBy != "alice@keymail.dev" || stored.Host != botHost { |
| + t.Fatalf("stored record = %+v", stored) |
| + } |
| + if stored.CreatedAt.IsZero() { |
| + t.Fatalf("stored record has no created_at: %+v", stored) |
| + } |
| + |
| + // An explicit channel is honoured over the manifest's default. |
| + status, _, out = rawReq(t, http.MethodPost, u, token, `{"name":"beta","region":"ie","channel":"canary/beta"}`) |
| + if status != http.StatusCreated { |
| + t.Fatalf("create with channel = %d %s, want 201", status, out) |
| + } |
| + if err := json.Unmarshal([]byte(out), &rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.Channel != "canary/beta" { |
| + t.Fatalf("channel = %q, want the requested canary/beta", rec.Channel) |
| + } |
| +} |
| + |
| +// TestAPISidecarCreateAlwaysStampsAChannel is the load-bearing one. Task 9's |
| +// reconcile pass PROTECTS a channel-less sidecar record — it is deliberately |
| +// never minted, because a route with no channel cannot resolve a release and |
| +// so has no build to exec. A record created without a channel would be |
| +// silently inert: minted by nothing, with no error anywhere. This door is |
| +// therefore the thing that has to stamp it, from the request if valid, else |
| +// the manifest, else the documented default. |
| +func TestAPISidecarCreateAlwaysStampsAChannel(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-side-channel") |
| + u := sidecarsURL(fx, "bab", "titogo") |
| + |
| + // A manifest carrying NO channel at all — the shape that would leave a |
| + // record channel-less if the door only copied the manifest through. |
| + m := titoSidecarManifest() |
| + m.Channel = "" |
| + m.Regions = nil |
| + enableSidecars(t, fx, "titogo", m) |
| + |
| + for _, c := range []struct{ what, body, want string }{ |
| + {"no channel anywhere", `{"name":"bot"}`, "stable"}, |
| + {"explicit channel", `{"name":"beta","channel":"canary/beta"}`, "canary/beta"}, |
| + {"blank channel falls through to the default", `{"name":"blank","channel":" "}`, "stable"}, |
| + } { |
| + status, _, out := rawReq(t, http.MethodPost, u, token, c.body) |
| + if status != http.StatusCreated { |
| + t.Fatalf("%s: create = %d %s, want 201", c.what, status, out) |
| + } |
| + var rec provision.SidecarRecord |
| + if err := json.Unmarshal([]byte(out), &rec); err != nil { |
| + t.Fatalf("%s: create body %s: %v", c.what, out, err) |
| + } |
| + if rec.Channel != c.want { |
| + t.Errorf("%s: channel = %q, want %q", c.what, rec.Channel, c.want) |
| + } |
| + } |
| + // The manifest's channel is used when it has one. |
| + m.Channel = "canary/tito" |
| + enableSidecars(t, fx, "titogo", m) |
| + status, _, out := rawReq(t, http.MethodPost, u, token, `{"name":"inherit"}`) |
| + if status != http.StatusCreated { |
| + t.Fatalf("create = %d %s, want 201", status, out) |
| + } |
| + var rec provision.SidecarRecord |
| + if err := json.Unmarshal([]byte(out), &rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.Channel != "canary/tito" { |
| + t.Errorf("channel = %q, want the manifest's canary/tito", rec.Channel) |
| + } |
| + |
| + // EVERY record this door wrote carries a non-empty channel, read back |
| + // from the bucket — the reconcile pass reads these bytes, not the |
| + // response body. |
| + recs, _, err := provision.ListSidecarRecords(context.Background(), fx.rel, "bab", "titogo", 100) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(recs) != 4 { |
| + t.Fatalf("stored records = %d, want 4", len(recs)) |
| + } |
| + for _, r := range recs { |
| + if r.Channel == "" { |
| + t.Errorf("record %s was stored with no channel — the reconcile pass protects it and never mints it", r.Name) |
| + } |
| + } |
| +} |
| + |
| +// TestAPISidecarCreateIsCreateOnly: the same name twice is a lost race, not |
| +// a silent overwrite — the winner's record stands, byte for byte. |
| +func TestAPISidecarCreateIsCreateOnly(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-side-createonly") |
| + enableSidecars(t, fx, "titogo", titoSidecarManifest()) |
| + u := sidecarsURL(fx, "bab", "titogo") |
| + if status, _, out := rawReq(t, http.MethodPost, u, token, `{"name":"bot","region":"ie"}`); status != http.StatusCreated { |
| + t.Fatalf("create = %d %s", status, out) |
| + } |
| + first, err := provision.GetSidecarRecord(context.Background(), fx.rel, "bab", "titogo", "bot") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + status, _, out := rawReq(t, http.MethodPost, u, token, `{"name":"bot","region":"ie","channel":"canary/other"}`) |
| + if status != http.StatusConflict { |
| + t.Fatalf("duplicate create = %d %s, want 409", status, out) |
| + } |
| + if !strings.Contains(out, "already exists") { |
| + t.Fatalf("duplicate refusal = %s, want it to say the sidecar already exists", out) |
| + } |
| + after, err := provision.GetSidecarRecord(context.Background(), fx.rel, "bab", "titogo", "bot") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if after.Channel != first.Channel || !after.CreatedAt.Equal(first.CreatedAt) { |
| + t.Fatalf("the losing create changed the winner's record: %+v then %+v", first, after) |
| + } |
| +} |
| + |
| +// TestAPISidecarDeleteIsIdempotent: the caller's goal is "not there", and a |
| +// second delete has reached it. Deletion also outlives POLICY — an app that |
| +// retired its sidecar manifest must still be able to clean up the records it |
| +// left behind, or provisioned rows are stranded with nothing able to retire |
| +// them. |
| +func TestAPISidecarDeleteIsIdempotent(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-side-delete") |
| + enableSidecars(t, fx, "titogo", titoSidecarManifest()) |
| + u := sidecarsURL(fx, "bab", "titogo") |
| + |
| + // A name that never existed: still 200. |
| + if status, _, out := rawReq(t, http.MethodDelete, u+"/ghost", token, ""); status != http.StatusOK { |
| + t.Fatalf("delete of an absent name = %d %s, want an idempotent 200", status, out) |
| + } |
| + // A hostile name is refused before the key builder, and deletes nothing. |
| + if status, _, out := rawReq(t, http.MethodDelete, u+"/..%2f..%2fsidecars.json", token, ""); status != http.StatusBadRequest { |
| + t.Fatalf("delete of a traversal name = %d %s, want 400", status, out) |
| + } |
| + if _, _, err := fx.rel.Get(context.Background(), provision.SidecarManifestKey("bab", "titogo")); err != nil { |
| + t.Fatalf("the manifest was destroyed by a traversal delete: %v", err) |
| + } |
| + |
| + if status, _, out := rawReq(t, http.MethodPost, u, token, `{"name":"bot","region":"ie"}`); status != http.StatusCreated { |
| + t.Fatalf("create = %d %s", status, out) |
| + } |
| + // Give it a wake object, so the delete's cleanup has something to do. |
| + if status, _, out := rawReq(t, http.MethodPost, u+"/bot/wake", token, ""); status != http.StatusOK { |
| + t.Fatalf("wake = %d %s", status, out) |
| + } |
| + status, _, out := rawReq(t, http.MethodDelete, u+"/bot", token, "") |
| + if status != http.StatusOK || !strings.Contains(out, "deleted") { |
| + t.Fatalf("delete = %d %s, want 200 deleted", status, out) |
| + } |
| + if _, _, err := fx.rel.Get(context.Background(), |
| + provision.SidecarRecordKey("bab", "titogo", "bot")); err == nil { |
| + t.Fatalf("the record survived its delete") |
| + } |
| + // The wake object goes with it: a per-member sidecar fleet would |
| + // otherwise leave one object per retired member in the bucket forever. |
| + if _, _, err := fx.rel.Get(context.Background(), |
| + release.SidecarWakeKey("bab", "titogo", "bot")); err == nil { |
| + t.Fatalf("the wake object outlived the record it belongs to") |
| + } |
| + // Again: still 200. |
| + if status, _, out := rawReq(t, http.MethodDelete, u+"/bot", token, ""); status != http.StatusOK { |
| + t.Fatalf("second delete = %d %s, want an idempotent 200", status, out) |
| + } |
| + |
| + // Deletion outlives the manifest. |
| + if status, _, out := rawReq(t, http.MethodPost, u, token, `{"name":"beta","region":"ie"}`); status != http.StatusCreated { |
| + t.Fatalf("create = %d %s", status, out) |
| + } |
| + if err := fx.rel.Delete(context.Background(), provision.SidecarManifestKey("bab", "titogo")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if status, _, out := rawReq(t, http.MethodDelete, u+"/beta", token, ""); status != http.StatusOK { |
| + t.Fatalf("delete with the manifest retired = %d %s, want 200", status, out) |
| + } |
| + // And the shape check still applies with no manifest to read. |
| + if status, _, out := rawReq(t, http.MethodDelete, u+"/..%2f..%2fchannels", token, ""); status != http.StatusBadRequest { |
| + t.Fatalf("traversal delete with no manifest = %d %s, want 400", status, out) |
| + } |
| +} |
| + |
| +// TestAPISidecarWakeBumpsTheGeneration: two wakes move the generation 1 then |
| +// 2, the serial moves both times (that is what makes a box act within |
| +// seconds), and the write goes through release.TouchSidecarWake — the one |
| +// writer for this object — rather than a second hand-rolled counter. |
| +func TestAPISidecarWakeBumpsTheGeneration(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-side-wake") |
| + enableSidecars(t, fx, "titogo", titoSidecarManifest()) |
| + u := sidecarsURL(fx, "bab", "titogo") |
| + if status, _, out := rawReq(t, http.MethodPost, u, token, `{"name":"bot","region":"ie"}`); status != http.StatusCreated { |
| + t.Fatalf("create = %d %s", status, out) |
| + } |
| + |
| + readGen := func() release.SidecarWake { |
| + t.Helper() |
| + raw, _, err := fx.rel.Get(context.Background(), release.SidecarWakeKey("bab", "titogo", "bot")) |
| + if err != nil { |
| + t.Fatalf("wake object: %v", err) |
| + } |
| + var w release.SidecarWake |
| + if err := json.Unmarshal(raw, &w); err != nil { |
| + t.Fatal(err) |
| + } |
| + return w |
| + } |
| + serial := func() int64 { |
| + t.Helper() |
| + doc, _, err := release.ReadSerial(context.Background(), fx.rel) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return doc.Serial |
| + } |
| + |
| + before := serial() |
| + if status, _, out := rawReq(t, http.MethodPost, u+"/bot/wake", token, ""); status != http.StatusOK { |
| + t.Fatalf("first wake = %d %s, want 200", status, out) |
| + } |
| + w := readGen() |
| + if w.Schema != 1 || w.Generation != 1 { |
| + t.Fatalf("after one wake = %+v, want schema 1 generation 1", w) |
| + } |
| + if w.By != "alice@keymail.dev" { |
| + t.Fatalf("wake by = %q, want the principal that asked", w.By) |
| + } |
| + if w.At == "" || w.Reason == "" { |
| + t.Fatalf("wake object carries no at/reason for a human debugging it: %+v", w) |
| + } |
| + afterFirst := serial() |
| + if afterFirst <= before { |
| + t.Fatalf("serial %d -> %d: the first wake did not announce itself", before, afterFirst) |
| + } |
| + |
| + if status, _, out := rawReq(t, http.MethodPost, u+"/bot/wake", token, ""); status != http.StatusOK { |
| + t.Fatalf("second wake = %d %s, want 200", status, out) |
| + } |
| + if w := readGen(); w.Generation != 2 { |
| + t.Fatalf("after two wakes generation = %d, want 2", w.Generation) |
| + } |
| + if s := serial(); s <= afterFirst { |
| + t.Fatalf("serial %d -> %d: the second wake did not announce itself", afterFirst, s) |
| + } |
| +} |
| + |
| +// TestAPISidecarEnableChain: the member-facing half of `carlos sidecars |
| +// enable`. It writes the app's policy, updates preserve the operator-only |
| +// fields (pool and regions), and its refusals are cheap and named. |
| +func TestAPISidecarEnableChain(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-side-enable") |
| + e := sidecarEnableURL(fx, "bab", "titogo") |
| + |
| + status, _, out := rawReq(t, http.MethodPut, e, token, |
| + `{"channel":"beta","args":["sidecar","run"],"placement":"no-inbound","idle_seconds":300,"max_run_seconds":3600,"hibernate":false}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("enable = %d %s, want 200", status, out) |
| + } |
| + m, err := provision.GetSidecarManifest(context.Background(), fx.rel, "bab", "titogo") |
| + if err != nil { |
| + t.Fatalf("manifest after enable: %v", err) |
| + } |
| + if m.Schema != 1 || m.App != "titogo" || m.Channel != "beta" || |
| + m.Placement != provision.PlacementNoInbound || m.IdleSeconds != 300 || m.MaxRunSeconds != 3600 { |
| + t.Fatalf("manifest = %+v", m) |
| + } |
| + if len(m.Args) != 2 || m.Args[0] != "sidecar" || m.Args[1] != "run" { |
| + t.Fatalf("manifest args = %v", m.Args) |
| + } |
| + if m.Hibernate == nil || *m.Hibernate { |
| + t.Fatalf("hibernate = %v, want an explicit false (absent and false are different facts)", m.Hibernate) |
| + } |
| + |
| + // Operator policy rides through an update untouched, and an omitted |
| + // field preserves what the manifest already carries. |
| + m.Pool, m.Regions = "tito", []string{"ie"} |
| + enableSidecars(t, fx, "titogo", *m) |
| + if status, _, out := rawReq(t, http.MethodPut, e, token, `{"channel":"stable"}`); status != http.StatusOK { |
| + t.Fatalf("update = %d %s, want 200", status, out) |
| + } |
| + m2, err := provision.GetSidecarManifest(context.Background(), fx.rel, "bab", "titogo") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if m2.Pool != "tito" || len(m2.Regions) != 1 || m2.Regions[0] != "ie" { |
| + t.Fatalf("operator policy was rewritten by a member update: %+v", m2) |
| + } |
| + if m2.Channel != "stable" || m2.Placement != provision.PlacementNoInbound || |
| + m2.IdleSeconds != 300 || len(m2.Args) != 2 || m2.Hibernate == nil || *m2.Hibernate { |
| + t.Fatalf("an omitted field did not survive the update: %+v", m2) |
| + } |
| + |
| + // The create door can now be used, and the enable announced itself. |
| + if status, _, out := rawReq(t, http.MethodPost, sidecarsURL(fx, "bab", "titogo"), token, |
| + `{"name":"bot","region":"ie"}`); status != http.StatusCreated { |
| + t.Fatalf("create after enable = %d %s, want 201", status, out) |
| + } |
| + |
| + // Cheap validation refusals, after the gate. |
| + for _, c := range []struct{ what, body string }{ |
| + {"bad channel", `{"channel":"NOT a channel"}`}, |
| + {"unknown placement", `{"placement":"anywhere"}`}, |
| + {"too many args", `{"args":[` + strings.Repeat(`"a",`, 16) + `"a"]}`}, |
| + {"empty arg", `{"args":[""]}`}, |
| + {"oversized arg", `{"args":["` + strings.Repeat("a", 257) + `"]}`}, |
| + {"negative idle", `{"idle_seconds":-1}`}, |
| + {"negative max run", `{"max_run_seconds":-1}`}, |
| + {"absurd idle", `{"idle_seconds":999999999}`}, |
| + } { |
| + if status, _, out := rawReq(t, http.MethodPut, e, token, c.body); status != http.StatusBadRequest { |
| + t.Errorf("enable %s = %d %s, want 400", c.what, status, out) |
| + } |
| + } |
| +} |
| + |
| +// TestAPISidecarListAndGet: the list is name-sorted, capped honestly, and |
| +// carries the best-effort running version joined from the fleet's own box |
| +// snapshots — a sidecar that has never reported reads empty, never the |
| +// desired version, because a stamp that overclaims is worse than an absent |
| +// one. |
| +func TestAPISidecarListAndGet(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-side-list") |
| + enableSidecars(t, fx, "titogo", titoSidecarManifest()) |
| + u := sidecarsURL(fx, "bab", "titogo") |
| + for _, n := range []string{"gamma", "alpha", "beta"} { |
| + if status, _, out := rawReq(t, http.MethodPost, u, token, |
| + fmt.Sprintf(`{"name":%q,"region":"ie"}`, n)); status != http.StatusCreated { |
| + t.Fatalf("create %s = %d %s", n, status, out) |
| + } |
| + } |
| + |
| + // A fresh box reporting alpha's version, and a STALE one reporting |
| + // beta's, which must drop out of the join rather than answer as current. |
| + fresh := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now()) |
| + fresh.Metrics = &status.BoxMetrics{RouteVersions: map[string]string{ |
| + "alpha.titogo.bab.sidecar.internal": "abc1234"}} |
| + if err := status.WriteBox(context.Background(), fx.rel, fresh); err != nil { |
| + t.Fatal(err) |
| + } |
| + stale := status.CollectBox(nil, status.Probes{MachineID: "box2"}, time.Now().Add(-time.Hour)) |
| + stale.Metrics = &status.BoxMetrics{RouteVersions: map[string]string{ |
| + "beta.titogo.bab.sidecar.internal": "deadbeef"}} |
| + if err := status.WriteBox(context.Background(), fx.rel, stale); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + code, _, out := rawReq(t, http.MethodGet, u, token, "") |
| + if code != http.StatusOK { |
| + t.Fatalf("list = %d %s, want 200", code, out) |
| + } |
| + var list struct { |
| + Sidecars []struct { |
| + Name string `json:"name"` |
| + Host string `json:"host"` |
| + Channel string `json:"channel"` |
| + RunningVersion string `json:"running_version"` |
| + } `json:"sidecars"` |
| + Truncated bool `json:"truncated"` |
| + } |
| + if err := json.Unmarshal([]byte(out), &list); err != nil { |
| + t.Fatalf("list body %s: %v", out, err) |
| + } |
| + if list.Truncated { |
| + t.Fatalf("three records reported truncated") |
| + } |
| + if len(list.Sidecars) != 3 { |
| + t.Fatalf("list = %+v, want three records", list.Sidecars) |
| + } |
| + if list.Sidecars[0].Name != "alpha" || list.Sidecars[2].Name != "gamma" { |
| + t.Fatalf("list is not name-sorted: %+v", list.Sidecars) |
| + } |
| + got := map[string]string{} |
| + for _, r := range list.Sidecars { |
| + if r.Channel == "" { |
| + t.Errorf("%s listed with no channel", r.Name) |
| + } |
| + got[r.Name] = r.RunningVersion |
| + } |
| + if got["alpha"] != "abc1234" { |
| + t.Errorf("alpha running_version = %q, want abc1234 (its box is fresh)", got["alpha"]) |
| + } |
| + if got["beta"] != "" { |
| + t.Errorf("beta running_version = %q, want empty — its only reporting box is stale", got["beta"]) |
| + } |
| + if got["gamma"] != "" { |
| + t.Errorf("gamma running_version = %q, want empty — no box has reported it", got["gamma"]) |
| + } |
| + |
| + // GET one. |
| + code, _, out = rawReq(t, http.MethodGet, u+"/alpha", token, "") |
| + if code != http.StatusOK { |
| + t.Fatalf("get = %d %s, want 200", code, out) |
| + } |
| + var rec provision.SidecarRecord |
| + if err := json.Unmarshal([]byte(out), &rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.Name != "alpha" || rec.Host != "alpha.titogo.bab.sidecar.internal" { |
| + t.Fatalf("get = %+v", rec) |
| + } |
| +} |
| + |
| +// TestAPISidecarNoStore: a console running without a deployment bucket |
| +// answers 503 on every verb rather than panicking on a nil store. |
| +func TestAPISidecarNoStore(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-side-nostore") |
| + fx.srv.cfg.Releases = nil |
| + u := sidecarsURL(fx, "bab", "titogo") |
| + for _, c := range []struct{ what, method, url, body string }{ |
| + {"enable", http.MethodPut, sidecarEnableURL(fx, "bab", "titogo"), `{}`}, |
| + {"list", http.MethodGet, u, ""}, |
| + {"create", http.MethodPost, u, `{"name":"bot","region":"ie"}`}, |
| + {"get", http.MethodGet, u + "/bot", ""}, |
| + {"delete", http.MethodDelete, u + "/bot", ""}, |
| + {"wake", http.MethodPost, u + "/bot/wake", ""}, |
| + } { |
| + if status, _, out := rawReq(t, c.method, c.url, token, c.body); status != http.StatusServiceUnavailable { |
| + t.Errorf("%s with no bucket = %d %s, want 503", c.what, status, out) |
| + } |
| + } |
| +} |
| + |
| +// unlistableStore is a bucket whose prefix listing fails — the one store |
| +// call the list door makes on its own behalf. |
| +type unlistableStore struct{ release.Store } |
| + |
| +func (u unlistableStore) List(context.Context, string) ([]string, error) { |
| + return nil, errors.New(brokenStoreErr) |
| +} |
| + |
| +// TestAPISidecarStoreFailure: a bucket that cannot be read or written is a |
| +// 502, and its error text — bucket names, request ids — never reaches the |
| +// caller. A read failure is NOT reported as "sidecars are not enabled". |
| +func TestAPISidecarStoreFailure(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-side-broken") |
| + enableSidecars(t, fx, "titogo", titoSidecarManifest()) |
| + u := sidecarsURL(fx, "bab", "titogo") |
| + |
| + fx.srv.cfg.Releases = brokenStore{fx.rel} |
| + status, _, out := rawReq(t, http.MethodPost, u, token, `{"name":"bot","region":"ie"}`) |
| + if status != http.StatusBadGateway { |
| + t.Fatalf("create against an unwritable bucket = %d %s, want 502", status, out) |
| + } |
| + if strings.Contains(out, "carlos-prod-secret-bucket") || strings.Contains(out, "ABC123") { |
| + t.Fatalf("refusal leaks the store's error text: %s", out) |
| + } |
| + |
| + fx.srv.cfg.Releases = unreadableStore{fx.rel} |
| + status, _, out = rawReq(t, http.MethodPost, u, token, `{"name":"bot","region":"ie"}`) |
| + if status != http.StatusBadGateway { |
| + t.Fatalf("create against an unreadable bucket = %d %s, want 502", status, out) |
| + } |
| + if strings.Contains(out, "sidecars enable") { |
| + t.Fatalf("a read failure was reported as the feature being disabled: %s", out) |
| + } |
| + // A bucket that cannot even be LISTED is the list's own 502. (A bucket |
| + // that lists but whose bodies cannot be read answers an empty list, not |
| + // an error: provision.ListSidecarRecords skips an unreadable record |
| + // rather than failing the page, the same rule ListRecords applies for |
| + // instances. Recorded here so the difference is deliberate.) |
| + fx.srv.cfg.Releases = unlistableStore{fx.rel} |
| + status, _, out = rawReq(t, http.MethodGet, u, token, "") |
| + if status != http.StatusBadGateway { |
| + t.Fatalf("list against an unlistable bucket = %d %s, want 502", status, out) |
| + } |
| + if strings.Contains(out, "carlos-prod-secret-bucket") { |
| + t.Fatalf("list refusal leaks the store's error text: %s", out) |
| + } |
| + |
| + fx.srv.cfg.Releases = undeletableStore{fx.rel} |
| + status, _, out = rawReq(t, http.MethodDelete, u+"/bot", token, "") |
| + if status != http.StatusBadGateway { |
| + t.Fatalf("delete against a bucket that cannot delete = %d %s, want 502", status, out) |
| + } |
| + if strings.Contains(out, "carlos-prod-secret-bucket") { |
| + t.Fatalf("delete refusal leaks the store's error text: %s", out) |
| + } |
| +} |
| + |
| +// TestAPISidecarRateLimit: the budget is spent AFTER the gate, so a |
| +// stranger's refusal never changes shape however many times they ask — |
| +// which is the gate-before-budget ordering asserted from outside. A limiter |
| +// consulted first would start answering 429 where the 404 belongs, and that |
| +// is a difference a prober can measure. |
| +func TestAPISidecarRateLimit(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-side-ratelimit") |
| + enableSidecars(t, fx, "titogo", titoSidecarManifest()) |
| + u := sidecarsURL(fx, "bab", "titogo") |
| + |
| + for i := 0; i < cliInstanceBurst; i++ { |
| + body := fmt.Sprintf(`{"name":"bot%d","region":"ie"}`, i) |
| + if status, _, out := rawReq(t, http.MethodPost, u, token, body); status != http.StatusCreated { |
| + t.Fatalf("create %d = %d %s, want 201 inside the budget", i, status, out) |
| + } |
| + } |
| + if status, _, out := rawReq(t, http.MethodPost, u, token, |
| + `{"name":"over","region":"ie"}`); status != http.StatusTooManyRequests { |
| + t.Fatalf("create past the budget = %d %s, want 429", status, out) |
| + } |
| + |
| + eve := cliToken(t, fx, "eve@keymail.dev", "code-side-rl-eve") |
| + want := canonicalNotFound(t) |
| + for i := 0; i < cliInstanceBurst+2; i++ { |
| + status, h, out := rawReq(t, http.MethodPost, u, eve, `{"name":"x","region":"ie"}`) |
| + assertNotFound(t, fmt.Sprintf("non-member probe %d", i), want, status, h, out) |
| + if t.Failed() { |
| + break |
| + } |
| + } |
| + // The write budget the ENABLE door uses is a different one: the member |
| + // above spent the record budget, not this. |
| + if status, _, out := rawReq(t, http.MethodPut, sidecarEnableURL(fx, "bab", "titogo"), |
| + token, `{"channel":"stable"}`); status != http.StatusOK { |
| + t.Fatalf("enable after spending the record budget = %d %s, want 200", status, out) |
| + } |
| +} |