diff --git a/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md index 3fd189ccc9..31816fc293 100644 --- a/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md @@ -54,15 +54,15 @@ rather than deleting unasked. ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------------- | -| `0` | success (a `404` on DELETE counts — it is already gone) | -| `0` | nothing deployed under that name, with `--yes` (teardown is idempotent) | -| `1` | invalid compute name | -| `1` | nothing deployed under that name, without `--yes` | -| `1` | the typed confirmation did not match the compute's name | -| `1` | confirmation needed but no interactive terminal to ask on, and no `--yes` | -| `1` | API error, or project not enrolled in the alpha | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------ | +| `0` | success (a `404` on DELETE counts — it is already gone) | +| `0` | nothing deployed under that name, with `--yes` (teardown is idempotent) | +| `1` | invalid compute name | +| `1` | nothing deployed under that name, without `--yes` | +| `1` | the typed confirmation did not match the compute's name | +| `1` | confirmation needed but no interactive terminal to ask on, and no `--yes` | +| `1` | API error, project not enrolled in the alpha, no such project, or a route the API does not serve | ## Environment Variables diff --git a/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts b/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts index c39a0fb78a..5555f4226e 100644 --- a/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts +++ b/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts @@ -67,12 +67,14 @@ export const computeDelete = Effect.fn("compute.delete")(function* (flags: Compu yield* rejectComputeEnvOutput(); const fetching = yield* output.task("Fetching compute..."); - // The lookup is a courtesy, not a prerequisite: it supplies the instance - // tally the confirmation quotes and the "already gone" verdict. The API - // grants the read and the delete separately — `edge_functions:read` for - // `GET`, `edge_functions:write` for `DELETE` — so a credential holding only - // the latter could not delete a compute it is entitled to delete. A refused - // read now leaves the compute *unknown* and the delete goes ahead. + // The lookup supplies the instance tally the confirmation quotes and the + // "already gone" verdict. A *refusal* is not a prerequisite: the API grants + // the read and the delete separately — `edge_functions:read` for `GET`, + // `edge_functions:write` for `DELETE` — so a credential holding only the + // latter would not be able to delete a compute it is entitled to delete. A + // refused read leaves the compute unknown and the delete goes ahead. A read + // that cannot reach compute at all — unserved route, unenrolled project, no + // such project — still aborts here, before any DELETE is sent. const lookup = yield* getCompute(api, projectRef, name).pipe( Effect.map((found) => ({ readable: true, compute: Option.getOrUndefined(found) })), Effect.catchIf( diff --git a/apps/cli/src/commands/experimental/compute/delete/delete.integration.test.ts b/apps/cli/src/commands/experimental/compute/delete/delete.integration.test.ts index b90ced4a70..d9cb532c76 100644 --- a/apps/cli/src/commands/experimental/compute/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/delete/delete.integration.test.ts @@ -12,7 +12,10 @@ import { ComputeDeleteConfirmationRequiredError, ComputeDeleteNotConfirmedError, ComputeNotDeployedError, + ComputeUnavailableError, ComputeApiUnexpectedStatusError, + ComputeProjectNotFoundError, + ComputeRouteNotFoundError, } from "../../../../shared/compute/compute.errors.ts"; import { ComputeEnvNotSupportedError } from "../compute.errors.ts"; import { computeDelete } from "./delete.handler.ts"; @@ -396,12 +399,17 @@ describe("compute delete", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + const notDeployed = { + status: 404, + body: { error: { code: "not_found.compute.instance", message: "Compute instance not found" } }, + }; + it.live("fails with `not deployed` before asking anything", () => Effect.gen(function* () { const repo = yield* project(); const { layer, out } = setupCompute({ workdir: repo.dir, - routes: { [getRoute]: { status: 404, body: { message: "compute not found" } } }, + routes: { [getRoute]: notDeployed }, }); return yield* Effect.gen(function* () { @@ -420,6 +428,139 @@ describe("compute delete", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + const routerNotFound = (method: string) => ({ + status: 404, + body: { + error: { + code: "not_found", + message: `Cannot ${method} ${computeRoute("/api")}`, + }, + }, + }); + + it.live("does not read an unserved route as a compute that was never deployed", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer } = setupCompute({ + workdir: repo.dir, + routes: { [getRoute]: routerNotFound("GET") }, + yes: true, + }); + + return yield* Effect.gen(function* () { + const error = yield* computeDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeRouteNotFoundError); + expect(error).not.toBeInstanceOf(ComputeNotDeployedError); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + const notEnrolled = { + status: 404, + body: { + error: { + code: "not_found.compute.not_enabled", + message: "Compute is not available for this project", + }, + }, + }; + + it.live("names the alpha, not an undeployed compute, when the project is not enrolled", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer, out } = setupCompute({ + workdir: repo.dir, + routes: { [getRoute]: notEnrolled }, + yes: true, + }); + + return yield* Effect.gen(function* () { + const error = yield* computeDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeUnavailableError); + expect(error).not.toBeInstanceOf(ComputeNotDeployedError); + expect(out.stdoutText).not.toContain("Deleted Compute"); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("does not report a delete it never reached as done", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer, out } = setupCompute({ + workdir: repo.dir, + routes: { ...routes, [deleteRoute]: routerNotFound("DELETE") }, + yes: true, + }); + + return yield* Effect.gen(function* () { + const error = yield* computeDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeRouteNotFoundError); + expect(out.stdoutText).not.toContain("Deleted Compute"); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + const projectNotFound = { + status: 404, + body: { error: { code: "not_found", message: "Not Found" } }, + }; + + it.live("does not read a missing project as a compute that was never deployed", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer, out, http } = setupCompute({ + workdir: repo.dir, + routes: { [getRoute]: projectNotFound }, + yes: true, + }); + + return yield* Effect.gen(function* () { + const error = yield* computeDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeProjectNotFoundError); + expect(error).not.toBeInstanceOf(ComputeNotDeployedError); + expect(http.routeKeys).toEqual([getRoute]); + expect(out.stdoutText).not.toContain("Deleted Compute"); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("does not report a delete against a missing project as done", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer, out } = setupCompute({ + workdir: repo.dir, + routes: { ...routes, [deleteRoute]: projectNotFound }, + yes: true, + }); + + return yield* Effect.gen(function* () { + const error = yield* computeDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeProjectNotFoundError); + expect(out.stdoutText).not.toContain("Deleted Compute"); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + // `deleteCompute` already treats a DELETE 404 as done; the pre-flight GET used // to contradict that, so a teardown script run twice failed the second time // for a compute in exactly the state it asked for. @@ -428,7 +569,7 @@ describe("compute delete", () => { const repo = yield* project(); const { layer, out, http } = setupCompute({ workdir: repo.dir, - routes: { [getRoute]: { status: 404, body: { message: "compute not found" } } }, + routes: { [getRoute]: notDeployed }, yes: true, }); @@ -448,7 +589,7 @@ describe("compute delete", () => { const repo = yield* project(); const { layer, out, http } = setupCompute({ workdir: repo.dir, - routes: { [getRoute]: { status: 404, body: { message: "compute not found" } } }, + routes: { [getRoute]: notDeployed }, yes: true, goOutput: "json", }); @@ -474,7 +615,7 @@ describe("compute delete", () => { const repo = yield* project(); const { layer, out } = setupCompute({ workdir: repo.dir, - routes: { ...routes, [deleteRoute]: { status: 404, body: { message: "already gone" } } }, + routes: { ...routes, [deleteRoute]: notDeployed }, yes: true, }); diff --git a/apps/cli/src/commands/experimental/compute/list/list.integration.test.ts b/apps/cli/src/commands/experimental/compute/list/list.integration.test.ts index 5739683b37..d7075695e1 100644 --- a/apps/cli/src/commands/experimental/compute/list/list.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/list/list.integration.test.ts @@ -12,6 +12,8 @@ import { ProjectRefNotLinkedError } from "../../../../config/project-ref.errors. import { ComputeEnvNotSupportedError } from "../compute.errors.ts"; import { ComputeApiUnexpectedStatusError, + ComputeProjectNotFoundError, + ComputeRouteNotFoundError, ComputeUnavailableError, } from "../../../../shared/compute/compute.errors.ts"; import { computeList } from "./list.handler.ts"; @@ -326,7 +328,10 @@ describe("compute list", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("reports a project outside the alpha as unavailable", () => + // Until the Management API ships `not_found.compute.not_enabled`, an unenrolled + // project answers the shared `generic_not_found`, which no branch claims — so + // this pins the fallback that carries the alpha refusal until then. + it.live("still reports the alpha refusal's pre-rollout body as unavailable", () => Effect.gen(function* () { const repo = yield* project(); const { layer } = setupCompute({ @@ -352,6 +357,87 @@ describe("compute list", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + it.live("reports the alpha refusal's own code as unavailable, not as a missing project", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer } = setupCompute({ + workdir: repo.dir, + routes: { + [listRoute]: { + status: 404, + body: { + error: { + code: "not_found.compute.not_enabled", + message: "Compute is not available for this project", + }, + }, + }, + }, + }); + + return yield* Effect.gen(function* () { + const error = yield* computeList({ projectRef: Option.none() }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeUnavailableError); + expect(error).not.toBeInstanceOf(ComputeProjectNotFoundError); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("names the unserved route instead of the project when the API has no such route", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer } = setupCompute({ + workdir: repo.dir, + routes: { + [listRoute]: { + status: 404, + body: { + error: { + code: "not_found", + message: `Cannot GET /v2/projects/${COMPUTE_PROJECT_REF}/compute`, + }, + }, + }, + }, + }); + + return yield* Effect.gen(function* () { + const error = yield* computeList({ projectRef: Option.none() }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeRouteNotFoundError); + expect(error).not.toBeInstanceOf(ComputeProjectNotFoundError); + expect((error as ComputeRouteNotFoundError).detail).toContain( + `GET /v2/projects/${COMPUTE_PROJECT_REF}/compute`, + ); + expect((error as ComputeRouteNotFoundError).suggestion).toContain("supabase issue"); + expect((error as ComputeRouteNotFoundError).suggestion).not.toContain("supabase link"); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("still reports a missing project when the 404 is the project's own", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer } = setupCompute({ + workdir: repo.dir, + routes: { + [listRoute]: { + status: 404, + body: { error: { code: "not_found", message: "Not Found" } }, + }, + }, + }); + + return yield* Effect.gen(function* () { + const error = yield* computeList({ projectRef: Option.none() }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeProjectNotFoundError); + expect((error as ComputeProjectNotFoundError).suggestion).toContain("supabase link"); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + it.live("surfaces an unexpected status rather than showing an empty list", () => Effect.gen(function* () { const repo = yield* project(); diff --git a/apps/cli/src/commands/experimental/compute/logs/logs.integration.test.ts b/apps/cli/src/commands/experimental/compute/logs/logs.integration.test.ts index 757116a0ae..8388419b9b 100644 --- a/apps/cli/src/commands/experimental/compute/logs/logs.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/logs/logs.integration.test.ts @@ -46,6 +46,7 @@ import { ComputeNotDeployedError, ComputeApiNetworkError, ComputeApiUnexpectedStatusError, + ComputeProjectNotFoundError, ComputeUnavailableError, } from "../../../../shared/compute/compute.errors.ts"; import { ComputeEnvNotSupportedError } from "../compute.errors.ts"; @@ -529,7 +530,31 @@ describe("compute logs", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("reports a project outside the alpha for a 404", () => + it.live("points at the project ref when the logs 404 names no such project", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer } = setupCompute({ + workdir: repo.dir, + routes: { + [LOGS_ROUTE]: { + status: 404, + body: { error: { code: "not_found", message: "Not Found" } }, + }, + }, + }); + + return yield* Effect.gen(function* () { + const error = yield* computeLogs(flags()).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeProjectNotFoundError); + expect((error as ComputeProjectNotFoundError).suggestion).toContain("supabase link"); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + // The analytics route is not gated on the alpha's allow-list, so only a body + // no other branch claims is left to read as the family's refusal. + it.live("reports a project outside the alpha for an unclassifiable 404", () => Effect.gen(function* () { const repo = yield* project(); const { layer } = setupCompute({ diff --git a/apps/cli/src/commands/experimental/compute/push/push.integration.test.ts b/apps/cli/src/commands/experimental/compute/push/push.integration.test.ts index 2811991d42..9cc6252af7 100644 --- a/apps/cli/src/commands/experimental/compute/push/push.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/push/push.integration.test.ts @@ -19,6 +19,7 @@ import { ComputeBuildFailedError, ComputeBuildTimeoutError, ComputeProjectNotFoundError, + ComputeRouteNotFoundError, ComputeUnavailableError, ComputeSourceEscapingLinkError, ComputeSourceMissingError, @@ -1031,10 +1032,13 @@ describe("compute push", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - // Both of the next two arrive as a 404 on the same route; only `error.code` - // separates them, so they are asserted against the bodies the API really - // sends rather than a shape of our own invention. - it.live("reports a project outside the alpha as unavailable", () => + // These all arrive as a 404 on the same route; only `error.code` separates + // them, so they are asserted against the bodies the API really sends rather + // than a shape of our own invention. Until the Management API ships + // `not_found.compute.not_enabled`, an unenrolled project answers the shared + // `generic_not_found`, which no branch claims — so the first pins the + // fallback that carries the alpha refusal until then. + it.live("still reports the alpha refusal's pre-rollout body as unavailable", () => Effect.gen(function* () { const repo = yield* project(); const { layer } = setupCompute({ @@ -1061,6 +1065,33 @@ describe("compute push", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + it.live("reports the alpha refusal's own code as unavailable, not as a missing project", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer } = setupCompute({ + workdir: repo.dir, + routes: routes({ + [`POST ${computeRoute("/api/uploads")}`]: { + status: 404, + body: { + error: { + code: "not_found.compute.not_enabled", + message: "Compute is not available for this project", + }, + }, + }, + }), + }); + + return yield* Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeUnavailableError); + expect((error as ComputeUnavailableError).suggestion).toContain("private alpha"); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + it.live("points at the project ref when no such project exists", () => Effect.gen(function* () { const repo = yield* project(); @@ -1084,6 +1115,34 @@ describe("compute push", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + it.live("names the unserved route instead of the project when the API has no such route", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer } = setupCompute({ + workdir: repo.dir, + routes: routes({ + [`POST ${computeRoute("/api/uploads")}`]: { + status: 404, + body: { + error: { + code: "not_found", + message: `Cannot POST /v2/projects/${COMPUTE_PROJECT_REF}/compute/api/uploads`, + }, + }, + }, + }), + }); + + return yield* Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeRouteNotFoundError); + expect((error as ComputeRouteNotFoundError).suggestion).not.toContain("supabase link"); + expect((error as ComputeRouteNotFoundError).suggestion).not.toContain("private alpha"); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + it.live("keeps the enrolment answer for a 404 body it does not recognize", () => Effect.gen(function* () { const repo = yield* project(); @@ -1350,6 +1409,40 @@ describe("compute push", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + it.live("surfaces a permanent poll failure on the first read instead of retrying it", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer, http } = setupCompute({ + workdir: repo.dir, + routes: routes({ + [`GET ${computeRoute("/api")}`]: { + status: 404, + body: { + error: { + code: "not_found.compute.not_enabled", + message: "Compute is not available for this project", + }, + }, + }, + }), + }); + + return yield* Effect.gen(function* () { + // A real retry schedule: a verdict the retry cannot change must not hold + // the poll open for its whole window. + const error = yield* computePush(flags(), { + pollSchedule: IMMEDIATE, + pollRetrySchedule: Schedule.recurs(3), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeUnavailableError); + expect(http.routeKeys.filter((key) => key === `GET ${computeRoute("/api")}`)).toHaveLength( + 1, + ); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + it.live("acts on the workdir's project, not the process's directory", () => Effect.gen(function* () { // `--workdir`/`SUPABASE_WORKDIR` names the project every command acts diff --git a/apps/cli/src/commands/experimental/compute/status/status.integration.test.ts b/apps/cli/src/commands/experimental/compute/status/status.integration.test.ts index 5e577fce9f..08bd6b7fd2 100644 --- a/apps/cli/src/commands/experimental/compute/status/status.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/status/status.integration.test.ts @@ -11,6 +11,9 @@ import { import { InvalidComputeNameError, ComputeNotDeployedError, + ComputeProjectNotFoundError, + ComputeRouteNotFoundError, + ComputeUnavailableError, } from "../../../../shared/compute/compute.errors.ts"; import { ComputeEnvNotSupportedError } from "../compute.errors.ts"; import { computeStatus } from "./status.handler.ts"; @@ -264,7 +267,14 @@ describe("compute status", () => { const repo = yield* project(); const { layer } = setupCompute({ workdir: repo.dir, - routes: { [getRoute]: { status: 404, body: { message: "compute not found" } } }, + routes: { + [getRoute]: { + status: 404, + body: { + error: { code: "not_found.compute.instance", message: "Compute instance not found" }, + }, + }, + }, }); return yield* Effect.gen(function* () { @@ -280,6 +290,93 @@ describe("compute status", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + it.live("names the alpha, not an undeployed compute, when the project is not enrolled", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer } = setupCompute({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 404, + body: { + error: { + code: "not_found.compute.not_enabled", + message: "Compute is not available for this project", + }, + }, + }, + }, + }); + + return yield* Effect.gen(function* () { + const error = yield* computeStatus({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeUnavailableError); + expect(error).not.toBeInstanceOf(ComputeNotDeployedError); + expect((error as ComputeUnavailableError).suggestion).toContain("private alpha"); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("points at the project ref when no such project exists", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer } = setupCompute({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 404, + body: { error: { code: "not_found", message: "Not Found" } }, + }, + }, + }); + + return yield* Effect.gen(function* () { + const error = yield* computeStatus({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeProjectNotFoundError); + expect(error).not.toBeInstanceOf(ComputeNotDeployedError); + expect((error as ComputeProjectNotFoundError).suggestion).toContain("supabase link"); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("names the unserved route instead of an undeployed compute", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer } = setupCompute({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 404, + body: { + error: { + code: "not_found", + message: `Cannot GET ${computeRoute("/api")}`, + }, + }, + }, + }, + }); + + return yield* Effect.gen(function* () { + const error = yield* computeStatus({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeRouteNotFoundError); + expect(error).not.toBeInstanceOf(ComputeNotDeployedError); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + it.live("refuses a name that could never have been written", () => Effect.gen(function* () { const repo = yield* project(); diff --git a/apps/cli/src/shared/compute/compute-api-status.ts b/apps/cli/src/shared/compute/compute-api-status.ts index 0042407032..56319a42fc 100644 --- a/apps/cli/src/shared/compute/compute-api-status.ts +++ b/apps/cli/src/shared/compute/compute-api-status.ts @@ -1,14 +1,20 @@ import { markSupabaseApiInputErrorAsUserInput, SupabaseApiInputError } from "@supabase/api/effect"; -import { Effect, Schema } from "effect"; +import { Effect, Option, Schema } from "effect"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { CLI_UPGRADE_GUIDE_URL } from "../cli/version.ts"; -import { ComputeApiNetworkError, ComputeApiUnexpectedStatusError } from "./compute.errors.ts"; +import { + ComputeApiNetworkError, + ComputeApiUnexpectedStatusError, + ComputeProjectNotFoundError, +} from "./compute.errors.ts"; /** * Status handling shared by every Compute API seam: the compute routes and the analytics logs * endpoint fail the same three ways (the request never left, the server answered something - * unexpected, or the body couldn't be read). Route-specific status meaning — like - * `projectScoped404`, which disambiguates a `/v2/projects/{ref}/compute` 404 by body — stays with its own route. + * unexpected, or the body couldn't be read), and both have to tell several 404s apart by body. + * Reading a 404 body is shared here; what each code *means* on a given route — like + * `projectScoped404` — stays with that route. */ /** @@ -39,15 +45,22 @@ export function mapRequestError(operation: string) { }; } -export const unexpectedStatus = Effect.fnUntraced(function* (options: { - readonly operation: string; - readonly status: number; - readonly body: string; -}) { - const trimmed = options.body.trim(); +/** + * The response body as text, empty when it cannot be read. Every caller wants it for an error + * message, where a failed read is not worth a second failure of its own. + */ +export const bodyText = (response: HttpClientResponse.HttpClientResponse) => + response.text.pipe(Effect.orElseSucceed(() => "")); + +/** Fails with the status the response carries, quoting whatever body came with it. */ +export const unexpectedStatus = Effect.fnUntraced(function* ( + operation: string, + response: HttpClientResponse.HttpClientResponse, +) { + const trimmed = (yield* bodyText(response)).trim(); return yield* new ComputeApiUnexpectedStatusError({ - status: options.status, - detail: `The Compute API answered ${options.status} while trying to ${options.operation}${ + status: response.status, + detail: `The Compute API answered ${response.status} while trying to ${operation}${ trimmed === "" ? "" : `: ${trimmed}` }.`, suggestion: "Retry shortly; if it persists, report it with `supabase issue`.", @@ -70,3 +83,58 @@ export const decodeBody = ( }), ), ); + +/** The response's JSON body decoded against `schema`, the shape every 2xx read here needs. */ +export const decodeJsonBody = ( + schema: Schema.Codec, + operation: string, + response: HttpClientResponse.HttpClientResponse, +) => + response.json.pipe( + Effect.mapError(mapRequestError(operation)), + Effect.flatMap((body) => decodeBody(schema, operation, body, response.status)), + ); + +/** + * The Management API's error envelope, as it arrives on a 404. + * + * `message` is `Unknown` rather than `String` so a non-string message cannot fail the decode and + * cost the code-based classification that follows it. + */ +const NotFoundBody = Schema.Struct({ + error: Schema.Struct({ + code: Schema.String, + message: Schema.optionalKey(Schema.Unknown), + }), +}); + +type NotFoundEnvelope = Schema.Schema.Type; + +/** The 404 body parsed into its envelope, or `None` when it is something else entirely. */ +export const parse404 = (body: string) => + Schema.decodeEffect(Schema.fromJsonString(NotFoundBody))(body).pipe(Effect.option); + +/** Express's default for an unrouted path. Anchored so a message merely containing it cannot match. */ +const ROUTE_NOT_FOUND_MESSAGE = /^Cannot [A-Z]+ \//; + +/** The route named by the router's own 404 text, when the body is that rather than a handler's. */ +export const unroutedPath = (parsed: Option.Option): Option.Option => { + if (Option.isNone(parsed)) return Option.none(); + const { message } = parsed.value.error; + // `Cannot GET /v2/projects/{ref}/compute` -> `GET /v2/projects/{ref}/compute` + return typeof message === "string" && ROUTE_NOT_FOUND_MESSAGE.test(message) + ? Option.some(message.slice("Cannot ".length)) + : Option.none(); +}; + +export const hasErrorCode = (parsed: Option.Option, code: string) => + Option.isSome(parsed) && parsed.value.error.code === code; + +/** The ref names no project this account can see — the same verdict on every seam that reads one. */ +export const projectNotFound = (projectRef: string) => + new ComputeProjectNotFoundError({ + detail: `No project ${projectRef} was found for this account.`, + suggestion: + "Check the project ref, or pick the project again with `supabase link`. " + + "If it belongs to another account, log in with `supabase login`.", + }); diff --git a/apps/cli/src/shared/compute/compute-api.ts b/apps/cli/src/shared/compute/compute-api.ts index 173fc86257..ef1a6662db 100644 --- a/apps/cli/src/shared/compute/compute-api.ts +++ b/apps/cli/src/shared/compute/compute-api.ts @@ -6,23 +6,46 @@ import { V2ListAllComputeInstancesOutput, type ApiClient, } from "@supabase/api/effect"; -import { Effect, Option, Schedule, Schema } from "effect"; +import { Effect, Option, Schedule } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; -import { decodeBody, mapRequestError, unexpectedStatus } from "./compute-api-status.ts"; +import { + bodyText, + decodeJsonBody, + hasErrorCode, + mapRequestError, + parse404, + projectNotFound, + unexpectedStatus, + unroutedPath, +} from "./compute-api-status.ts"; import { ComputeBuildTimeoutError, ComputeProjectNotFoundError, + ComputeRouteNotFoundError, ComputeUnavailableError, ComputeUploadFailedError, } from "./compute.errors.ts"; /** - * The seam every compute command talks to: `/v2/projects/{ref}/compute` on the Management API. A - * 404 here is overloaded — a project outside the alpha's allow-list, an unknown project ref, and - * an undeployed compute all answer the same way. A named-compute 404 is reported as "not deployed"; - * a collection-endpoint 404, where no compute name could be wrong, is split by its body instead — - * see {@link projectScoped404}. + * The seam every compute command talks to: `/v2/projects/{ref}/compute` on the Management API. + * + * Four unrelated conditions answer 404 here, and `error.code` is the only thing that tells them + * apart: + * + * | condition | code | + * | --- | --- | + * | project outside the alpha's allow-list | `not_found.compute.not_enabled` | + * | no such project | `not_found` | + * | no such route | `not_found`, with the router's own `Cannot GET /v2/...` message | + * | no compute deployed under that name | `not_found.compute.instance` | + * + * Only `GET /compute/{name}` and `DELETE /compute/{name}` can mean the last one: every other + * route — list, uploads, deploy — cannot mean an absent compute, since an enrolled project with + * none answers `200 {"data":[]}` and uploads and deploy create. So those classify through + * {@link projectScoped404}, and the two named routes go through + * {@link refuseUnreachableCompute}, which fails on the first three and returns on anything else + * so the caller can read it as "not deployed". */ /** The compute shape the API returns, flattened out of its JSON:API envelope. */ @@ -82,46 +105,48 @@ function toComputeRecord(data: ComputeResourceData): ComputeRecord { }; } -const computeSuggestion = - "Compute is in private alpha. Ask in the Supabase dashboard to have this project enrolled."; +/** The code the API answers with for a project outside the alpha's allow-list. */ +const NOT_ENROLLED_CODE = "not_found.compute.not_enabled"; + +const notEnrolled = (projectRef: string) => + new ComputeUnavailableError({ + detail: `Compute is not available for project ${projectRef}.`, + suggestion: "Compute is in private alpha. Stay tuned for the public alpha coming soon.", + }); + +const routeNotFound = (projectRef: string, route: string) => + new ComputeRouteNotFoundError({ + detail: `The Management API does not serve ${route}, so this CLI cannot reach compute for project ${projectRef}.`, + // Compute is an allow-listed alpha, so neither a newer CLI nor enrolment puts a route back. + suggestion: "Report it with `supabase issue`, including the route named above.", + }); /** - * The `error.code` a 404 carries — the only way to tell an unenrolled project from one that - * doesn't exist, since both answer 404 on the same routes: + * Fails when a named-compute 404 was not about the compute at all — the route is unserved, the + * project is not in the alpha, or there is no such project — and returns otherwise so the caller + * can read it as "not deployed". Without it, an unenrolled project is told its compute is not + * deployed, and `delete` claims it removed something it never reached. * - * - not enrolled -> `{"error":{"code":"generic_not_found","message":"Compute is not available for this project"}}` - * - no such project -> `{"error":{"code":"not_found","message":"Not Found"}}` + * The absence itself carries `not_found.compute.instance`, so it falls through here along with + * any body this CLI does not recognize: on these two routes "not deployed" is the 404 that + * nothing else claimed. */ -const NotFoundBody = Schema.Struct({ - error: Schema.Struct({ code: Schema.String }), -}); +const refuseUnreachableCompute = Effect.fnUntraced(function* (projectRef: string, body: string) { + const parsed = yield* parse404(body); + const route = unroutedPath(parsed); -/** - * Which of the two a project-scoped 404 was. Only `not_found` is read as a missing project — an - * unrecognized body defaults to the enrolment answer, since guessing the other way would send - * someone to check a ref that's actually fine. - */ -const projectScoped404 = Effect.fnUntraced(function* (options: { - readonly projectRef: string; - readonly body: string; -}) { - const parsed = yield* Schema.decodeEffect(Schema.fromJsonString(NotFoundBody))(options.body).pipe( - Effect.option, - ); + if (Option.isSome(route)) return yield* routeNotFound(projectRef, route.value); + if (hasErrorCode(parsed, NOT_ENROLLED_CODE)) return yield* notEnrolled(projectRef); + if (hasErrorCode(parsed, "not_found")) return yield* projectNotFound(projectRef); +}); - if (Option.isSome(parsed) && parsed.value.error.code === "not_found") { - return new ComputeProjectNotFoundError({ - detail: `No project ${options.projectRef} was found for this account.`, - suggestion: - "Check the project ref, or pick the project again with `supabase link`. " + - "If it belongs to another account, log in with `supabase login`.", - }); - } +/** Fails with whichever condition a collection-endpoint 404 was; none of them can mean an absence there. */ +const projectScoped404 = Effect.fnUntraced(function* (projectRef: string, body: string) { + yield* refuseUnreachableCompute(projectRef, body); - return new ComputeUnavailableError({ - detail: `Compute is not available for project ${options.projectRef}.`, - suggestion: computeSuggestion, - }); + // Unavailable is the safe default for an unrecognized body: guessing the other way would send + // someone to check a ref that is actually fine. + return yield* notEnrolled(projectRef); }); export const listCompute = Effect.fnUntraced(function* (api: ApiClient, projectRef: string) { @@ -131,35 +156,17 @@ export const listCompute = Effect.fnUntraced(function* (api: ApiClient, projectR .pipe(Effect.mapError(mapRequestError(operation))); if (response.status === 404) { - const error = yield* projectScoped404({ - projectRef, - body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), - }); - return yield* error; + return yield* projectScoped404(projectRef, yield* bodyText(response)); } if (response.status !== 200) { - return yield* unexpectedStatus({ - operation, - status: response.status, - body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), - }); + return yield* unexpectedStatus(operation, response); } - const body = yield* response.json.pipe(Effect.mapError(mapRequestError(operation))); - const decoded = yield* decodeBody( - V2ListAllComputeInstancesOutput, - operation, - body, - response.status, - ); + const decoded = yield* decodeJsonBody(V2ListAllComputeInstancesOutput, operation, response); return decoded.data.map(toComputeRecord); }); -/** - * One compute, or `None` when the API has no record of it — which is also what a - * project outside the alpha's allow-list answers, so callers report it as "not - * deployed" and point at `push` rather than guessing which of the two it was. - */ +/** One compute, or `None` when this project has no record of it — see the 404 notes above. */ export const getCompute = Effect.fnUntraced(function* ( api: ApiClient, projectRef: string, @@ -171,18 +178,14 @@ export const getCompute = Effect.fnUntraced(function* ( .pipe(Effect.mapError(mapRequestError(operation))); if (response.status === 404) { + yield* refuseUnreachableCompute(projectRef, yield* bodyText(response)); return Option.none(); } if (response.status !== 200) { - return yield* unexpectedStatus({ - operation, - status: response.status, - body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), - }); + return yield* unexpectedStatus(operation, response); } - const body = yield* response.json.pipe(Effect.mapError(mapRequestError(operation))); - const decoded = yield* decodeBody(V2GetAComputeInstanceOutput, operation, body, response.status); + const decoded = yield* decodeJsonBody(V2GetAComputeInstanceOutput, operation, response); return Option.some(toComputeRecord(decoded.data)); }); @@ -197,27 +200,13 @@ export const createComputeUpload = Effect.fnUntraced(function* ( .pipe(Effect.mapError(mapRequestError(operation))); if (response.status === 404) { - const error = yield* projectScoped404({ - projectRef, - body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), - }); - return yield* error; + return yield* projectScoped404(projectRef, yield* bodyText(response)); } if (response.status !== 201 && response.status !== 200) { - return yield* unexpectedStatus({ - operation, - status: response.status, - body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), - }); + return yield* unexpectedStatus(operation, response); } - const body = yield* response.json.pipe(Effect.mapError(mapRequestError(operation))); - const decoded = yield* decodeBody( - V2CreateComputeInstanceUploadOutput, - operation, - body, - response.status, - ); + const decoded = yield* decodeJsonBody(V2CreateComputeInstanceUploadOutput, operation, response); return { uploadId: decoded.data.id, url: decoded.data.attributes.url, @@ -249,11 +238,8 @@ export const uploadBuildContext = Effect.fnUntraced(function* ( Effect.mapError( (error) => new ComputeUploadFailedError({ - // Deliberately not `error.message`, which is what the other transport - // failures in this module use: it appends the URL that failed, and - // here that URL is the write-capable signature. The reason's own - // description is the part worth showing, and the destination is - // already named by the step the user is watching. + // Not `error.message` as elsewhere in this module: it appends the URL that failed, + // and here that URL is the write-capable signature. detail: `Uploading the build context failed: ${ error.reason.description ?? "the upload request did not complete" }.`, @@ -263,7 +249,7 @@ export const uploadBuildContext = Effect.fnUntraced(function* ( ); if (response.status < 200 || response.status >= 300) { - const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + const body = yield* bodyText(response); return yield* new ComputeUploadFailedError({ detail: `Uploading the build context failed with status ${response.status}${ body.trim() === "" ? "" : `: ${body.trim()}` @@ -297,27 +283,13 @@ export const deployCompute = Effect.fnUntraced(function* ( .pipe(Effect.mapError(mapRequestError(operation))); if (response.status === 404) { - const error = yield* projectScoped404({ - projectRef, - body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), - }); - return yield* error; + return yield* projectScoped404(projectRef, yield* bodyText(response)); } if (response.status !== 202 && response.status !== 200 && response.status !== 201) { - return yield* unexpectedStatus({ - operation, - status: response.status, - body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), - }); + return yield* unexpectedStatus(operation, response); } - const body = yield* response.json.pipe(Effect.mapError(mapRequestError(operation))); - const decoded = yield* decodeBody( - V2DeployAComputeInstanceOutput, - operation, - body, - response.status, - ); + const decoded = yield* decodeJsonBody(V2DeployAComputeInstanceOutput, operation, response); return toComputeRecord(decoded.data); }); @@ -331,17 +303,18 @@ export const deleteCompute = Effect.fnUntraced(function* ( .executeRaw(operationDefinitions.v2DeleteAComputeInstance, { ref: projectRef, name }) .pipe(Effect.mapError(mapRequestError(operation))); - // 404 is the caller's own "not deployed" verdict to report; a delete that - // races another one is still a delete that happened. - if (response.status === 204 || response.status === 200 || response.status === 404) { + // A 404 no other condition claimed is the caller's own "not deployed" verdict + // to report; a delete that races another one is still a delete that happened. + // The three that `refuseUnreachableCompute` does claim fail instead, so this + // cannot report removing something it never reached. + if (response.status === 404) { + return yield* refuseUnreachableCompute(projectRef, yield* bodyText(response)); + } + if (response.status === 204 || response.status === 200) { return; } - return yield* unexpectedStatus({ - operation, - status: response.status, - body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), - }); + return yield* unexpectedStatus(operation, response); }); /** @@ -362,6 +335,15 @@ const COMPUTE_POLL_READ_RETRY = Schedule.spaced("2 seconds").pipe( Schedule.upTo({ duration: "30 seconds" }), ); +/** + * Verdicts about the route, the project or its enrolment, none of which a retry can change — so + * these surface on the first read instead of holding the poll open for the full retry window. + */ +const isPermanentReadFailure = (error: unknown) => + error instanceof ComputeRouteNotFoundError || + error instanceof ComputeUnavailableError || + error instanceof ComputeProjectNotFoundError; + export const awaitComputeBuild = Effect.fnUntraced(function* ( api: ApiClient, projectRef: string, @@ -373,22 +355,22 @@ export const awaitComputeBuild = Effect.fnUntraced(function* ( /** Called with each poll's result, for progress reporting. */ readonly onPoll?: (compute: ComputeRecord) => Effect.Effect; /** - * ` --project-ref ` to append to the suggestion below, when the caller reached this - * project via the flag rather than a link. The suggestion is copied verbatim, so omitting it - * would re-resolve against whatever this checkout happens to be linked to. + * ` --project-ref ` to append to the timeout suggestion, so re-running it cannot + * re-resolve against whatever this checkout happens to be linked to. */ readonly refSuffix?: string; } = {}, ) { const poll = Effect.gen(function* () { - // A build can run for minutes, so a single blip on one read should not throw - // away a deploy that is progressing fine. + // A build runs for minutes; one blip on one read must not abandon a deploy that is fine. const compute = yield* getCompute(api, projectRef, name).pipe( - Effect.retry({ schedule: options.retrySchedule ?? COMPUTE_POLL_READ_RETRY }), + Effect.retry({ + schedule: options.retrySchedule ?? COMPUTE_POLL_READ_RETRY, + while: (error) => !isPermanentReadFailure(error), + }), ); if (Option.isNone(compute)) { - // The deploy was accepted, so the compute exists; a 404 here is the read - // racing the write. Report it as still building and poll again. + // The deploy was accepted, so a 404 here is the read racing the write, not an absence. return undefined; } if (options.onPoll !== undefined) { diff --git a/apps/cli/src/shared/compute/compute-logs-api.ts b/apps/cli/src/shared/compute/compute-logs-api.ts index 0faeb0e60e..12190965c3 100644 --- a/apps/cli/src/shared/compute/compute-logs-api.ts +++ b/apps/cli/src/shared/compute/compute-logs-api.ts @@ -1,10 +1,21 @@ import { operationDefinitions, type ApiClient } from "@supabase/api/effect"; import { Effect, Option, Predicate, Schema } from "effect"; -import { decodeBody, mapRequestError, unexpectedStatus } from "./compute-api-status.ts"; +import { + bodyText, + decodeBody, + decodeJsonBody, + hasErrorCode, + mapRequestError, + parse404, + projectNotFound, + unexpectedStatus, + unroutedPath, +} from "./compute-api-status.ts"; import { ComputeLogsQueryFailedError, ComputeLogsRateLimitedError, ComputeLogsUsageExceededError, + ComputeRouteNotFoundError, ComputeUnavailableError, } from "./compute.errors.ts"; import { computeLogsQuery } from "./compute-logs.sql.ts"; @@ -149,27 +160,34 @@ export const fetchComputeLogs = Effect.fnUntraced(function* ( suggestion: "Wait a minute before retrying, and avoid running several tails at once.", }); } - // The route gates on the same private-alpha allow-list as the rest of the - // family, and answers 404 for a project outside it. + // Unlike the compute routes this one is not gated on the alpha's allow-list, + // so its 404 is about the project or the route, and only an unrecognized body + // is left to read as the family's usual refusal. if (response.status === 404) { + const parsed = yield* parse404(yield* bodyText(response)); + const route = unroutedPath(parsed); + + if (Option.isSome(route)) { + return yield* new ComputeRouteNotFoundError({ + detail: `The Management API does not serve ${route.value}, so this CLI cannot read logs for project ${projectRef}.`, + suggestion: "Report it with `supabase issue`, including the route named above.", + }); + } + if (hasErrorCode(parsed, "not_found")) { + return yield* projectNotFound(projectRef); + } return yield* new ComputeUnavailableError({ detail: `Logs are not available for project ${projectRef}.`, - suggestion: - "Compute is in private alpha. Ask in the Supabase dashboard to have this project enrolled.", + suggestion: "Compute is in private alpha. Stay tuned for the public alpha coming soon.", }); } if (response.status !== 200) { // A rejected query or the server's 30-second timeout lands here rather than // in the 200-with-`error` branch below, so both paths have to exist. - return yield* unexpectedStatus({ - operation, - status: response.status, - body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), - }); + return yield* unexpectedStatus(operation, response); } - const body = yield* response.json.pipe(Effect.mapError(mapRequestError(operation))); - const decoded = yield* decodeBody(LogsResponse, operation, body, response.status); + const decoded = yield* decodeJsonBody(LogsResponse, operation, response); // Checked before `result`: this endpoint reports a failed query with a 200 and // a populated `error`, so reading `result` first reports success on a failure. diff --git a/apps/cli/src/shared/compute/compute.errors.ts b/apps/cli/src/shared/compute/compute.errors.ts index 94ee2c5859..ae29737c74 100644 --- a/apps/cli/src/shared/compute/compute.errors.ts +++ b/apps/cli/src/shared/compute/compute.errors.ts @@ -204,9 +204,9 @@ export class ComputeNotDeployedError extends Data.TaggedError("ComputeNotDeploye } /** - * Compute are in private alpha: an unenrolled project's routes answer 404, indistinguishable at - * the transport level from an unknown compute — so this is only raised on collection endpoints, - * where there's no compute name that could have been wrong. + * Compute are in private alpha, and an unenrolled project's routes answer 404 with their own + * `not_found.compute.not_enabled`. Raised on every compute route, named ones included, plus as + * the fallback for a 404 body this CLI cannot classify. */ export class ComputeUnavailableError extends Data.TaggedError("ComputeUnavailableError")<{ readonly detail: string; @@ -231,6 +231,20 @@ export class ComputeProjectNotFoundError extends Data.TaggedError("ComputeProjec } } +/** + * The Management API serves no such route. Separated from {@link ComputeProjectNotFoundError} + * because the router answers an unrouted path under the same `not_found` code a missing project + * does, and blaming the project ref sends someone whose project is fine nowhere useful. + */ +export class ComputeRouteNotFoundError extends Data.TaggedError("ComputeRouteNotFoundError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return { ...actionability.apiStatus, fingerprint_suffix: "not_found" }; + } +} + /** * Any other status the Compute routes answered with. Classified from the status it carries rather * than bucketed as a generic service failure — a 401 is the user's to fix by logging in, a 403 by diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index 73052d22ba..56b0e0c8f9 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -60,6 +60,7 @@ ComputeLogsUsageExceededError ComputeNewWorkdirError ComputeNotDeployedError ComputeProjectNotFoundError +ComputeRouteNotFoundError ComputeSourceEscapingLinkError ComputeSourceMissingError ComputeStacksValidationError