Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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* () {
Expand All @@ -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.
Expand All @@ -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,
});

Expand All @@ -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",
});
Expand All @@ -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,
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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({
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
ComputeNotDeployedError,
ComputeApiNetworkError,
ComputeApiUnexpectedStatusError,
ComputeProjectNotFoundError,
ComputeUnavailableError,
} from "../../../../shared/compute/compute.errors.ts";
import { ComputeEnvNotSupportedError } from "../compute.errors.ts";
Expand Down Expand Up @@ -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({
Expand Down
Loading
Loading