diff --git a/apps/cli/src/legacy/commands/workers/delete/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/delete/SIDE_EFFECTS.md new file mode 100644 index 0000000000..c0d579c2da --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/delete/SIDE_EFFECTS.md @@ -0,0 +1,87 @@ +# `supabase workers delete ` + +> **No live test yet.** `workers` runs against the v2 Management API, which the +> supabase/cli-e2e-ci supabox stack is not expected to serve, so a `*.live.test.ts` +> here would be permanently skipped or permanently red. Revisit when the v2 +> Workers routes are available on that stack. + +## Files Read + +| Path | Format | When | +| --------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.json` | JSON | when present — preferred over `config.toml`; the source directory it kept. Best-effort: a config that will not load degrades to "nothing local" rather than failing the command | +| `/supabase/config.toml` | TOML | when no `config.json` exists — the same, on the same best-effort terms | +| `/` | directory | canonicalised and stat'd, to decide whether the kept-source line is stated at all | +| `/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` is unset and the keyring holds no credential | +| `/supabase/.temp/project-ref` | plain text | when neither `--project-ref` nor `SUPABASE_PROJECT_ID` is set — names the linked project | +| `/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | +| `` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | + +## Files Written + +| Path | Format | When | +| ----------------------------------------------- | ------ | --------------------------------------------------------------- | +| `/telemetry.json` | JSON | always — flushed on success and on failure | +| `/supabase/.temp/linked-project.json` | JSON | after the project ref resolves, when the cache does not hold it | + +The worker's directory and its `[workers.]` entry are deliberately left +on disk; only the remote worker is deleted. + +## Confirmation + +Interactively, the worker's name has to be typed back before anything is +deleted. `--yes` (the root persistent flag) or `SUPABASE_YES` skips that. With +neither — and no interactive terminal to prompt on, which includes a redirected +stdout and any `--output-format json`/`stream-json` run — the command refuses +rather than deleting unasked. + +## API Routes + +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ----------------------------------- | ------------ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v2/projects/{ref}/workers/{name}` | Bearer token | none | `instances.live` when present, else `spec.instances` (for the confirmation). A `403` is tolerated: the worker is treated as unknown and the `DELETE` still runs, since the two endpoints are granted separately (`edge_functions:read` vs `edge_functions:write`) | +| `DELETE` | `/v2/projects/{ref}/workers/{name}` | Bearer token | none | status only | +| `GET` | `/v1/projects` | Bearer token | none | `id`, `name`, `organization_slug`, `region` — only when no ref resolved and the session is interactive, to populate the project picker | + +## 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 worker name | +| `1` | nothing deployed under that name, without `--yes` | +| `1` | the typed confirmation did not match the worker'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 | + +## Environment Variables + +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref, consulted after `--project-ref` | no (falls back to `supabase/.temp/project-ref`, then the picker) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | +| `SUPABASE_YES` | auto-confirms the deletion, as `--yes` does | no (defaults to prompting) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + +No custom events — only the `cli_command_executed` that the instrumentation +wrapper emits for every command. + +## Output Formats + +| Mode | stdout | stderr | +| ----------------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------- | +| text (default) | the confirmation prompt, then what was deleted and kept | that nothing local was kept, when nothing was | +| `--output-format json` | one structured result carrying `worker_name`, `project_ref`, `kept_*` | as above | +| `--output-format stream-json` | the same result as a single terminal event | as above | +| `-o json` / `yaml` / `toml` | the same payload in that encoding, and nothing else | as above | +| `-o pretty` / `table` / `csv` | the text rendering — these fall through rather than encoding | as above | +| `-o env` | refused **before** the DELETE; discovering it at emit time deleted the worker and then failed | the error | diff --git a/apps/cli/src/legacy/commands/workers/delete/delete.command.ts b/apps/cli/src/legacy/commands/workers/delete/delete.command.ts new file mode 100644 index 0000000000..b1d12d1b44 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/delete/delete.command.ts @@ -0,0 +1,43 @@ +import { Argument, Command, Flag } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyWorkersDelete } from "./delete.handler.ts"; + +// No local `--yes`: it is a root persistent flag every other confirming command +// reads through `legacyResolveYes`, so redeclaring it here would shadow the +// global, list `--yes` twice in `--help`, and quietly ignore `SUPABASE_YES`. +const config = { + name: Argument.string("name").pipe(Argument.withDescription("Worker to delete.")), + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), +} as const; + +export type LegacyWorkersDeleteFlags = CliCommand.Command.Config.Infer; + +export const legacyWorkersDeleteCommand = Command.make("delete", config).pipe( + Command.withDescription( + "Delete a worker from the linked Supabase project. Irreversible; its local directory and supabase/config.toml entry are kept.", + ), + Command.withShortDescription("Delete a worker from Supabase"), + Command.withExamples([ + { + command: "supabase workers delete api", + description: "Delete a worker, confirming by typing its name", + }, + { + command: "supabase workers delete api --yes", + description: "Skip the confirmation prompt (scripts and CI)", + }, + ]), + Command.withHandler((flags) => + legacyWorkersDelete(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["workers", "delete"])), +); diff --git a/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts b/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts new file mode 100644 index 0000000000..f742e81467 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts @@ -0,0 +1,248 @@ +import { Effect, Option } from "effect"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { legacyAqua } from "../../../shared/legacy-colors.ts"; +import { legacyRenderWorkerDetails } from "../workers.format.ts"; +import { + legacyEmitWorkersMachineOutput, + legacyRejectWorkersEnvOutput, + legacyWorkersMachineOutputRequested, + legacyWorkersProjectRefSuffix, +} from "../workers.output.ts"; +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { displayPath } from "../../../../shared/workers/worker-paths.ts"; +import { deleteWorker, getWorker } from "../../../../shared/workers/workers-api.ts"; +import { + WorkerDeleteConfirmationRequiredError, + WorkerDeleteNotConfirmedError, + WorkerNotDeployedError, + WorkersApiUnexpectedStatusError, +} from "../../../../shared/workers/workers.errors.ts"; +import { legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { Tty } from "../../../../shared/runtime/tty.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { + legacyDescribeWorkerForReporting, + legacyLoadWorkersProjectForReporting, + legacyValidateWorkerName, +} from "../workers.shared.ts"; +import type { LegacyWorkersDeleteFlags } from "./delete.command.ts"; + +/** + * `supabase workers delete [name]` — delete the worker; its instances and image + * are torn down asynchronously. Whether it exists is asked of the API, never of + * a local file. + * + * Note what it does *not* remove: the worker's directory and its `config.toml` + * entry stay on disk, so `push ` brings it straight back — which is why + * the command says so. + * + * Being irreversible, an interactive session has to type the worker's name back + * to proceed — the same "confirm by typing it" pattern as GitHub's own repo + * deletion, rather than a bare y/n that is too easy to reflexively confirm. + * `--yes`/`SUPABASE_YES` skips it for scripts, resolved through + * `legacyResolveYes` like every other confirming command rather than through a + * local flag that would shadow the root one. It also makes an already-absent + * worker a success: teardown run twice should not fail the second time. + * + * Without a terminal to prompt on there is no third option: `interactive` tracks + * stdout, so merely redirecting output would otherwise delete unattended. This + * refuses instead, and says which flag would have authorised it. + */ +export const legacyWorkersDelete = Effect.fn("legacy.workers.delete")(function* ( + flags: LegacyWorkersDeleteFlags, +) { + const output = yield* Output; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + const tty = yield* Tty; + // `--yes` OR `SUPABASE_YES`, matching `projects delete` and every other + // command that guards a destructive step behind a prompt. + const yes = yield* legacyResolveYes; + + // The ref is resolved outside the finalizers because caching it is one of + // them; everything that can fail on its own — loading `config.toml`, + // validating the name, resolving the worker — belongs inside, so those + // failures still flush telemetry. Same shape as `config/push`. + const projectRef = yield* resolver.resolve(flags.projectRef); + // Every retry this command suggests is for a *destructive* re-run, so the ref + // has to survive the copy-paste. + const refSuffix = legacyWorkersProjectRefSuffix(flags.projectRef); + + yield* Effect.gen(function* () { + const project = yield* legacyLoadWorkersProjectForReporting(); + const name = yield* legacyValidateWorkerName(flags.name); + const worker = yield* legacyDescribeWorkerForReporting(project, name); + + // Before the first API call, not at emit time: the emit branch is reached + // *after* the DELETE, so `--yes -o env` deleted the worker and only then + // exited non-zero with no payload — which a script reads as a failed delete. + yield* legacyRejectWorkersEnvOutput(); + + const fetching = yield* output.task("Fetching worker..."); + // 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 worker it is entitled to delete. A refused + // read now leaves the worker *unknown* and the delete goes ahead. + const lookup = yield* getWorker(api, projectRef, name).pipe( + Effect.map((found) => ({ readable: true, worker: Option.getOrUndefined(found) })), + Effect.catchIf( + (error) => error instanceof WorkersApiUnexpectedStatusError && error.status === 403, + () => Effect.succeed({ readable: false, worker: undefined }), + ), + Effect.tapError(() => fetching.fail()), + ); + yield* fetching.clear(); + + const deployed = lookup.worker; + const machineOutput = yield* legacyWorkersMachineOutputRequested(); + + // `--yes` is the scripted path, and `deleteWorker` already treats a DELETE + // 404 as done — "a delete that races another one is still a delete that + // happened". The pre-flight GET contradicted that for teardown: a script run + // twice exited non-zero the second time, for a worker in exactly the state + // it asked for. Interactively the error stays: somebody typed this command + // and wants to hear the worker was not there. + if (lookup.readable && deployed === undefined && !yes) { + return yield* Effect.fail( + new WorkerNotDeployedError({ + detail: `Nothing is deployed for "${name}" in project ${projectRef}.`, + // `status`'s wording, inherited, pointed the wrong way here: somebody + // deleting "api" and hearing "nothing is deployed" does not want to + // deploy it — they want to see what *is* deployed. + suggestion: `See what is deployed with \`supabase workers list${refSuffix}\`.`, + }), + ); + } + + if (!yes) { + // `-o json` leaves `output.format` as `text`, so the format check alone + // still let the warning and the prompt run — onto the stdout the user had + // asked to carry a payload. A machine format is as non-interactive as a + // redirected stdout, whichever flag asked for it. + // + // `output.interactive` only tracks *stdout*, so on its own it still let + // `printf 'api\n' | supabase workers delete api` feed the pipe straight + // into the prompt and delete without `--yes`. The confirmation is only + // meaningful from a keyboard, so stdin has to be a terminal too — the same + // pair `projects delete` guards its prompt with. + if (output.format !== "text" || machineOutput || !output.interactive || !tty.stdinIsTty) { + return yield* Effect.fail( + new WorkerDeleteConfirmationRequiredError({ + detail: `Deleting "${name}" from project ${projectRef} needs confirmation, and there is no interactive terminal to ask on.`, + suggestion: `Re-run \`supabase workers delete ${name} --yes${refSuffix}\` to confirm without a prompt.`, + }), + ); + } + + // The live tally when the API reports one, labelled "declared" when it + // does not. `spec.instances` is the target, which for a worker still + // provisioning differs from what is running — and a destructive prompt is + // the wrong place to overstate. + // Absent when the read was refused: the prompt still asks for the name, + // it just cannot quote a count it was not allowed to see. + const live = deployed?.instances?.live; + const declared = deployed?.spec.instances; + const terminating = + live !== undefined + ? live > 0 + ? ` ${live} running instance${live === 1 ? "" : "s"} will be terminated.` + : "" + : declared !== undefined && declared > 0 + ? ` ${declared} declared instance${declared === 1 ? "" : "s"} will be terminated.` + : ""; + yield* output.raw( + `This permanently deletes "${name}" from project ${projectRef}.${terminating}\n`, + ); + const typed = yield* output.promptText(`Type ${name} to confirm`); + // Trimmed: a trailing space from a paste is not a different answer, and + // making someone re-run a destructive command over one is just friction. + if (typed.trim() !== name) { + return yield* Effect.fail( + new WorkerDeleteNotConfirmedError({ + detail: `The confirmation did not match "${name}", so nothing was deleted.`, + suggestion: `Re-run \`supabase workers delete ${name}${refSuffix}\` and type the name exactly, or pass --yes.`, + }), + ); + } + } + + // Skipped only when the fetch actually said there is nothing there. An + // unreadable worker still gets the DELETE — that request is the one the + // credential is entitled to make, and the API treats a 404 on it as done. + if (deployed !== undefined || !lookup.readable) { + const deleting = yield* output.task("Deleting worker..."); + yield* deleteWorker(api, projectRef, name).pipe(Effect.tapError(() => deleting.fail())); + yield* deleting.clear(); + } + + // A worker deployed from another checkout has neither a local entry nor a + // local directory, so there is nothing here that was kept. + const keptSource = worker.sourceExists + ? displayPath(project.projectRoot, worker.sourceDir) + : undefined; + const keptEntry = worker.entry !== undefined; + + const payload = { + worker_name: name, + project_ref: projectRef, + ...(keptSource === undefined ? {} : { kept_source: keptSource }), + kept_config_entry: keptEntry, + }; + + // `-o` asks for a machine-readable stdout, so nothing human may be written + // to it — `output.success` logs to stdout in text mode. + if (yield* legacyEmitWorkersMachineOutput(payload)) { + return; + } + + if (output.format !== "text") { + yield* output.success("", payload); + return; + } + + { + if (deployed === undefined && lookup.readable) { + yield* output.raw( + `Nothing was deployed for ${legacyAqua(name, process.stdout)} in project ${projectRef}, so there was nothing to delete.\n`, + ); + return; + } + + yield* output.raw( + `Deleted Worker ${legacyAqua(name, process.stdout)} from project ${projectRef}\n`, + ); + + // "Deleted" reads more final than it is *when there is something left* — + // so only say so when there is. For an orphan there is nothing local to + // keep, and pointing at `push` would send the user at a command that has + // no source to deploy. + const kept = [ + ...(keptSource === undefined ? [] : [keptSource]), + ...(keptEntry ? ["its supabase/config.toml entry"] : []), + ]; + if (kept.length > 0) { + yield* output.raw(legacyRenderWorkerDetails([["Kept", kept.join(", ")]])); + // Only when the source is still there: a retained `config.toml` entry + // alone is not enough to redeploy from, so `push` would fail on the very + // command this line recommends. + if (keptSource !== undefined) { + yield* output.raw(`Redeploy it with supabase workers push ${name}${refSuffix}.\n`); + } + } else { + yield* output.raw( + `Nothing for "${name}" exists in this project on disk, so nothing was kept.\n`, + "stderr", + ); + } + } + }).pipe( + Effect.ensuring(linkedProjectCache.cache(projectRef)), + Effect.ensuring(telemetryState.flush), + ); +}); diff --git a/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts b/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts new file mode 100644 index 0000000000..39ac309be9 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts @@ -0,0 +1,620 @@ +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Option } from "effect"; +import { + makeWorkersProject, + setupLegacyWorkers, + workerResource, + workersRoute, + WORKERS_PROJECT_REF, +} from "../../../../../tests/helpers/legacy-workers.ts"; +import { + WorkerDeleteConfirmationRequiredError, + WorkerDeleteNotConfirmedError, + WorkerNotDeployedError, + WorkersApiUnexpectedStatusError, +} from "../../../../shared/workers/workers.errors.ts"; +import { LegacyWorkersEnvNotSupportedError } from "../workers.errors.ts"; +import { legacyWorkersDelete } from "./delete.handler.ts"; + +const CONFIG = `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsize = "2gb"\n`; + +/** + * A project with `api` configured and on disk by default. Pass a bare config to + * get the orphan case — a worker deployed from somebody else's checkout, with + * nothing local behind it. + */ +function project(config = CONFIG) { + const created = makeWorkersProject({ + "supabase/config.toml": config, + ...(config === CONFIG ? { "supabase/workers/api/index.js": "export default {};\n" } : {}), + }); + return { + dir: created.dir, + cleanup: () => rmSync(created.dir, { recursive: true, force: true }), + }; +} + +const getRoute = `GET ${workersRoute("/api")}`; +const deleteRoute = `DELETE ${workersRoute("/api")}`; + +const routes = { + [getRoute]: { + status: 200, + body: { data: workerResource({ name: "api", runtime: "node", instances: 3 }) }, + }, + [deleteRoute]: { status: 204 }, +}; + +describe("legacy workers delete", () => { + it.live("deletes after the name is typed back, and keeps the local files", () => { + const repo = project(); + const { layer, out, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes, + promptTextResponses: ["api"], + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ + name: "api", + projectRef: Option.none(), + }); + + expect(http.routeKeys).toEqual([getRoute, deleteRoute]); + expect(out.stdoutText).toContain("permanently deletes"); + // Labelled "declared" because this response carries no live tally. + // `spec.instances` is the target, not what is running. + expect(out.stdoutText).toContain("3 declared instances"); + expect(out.stdoutText).toContain("Kept"); + + // Nothing local is touched — that is what makes `push` a one-command undo. + expect(existsSync(join(repo.dir, "supabase", "workers", "api", "index.js"))).toBe(true); + expect(readFileSync(join(repo.dir, "supabase", "config.toml"), "utf8")).toBe(CONFIG); + expect(out.stdoutText).toContain("supabase workers push api"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // The refusal used to live at emit time, which on this command is *after* the + // DELETE: `--yes -o env` removed the worker and then exited non-zero with no + // payload, which a script reads as "the delete failed" and may retry. + // Deletion never touches local files, so a malformed local config has no + // business standing between the user and a worker they named explicitly. + it.live("deletes a remote worker despite an unparseable local config", () => { + const repo = project("project_id = [unclosed\n"); + const otherRef = "qrstuvwxyzabcdefghij"; + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + yes: true, + routes: { + [`GET /v2/projects/${otherRef}/workers/api`]: { + status: 200, + body: { data: workerResource({ name: "api" }) }, + }, + [`DELETE /v2/projects/${otherRef}/workers/api`]: { status: 204 }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.some(otherRef) }); + + expect(http.routeKeys).toContain(`DELETE /v2/projects/${otherRef}/workers/api`); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // The API grants `edge_functions:read` for the GET and `edge_functions:write` + // for the DELETE separately, so a credential holding only write could not + // delete a worker it is entitled to delete. + it.live("deletes with --yes when the credential may not read the worker", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + yes: true, + routes: { + [getRoute]: { status: 403, body: { message: "insufficient scope" } }, + [deleteRoute]: { status: 204 }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() }); + + expect(http.routeKeys).toEqual([getRoute, deleteRoute]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("still confirms interactively when the worker cannot be read", () => { + const repo = project(); + const { layer, out, http } = setupLegacyWorkers({ + workdir: repo.dir, + promptTextResponses: ["api"], + routes: { + [getRoute]: { status: 403, body: { message: "insufficient scope" } }, + [deleteRoute]: { status: 204 }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("permanently deletes"); + // No count is quoted: the read that would have supplied one was refused. + expect(out.stdoutText).not.toContain("will be terminated"); + expect(http.routeKeys).toEqual([getRoute, deleteRoute]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // A refusal is not an absence: only a real 404 means there was nothing there. + it.live("reports an unreadable worker as deleted, not as nothing to delete", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + yes: true, + routes: { + [getRoute]: { status: 403, body: { message: "insufficient scope" } }, + [deleteRoute]: { status: 204 }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("Deleted Worker"); + expect(out.stdoutText).not.toContain("nothing to delete"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("refuses -o env before deleting anything", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes, + yes: true, + goOutput: "env", + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(LegacyWorkersEnvNotSupportedError); + expect(http.routeKeys).toEqual([]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("deletes nothing when the confirmation does not match", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes, + promptTextResponses: ["nope"], + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerDeleteNotConfirmedError); + expect(http.routeKeys).toEqual([getRoute]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // The suggested retry is copy-pasted verbatim and carries `--yes`, so dropping + // an explicit ref points a no-prompt delete at whatever this checkout is + // linked to — a same-named worker in a project the user never named. + it.live("keeps an explicit --project-ref in the retry it suggests", () => { + const repo = project(); + const otherRef = "qrstuvwxyzabcdefghij"; + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + format: "json", + routes: { + [`GET /v2/projects/${otherRef}/workers/api`]: { + status: 200, + body: { data: workerResource({ name: "api" }) }, + }, + }, + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersDelete({ + name: "api", + projectRef: Option.some(otherRef), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerDeleteConfirmationRequiredError); + const suggestion = + error instanceof WorkerDeleteConfirmationRequiredError ? error.suggestion : ""; + expect(suggestion).toContain(`--project-ref ${otherRef}`); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("leaves the retry bare when the ref came from the link", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ workdir: repo.dir, format: "json", routes }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerDeleteConfirmationRequiredError); + const suggestion = + error instanceof WorkerDeleteConfirmationRequiredError ? error.suggestion : ""; + expect(suggestion).not.toContain("--project-ref"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("keeps an explicit --project-ref in the confirmation-mismatch retry", () => { + const repo = project(); + const otherRef = "qrstuvwxyzabcdefghij"; + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + promptTextResponses: ["nope"], + routes: { + [`GET /v2/projects/${otherRef}/workers/api`]: { + status: 200, + body: { data: workerResource({ name: "api" }) }, + }, + }, + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersDelete({ + name: "api", + projectRef: Option.some(otherRef), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerDeleteNotConfirmedError); + const suggestion = error instanceof WorkerDeleteNotConfirmedError ? error.suggestion : ""; + expect(suggestion).toContain(`--project-ref ${otherRef}`); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("skips the confirmation with --yes", () => { + const repo = project(); + const { layer, out, http } = setupLegacyWorkers({ workdir: repo.dir, routes, yes: true }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() }); + + expect(http.routeKeys).toEqual([getRoute, deleteRoute]); + expect(out.stdoutText).not.toContain("permanently deletes"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // `printf 'api\n' | supabase workers delete api`: stdout is still a TTY, so + // `output.interactive` stayed true and the prompt read the worker name off the + // pipe — a confirmation the user never typed. + it.live("refuses to read the confirmation off a piped stdin", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes, + stdinIsTty: false, + promptTextResponses: ["api"], + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerDeleteConfirmationRequiredError); + expect(http.routeKeys).not.toContain(deleteRoute); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("refuses to delete unattended rather than skipping the confirmation", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, format: "json", routes }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerDeleteConfirmationRequiredError); + expect(http.routeKeys).toEqual([getRoute]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // `interactive` follows stdout, so a plain `>` redirect reaches this branch + // even from a live terminal — the case that used to delete without asking. + it.live("refuses when stdout is redirected and no --yes was given", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + interactive: false, + routes, + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerDeleteConfirmationRequiredError); + expect(http.routeKeys).toEqual([getRoute]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("deletes unattended when SUPABASE_YES or --yes authorises it", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + format: "json", + routes, + yes: true, + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() }); + + expect(http.routeKeys).toEqual([getRoute, deleteRoute]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("fails with `not deployed` before asking anything", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { [getRoute]: { status: 404, body: { message: "worker not found" } } }, + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerNotDeployedError); + // Not `workers push`: somebody deleting "api" does not want to deploy it. + const suggestion = error instanceof WorkerNotDeployedError ? error.suggestion : ""; + expect(suggestion).toContain("supabase workers list"); + expect(suggestion).not.toContain("workers push"); + expect(out.messages.filter((message) => message.type === "warn")).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // `deleteWorker` 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 worker in exactly the state it asked for. + it.live("succeeds under --yes when the worker is already gone", () => { + const repo = project(); + const { layer, out, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { [getRoute]: { status: 404, body: { message: "worker not found" } } }, + yes: true, + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() }); + + // Nothing to delete, so nothing is asked of the API beyond the lookup. + expect(http.routeKeys).toEqual([getRoute]); + expect(out.stdoutText).toContain("nothing to delete"); + expect(out.stdoutText).not.toContain("Deleted Worker"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("emits the same payload shape for a no-op delete", () => { + const repo = project(); + const { layer, out, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { [getRoute]: { status: 404, body: { message: "worker not found" } } }, + yes: true, + goOutput: "json", + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() }); + + const parsed: unknown = JSON.parse(out.stdoutText); + expect(parsed).toMatchObject({ + worker_name: "api", + project_ref: WORKERS_PROJECT_REF, + kept_config_entry: true, + }); + expect(http.routeKeys).toEqual([getRoute]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("treats a delete that races another one as done", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { ...routes, [deleteRoute]: { status: 404, body: { message: "already gone" } } }, + yes: true, + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("Kept"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("surfaces an unexpected delete status", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { ...routes, [deleteRoute]: { status: 500, body: { message: "boom" } } }, + yes: true, + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkersApiUnexpectedStatusError); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("emits a structured result in json mode", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + format: "json", + routes, + yes: true, + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() }); + + const success = out.messages.findLast( + (message) => message.type === "success" && message.data !== undefined, + ); + expect(success?.data).toEqual({ + worker_name: "api", + project_ref: WORKERS_PROJECT_REF, + kept_source: join("supabase", "workers", "api"), + kept_config_entry: true, + }); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // `-o json` leaves `output.format` as `text`, so the interactive check alone + // still ran the warning and the prompt — onto the stdout the payload was + // supposed to own. + it.live("refuses rather than prompting when -o json asked for the stdout", () => { + const repo = project(); + const { layer, out, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes, + goOutput: "json", + promptTextResponses: ["api"], + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerDeleteConfirmationRequiredError); + expect(out.stdoutText).not.toContain("permanently deletes"); + expect(http.routeKeys).not.toContain(deleteRoute); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // The live tally is what is actually running; `spec.instances` is the target. + // For a worker mid-provision the two differ, and a destructive confirmation is + // the worst place to overstate. + it.live("counts the live instances in the confirmation when the API reports them", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + promptTextResponses: ["api"], + routes: { + ...routes, + [getRoute]: { + status: 200, + body: { + data: workerResource({ + name: "api", + instances: 3, + instanceCounts: { declared: 3, live: 1, ready: 1, stale: 0 }, + }), + }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("1 running instance will be terminated"); + expect(out.stdoutText).not.toContain("3 running"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // An orphan — deployed from another checkout — has no local entry and no local + // directory, so there is nothing that was "kept" and `push` has no source to + // redeploy from. + it.live("does not claim to have kept local files it never had", () => { + const repo = project('project_id = "demo"\n'); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + yes: true, + routes: { + [`GET ${workersRoute("/stray")}`]: { + status: 200, + body: { data: workerResource({ name: "stray" }) }, + }, + [`DELETE ${workersRoute("/stray")}`]: { status: 204 }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "stray", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("Deleted Worker"); + expect(out.stdoutText).not.toContain("Kept"); + expect(out.stdoutText).not.toContain("workers push stray"); + expect(out.stderrText).toContain("nothing was kept"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("deletes a deployed worker named root", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + yes: true, + routes: { + [`GET ${workersRoute("/root")}`]: { + status: 200, + body: { data: workerResource({ name: "root" }) }, + }, + [`DELETE ${workersRoute("/root")}`]: { status: 204 }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "root", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("Deleted Worker"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // A `config.toml` entry on its own is not something `push` can deploy from, so + // recommending it would send the user at a command that fails. + it.live("keeps the config entry but does not advise redeploying without a source", () => { + const repo = project(); + rmSync(join(repo.dir, "supabase", "workers", "api"), { recursive: true, force: true }); + const { layer, out } = setupLegacyWorkers({ workdir: repo.dir, routes, yes: true }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("supabase/config.toml entry"); + expect(out.stdoutText).not.toContain("workers push api"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // Deletion never reads the local source, so a `source` that no longer resolves + // inside the project must not block removing the remote worker. + it.live("deletes the remote worker even when the configured source is unusable", () => { + const repo = project('project_id = "demo"\n\n[workers.api]\nsource = "../../elsewhere"\n'); + const { layer, out, http } = setupLegacyWorkers({ workdir: repo.dir, routes, yes: true }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() }); + + expect(http.routeKeys).toContain(deleteRoute); + expect(out.stdoutText).toContain("Deleted Worker"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); +}); diff --git a/apps/cli/src/legacy/commands/workers/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/list/SIDE_EFFECTS.md new file mode 100644 index 0000000000..d019f17f1e --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/list/SIDE_EFFECTS.md @@ -0,0 +1,69 @@ +# `supabase workers list` + +> **No live test yet.** `workers` runs against the v2 Management API, which the +> supabase/cli-e2e-ci supabox stack is not expected to serve, so a `*.live.test.ts` +> here would be permanently skipped or permanently red. Revisit when the v2 +> Workers routes are available on that stack. + +## Files Read + +| Path | Format | When | +| --------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.json` | JSON | always when present — preferred over `config.toml`; the `[workers.*]` entries | +| `/supabase/config.toml` | TOML | always when no `config.json` exists — the same entries | +| `/supabase/workers/` | directory | always — enumerated and each child stat'd, so a directory with no `[workers.]` entry still appears in the inventory. Absent reads as no workers; any other read failure fails the command | +| `/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` is unset and the keyring holds no credential | +| `/supabase/.temp/project-ref` | plain text | when neither `--project-ref` nor `SUPABASE_PROJECT_ID` is set — names the linked project | +| `/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | +| `` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | + +## Files Written + +| Path | Format | When | +| ----------------------------------------------- | ------ | --------------------------------------------------------------- | +| `/telemetry.json` | JSON | always — flushed on success and on failure | +| `/supabase/.temp/linked-project.json` | JSON | after the project ref resolves, when the cache does not hold it | + +## API Routes + +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v2/projects/{ref}/workers` | Bearer token | none | `data[].id`, `data[].attributes.spec/build_state/deleting` | +| `GET` | `/v1/projects` | Bearer token | none | `id`, `name`, `organization_slug`, `region` — only when no ref resolved and the session is interactive, to populate the project picker | + +## Exit Codes + +| Code | Condition | +| ---- | ----------------------------------------------- | +| `0` | success, including when the project has none | +| `1` | API error, or project not enrolled in the alpha | + +## Environment Variables + +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref, consulted after `--project-ref` | no (falls back to `supabase/.temp/project-ref`, then the picker) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + +No custom events — only the `cli_command_executed` that the instrumentation +wrapper emits for every command. + +## Output Formats + +| Mode | stdout | stderr | +| ----------------------------- | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| text (default) | the inventory table | notes about deployed workers with no entry or source | +| `--output-format json` | one structured result carrying `project_ref`, `workers` | as above | +| `--output-format stream-json` | the same result as a single terminal event | as above | +| `-o json` / `yaml` / `toml` | the same payload in that encoding, and nothing else | as above | +| `-o pretty` / `table` / `csv` | the text rendering — these fall through rather than encoding | as above | +| `-o env` | refused before any request; the payload carries a `workers` array a flat `KEY=value` list cannot express | the error | diff --git a/apps/cli/src/legacy/commands/workers/list/list.command.ts b/apps/cli/src/legacy/commands/workers/list/list.command.ts new file mode 100644 index 0000000000..ee09a79cac --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/list/list.command.ts @@ -0,0 +1,35 @@ +import { Command, Flag } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyWorkersList } from "./list.handler.ts"; + +const config = { + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), +} as const; + +export type LegacyWorkersListFlags = CliCommand.Command.Config.Infer; + +export const legacyWorkersListCommand = Command.make("list", config).pipe( + Command.withDescription( + "List this project's workers, deployed or not: the union of supabase/config.toml's entries and what the Workers API reports.", + ), + Command.withShortDescription("List this project's workers"), + Command.withExamples([ + { + command: "supabase workers list", + description: "See every worker in the linked project", + }, + ]), + Command.withHandler((flags) => + legacyWorkersList(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["workers", "list"])), +); diff --git a/apps/cli/src/legacy/commands/workers/list/list.handler.ts b/apps/cli/src/legacy/commands/workers/list/list.handler.ts new file mode 100644 index 0000000000..d6e5c36cca --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/list/list.handler.ts @@ -0,0 +1,206 @@ +import { Effect } from "effect"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { renderGlamourTable } from "../../../output/legacy-glamour-table.ts"; +import { legacyEmitWorkersMachineOutput, legacyRejectWorkersEnvOutput } from "../workers.output.ts"; +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyCliSettings } from "../../../config/legacy-cli-settings.service.ts"; +import { formatApiSize } from "../../../../shared/workers/worker-runtimes.ts"; +import { workerUrl } from "../../../../shared/workers/worker-url.ts"; +import { listWorkers, type WorkerRecord } from "../../../../shared/workers/workers-api.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { legacyDiscoverWorkerNames, legacyLoadWorkersProject } from "../workers.shared.ts"; +import type { LegacyWorkersListFlags } from "./list.command.ts"; + +/** + * `supabase workers list` — every worker in this project, deployed or not. + * + * A union of two sources, because either half alone is misleading: the + * project's `[workers.*]` entries (scaffolded, maybe never deployed) and what + * the API reports as deployed (including anything deployed from elsewhere, or + * from a directory since deleted). A worker in the config with nothing deployed + * shows as `not deployed`; a deployed worker with no local entry is called out, + * since pushing it from here would have to guess its runtime. + * + * The list endpoint deliberately makes no per-worker backend call, so it + * carries no live instance tally — the `INSTANCES` column is the declared + * count from the spec. `status` is where the live tally lives. + */ + +const HEADERS = ["NAME", "RUNTIME", "SIZE", "STATE", "INSTANCES", "URL"] as const; + +interface WorkerRow { + readonly name: string; + /** Has a `[workers.]` entry in `config.toml`. */ + readonly configured: boolean; + /** Exists on this machine at all — a config entry, a directory, or both. */ + readonly local: boolean; + readonly deployed: WorkerRecord | undefined; + readonly localRuntime: string | undefined; + readonly url: string | undefined; +} + +function stateLabel(row: WorkerRow): string { + if (row.deployed === undefined) { + return "not deployed"; + } + if (row.deployed.deleting === true) { + return "deleting"; + } + return row.deployed.buildState; +} + +/** + * The API omits `spec.runtime` only for a context-only build, so for a deployed + * worker its absence *is* "dockerfile". For one that has never been deployed + * there is nothing to infer from — `push` would guess from marker files — so say + * unknown rather than assert a runtime it may not have. + */ +function runtimeLabelFor(row: WorkerRow): string | undefined { + if (row.deployed !== undefined) { + return row.deployed.spec.runtime ?? "dockerfile"; + } + return row.localRuntime; +} + +function runtimeLabel(row: WorkerRow): string { + return runtimeLabelFor(row) ?? "-"; +} + +function toCells(row: WorkerRow): ReadonlyArray { + return [ + row.name, + runtimeLabel(row), + row.deployed === undefined ? "-" : formatApiSize(row.deployed.spec.size), + stateLabel(row), + row.deployed === undefined ? "-" : String(row.deployed.spec.instances), + row.url ?? "-", + ]; +} + +export const legacyWorkersList = Effect.fn("legacy.workers.list")(function* ( + flags: LegacyWorkersListFlags, +) { + const output = yield* Output; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + const settings = yield* LegacyCliSettings; + + // The ref is resolved outside the finalizers because caching it is one of + // them; everything that can fail on its own — loading `config.toml`, + // validating the name, resolving the worker — belongs inside, so those + // failures still flush telemetry. Same shape as `config/push`. + const projectRef = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + const project = yield* legacyLoadWorkersProject(); + + // Up front, like the rest of the family: this payload always carries a + // `workers` array, so `-o env` can never encode it, and finding that out at + // emit time means failing after the fetch has already been paid for. + yield* legacyRejectWorkersEnvOutput(); + + const fetching = yield* output.task("Fetching workers..."); + const deployed = yield* listWorkers(api, projectRef).pipe( + Effect.tapError(() => fetching.fail()), + ); + yield* fetching.clear(); + + const byName = new Map(deployed.map((worker) => [worker.name, worker])); + const configuredNames = Object.keys(project.section.workers); + // Three sources: config entries, deployed workers, and directories under the + // workers root. The last are deployable — `legacyDiscoverWorkerNames` is the + // walk a bare `push` does — so the inventory has to show them. + const discoveredNames = yield* legacyDiscoverWorkerNames(project); + const names = [...new Set([...configuredNames, ...discoveredNames, ...byName.keys()])].sort(); + + const rows: Array = names.map((name) => { + const record = byName.get(name); + return { + name, + configured: configuredNames.includes(name), + local: configuredNames.includes(name) || discoveredNames.includes(name), + deployed: record, + localRuntime: project.section.workers[name]?.runtime, + url: + record !== undefined && record.spec.exposure === "public" + ? workerUrl(projectRef, settings.projectHost, name) + : undefined, + }; + }); + + const payload = { + project_ref: projectRef, + workers: rows.map((row) => ({ + name: row.name, + configured: row.configured, + local: row.local, + deployed: row.deployed !== undefined, + // Read the same way `runtimeLabel` reads it, so `-o json` and the text + // table cannot disagree: for a deployed worker an absent `spec.runtime` + // *means* dockerfile, and falling back to the local config there + // reported a stale runtime the deployment had moved off. + runtime: runtimeLabelFor(row), + size: row.deployed?.spec.size, + state: stateLabel(row), + instances: row.deployed?.spec.instances, + url: row.url, + })), + }; + + // `-o` is independent of `--output-format`: it leaves `output.format` as + // `text`, so this has to be checked before the text branch below, not + // inside the structured one. + if (yield* legacyEmitWorkersMachineOutput(payload)) { + return; + } + + if (output.format !== "text") { + yield* output.success("", payload); + return; + } + + if (rows.length === 0) { + yield* output.raw("No workers found. Scaffold one with supabase workers new .\n"); + return; + } + + yield* output.raw(renderGlamourTable([...HEADERS], rows.map(toCells))); + + // Two different problems, and they need different advice. A worker with a + // local directory but no entry can be pushed — the runtime is the only + // unknown. One with nothing local at all cannot: `deployOneWorker` checks + // the source directory *before* inferring a runtime and fails with + // `WorkerSourceMissingError`, so telling that user about runtime guessing + // points them at the wrong prerequisite. + const unconfigured = rows + .filter((row) => row.deployed !== undefined && !row.configured && row.local) + .map((row) => row.name); + if (unconfigured.length > 0) { + yield* output.raw( + `${unconfigured.join(", ")} ${ + unconfigured.length === 1 ? "is" : "are" + } deployed but absent from supabase/config.toml: pushing from here would have to guess the runtime.\n`, + "stderr", + ); + } + + const remoteOnly = rows + .filter((row) => row.deployed !== undefined && !row.local) + .map((row) => row.name); + if (remoteOnly.length > 0) { + yield* output.raw( + `${remoteOnly.join(", ")} ${ + remoteOnly.length === 1 ? "is" : "are" + } deployed but ${remoteOnly.length === 1 ? "has" : "have"} no source in this project: scaffold or restore it before pushing from here.\n`, + "stderr", + ); + } + }).pipe( + Effect.ensuring(linkedProjectCache.cache(projectRef)), + Effect.ensuring(telemetryState.flush), + ); +}); diff --git a/apps/cli/src/legacy/commands/workers/list/list.integration.test.ts b/apps/cli/src/legacy/commands/workers/list/list.integration.test.ts new file mode 100644 index 0000000000..c9279f5cbc --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/list/list.integration.test.ts @@ -0,0 +1,427 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Option } from "effect"; +import { + makeWorkersProject, + setupLegacyWorkers, + workerResource, + workersRoute, + WORKERS_PROJECT_REF, +} from "../../../../../tests/helpers/legacy-workers.ts"; +import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref.errors.ts"; +import { LegacyWorkersEnvNotSupportedError } from "../workers.errors.ts"; +import { + WorkersApiUnexpectedStatusError, + WorkersUnavailableError, +} from "../../../../shared/workers/workers.errors.ts"; +import { legacyWorkersList } from "./list.handler.ts"; + +const CONFIG = `project_id = "demo" + +[workers.api] +runtime = "node" +size = "2gb" + +[workers.old] +runtime = "deno" +`; + +function project(config = CONFIG) { + const created = makeWorkersProject({ "supabase/config.toml": config }); + return { + dir: created.dir, + cleanup: () => rmSync(created.dir, { recursive: true, force: true }), + }; +} + +const listRoute = `GET ${workersRoute()}`; + +describe("legacy workers list", () => { + it.live("shows configured and deployed workers as one inventory", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [listRoute]: { + status: 200, + body: { + data: [ + workerResource({ name: "api", runtime: "node", imageVersion: "v3" }), + workerResource({ + name: "box", + runtime: "sandbox", + exposure: "private", + instances: 2, + }), + ], + }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + const stdout = out.stdoutText; + expect(stdout).toContain("NAME"); + + const rows = stdout.split("\n").filter((line) => /\|/.test(line) && /api|box|old/.test(line)); + expect(rows).toHaveLength(3); + // Sorted by name, so `api`, `box`, then the scaffolded-but-undeployed `old`. + expect(rows[0]).toContain("2gb (1 vCPU)"); + expect(rows[0]).toContain(`https://${WORKERS_PROJECT_REF}.supabase.co/workers/v1/api`); + expect(rows[1]).toContain("sandbox"); + expect(rows[2]).toContain("not deployed"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("does not assert a runtime for a worker that has never been deployed", () => { + const repo = project(`project_id = "demo"\n\n[workers.ghost]\n`); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { [listRoute]: { status: 200, body: { data: [] } } }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + const row = out.stdoutText.split("\n").find((line) => line.includes("ghost")); + expect(row).toBeDefined(); + expect(row).not.toContain("dockerfile"); + expect(row).toContain("not deployed"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // A local directory with no `[workers.]` entry: pushable, and the + // runtime is the only thing a push would have to work out for itself. + it.live("calls out a deployed worker that config.toml does not know about", () => { + const created = makeWorkersProject({ + "supabase/config.toml": `project_id = "demo"\n`, + "supabase/workers/stray/index.js": "export default {};\n", + }); + const repo = { + dir: created.dir, + cleanup: () => rmSync(created.dir, { recursive: true, force: true }), + }; + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [listRoute]: { + status: 200, + body: { data: [workerResource({ name: "stray", runtime: "node" })] }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + expect(out.stderrText).toContain("stray"); + expect(out.stderrText).toContain("guess the runtime"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // Nothing local at all: `deployOneWorker` checks the source directory before + // it ever infers a runtime, so "would have to guess the runtime" named the + // wrong prerequisite for this one. + it.live("tells a worker with no local source to restore it, not to expect a guess", () => { + const repo = project(`project_id = "demo"\n`); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [listRoute]: { + status: 200, + body: { data: [workerResource({ name: "stray", runtime: "node" })] }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + expect(out.stderrText).toContain("no source in this project"); + expect(out.stderrText).not.toContain("guess the runtime"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("says so when the project has no workers at all", () => { + const repo = project(`project_id = "demo"\n`); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { [listRoute]: { status: 200, body: { data: [] } } }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + expect(out.stdoutText).toContain( + "No workers found. Scaffold one with supabase workers new .", + ); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("emits the inventory as structured data in json mode", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + format: "json", + routes: { + [listRoute]: { + status: 200, + body: { data: [workerResource({ name: "api", runtime: "node" })] }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + const success = out.messages.findLast( + (message) => message.type === "success" && message.data !== undefined, + ); + expect(success?.data).toMatchObject({ project_ref: WORKERS_PROJECT_REF }); + expect(success?.data?.["workers"]).toEqual([ + { + name: "api", + configured: true, + local: true, + deployed: true, + runtime: "node", + size: "2gb-1vcpu", + state: "active", + instances: 1, + url: `https://${WORKERS_PROJECT_REF}.supabase.co/workers/v1/api`, + }, + { + name: "old", + configured: true, + local: true, + deployed: false, + runtime: "deno", + size: undefined, + state: "not deployed", + instances: undefined, + url: undefined, + }, + ]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("serialises the inventory for the Go -o flag", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + goOutput: "json", + routes: { + [listRoute]: { + status: 200, + body: { data: [workerResource({ name: "api", runtime: "node" })] }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + // `-o` payloads own stdout outright: no clack success line may share it. + const parsed = JSON.parse(out.stdoutText); + expect(parsed.project_ref).toBe(WORKERS_PROJECT_REF); + expect(parsed.workers).toHaveLength(2); + expect(out.messages.filter((m) => m.type === "success")).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("refuses -o env before making any request at all", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + goOutput: "env", + routes: { [listRoute]: { status: 200, body: { data: [] } } }, + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersList({ projectRef: Option.none() }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(LegacyWorkersEnvNotSupportedError); + expect(http.routeKeys).toEqual([]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("reports a project outside the alpha as unavailable", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [listRoute]: { + status: 404, + body: { + error: { + code: "generic_not_found", + message: "Workers are not available for this project", + }, + }, + }, + }, + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersList({ projectRef: Option.none() }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkersUnavailableError); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("surfaces an unexpected status rather than showing an empty list", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { [listRoute]: { status: 500, body: { message: "boom" } } }, + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersList({ projectRef: Option.none() }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkersApiUnexpectedStatusError); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("uses an explicit --project-ref without a linked project", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + linked: false, + routes: { + "GET /v2/projects/qrstuvwxyzabcdefghij/workers": { status: 200, body: { data: [] } }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.some("qrstuvwxyzabcdefghij") }); + + expect(http.routeKeys).toEqual(["GET /v2/projects/qrstuvwxyzabcdefghij/workers"]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("requires a linked project when no ref is given", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ workdir: repo.dir, linked: false }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersList({ projectRef: Option.none() }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(LegacyProjectNotLinkedError); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // A directory under the workers root with no `[workers.]` entry is what + // a bare `push` discovers and deploys, so an inventory that leaves it out can + // say "No workers found" about a worker `push` would happily deploy. + it.live("includes a local worker directory that has no config entry", () => { + const repo = project('project_id = "demo"\n'); + mkdirSync(join(repo.dir, "supabase", "workers", "scaffolded"), { recursive: true }); + writeFileSync(join(repo.dir, "supabase", "workers", "scaffolded", "index.js"), "export {};\n"); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { [listRoute]: { status: 200, body: { data: [] } } }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + expect(out.stdoutText).toContain("scaffolded"); + expect(out.stdoutText).not.toContain("No workers found"); + // Never deployed, so it is not announced as a deployed-but-unconfigured + // orphan either. + expect(out.stderrText).not.toContain("scaffolded"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // The API omits `spec.runtime` only for a context-only build, so for a + // deployed worker its absence *is* dockerfile. Falling back to the local + // config there made `-o json` report a runtime the text table contradicted. + it.live("reports a deployed dockerfile worker as dockerfile in both renderings", () => { + const repo = project('project_id = "demo"\n\n[workers.api]\nruntime = "node"\n'); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + format: "json", + routes: { + [listRoute]: { status: 200, body: { data: [workerResource({ name: "api" })] } }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + const success = out.messages.findLast( + (message) => message.type === "success" && message.data !== undefined, + ); + expect(success?.data?.["workers"]).toMatchObject([{ name: "api", runtime: "dockerfile" }]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // An undeployed worker has no `size`/`instances` and a private one no `url`, + // so a realistic inventory hands the encoder a payload full of holes. Pins + // that they are omitted rather than rendered or thrown on. + it.live("encodes TOML for an inventory holding undeployed and private workers", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + goOutput: "toml", + routes: { + [listRoute]: { + status: 200, + body: { + data: [workerResource({ name: "api", runtime: "node", exposure: "private" })], + }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + expect(out.stdoutText).toContain("project_ref = "); + expect(out.stdoutText).not.toContain("undefined"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // `pretty` is the human default; `table` and `csv` are accepted by the global + // flag for `db query`'s benefit, and every resource command is meant to ignore + // them and render text. All three used to fall through to the TOML encoder, + // which is the trap the payload allowlist closes. + it.live.each(["pretty", "table", "csv"] as const)( + "renders text rather than TOML for -o %s", + (goOutput) => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + goOutput, + routes: { + [listRoute]: { + status: 200, + body: { data: [workerResource({ name: "api", runtime: "node" })] }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + expect(out.stdoutText).toContain("NAME"); + expect(out.stdoutText).not.toContain("project_ref = "); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }, + ); + + it.live("flushes telemetry when the project config cannot be loaded", () => { + const repo = project("project_id = [unclosed\n"); + const { layer, telemetry } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }).pipe(Effect.flip); + + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); +}); diff --git a/apps/cli/src/legacy/commands/workers/status/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/status/SIDE_EFFECTS.md new file mode 100644 index 0000000000..84ad0e7c46 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/status/SIDE_EFFECTS.md @@ -0,0 +1,71 @@ +# `supabase workers status ` + +> **No live test yet.** `workers` runs against the v2 Management API, which the +> supabase/cli-e2e-ci supabox stack is not expected to serve, so a `*.live.test.ts` +> here would be permanently skipped or permanently red. Revisit when the v2 +> Workers routes are available on that stack. + +## Files Read + +| Path | Format | When | +| --------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.json` | JSON | when present — preferred over `config.toml`; the worker's source directory. Best-effort: a config that will not load degrades to "nothing local" rather than failing the command | +| `/supabase/config.toml` | TOML | when no `config.json` exists — the same, on the same best-effort terms | +| `/` | directory | canonicalised and stat'd, to decide whether the source row is stated at all | +| `/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` is unset and the keyring holds no credential | +| `/supabase/.temp/project-ref` | plain text | when neither `--project-ref` nor `SUPABASE_PROJECT_ID` is set — names the linked project | +| `/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | +| `` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | + +## Files Written + +| Path | Format | When | +| ----------------------------------------------- | ------ | --------------------------------------------------------------- | +| `/telemetry.json` | JSON | always — flushed on success and on failure | +| `/supabase/.temp/linked-project.json` | JSON | after the project ref resolves, when the cache does not hold it | + +## API Routes + +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ----------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v2/projects/{ref}/workers/{name}` | Bearer token | none | `spec`, `build_state`, `state_reason`, `image_version`, `instances`, `instances_error`, `deleting` | +| `GET` | `/v1/projects` | Bearer token | none | `id`, `name`, `organization_slug`, `region` — only when no ref resolved and the session is interactive, to populate the project picker | + +## Exit Codes + +| Code | Condition | +| ---- | ----------------------------------------------- | +| `0` | success | +| `1` | invalid worker name | +| `1` | nothing deployed under that name | +| `1` | API error, or project not enrolled in the alpha | + +## Environment Variables + +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref, consulted after `--project-ref` | no (falls back to `supabase/.temp/project-ref`, then the picker) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + +No custom events — only the `cli_command_executed` that the instrumentation +wrapper emits for every command. + +## Output Formats + +| Mode | stdout | stderr | +| ----------------------------- | ------------------------------------------------------------------------------------------------------ | ---------------------------- | +| text (default) | the details block, plus the build-retry line on a failure | an unreadable instance tally | +| `--output-format json` | one structured result carrying every reported field | as above | +| `--output-format stream-json` | the same result as a single terminal event | as above | +| `-o json` / `yaml` / `toml` | the same payload in that encoding, and nothing else | as above | +| `-o pretty` / `table` / `csv` | the text rendering — these fall through rather than encoding | as above | +| `-o env` | refused before any request; the payload nests an instance tally a flat `KEY=value` list cannot express | the error | diff --git a/apps/cli/src/legacy/commands/workers/status/status.command.ts b/apps/cli/src/legacy/commands/workers/status/status.command.ts new file mode 100644 index 0000000000..15f4e5c23c --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/status/status.command.ts @@ -0,0 +1,36 @@ +import { Argument, Command, Flag } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyWorkersStatus } from "./status.handler.ts"; + +const config = { + name: Argument.string("name").pipe(Argument.withDescription("Worker to inspect.")), + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), +} as const; + +export type LegacyWorkersStatusFlags = CliCommand.Command.Config.Infer; + +export const legacyWorkersStatusCommand = Command.make("status", config).pipe( + Command.withDescription( + "Show one worker in detail: build state, size, access, image, live instance tally and source directory.", + ), + Command.withShortDescription("Show a worker in detail"), + Command.withExamples([ + { + command: "supabase workers status api", + description: "Inspect a specific worker", + }, + ]), + Command.withHandler((flags) => + legacyWorkersStatus(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["workers", "status"])), +); diff --git a/apps/cli/src/legacy/commands/workers/status/status.handler.ts b/apps/cli/src/legacy/commands/workers/status/status.handler.ts new file mode 100644 index 0000000000..b29606b7af --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/status/status.handler.ts @@ -0,0 +1,160 @@ +import { Effect, Option } from "effect"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { legacyRenderWorkerDetails } from "../workers.format.ts"; +import { + legacyEmitWorkersMachineOutput, + legacyRejectWorkersEnvOutput, + legacyWorkersProjectRefSuffix, +} from "../workers.output.ts"; +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyCliSettings } from "../../../config/legacy-cli-settings.service.ts"; +import { displayPath } from "../../../../shared/workers/worker-paths.ts"; +import { formatApiSize } from "../../../../shared/workers/worker-runtimes.ts"; +import { workerUrl } from "../../../../shared/workers/worker-url.ts"; +import { getWorker } from "../../../../shared/workers/workers-api.ts"; +import { WorkerNotDeployedError } from "../../../../shared/workers/workers.errors.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { + legacyDescribeWorkerForReporting, + legacyLoadWorkersProjectForReporting, + legacyValidateWorkerName, +} from "../workers.shared.ts"; +import type { LegacyWorkersStatusFlags } from "./status.command.ts"; + +/** + * `supabase workers status [name]` — everything known about one worker. + * + * `list`'s companion: the size, image and URL a `push` printed once and then + * scrolled away, plus the live instance tally, which is the only place it is + * available — the list endpoint stays free of per-worker backend calls. + */ +export const legacyWorkersStatus = Effect.fn("legacy.workers.status")(function* ( + flags: LegacyWorkersStatusFlags, +) { + const output = yield* Output; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + const settings = yield* LegacyCliSettings; + + // The ref is resolved outside the finalizers because caching it is one of + // them; everything that can fail on its own — loading `config.toml`, + // validating the name, resolving the worker — belongs inside, so those + // failures still flush telemetry. Same shape as `config/push`. + const projectRef = yield* resolver.resolve(flags.projectRef); + const refSuffix = legacyWorkersProjectRefSuffix(flags.projectRef); + + yield* Effect.gen(function* () { + const project = yield* legacyLoadWorkersProjectForReporting(); + const name = yield* legacyValidateWorkerName(flags.name); + const worker = yield* legacyDescribeWorkerForReporting(project, name); + + // Up front, like the rest of the family: discovering an unencodable format + // at emit time means failing after the fetch has already been paid for. + yield* legacyRejectWorkersEnvOutput(); + + const fetching = yield* output.task("Fetching worker..."); + const found = yield* getWorker(api, projectRef, name).pipe( + Effect.tapError(() => fetching.fail()), + ); + yield* fetching.clear(); + + if (Option.isNone(found)) { + return yield* Effect.fail( + new WorkerNotDeployedError({ + detail: `Nothing is deployed for "${name}" in project ${projectRef}.`, + suggestion: `Deploy it with \`supabase workers push ${name}${refSuffix}\`.`, + }), + ); + } + + const record = found.value; + const url = + record.spec.exposure === "public" + ? workerUrl(projectRef, settings.projectHost, name) + : undefined; + // Reported only when an entry or the directory establishes it. With neither, + // the path is an inference about a worker that may have been deployed from + // another checkout. + // + // `sourceResolved` matters for the entry half: when the configured `source` + // could not be resolved, `sourceDir` is the *default* directory standing in + // for it, and printing that would name a path the entry does not. + const sourceDisplay = + (worker.entry !== undefined && worker.sourceResolved) || worker.sourceExists + ? displayPath(project.projectRoot, worker.sourceDir) + : undefined; + + const payload = { + worker_name: name, + project_ref: projectRef, + runtime: record.spec.runtime ?? "dockerfile", + size: record.spec.size, + exposure: record.spec.exposure, + build_state: record.buildState, + state_reason: record.stateReason, + image_version: record.imageVersion, + deleting: record.deleting, + declared_instances: record.spec.instances, + instances: record.instances, + instances_error: record.instancesError, + ...(sourceDisplay === undefined ? {} : { source: sourceDisplay }), + ...(url === undefined ? {} : { url }), + }; + + // `-o` asks for a machine-readable stdout, so nothing human may be written + // to it — `output.success` logs to stdout in text mode. + if (yield* legacyEmitWorkersMachineOutput(payload)) { + return; + } + + // One structured emission, in the structured branch only. Calling + // `output.success` before this check emitted the payload twice: the JSON + // layer appends each success to stdout, so `JSON.parse` failed, and + // `stream-json` saw two terminal result events. + if (output.format !== "text") { + yield* output.success("", payload); + return; + } + + const details: Array = [ + ["State", record.deleting === true ? "deleting" : record.buildState], + ["Reason", record.stateReason ?? ""], + ["Runtime", record.spec.runtime ?? "dockerfile"], + ["Size", formatApiSize(record.spec.size)], + ["Image", record.imageVersion ?? ""], + ["Access", record.spec.exposure], + [ + // Every number in the tally line comes from the tally: mixing + // `instances.ready` with `spec.instances` compares a snapshot against + // the desired count, which mid-scale renders fractions like `3/1 ready`. + "Instances", + record.instances !== undefined + ? `${record.instances.ready}/${record.instances.declared} ready, ${record.instances.live} live, ${record.instances.stale} stale` + : `${record.spec.instances} declared`, + ], + ["URL", url ?? ""], + ["Project", projectRef], + // `legacyRenderWorkerDetails` drops empty-valued rows, so an unknown + // source omits the row rather than printing a guess. + ["Source", sourceDisplay ?? ""], + ]; + + yield* output.raw(legacyRenderWorkerDetails(details)); + + if (record.instances === undefined && record.instancesError !== undefined) { + yield* output.raw(`Instance counts could not be read: ${record.instancesError}\n`, "stderr"); + } + // Not while it is being torn down: deletion is asynchronous, so a push here + // races the tombstone or resurrects the very worker the user is removing. + if (record.buildState === "failed" && record.deleting !== true) { + yield* output.raw(`Fix the issue, then re-run supabase workers push ${name}${refSuffix}.\n`); + } + }).pipe( + Effect.ensuring(linkedProjectCache.cache(projectRef)), + Effect.ensuring(telemetryState.flush), + ); +}); diff --git a/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts b/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts new file mode 100644 index 0000000000..ab83eab319 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts @@ -0,0 +1,519 @@ +import { rmSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Option } from "effect"; +import { + makeWorkersProject, + setupLegacyWorkers, + workerResource, + workersRoute, + WORKERS_PROJECT_REF, +} from "../../../../../tests/helpers/legacy-workers.ts"; +import { + InvalidWorkerNameError, + WorkerNotDeployedError, +} from "../../../../shared/workers/workers.errors.ts"; +import { LegacyWorkersEnvNotSupportedError } from "../workers.errors.ts"; +import { legacyWorkersStatus } from "./status.handler.ts"; + +const CONFIG = `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsize = "2gb"\n`; + +function project(files: Readonly> = {}) { + const created = makeWorkersProject({ + "supabase/config.toml": CONFIG, + "supabase/workers/api/index.js": "export default {};\n", + ...files, + }); + return { + dir: created.dir, + cleanup: () => rmSync(created.dir, { recursive: true, force: true }), + }; +} + +const getRoute = `GET ${workersRoute("/api")}`; + +describe("legacy workers status", () => { + it.live("reports the deployment facts and the live instance tally", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { + data: workerResource({ + name: "api", + runtime: "node", + imageVersion: "v3", + instances: 3, + instanceCounts: { declared: 3, live: 3, ready: 2, stale: 1 }, + }), + }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + const stdout = out.stdoutText; + expect(stdout).toContain("State"); + expect(stdout).toContain("active"); + expect(stdout).toContain("node"); + expect(stdout).toContain("2gb (1 vCPU)"); + expect(stdout).toContain("public"); + expect(stdout).toContain(WORKERS_PROJECT_REF); + expect(stdout).toContain("v3"); + expect(stdout).toContain("2/3 ready, 3 live, 1 stale"); + expect(stdout).toContain(`https://${WORKERS_PROJECT_REF}.supabase.co/workers/v1/api`); + expect(stdout).toContain(join("supabase", "workers", "api")); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("reports the deployed runtime, not a stale config.toml entry", () => { + // config.toml says node; the deployment carries no spec.runtime, which the + // API only omits for a context-only (Dockerfile) build. + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { data: workerResource({ name: "api" }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + const runtimeLine = out.stdoutText + .split("\n") + .find((line) => line.trim().startsWith("Runtime")); + expect(runtimeLine).toContain("dockerfile"); + expect(runtimeLine).not.toContain("node"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("falls back to the declared count when no tally came back", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { data: workerResource({ name: "api", runtime: "node", instances: 2 }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("2 declared"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("warns rather than lying when the instance read-through failed", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { + data: workerResource({ + name: "api", + runtime: "node", + instancesError: "backend unreachable", + }), + }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + expect(out.stderrText).toContain("backend unreachable"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // Mid-scale the snapshot and the desired spec disagree; reading the numerator + // from one and the denominator from the other rendered fractions like + // `3/1 ready`. + it.live("reads the whole tally from one snapshot while scaling", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { + data: workerResource({ + name: "api", + runtime: "node", + instances: 1, + instanceCounts: { declared: 3, live: 3, ready: 3, stale: 0 }, + }), + }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("3/3 ready"); + expect(out.stdoutText).not.toContain("3/1 ready"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // Deletion is asynchronous, so pushing here races the tombstone or resurrects + // the worker the user is removing. + it.live("withholds the build retry while the worker is being deleted", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { + data: workerResource({ + name: "api", + runtime: "node", + buildState: "failed", + stateReason: "exit status 1", + deleting: true, + }), + }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("deleting"); + expect(out.stdoutText).not.toContain("re-run supabase workers push"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("points a failed build at the retry, with the reason", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { + data: workerResource({ + name: "api", + runtime: "node", + buildState: "failed", + stateReason: "exit status 1", + }), + }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("failed"); + expect(out.stdoutText).toContain("exit status 1"); + expect(out.stdoutText).toContain("supabase workers push api"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("shows a worker being torn down as deleting", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { data: workerResource({ name: "api", runtime: "node", deleting: true }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("deleting"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("fails with `not deployed` and points at push", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { [getRoute]: { status: 404, body: { message: "worker not found" } } }, + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersStatus({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerNotDeployedError); + const suggestion = error instanceof WorkerNotDeployedError ? error.suggestion : ""; + expect(suggestion).toContain("supabase workers push api"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("refuses a name that could never have been written", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersStatus({ + name: "My_Worker", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(InvalidWorkerNameError); + expect(http.requests).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("reports the worker's source directory even when it lives outside supabase/", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsource = "packages/api"\n`, + "packages/api/index.js": "export default {};\n", + }); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { data: workerResource({ name: "api", runtime: "node" }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain(join("packages", "api")); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // A `source` that escapes the project cannot be resolved, so the describe + // falls back to the default directory. Printing that named a path the entry + // does not, presenting a guess as established local state. + it.live("omits the source when the configured one cannot be resolved", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsource = "../../elsewhere"\n`, + }); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { data: workerResource({ name: "api", runtime: "node" }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("active"); + expect(out.stdoutText).not.toContain("Source"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("emits the same facts as structured data in json mode", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + format: "json", + routes: { + [getRoute]: { + status: 200, + body: { + data: workerResource({ + name: "api", + runtime: "node", + imageVersion: "v3", + instanceCounts: { declared: 1, live: 1, ready: 1, stale: 0 }, + }), + }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + const success = out.messages.findLast( + (message) => message.type === "success" && message.data !== undefined, + ); + expect(success?.data).toMatchObject({ + worker_name: "api", + project_ref: WORKERS_PROJECT_REF, + runtime: "node", + size: "2gb-1vcpu", + exposure: "public", + build_state: "active", + image_version: "v3", + declared_instances: 1, + instances: { declared: 1, live: 1, ready: 1, stale: 0 }, + }); + // The detail lines are text-mode only. + expect(out.stdoutText).toBe(""); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // The JSON layer appends each success to stdout, so emitting the payload twice + // made `JSON.parse(stdout)` fail outright and gave `stream-json` two terminal + // result events. + it.live("emits exactly one structured result in json mode", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + format: "json", + routes: { + [getRoute]: { status: 200, body: { data: workerResource({ name: "api" }) } }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + const results = out.messages.filter( + (message) => message.type === "success" && message.data !== undefined, + ); + expect(results).toHaveLength(1); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // A worker deployed from another checkout has no entry and no directory here, + // so `supabase/workers/` is pure inference — reporting it as the + // worker's source named a path that was not there. + it.live("omits the source for a worker with nothing local to point at", () => { + const repo = project({ "supabase/config.toml": 'project_id = "demo"\n' }); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [`GET ${workersRoute("/stray")}`]: { + status: 200, + body: { data: workerResource({ name: "stray" }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "stray", projectRef: Option.none() }); + + expect(out.stdoutText).not.toContain("workers/stray"); + expect(out.stdoutText).not.toContain("Source"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // `root` is an ordinary worker name: a valid DNS label, and `[workers]` has no + // reserved keys — `readWorkersSection` reads every table under it as a worker. + // Here as a guard against the name picking up a special case it never had. + it.live("inspects a deployed worker named root", () => { + const repo = project(); + const { layer, out, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [`GET ${workersRoute("/root")}`]: { + status: 200, + body: { data: workerResource({ name: "root" }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "root", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("active"); + expect(http.routeKeys).toEqual([`GET ${workersRoute("/root")}`]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // `state_reason`, `image_version`, `deleting` and `instances_error` are all + // optional, so a healthy worker's payload is mostly holes. Pins that they are + // omitted rather than rendered. + it.live("encodes TOML for a worker whose optional fields are absent", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + goOutput: "toml", + routes: { + [getRoute]: { + status: 200, + body: { data: workerResource({ name: "api", runtime: "node", exposure: "private" }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("worker_name = "); + expect(out.stdoutText).not.toContain("undefined"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // The project is consulted only for the optional Source row, so an unrelated + // local parse error should not stand between the user and a remote worker + // they named explicitly. + it.live("inspects a remote worker despite an unparseable local config", () => { + const repo = project({ "supabase/config.toml": "project_id = [unclosed\n" }); + const otherRef = "qrstuvwxyzabcdefghij"; + const { layer, out, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [`GET /v2/projects/${otherRef}/workers/api`]: { + status: 200, + body: { data: workerResource({ name: "api", runtime: "node" }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.some(otherRef) }); + + expect(http.routeKeys).toEqual([`GET /v2/projects/${otherRef}/workers/api`]); + expect(out.stdoutText).toContain("active"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("refuses -o env before making any request at all", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + goOutput: "env", + routes: { [getRoute]: { status: 200, body: { data: workerResource({ name: "api" }) } } }, + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }).pipe( + Effect.flip, + ); + + expect(error).toBeInstanceOf(LegacyWorkersEnvNotSupportedError); + expect(http.routeKeys).toEqual([]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("flushes telemetry when the worker name is invalid", () => { + const repo = project(); + const { layer, telemetry } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "Not_A_Label", projectRef: Option.none() }).pipe( + Effect.flip, + ); + + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); +}); diff --git a/apps/cli/src/legacy/commands/workers/workers.command.ts b/apps/cli/src/legacy/commands/workers/workers.command.ts index d575670118..b5b536fdb7 100644 --- a/apps/cli/src/legacy/commands/workers/workers.command.ts +++ b/apps/cli/src/legacy/commands/workers/workers.command.ts @@ -1,11 +1,20 @@ import { Command } from "effect/unstable/cli"; +import { legacyWorkersDeleteCommand } from "./delete/delete.command.ts"; +import { legacyWorkersListCommand } from "./list/list.command.ts"; import { legacyWorkersNewCommand } from "./new/new.command.ts"; import { legacyWorkersPushCommand } from "./push/push.command.ts"; +import { legacyWorkersStatusCommand } from "./status/status.command.ts"; export const legacyWorkersCommand = Command.make("workers").pipe( Command.withDescription( "Manage Supabase Workers: containers that run your code next to your project, deployed from supabase/workers//.", ), Command.withShortDescription("Manage Supabase Workers"), - Command.withSubcommands([legacyWorkersNewCommand, legacyWorkersPushCommand]), + Command.withSubcommands([ + legacyWorkersNewCommand, + legacyWorkersPushCommand, + legacyWorkersListCommand, + legacyWorkersStatusCommand, + legacyWorkersDeleteCommand, + ]), ); diff --git a/apps/cli/src/legacy/commands/workers/workers.output.ts b/apps/cli/src/legacy/commands/workers/workers.output.ts index 840b0a23d2..392fce7069 100644 --- a/apps/cli/src/legacy/commands/workers/workers.output.ts +++ b/apps/cli/src/legacy/commands/workers/workers.output.ts @@ -19,13 +19,34 @@ import { LegacyWorkersEnvNotSupportedError } from "./workers.errors.ts"; * rendering — `output.success` writes to stdout in text mode and would corrupt * the payload otherwise. */ +/** + * Which `-o` values these commands answer with a payload. + * + * An allowlist, because the emitter's last branch is TOML: a denylist made every + * value it had not heard of serialise as TOML, so the next format the global + * flag learns would silently emit TOML from every workers command until somebody + * remembered to exclude it. `pretty` is the human default, and `table`/`csv` are + * accepted by the global flag only because `db query` reads them — every + * resource command falls through to its own text rendering for those, which is + * what an unrecognised value should do too. + * + * `env` is in the set so it reaches the refusal below rather than falling + * through to text: it is a format these commands *recognise* and cannot encode, + * which is a different answer from one they have never heard of. + */ +const PAYLOAD_FORMATS = new Set(["json", "yaml", "toml", "env"]); + +function emitsPayloadFor(goFormat: string | undefined): boolean { + return goFormat !== undefined && PAYLOAD_FORMATS.has(goFormat); +} + export const legacyEmitWorkersMachineOutput = Effect.fnUntraced(function* ( payload: Record, ) { const output = yield* Output; const goFormat = Option.getOrUndefined(yield* LegacyOutputFlag); - if (goFormat === undefined || goFormat === "pretty") { + if (!emitsPayloadFor(goFormat)) { return false; } @@ -56,8 +77,7 @@ export const legacyEmitWorkersMachineOutput = Effect.fnUntraced(function* ( * by which point those lines would already be on stdout. */ export const legacyWorkersMachineOutputRequested = Effect.fnUntraced(function* () { - const goFormat = Option.getOrUndefined(yield* LegacyOutputFlag); - return goFormat !== undefined && goFormat !== "pretty"; + return emitsPayloadFor(Option.getOrUndefined(yield* LegacyOutputFlag)); }); /** @@ -76,3 +96,18 @@ export const legacyRejectWorkersEnvOutput = Effect.fnUntraced(function* () { }); } }); + +/** + * The `--project-ref` a retry suggestion has to carry, or `""` when the ref came + * from the link. + * + * A suggested command is copy-pasted verbatim, so one that drops an explicit + * `--project-ref` re-resolves to whatever *this* checkout is linked to. On + * `delete --yes` that is a same-named worker in a project the user never named, + * removed without a prompt. + * + * Keyed off the flag rather than the resolved ref: when the link supplied it, + * appending it again is noise on a command that already resolves correctly. + */ +export const legacyWorkersProjectRefSuffix = (projectRef: Option.Option): string => + Option.isSome(projectRef) ? ` --project-ref ${projectRef.value}` : ""; diff --git a/apps/cli/src/legacy/commands/workers/workers.shared.ts b/apps/cli/src/legacy/commands/workers/workers.shared.ts index ccc5762b92..3b84e14404 100644 --- a/apps/cli/src/legacy/commands/workers/workers.shared.ts +++ b/apps/cli/src/legacy/commands/workers/workers.shared.ts @@ -88,13 +88,62 @@ export const legacyLoadWorkersProject = () => loadWorkersProject({ tomlOnly: fal */ export const legacyLoadWorkersProjectForEntryWrite = () => loadWorkersProject({ tomlOnly: true }); +/** + * As {@link legacyLoadWorkersProject}, but never failing on the project config. + * + * For commands that only *report* on local state — `status` and `delete` — + * which act on the remote worker and consult the project purely to add the + * optional source detail. Making it a prerequisite stranded a deployed worker + * behind an unrelated local parse error, even when `--project-ref` named the + * project explicitly and nothing local was going to be touched. + * + * A config that will not load reads the same as a project with no + * `[workers.*]` entries: no entry, no configured source, so no source row. + * Same degrade-rather-than-fail shape as + * {@link legacyDescribeWorkerForReporting}, which does it for the source path. + */ +export const legacyLoadWorkersProjectForReporting = Effect.fnUntraced(function* () { + const loaded = yield* legacyLoadWorkersProject().pipe(Effect.option); + if (Option.isSome(loaded)) { + return loaded.value; + } + + const settings = yield* LegacyCliSettings; + const projectRoot = settings.workdir; + const supabaseDir = join(projectRoot, "supabase"); + return { + projectRoot, + supabaseDir, + configPath: join(supabaseDir, "config.toml"), + section: readWorkersSection(undefined), + workersDir: workersDir(projectRoot), + } satisfies LegacyWorkersProject; +}); + export interface LegacyResolvedWorker { readonly name: string; readonly entry: WorkerEntry | undefined; /** The worker's default directory, `supabase/workers//`. */ readonly defaultDir: string; - /** Where its code actually lives, honouring `[workers.] source`. */ + /** Where its code would live, honouring `[workers.] source`. */ readonly sourceDir: string; + /** + * Whether anything local actually establishes {@link sourceDir}. + * + * `sourceDir` is always computable — with no entry it falls back to the default + * directory — so it cannot on its own tell a worker whose code is on this + * machine from one deployed out of another checkout. Commands that print local + * paths need that difference before they state one as fact. + */ + readonly sourceExists: boolean; + /** + * Whether {@link sourceDir} is the path the project actually names. + * + * False only when resolution failed and the default directory stood in for a + * `source` the entry does name — reporting that fallback as the worker's + * source states a path the project never mentioned. + */ + readonly sourceResolved: boolean; } /** @@ -102,26 +151,67 @@ export interface LegacyResolvedWorker { * verdict needs the filesystem: `source` comes from a committed `config.toml`, * and a directory inside the project can symlink anywhere outside it. */ +/** + * As {@link legacyDescribeWorker}, but never failing on the source path. + * + * For commands that only *report* on local state — `status` and `delete` — where + * the source is a detail of the output, not a prerequisite. Making confinement + * mandatory there stranded the remote worker: a `source` that resolves outside + * the project (an in-project directory that became a symlink, say) failed the + * describe before either API call, so `delete` could not remove a worker whose + * local files it was never going to touch. + * + * `push` keeps the strict version, because there the source *is* what gets + * packaged and uploaded. + */ +export const legacyDescribeWorkerForReporting = Effect.fnUntraced(function* ( + project: LegacyWorkersProject, + name: string, +) { + const described = yield* legacyDescribeWorker(project, name).pipe(Effect.option); + if (Option.isSome(described)) { + return described.value; + } + // The path is unusable, which for reporting purposes reads the same as having + // nothing local at all. `sourceResolved: false` keeps callers from printing + // this stand-in as the source the entry names — it is the default directory, + // not the path that failed. + return { + name, + entry: project.section.workers[name], + defaultDir: workerDir(project.projectRoot, name), + sourceDir: workerDir(project.projectRoot, name), + sourceExists: false, + sourceResolved: false, + } satisfies LegacyResolvedWorker; +}); + export const legacyDescribeWorker = Effect.fnUntraced(function* ( project: LegacyWorkersProject, name: string, ) { + const fs = yield* FileSystem.FileSystem; const entry = project.section.workers[name]; const defaultDir = workerDir(project.projectRoot, name); + const sourceDir = yield* workerSourceDir({ + projectRoot: project.projectRoot, + defaultDir, + name, + configuredSource: entry?.source, + }); + const info = yield* fs.stat(sourceDir).pipe(Effect.option); + return { name, entry, defaultDir, - sourceDir: yield* workerSourceDir({ - projectRoot: project.projectRoot, - defaultDir, - name, - configuredSource: entry?.source, - }), + sourceDir, + sourceExists: Option.isSome(info) && info.value.type === "Directory", + sourceResolved: true, } satisfies LegacyResolvedWorker; }); -/** Reject a name the CLI could never have written, before acting on it. */ +/** Reject a name that could never be a worker, before acting on it. */ export const legacyValidateWorkerName = Effect.fnUntraced(function* (name: string) { const invalid = validateWorkerNameMessage(name); if (invalid !== undefined) { diff --git a/apps/cli/src/shared/workers/worker-runtimes.ts b/apps/cli/src/shared/workers/worker-runtimes.ts index 897087b073..f72fdf0d99 100644 --- a/apps/cli/src/shared/workers/worker-runtimes.ts +++ b/apps/cli/src/shared/workers/worker-runtimes.ts @@ -116,7 +116,12 @@ const WORKER_NAME_PATTERN = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/; const workerNameRequirement = "Use lowercase letters, digits and hyphens, starting and ending with a letter or digit."; -/** `undefined` when `name` is a valid worker name, else why it is not. */ +/** + * `undefined` when `name` is a name this CLI can *record*, else why it is not. + * + * For commands that write `[workers.]` — which is `new`, and `push` only + * because it deploys what `new` wrote. + */ export function validateWorkerNameMessage(name: string): string | undefined { return WORKER_NAME_PATTERN.test(name) ? undefined : workerNameRequirement; } diff --git a/apps/cli/src/shared/workers/workers-api.ts b/apps/cli/src/shared/workers/workers-api.ts index 1d15a12cc9..5af45813cf 100644 --- a/apps/cli/src/shared/workers/workers-api.ts +++ b/apps/cli/src/shared/workers/workers-api.ts @@ -5,6 +5,7 @@ import { V2CreateWorkerUploadOutput, V2DeployAWorkerOutput, V2GetAWorkerOutput, + V2ListAllWorkersOutput, type ApiClient, } from "@supabase/api/effect"; import { Effect, Option, Schedule, Schema } from "effect"; @@ -26,11 +27,11 @@ import { * * The routes are deliberately few — list, get, mint an upload slot, deploy, * delete — so this module is thin, and what it mostly adds is status handling. - * The alpha's allow-list answers 404 for a project that is not enrolled, which - * at the transport level is indistinguishable from "no such worker"; so a 404 - * on a collection endpoint (where no worker name could have been wrong) becomes - * {@link WorkersUnavailableError}, and a 404 on a named worker is reported by - * the caller as "not deployed". + * A 404 is overloaded on these routes: it is the answer for a project outside + * the alpha's allow-list, for a project ref that names nothing, and for a + * worker that is not deployed. A 404 on a named worker is reported by the + * caller as "not deployed"; one on a collection endpoint, where no worker name + * could have been wrong, is split by its body — see {@link projectScoped404}. */ /** The worker shape the API returns, flattened out of its JSON:API envelope. */ @@ -197,12 +198,43 @@ const decodeBody = ( ), ); +export const listWorkers = Effect.fnUntraced(function* (api: ApiClient, projectRef: string) { + const operation = "list workers"; + const response = yield* api + .executeRaw(operationDefinitions.v2ListAllWorkers, { ref: projectRef }) + .pipe(Effect.mapError(mapRequestError(operation))); + + if (response.status === 404) { + return yield* Effect.fail( + yield* projectScoped404({ + projectRef, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), + }), + ); + } + if (response.status !== 200) { + return yield* unexpectedStatus({ + operation, + status: response.status, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), + }); + } + + const body = yield* response.json.pipe(Effect.mapError(mapRequestError(operation))); + const decoded = yield* decodeBody(V2ListAllWorkersOutput, operation, body, response.status); + return decoded.data.map(toWorkerRecord); +}); + /** * One worker, 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. */ -const getWorker = Effect.fnUntraced(function* (api: ApiClient, projectRef: string, name: string) { +export const getWorker = Effect.fnUntraced(function* ( + api: ApiClient, + projectRef: string, + name: string, +) { const operation = `read worker "${name}"`; const response = yield* api .executeRaw(operationDefinitions.v2GetAWorker, { ref: projectRef, name }) @@ -359,6 +391,29 @@ export const deployWorker = Effect.fnUntraced(function* ( return toWorkerRecord(decoded.data); }); +export const deleteWorker = Effect.fnUntraced(function* ( + api: ApiClient, + projectRef: string, + name: string, +) { + const operation = `delete worker "${name}"`; + const response = yield* api + .executeRaw(operationDefinitions.v2DeleteAWorker, { 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) { + return; + } + + return yield* unexpectedStatus({ + operation, + status: response.status, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), + }); +}); + /** * The build runs asynchronously — deploy answers 202 and the worker reaches * `active` or `failed` later — so `push` polls `get` until `build_state` leaves diff --git a/apps/cli/src/shared/workers/workers.errors.ts b/apps/cli/src/shared/workers/workers.errors.ts index 2cdfc97e35..44826c9315 100644 --- a/apps/cli/src/shared/workers/workers.errors.ts +++ b/apps/cli/src/shared/workers/workers.errors.ts @@ -153,6 +153,19 @@ export class WorkersApiNetworkError extends Data.TaggedError("WorkersApiNetworkE } } +/** + * The named worker is not deployed. `status`/`delete` share this verbatim: the + * question "does this exist?" is asked of the API, never of a local directory. + */ +export class WorkerNotDeployedError extends Data.TaggedError("WorkerNotDeployedError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + /** * Workers are in private alpha: the routes answer 404 for a project that is not * enrolled, which is indistinguishable from an unknown worker at the transport @@ -203,3 +216,34 @@ export class WorkersApiUnexpectedStatusError extends Data.TaggedError( return statusCodeActionability(this.status); } } + +/** The user answered the `delete` confirmation with something other than the name. */ +export class WorkerDeleteNotConfirmedError extends Data.TaggedError( + "WorkerDeleteNotConfirmedError", +)<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} + +/** + * `delete` could not ask for confirmation and was not told to skip it. + * + * There is nowhere to read a typed answer from without an interactive terminal, + * and the alternative to refusing is deleting on the strength of the command + * line alone — so a redirected stdout or a CI runner has to pass `--yes` + * (or `SUPABASE_YES`) to say that out loud. + */ +export class WorkerDeleteConfirmationRequiredError extends Data.TaggedError( + "WorkerDeleteConfirmationRequiredError", +)<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/tests/helpers/legacy-workers.ts b/apps/cli/tests/helpers/legacy-workers.ts index 2a838b1aba..4b256041bd 100644 --- a/apps/cli/tests/helpers/legacy-workers.ts +++ b/apps/cli/tests/helpers/legacy-workers.ts @@ -11,12 +11,13 @@ import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { LegacyPlatformApi } from "../../src/legacy/auth/legacy-platform-api.service.ts"; import { LegacyCliSettings } from "../../src/legacy/config/legacy-cli-settings.service.ts"; import { LegacyProjectRefResolver } from "../../src/legacy/config/legacy-project-ref.service.ts"; -import { LegacyOutputFlag } from "../../src/shared/legacy/global-flags.ts"; +import { CliArgs } from "../../src/shared/cli/cli-args.service.ts"; +import { LegacyOutputFlag, LegacyYesFlag } from "../../src/shared/legacy/global-flags.ts"; import { randomLayer } from "../../src/shared/runtime/random.layer.ts"; import { LegacyProjectNotLinkedError } from "../../src/legacy/config/legacy-project-ref.errors.ts"; import { mockLegacyLinkedProjectCacheLayer } from "./legacy-mocks.ts"; import { LegacyTelemetryState } from "../../src/legacy/telemetry/legacy-telemetry-state.service.ts"; -import { mockOutput, mockRuntimeInfo } from "./mocks.ts"; +import { mockOutput, mockRuntimeInfo, mockTty } from "./mocks.ts"; /** * Shared scaffolding for the `supabase workers` command integration tests. @@ -253,12 +254,26 @@ export interface WorkersSetupOptions { readonly cwd?: string; readonly format?: "text" | "json" | "stream-json"; readonly interactive?: boolean; + /** + * Whether stdin is a terminal. Defaults to `interactive`, so a text-mode test + * can prompt; set it false to model a piped stdin with a TTY stdout, which is + * what `printf 'api\n' | supabase workers delete api` looks like. + */ + readonly stdinIsTty?: boolean; readonly linked?: boolean; readonly promptTextResponses?: ReadonlyArray; readonly promptSelectResponses?: ReadonlyArray; readonly routes?: WorkersHttpRoutes; - /** The Go `-o`/`--output` flag, which every command family here honours. */ - readonly goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; + /** + * The `-o`/`--output` flag, with every value the global flag accepts — + * including `table` and `csv`, which these commands are meant to ignore and + * render text for. + */ + readonly goOutput?: "env" | "pretty" | "json" | "toml" | "yaml" | "table" | "csv"; + /** The root `--yes`, read by `delete` through `legacyResolveYes`. */ + readonly yes?: boolean; + /** Raw argv, which `legacyResolveYes` scans for an explicit `--yes=false`. */ + readonly cliArgs?: ReadonlyArray; } /** @@ -286,9 +301,10 @@ function mockWorkersTelemetryState() { } export function setupLegacyWorkers(options: WorkersSetupOptions) { + const interactive = options.interactive ?? (options.format ?? "text") === "text"; const out = mockOutput({ format: options.format ?? "text", - interactive: options.interactive ?? (options.format ?? "text") === "text", + interactive, ...(options.promptTextResponses === undefined ? {} : { promptTextResponses: options.promptTextResponses }), @@ -307,6 +323,7 @@ export function setupLegacyWorkers(options: WorkersSetupOptions) { out.layer, http.layer, mockRuntimeInfo({ cwd: options.cwd ?? options.workdir }), + mockTty({ stdinIsTty: options.stdinIsTty ?? interactive, stdoutIsTty: interactive }), legacyTestCliConfigLayer(options.workdir), legacyTestProjectRefLayer(options.linked !== false), telemetry.layer, @@ -316,6 +333,8 @@ export function setupLegacyWorkers(options: WorkersSetupOptions) { LegacyOutputFlag, options.goOutput === undefined ? Option.none() : Option.some(options.goOutput), ), + Layer.succeed(LegacyYesFlag, options.yes ?? false), + Layer.succeed(CliArgs, { args: options.cliArgs ?? [] }), BunServices.layer, ), };