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" "path/filepath" "strings" "testing" "time" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/registry" "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) } // A real SIBLING app in the token's own account, with sidecars enabled on // it. The foreign-app refusal must rest on the token's app binding, not on // the app being absent or the feature being off: if app scoping broke, // every gate past this point would let the call through to a create. if _, err := fx.st.CreateAppIn(OpsAccountID, "sibling", "alice@keymail.dev", now); err != nil { t.Fatal(err) } enableSidecars(t, fx, "sibling", titoSidecarManifest()) 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, unclaimed app", http.MethodGet, sidecarsURL(fx, "bab", "other"), appTok, ""}, {"app token, unclaimed app create", http.MethodPost, sidecarsURL(fx, "bab", "other"), appTok, body}, // The rigorous half: a REAL sibling app in the token's own account, // with sidecars enabled on it, so nothing but the binding can be // doing the refusing. {"app token, sibling app in its own account", http.MethodGet, sidecarsURL(fx, "bab", "sibling"), appTok, ""}, {"app token, sibling app create", http.MethodPost, sidecarsURL(fx, "bab", "sibling"), appTok, body}, {"app token, sibling app get", http.MethodGet, sidecarsURL(fx, "bab", "sibling") + "/bot", appTok, ""}, {"app token, sibling app delete", http.MethodDelete, sidecarsURL(fx, "bab", "sibling") + "/bot", appTok, ""}, {"app token, sibling app wake", http.MethodPost, sidecarsURL(fx, "bab", "sibling") + "/bot/wake", appTok, ""}, // 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 nothing landed in the sibling app, whose sidecars ARE enabled: the // app token reached neither the manifest nor the write. if _, _, err := fx.rel.Get(context.Background(), provision.SidecarRecordKey("bab", "sibling", "bot")); err == nil { t.Fatalf("an app token wrote into a SIBLING app in its own account — the binding is not scoping by app") } // 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) } // 403, not 401: the enable door carries the ZERO role Set (the escalation // cut — see closedDoors), so the role gate refuses a service credential // at the registration, before the handler's own apiAccountApp ever runs. // The refusal is the same one this door always made; only the answer a // credential gets for it moved. if status, _, out := rawReq(t, http.MethodPut, sidecarEnableURL(fx, "bab", "titogo"), appTok, `{}`); status != http.StatusForbidden { t.Fatalf("app token enabling sidecars = %d %s, want 403", 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.StatusForbidden { t.Fatalf("app token on promote = %d %s, want 403", status, out) } if status, _, out := rawReq(t, http.MethodPost, fx.ts.URL+"/api/cli/apps/bab/titogo/instance-token", appTok, `{}`); status != http.StatusForbidden { t.Fatalf("app token minting a token = %d %s, want 403", status, out) } if status, _, out := rawReq(t, http.MethodPut, sidecarEnableURL(fx, "bab", "titogo"), appTok, `{"channel":"beta"}`); status != http.StatusForbidden { t.Fatalf("app token enabling sidecars = %d %s, want 403", 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 serving version and gamma's installed // version, and a stale box reporting beta's serving version. fresh := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now()) fresh.Metrics = &status.BoxMetrics{ RouteServing: map[string]string{"alpha.titogo.bab.sidecar.internal": "abc1234"}, // gamma is installed but not started: root's stamp is not a running build. RouteVersions: map[string]string{"gamma.titogo.bab.sidecar.internal": "f00d123"}, } 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{RouteServing: 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 — an install stamp is not a running build", 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) } } // TestAPISidecarCreateInheritsFleetPlacement: a fleet-placed app's sidecars // inherit the app's placement, exactly as its instances do — the request // body's region is ignored entirely, and even a region the manifest's // allow-list would otherwise refuse succeeds, because that validation is // never consulted once the app is placed. This is not a nicety: Eleven, the // customer this whole family exists for, places its app onto a fleet, and // "fleet/" can never pass provision.ValidRegion — so without the // override there is no such thing as a usable Eleven sidecar record. // // A route is seeded first for appPlacement's route-derived fallback path, // the realistic shape of an app already running before it was placed (see // TestAPIInstanceCreateInheritsFleetPlacement's own doc comment). func TestAPISidecarCreateInheritsFleetPlacement(t *testing.T) { fx, token := instanceFixture(t, "code-side-placed") enableSidecars(t, fx, "titogo", titoSidecarManifest()) // Regions: []string{"ie"} reg, err := registry.Open(fx.regPath) if err != nil { t.Fatal(err) } if err := reg.Upsert(registry.Route{Host: "www.go.tito.io", Kind: "instance", App: "titogo", Account: "bab"}); err != nil { t.Fatal(err) } if status, body := postJSON(t, fx.ts.URL+"/api/cli/fleets/bab/new", map[string]string{"name": "eleven", "bucket": "eleven-fleet-data"}, token); status != http.StatusOK { t.Fatalf("create fleet = %d %v, want 200", status, body) } if status, body := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/place", map[string]string{"target": "fleet/eleven"}, token); status != http.StatusOK { t.Fatalf("place = %d %v, want 200", status, body) } // "us" is outside titoSidecarManifest's ["ie"] allow-list — refused on an // unplaced app (TestAPISidecarCreateUnplacedHonorsBodyRegion below), but // accepted and overridden here, which is the whole point of the ordering. status, _, out := rawReq(t, http.MethodPost, sidecarsURL(fx, "bab", "titogo"), token, `{"name":"bot","region":"us"}`) if status != http.StatusCreated { t.Fatalf("create on a placed 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.Region != "fleet/eleven" { t.Fatalf("record Region = %q, want the inherited fleet/eleven (body region ignored)", rec.Region) } // The stored record agrees, and — the half that would otherwise make this // inert — it passes the shape rail the reconcile pass re-checks it at, so // a fleet-placed sidecar is mintable rather than permanently protected. stored, err := provision.GetSidecarRecord(context.Background(), fx.rel, "bab", "titogo", "bot") if err != nil { t.Fatal(err) } if stored.Region != "fleet/eleven" { t.Fatalf("stored record Region = %q, want fleet/eleven", stored.Region) } if err := provision.ValidateSidecarShape(*stored); err != nil { t.Fatalf("the record this door wrote fails its own shape rail: %v", err) } } // TestAPISidecarCreateUnplacedHonorsBodyRegion is the regression guard for // the inheritance above: with no placement, the ordinary contract is // unchanged — the body's region is shape-checked and matched against the // manifest's allow-list exactly as before. func TestAPISidecarCreateUnplacedHonorsBodyRegion(t *testing.T) { fx, token := instanceFixture(t, "code-side-unplaced") enableSidecars(t, fx, "titogo", titoSidecarManifest()) u := sidecarsURL(fx, "bab", "titogo") status, _, out := rawReq(t, http.MethodPost, u, token, `{"name":"bot","region":"ie"}`) 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.Region != "ie" { t.Fatalf("Region = %q, want the body's ie (no placement in play)", rec.Region) } if status, _, out := rawReq(t, http.MethodPost, u, token, `{"name":"beta","region":"us"}`); status != http.StatusBadRequest { t.Fatalf("create outside the allow-list on an unplaced app = %d %s, want 400", status, out) } // And a fleet target is NOT a way to smuggle one past the allow-list: an // unplaced app's region is user input, and user input takes ValidRegion. if status, _, out := rawReq(t, http.MethodPost, u, token, `{"name":"beta","region":"fleet/eleven"}`); status != http.StatusBadRequest { t.Fatalf("create naming a fleet region on an UNPLACED app = %d %s, want 400", status, out) } } // TestAPISidecarCreatePlacementLookupFailure: a registry the console cannot // open is the instance door's error shape — 502 "could not check this app's // placement" — and never a record written against an unknown placement. func TestAPISidecarCreatePlacementLookupFailure(t *testing.T) { fx, token := instanceFixture(t, "code-side-placefail") enableSidecars(t, fx, "titogo", titoSidecarManifest()) fx.srv.cfg.Registry = filepath.Join(t.TempDir(), "no-such-dir", "registry.db") status, _, out := rawReq(t, http.MethodPost, sidecarsURL(fx, "bab", "titogo"), token, `{"name":"bot","region":"ie"}`) if status != http.StatusBadGateway { t.Fatalf("create with an unopenable registry = %d %s, want 502", status, out) } if !strings.Contains(out, "placement") { t.Fatalf("refusal = %s, want it to name the check that failed", out) } if _, _, err := fx.rel.Get(context.Background(), provision.SidecarRecordKey("bab", "titogo", "bot")); err == nil { t.Fatalf("a record was written despite an unknown placement") } } // TestAPISidecarRateLimitNamesTheVerbFamily: the budget is shared with the // instance verbs deliberately (one credential, one kind of work), but a // member who spends it creating sidecars is told about sidecars. func TestAPISidecarRateLimitNamesTheVerbFamily(t *testing.T) { fx, token := instanceFixture(t, "code-side-429text") enableSidecars(t, fx, "titogo", titoSidecarManifest()) u := sidecarsURL(fx, "bab", "titogo") for i := 0; i < cliInstanceBurst; i++ { if status, _, out := rawReq(t, http.MethodPost, u, token, fmt.Sprintf(`{"name":"bot%d","region":"ie"}`, i)); status != http.StatusCreated { t.Fatalf("create %d = %d %s", i, status, out) } } status, _, out := rawReq(t, http.MethodPost, u, token, `{"name":"over","region":"ie"}`) if status != http.StatusTooManyRequests { t.Fatalf("create past the budget = %d %s, want 429", status, out) } if !strings.Contains(out, "sidecar requests") { t.Fatalf("429 body = %s, want it to name the verb family the caller was using", out) } }