diff --git a/.drive/projects/node-addressing/slices/a2-deploy-list/plan.md b/.drive/projects/node-addressing/slices/a2-deploy-list/plan.md new file mode 100644 index 000000000..d731f1bf0 --- /dev/null +++ b/.drive/projects/node-addressing/slices/a2-deploy-list/plan.md @@ -0,0 +1,25 @@ +# A2 — dispatch plan + +Spec: [spec.md](spec.md). Branch `drive/deploy-list`, cut from `origin/main` `87d001de`. Dispatches run in order. The PR opens after review and QA. + +### D1. The command + +- **Outcome:** `deploy list` in composer-cli's family, with `--stage`, `--name`, `--entry`, and human, stdout and JSON output as the spec says. The join and kind mapping are a pure function with unit tests: root, service, both Postgres types, bucket, module, a node with no row, a missing topology. The command test uses a fake Management API client. +- **Gate:** `pnpm typecheck`, `pnpm lint`, `pnpm lint:deps`, composer-cli tests. + +### D2. The end-to-end check and the docs + +- **Outcome:** the deploy-verify-destroy action runs `deploy list --json` after deploy and checks it against the platform rows. The design records and guides from the spec are updated. +- **Gate:** `pnpm lint`; the e2e workflow on the PR. + +### Review + +`/drive-code-review` on the branch (no walkthrough). Fix findings in follow-up dispatches. + +### D3. prisma-cli mounts the command + +- **Outcome:** a prisma/prisma-cli PR adds `"deploy list"` next to `"emulator list"`, once the composer-cli release that has it is out. + +### D4. QA + +- **Outcome:** deploy an example to a named stage on the real platform, run `deploy list` for it and for production, check every node, then delete the test Project. diff --git a/.drive/projects/node-addressing/slices/a2-deploy-list/qa.md b/.drive/projects/node-addressing/slices/a2-deploy-list/qa.md new file mode 100644 index 000000000..a3a1e047e --- /dev/null +++ b/.drive/projects/node-addressing/slices/a2-deploy-list/qa.md @@ -0,0 +1,129 @@ +# QA — A2 `deploy list` against the real platform + +Run 2026-10-27 on `drive/deploy-list` at `5464a84d`, after `pnpm build`, under bun, from `examples/storefront-auth`. + +Environment: `~/.config/prisma-compose/deploy.env` (service token, workspace, Alchemy password), plus `PRISMA_REGION=us-east-1` and `AUTH_SIGNING_SECRET=sk_test_ci_storefront_auth`, as CI sets them. + +The installed `prisma` does not mount `deploy list` yet (D3). The human tables below come from a scratch copy of `scripts/e2e-verify-deploy-list.ts` that mounts the same Composer family with the same `createCli` options, passes its arguments through, and prints stdout and stderr as they are. The human table goes to stderr; the tab-separated lines go to stdout. + +App name `a2-qa-99d5e9`, named stage `qa-99d5e9`. + +## Results + +| Check | Result | +| --- | --- | +| Deploy to production | PASS | +| `e2e-verify-deploy-list.ts` on production | PASS | +| Human table on production | PASS | +| Deploy to the named stage | PASS | +| `e2e-verify-deploy-list.ts` on the named stage (`STAGE=qa-99d5e9`) | PASS | +| Human table on the named stage | PASS | +| `deploy list --stage does-not-exist` fails with `DEPLOY.TARGET_NOT_FOUND` | PASS | +| Project deleted and gone | PASS | + +The named stage's listing shows its own rows (`db_paj9…`, `cps_eju3…`, `cps_c0q5…`), not production's, though the Project holds rows for both Branches with the same `logicalId`s. + +## Production + +``` +$ bun ../../node_modules/.bin/prisma deploy --name a2-qa-99d5e9 +... +✔ Deployed a2-qa-99d5e9 to production in 46.7s. +``` + +``` +$ STACK_NAME=a2-qa-99d5e9 bun ../../scripts/e2e-verify-deploy-list.ts +deploy list --name a2-qa-99d5e9 --json exited 0: +{"kind":"result","envelope":{"ok":true,"commandId":"deploy.list","result":[{"address":"a2-qa-99d5e9","kind":"project","platformId":"proj_x46eyjv91k1femz0acf82vdt","url":null},{"address":"a2-qa-99d5e9.auth","kind":"module","platformId":null,"url":null},{"address":"a2-qa-99d5e9.auth.database","kind":"postgres","platformId":"db_wi2h43um3td2e828a689xw08","url":null},{"address":"a2-qa-99d5e9.auth.service","kind":"service","platformId":"cps_v4lvdnj8la6pqcugsglxv1b5","url":"https://v4lvdnj8la6pqcugsglxv1b5.ewr.prisma.build"},{"address":"a2-qa-99d5e9.storefront","kind":"service","platformId":"cps_cnd2dlaije0vyimghp7qfdp6","url":"https://cnd2dlaije0vyimghp7qfdp6.ewr.prisma.build"}],...}} + +Topology on branch br_k0h9v9o7cfz9sz1y2ueuayri: + module a2-qa-99d5e9 + module a2-qa-99d5e9.auth + resource a2-qa-99d5e9.auth.database + service a2-qa-99d5e9.auth.service + service a2-qa-99d5e9.storefront +Rows in project proj_x46eyjv91k1femz0acf82vdt: + app cps_cnd2dlaije0vyimghp7qfdp6 branch=br_k0h9v9o7cfz9sz1y2ueuayri logicalId=a2-qa-99d5e9.storefront + app cps_v4lvdnj8la6pqcugsglxv1b5 branch=br_k0h9v9o7cfz9sz1y2ueuayri logicalId=a2-qa-99d5e9.auth.service + database db_wi2h43um3td2e828a689xw08 branch=br_k0h9v9o7cfz9sz1y2ueuayri logicalId=a2-qa-99d5e9.auth.database +deploy list shows every topology node with its platform ID and URL. +``` + +``` +$ deploy list --name a2-qa-99d5e9 --format human +Address Kind Platform ID URL +a2-qa-99d5e9 project proj_x46eyjv91k1femz0acf82vdt — +a2-qa-99d5e9.auth module — — +a2-qa-99d5e9.auth.database postgres db_wi2h43um3td2e828a689xw08 — +a2-qa-99d5e9.auth.service service cps_v4lvdnj8la6pqcugsglxv1b5 https://v4lvdnj8la6pqcugsglxv1b5.ewr.prisma.build +a2-qa-99d5e9.storefront service cps_cnd2dlaije0vyimghp7qfdp6 https://cnd2dlaije0vyimghp7qfdp6.ewr.prisma.build +``` + +## Named stage + +``` +$ bun ../../node_modules/.bin/prisma deploy --name a2-qa-99d5e9 --stage qa-99d5e9 +... +✔ Deployed a2-qa-99d5e9 to qa-99d5e9 in 45.3s. +``` + +``` +$ STACK_NAME=a2-qa-99d5e9 STAGE=qa-99d5e9 bun ../../scripts/e2e-verify-deploy-list.ts +deploy list --name a2-qa-99d5e9 --stage qa-99d5e9 --json exited 0: +{"kind":"result","envelope":{"ok":true,"commandId":"deploy.list","result":[{"address":"a2-qa-99d5e9","kind":"project","platformId":"proj_x46eyjv91k1femz0acf82vdt","url":null},{"address":"a2-qa-99d5e9.auth","kind":"module","platformId":null,"url":null},{"address":"a2-qa-99d5e9.auth.database","kind":"postgres","platformId":"db_paj9wnn6izpx5ma5p9hv1pyo","url":null},{"address":"a2-qa-99d5e9.auth.service","kind":"service","platformId":"cps_eju3icljkbwes2op4hoivjkd","url":"https://eju3icljkbwes2op4hoivjkd.ewr.prisma.build"},{"address":"a2-qa-99d5e9.storefront","kind":"service","platformId":"cps_c0q52ww83ng7uzn387pjtyvb","url":"https://c0q52ww83ng7uzn387pjtyvb.ewr.prisma.build"}],...}} + +Topology on branch br_yf243sg4fu64rgxcu1inhfvv: + module a2-qa-99d5e9 + module a2-qa-99d5e9.auth + resource a2-qa-99d5e9.auth.database + service a2-qa-99d5e9.auth.service + service a2-qa-99d5e9.storefront +Rows in project proj_x46eyjv91k1femz0acf82vdt: + app cps_cnd2dlaije0vyimghp7qfdp6 branch=br_k0h9v9o7cfz9sz1y2ueuayri logicalId=a2-qa-99d5e9.storefront + app cps_v4lvdnj8la6pqcugsglxv1b5 branch=br_k0h9v9o7cfz9sz1y2ueuayri logicalId=a2-qa-99d5e9.auth.service + app cps_eju3icljkbwes2op4hoivjkd branch=br_yf243sg4fu64rgxcu1inhfvv logicalId=a2-qa-99d5e9.auth.service + app cps_c0q52ww83ng7uzn387pjtyvb branch=br_yf243sg4fu64rgxcu1inhfvv logicalId=a2-qa-99d5e9.storefront + database db_wi2h43um3td2e828a689xw08 branch=br_k0h9v9o7cfz9sz1y2ueuayri logicalId=a2-qa-99d5e9.auth.database + database db_paj9wnn6izpx5ma5p9hv1pyo branch=br_yf243sg4fu64rgxcu1inhfvv logicalId=a2-qa-99d5e9.auth.database +deploy list shows every topology node with its platform ID and URL. +``` + +``` +$ deploy list --name a2-qa-99d5e9 --stage qa-99d5e9 --format human +Address Kind Platform ID URL +a2-qa-99d5e9 project proj_x46eyjv91k1femz0acf82vdt — +a2-qa-99d5e9.auth module — — +a2-qa-99d5e9.auth.database postgres db_paj9wnn6izpx5ma5p9hv1pyo — +a2-qa-99d5e9.auth.service service cps_eju3icljkbwes2op4hoivjkd https://eju3icljkbwes2op4hoivjkd.ewr.prisma.build +a2-qa-99d5e9.storefront service cps_c0q52ww83ng7uzn387pjtyvb https://c0q52ww83ng7uzn387pjtyvb.ewr.prisma.build +``` + +## A stage that does not exist + +``` +$ deploy list --name a2-qa-99d5e9 --stage does-not-exist --json +{"kind":"result","envelope":{"ok":false,"commandId":"deploy.list","error":{"code":"DEPLOY.TARGET_NOT_FOUND","severity":"error","summary":"Nothing deployed for a2-qa-99d5e9/does-not-exist.","nextActions":[{"kind":"user-choice","label":"Deploy it first."}],...}} +exit 2 +``` + +## Teardown + +``` +$ bun ../../scripts/composer-destroy.ts --name a2-qa-99d5e9 --stage qa-99d5e9 +Done: 18 succeeded (27.5s) +Destroyed. + +$ bun ../../scripts/composer-destroy.ts --name a2-qa-99d5e9 --production +Done: 18 succeeded (28.7s) +Removed the Project (proj_x46eyjv91k1femz0acf82vdt) — nothing was left in it. +Destroyed. + +$ curl -H "Authorization: Bearer $PRISMA_SERVICE_TOKEN" https://api.prisma.io/v1/projects/proj_x46eyjv91k1femz0acf82vdt +404 + +$ deploy list --name a2-qa-99d5e9 --json +... "code":"DEPLOY.TARGET_NOT_FOUND","summary":"Nothing deployed for a2-qa-99d5e9." ... +exit 2 +``` + +No Project named `a2-qa-99d5e9` remains in the workspace's project list. diff --git a/.drive/projects/node-addressing/slices/a2-deploy-list/reviews/code-review.md b/.drive/projects/node-addressing/slices/a2-deploy-list/reviews/code-review.md new file mode 100644 index 000000000..01f18b96b --- /dev/null +++ b/.drive/projects/node-addressing/slices/a2-deploy-list/reviews/code-review.md @@ -0,0 +1,106 @@ +# Code review — A2 `prisma deploy list` + +Range: `origin/main...drive/deploy-list` (merge base `87d001de`, head `2e16ca74`). Spec: [../spec.md](../spec.md). + +## Summary + +The command does what the spec says, the join is correct, and the tests are well aimed. The gaps are in the end-to-end check (it does not check `kind`, and it only runs on production), one unhelpful error, and the resource-type table being copied a third time. + +Checked locally: `bun test` for `test/deploy-list.test.ts` and `test/deployed-nodes.test.ts` (21 pass), `scripts/deploy-list-check.test.ts` (8 pass), `pnpm lint:deps` (clean). The Management API SDK's `getV1Services`, `getV1Databases` and `getV1Buckets` all accept `projectId` and `branchId` filters, so the Branch filter is real and rows from another stage with the same `logicalId` cannot leak in. + +## What looks solid + +- The join is a pure function with one test per kind, plus missing-row, empty and order cases. +- The command test asserts the actual request parameters (`branchId` on `/v1/services`, `gitName` on the Branch lookup), not just the output. +- An invalid `--stage` is rejected before any network call, and the test asserts that no request was made. +- The Management API code is lazily imported, so loading the family stays cheap. +- The e2e check reads the platform independently of the command, through `/v1/apps` and its own Branch filter. + +## Findings + +### F01 — The e2e check never compares `kind` (should-fix) + +- **Location:** `scripts/deploy-list-check.ts` lines 63–86. +- **Issue:** `deployListMismatches` checks address order, platform ID and URL, but not `kind`. A regression that printed `module` for every node, or `raw-postgres` instead of `postgres`, would pass the real-platform check. +- **Suggestion:** Compute the expected kind from the topology node (`project` for the root, `service`, `postgres` for both Postgres types, `bucket` for `s3`, `module`, otherwise the raw `type`) and report a mismatch. Add a case to `scripts/deploy-list-check.test.ts`. + +### F02 — A credential with no workspace gives an unhelpful error (should-fix) + +- **Location:** `packages/9-public/composer-cli/src/family/list-deployed.ts` lines 47–49. +- **Issue:** It fails with `DEPLOY.CONTAINER_FAILED` and the message "The active credential names no workspace.", with no `fix`. `prisma deploy` in the same situation says `PRISMA_WORKSPACE_ID` is required (`packages/1-prisma-cloud/1-extensions/target/src/container.ts` line 163). The user learns what is wrong but not what to do, and the two commands disagree. +- **Suggestion:** Use the same message deploy uses, or add a `fix` naming `PRISMA_WORKSPACE_ID`. Add a command test for it. + +### F03 — The target's resource types are restated a third time (should-fix) + +- **Location:** `packages/9-public/composer-cli/src/family/deployed-nodes.ts` lines 11–15. +- **Issue:** `RESOURCE_KINDS` repeats which target types (`postgres`, `raw-postgres`, `s3`) have a database or bucket row. The same table is in `scripts/platform-identity.ts` (lines 23–27) and `packages/1-prisma-cloud/1-extensions/target/src/local-target/emulators.ts` (line 43). If the target adds a database type, `deploy list` shows its raw type with no platform ID, and nothing fails: the e2e check reads its own copy. +- **Suggestion:** Export one map from resource type to row list from the Prisma Cloud domain (for example next to `readDeployedStage`) and use it in `deployed-nodes.ts` and `scripts/platform-identity.ts`. + +### F04 — The e2e check only covers production (should-fix) + +- **Location:** `.github/actions/deploy-verify-destroy/action.yml` lines 68–74. +- **Issue:** The step sets no `STAGE`, and the action only deploys production. The named-stage path (Branch by `gitName`, rows filtered by that Branch) is proven only against the fake client. The spec's done condition asks for "a real deployed stage". +- **Suggestion:** Make D4 QA explicitly run `deploy list --stage ` against a real named-stage deploy and record the output in the slice, since the e2e workflow has no stage deploy to reuse. + +### F05 — Dead branch in `readDeployedStage` (nit) + +- **Location:** `packages/1-prisma-cloud/0-lowering/lowering/src/deployed.ts` lines 46–47. +- **Issue:** `resolveContainer` always returns `branchId` for a named stage and `defaultBranchId` for production, so `branchId === undefined` cannot happen. Returning `undefined` there would read as "nothing deployed", which hides a real bug if it ever did happen. +- **Suggestion:** Fail with an error ("resolved no Branch") as `scripts/platform-state.ts` does, instead of returning `undefined`. + +### F06 — The human-table test checks only two substrings (nit) + +- **Location:** `packages/9-public/composer-cli/test/deploy-list.test.ts` lines 240–248. +- **Issue:** It asserts that stderr contains `shop.catalog.db` and the URL. It would pass if the columns were missing or misordered. +- **Suggestion:** Also assert the header row (`Address`, `Kind`, `Platform ID`, `URL`) and that one row shows `shop.catalog.db`, `postgres` and `db_1` on the same line. + +### F07 — The release claim depends on work not yet done (nit) + +- **Location:** `docs/guides/deploying.md` lines 321–322, `skills/prisma-composer-core-concepts/SKILL.md` line 408. +- **Issue:** Both say `prisma deploy list` "first ships in the `prisma` release after 8.0.0-rc.26". That depends on the D3 prisma-cli pull request, which has not been opened. +- **Suggestion:** Recheck the version when D3 merges and correct both lines if it ships later. + +## Deferred (out of scope) + +- **Read failures use `DEPLOY.CONTAINER_FAILED`.** A topology or row read that fails with a 500 surfaces as a container failure (`list-deployed.ts` lines 27–33). It is the nearest existing ADR-0044 code; a read-specific code would mean amending ADR-0044, which is beyond this slice. +- **Two rows with one `logicalId` on a Branch.** The join takes the first match (`deployed-nodes.ts` lines 29–31 and 40). `e2e-verify-identity.ts` already fails a deploy that leaves duplicates, so this is guarded elsewhere. + +## Already addressed + +After this review, fixed on the branch: + +- F01: `aa414ec7`. The check compares `kind`. +- F02: `90cf60cf`. The error says `PRISMA_WORKSPACE_ID` is required, as deploy does. +- F03: `3ee8e3b9`. One map, `RESOURCE_ROW_KINDS`, in `lowering/src/resource-rows.ts`, used by the command, the scripts and the emulators. +- F04: real named-stage QA in [qa.md](../qa.md). +- F05: `5464a84d`. A missing Branch fails with an error. +- F06: `90cf60cf`. The test checks the header and two full rows. +- F07: the docs no longer name a release version. + +## Acceptance-criteria verification + +The spec has no numbered acceptance criteria. These are its "Chosen design" bullets and done conditions, read as criteria. + +| AC | Verdict | Detail | +| --- | --- | --- | +| AC1: Lives in composer-cli next to `emulator list` | **PASS** | `src/exports/family.ts` adds `'deploy list'`; test "the published family ships it". | +| AC2: Reads the application topology, not Alchemy state | **PASS** | `deployed.ts` lines 50–54 read only the topology endpoint; the fake client throws on any other route, and the command tests pass. | +| AC3: Join by `logicalId` in the Branch's lists, filtered by project and Branch; root is the Project | **PASS** | `deployed.ts` lines 67–77 send `projectId` and `branchId`; `deploy-list.test.ts` lines 160–162 assert the query; `deployed-nodes.test.ts` covers root and the `logicalId` match among decoys. | +| AC4: Kind mapping (`project`, `service`, `postgres` for both, `bucket`, `module`, else raw type; empty ID with no row) | **PASS** | `deployed-nodes.test.ts` has one test per case, including `s3-credentials` and a missing row. The e2e check does not verify kind (F01). | +| AC5: URL is the service's `appEndpointDomain` | **PASS** | Unit test and command test assert `url: 'https://api.prisma.app'` from the row; e2e compares it against `/v1/apps`. | +| AC6: Stage lookup is find-only by `logicalId` and `gitName`, production by default; `--name` and `--entry` work | **PASS** | Command tests assert the `gitName` query and the Branch used for the topology, and the Project chosen under `--name` and `--entry`. | +| AC7: Nothing deployed fails with `DEPLOY.TARGET_NOT_FOUND`, naming app and stage | **PASS** | Table test covers no Project, no Branch (message names `shop/pr-42`), 404 topology and empty topology. | +| AC8: Human table, tab-separated stdout, `--json` array with `null` | **PASS** | stdout test asserts the exact text; JSON test asserts the exact array with `null`s. The human-table test is loose (F06). | +| AC9: prisma-cli mounts `deploy list` | **NOT VERIFIED** | D3, not in this branch. | +| AC10: Design records and guides updated | **PASS** | ADR-0050 and ADR-0053 amendments, the ADR index, `deploy-cli.md`, `deploying.md`, `running-locally.md` and the skill all describe the command consistently with the code. | +| Done 1: Against a real deployed stage, every node has the right IDs and URLs | **NOT VERIFIED** | Production is covered by the e2e step once it runs in CI; a named stage is not covered anywhere yet (F04). D4 QA pending. | +| Done 2: The e2e workflow runs it after deploy and checks `--json` against the platform | **WEAK** | The step exists and checks order, IDs and URLs, but not kind (F01). It has not been seen running on a pull request yet. | + +### Summary + +| Result | Count | ACs | +| --- | --- | --- | +| PASS | 9 | AC1–AC8, AC10 | +| FAIL | 0 | | +| NOT VERIFIED | 2 | AC9, Done 1 | +| WEAK | 1 | Done 2 | diff --git a/.drive/projects/node-addressing/slices/a2-deploy-list/reviews/system-design-review.md b/.drive/projects/node-addressing/slices/a2-deploy-list/reviews/system-design-review.md new file mode 100644 index 000000000..a49b9f727 --- /dev/null +++ b/.drive/projects/node-addressing/slices/a2-deploy-list/reviews/system-design-review.md @@ -0,0 +1,52 @@ +# System-design review — A2 `prisma deploy list` + +Range: `origin/main...drive/deploy-list` (merge base `87d001de`, head `2e16ca74`, 12 commits). +Spec: [../spec.md](../spec.md). No pull request yet. + +Read before reviewing: `docs/design/01-principles/` (both files), the ADR index, ADR-0050 and ADR-0053 with their new amendments, ADR-0044's `DEPLOY` codes, `docs/design/10-domains/deploy-cli.md`. + +## What the branch adds + +A read-only command, `deploy list`, that prints one row per node of the application topology the last deploy wrote to a stage's Branch, joined by `logicalId` to the Branch's service, database and bucket rows. Three new pieces carry it: + +- `readDeployedStage` in the Prisma Cloud lowering package (`packages/1-prisma-cloud/0-lowering/lowering/src/deployed.ts`). It finds the Project and Branch with the existing find-only `resolveContainer`, reads the topology, and lists the Branch's rows filtered by `projectId` and `branchId`. +- `resolveAppIdentity` and `validateStageName`, exposed on a new `@internal/cli/app-identity` subpath, so a command can learn the app name without Load or assembly. +- The command itself in `@prisma/composer-cli`, next to `emulator list`, with the join as a pure function (`deployed-nodes.ts`) and the Management API work lazily imported (`list-deployed.ts`). + +## Fit with the recorded design + +- **Placement matches ADR-0053.** `emulator list` set the pattern: a command that needs Prisma Cloud code is added to the family by `@prisma/composer-cli`, and the framework CLI does not grow a target hook. `deploy list` follows it exactly. The framework family's doc comment and both ADR amendments say so. No principle is contradicted: the framework core still knows nothing about Prisma Cloud. +- **Node identity.** The join is by full address against `logicalId`, which is what the "address is identity everywhere" principle and ADR-0057 require. Row IDs are shown as data, never used as keys. Good. +- **No deploy-lease contention.** Reading the topology rather than Alchemy state is the right call and is recorded in `deploy-cli.md`. +- **Command shape.** ADR-0050 says `prisma deploy` is a bare verb. `deploy list` makes `deploy` both a command and a group, the same shape `dev` already has with `dev url` and `dev stop`. The ADR-0050 amendment states this. It does not reverse the decision; it extends it as ADR-0053 did. +- **Error codes.** `DEPLOY.TARGET_NOT_FOUND` and `DEPLOY.STAGE_INVALID` are existing ADR-0044 codes used with the meaning `destroy` already gives them. + +## Naming and typology + +- `@internal/cli/app-identity`: named for the concept it exposes (who the app is). It also exports `validateStageName`, which is not app identity. Acceptable as "what a read-only command needs before a lookup", but the subpath's doc comment has to explain the stage check; a reader would not guess it from the name. No action needed. +- `DeployedStage`, `DeployedRow`, `DeployedServiceRow`, `DeployedNode`: consistent "deployed" prefix across the lowering read and the command's output row. Each name says what it is. No qualifier-prefix problem. +- `listDeployed` / `ListDeployed` / `ListDeployedInput`, parallel to `listEmulators` / `ListEmulators`. Symmetric with the precedent. + +### The `kind` column mixes three vocabularies + +The spec settled this, so it is an observation for Will, not a finding. The `kind` value comes from three different sources: + +- `project` describes the platform row (the root's topology kind is `module`). +- `service` and `module` are the topology's structural `kind`. +- `postgres` and `bucket` are renamed resource types (`raw-postgres` becomes `postgres`, `s3` becomes `bucket`), while every other resource passes its raw `type` through (`s3-credentials`). + +So `kind` is an open set whose values do not share one meaning. An agent filtering on `kind == "bucket"` works today; an agent asking "which nodes are resources" cannot answer from this column. If that matters later, adding the topology's `kind` and `type` as separate JSON fields would answer it without changing the human table. + +### The resource-type vocabulary now has three copies + +`deployed-nodes.ts` restates which target resource types become a database or a bucket row. `scripts/platform-identity.ts` has the same table, and the target's `local-target/emulators.ts` has a third (`DATABASE_TYPES`). The types themselves belong to the Prisma Cloud target pack. If the target adds a database type, `deploy list` will silently show the raw type with no platform ID, and the e2e check will agree with it because it reads its own copy. See code-review F03. + +## Test strategy at the architecture level + +- The join is a pure function with unit tests per kind. The command is tested through the engine with a fake Management API, including request parameters (the Branch filter). This is the right split. +- The e2e check reads the platform independently (`/v1/apps` across the whole Project, then filters by Branch itself) rather than reusing `readDeployedStage`. That independence is what makes it a real check of the command, and it is worth keeping. +- `scripts/platform-state.ts` now holds the shared platform read for both e2e checks. It imports lowering source files by relative path, as the old `e2e-verify-identity.ts` did. No change in kind. + +## Out of scope here + +Implementation correctness, failure modes and acceptance criteria are in [code-review.md](code-review.md). Adopter learnability of the guide text would be a devrel review. diff --git a/.drive/projects/node-addressing/slices/a2-deploy-list/spec.md b/.drive/projects/node-addressing/slices/a2-deploy-list/spec.md new file mode 100644 index 000000000..940673ff5 --- /dev/null +++ b/.drive/projects/node-addressing/slices/a2-deploy-list/spec.md @@ -0,0 +1,60 @@ +# A2 — `prisma deploy list` lists what is deployed on a stage + +## At a glance + +``` +$ prisma deploy list --stage pr-42 +ADDRESS KIND PLATFORM ID URL +shop project proj_123 +shop.api service svc_456 https://….prisma.app +shop.catalog module +shop.catalog.db postgres db_789 +shop.files bucket bkt_012 + +$ prisma deploy list --json # production, machine-readable +``` + +## Chosen design + +From design note 8, grounded in the code at `origin/main` `87d001de`: + +- **Where it lives.** `packages/9-public/composer-cli`, next to `emulator list`. It reads Prisma Cloud data, and the framework CLI has no extension hook for "read what is deployed". Adding one for a single command is not worth it. +- **What it reads.** The stage's application topology (`GET /v1/projects/{projectId}/branches/{branchId}/application-topology`), which every deploy writes. Not the Alchemy state: reading that needs the deploy lease and would collide with a running deploy. +- **The join.** Each topology node is one row of output, in topology order. Its platform row is found by `logicalId` in the Branch's services, databases and buckets lists (filtered by `projectId` and `branchId`). The root node's row is the Project. +- **Kind.** `project` for the root, `service`, `postgres` for both Postgres types, `bucket`, `module` for other modules. Any other resource shows its topology `type`. A node with no platform row has an empty platform ID. +- **URL.** Services only: the row's `appEndpointDomain`, which is already a full `https://` URL and is the same URL a deploy prints. +- **Finding the stage.** The same find-only lookup deploy uses: the Project by `logicalId` (the app name), the Branch by `gitName`, production when no `--stage` is given. The app name comes from the entry, as `deploy` resolves it (`resolveAppIdentity`), with `--name` and `--entry` working as on `deploy`. +- **Nothing deployed.** No Project, no Branch, or no topology (404 or no nodes) fails with `DEPLOY.TARGET_NOT_FOUND`, naming the app and stage. +- **Output.** Human: a table. `stdout`: one tab-separated line per node. `--json`: an array of `{ address, kind, platformId, url }`, with `null` for missing values. +- **Mounting in `prisma`.** The `prisma` host in prisma/prisma-cli mounts each Composer command by name. A small prisma-cli PR adds `"deploy list"` next to `"emulator list"`. +- **Design records, in this PR.** ADR-0053 (or ADR-0050, whichever lists the family's commands) gets an amendment for `deploy list`. `deploy-cli.md`, `docs/guides/deploying.md` (a short section on listing a stage), and the agent skill show it. + +## Scope + +**In:** everything above. + +**Out:** +- Platform rows with no topology node (for example after a failed topology write). The topology drives the list. +- Showing a node's status or its last deploy. +- Addresses on platform commands (B1). + +## Pre-investigated edge cases + +- The topology is written before the apply, and the write is best-effort. After a failed deploy a node may have no row; it lists with an empty platform ID. +- S3 credentials resources and non-root modules have no row. +- Production has no Branch filter for `isDefault`; reuse the existing paged lookup. + +## Slice-specific done conditions + +- Against a real deployed stage, the command lists every node with the right IDs and URLs. +- The e2e deploy workflow runs it after deploy and checks its `--json` output against the platform rows. Until prisma-cli ships `deploy list`, the check may run the Composer command family directly rather than through `prisma`. + +## Open questions + +None. + +## References + +- [Project spec](../../spec.md), [design notes](../../design-notes.md) decision 8. +- ADR-0023, ADR-0024, ADR-0043, ADR-0044, ADR-0050, ADR-0053, ADR-0057. +- `docs/design/05-prisma-cloud/pdp-data-model.md`, `build-reporting.md`. diff --git a/.github/actions/deploy-verify-destroy/action.yml b/.github/actions/deploy-verify-destroy/action.yml index d40aef7a7..05c0016ec 100644 --- a/.github/actions/deploy-verify-destroy/action.yml +++ b/.github/actions/deploy-verify-destroy/action.yml @@ -65,6 +65,14 @@ runs: STACK_NAME: ${{ inputs.stack-name }} run: bun "${{ github.workspace }}/scripts/e2e-verify-identity.ts" + - name: Verify deploy list + # Reads the app's config and entry, so it runs from the example's directory. + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + STACK_NAME: ${{ inputs.stack-name }} + run: bun "$GITHUB_WORKSPACE/scripts/e2e-verify-deploy-list.ts" + - name: Destroy # Check the Deploy step's outcome, not `.alchemy/` presence — deploy # state is hosted, not local to the runner. diff --git a/docs/design/10-domains/deploy-cli.md b/docs/design/10-domains/deploy-cli.md index e47cce219..e19771509 100644 --- a/docs/design/10-domains/deploy-cli.md +++ b/docs/design/10-domains/deploy-cli.md @@ -28,8 +28,9 @@ and Two commands, mounted by the `prisma` CLI from `@prisma/composer-cli`'s command family, plus one operation with no command. The family also carries -`dev url`, `dev stop` and `emulator list`, which act on the local app and the -local emulators; [local-dev.md](local-dev.md) covers them +`prisma deploy list`, below, and `dev url`, `dev stop` and `emulator list`, +which act on the local app and the local emulators; [local-dev.md](local-dev.md) +covers those three ([ADR-0053](../90-decisions/ADR-0053-dev-url-dev-stop-and-emulator-list.md)). - **`prisma deploy`** — deploy the application whose root node is the default @@ -39,6 +40,20 @@ local emulators; [local-dev.md](local-dev.md) covers them working directory. With neither, the command fails with `CONFIG.ENTRY_MISSING`. The entry is an input to the tool, not an application fact ([ADR-0003](../90-decisions/ADR-0003-deploy-derives-everything-from-the-root-node.md)). The generated stack file gets the entry as an absolute path. +- **`prisma deploy list`** — list every node the app has deployed on a stage + (default: production), one row per node of the application topology the + last deploy wrote, in topology order. Each row has the node's address, its + kind (`project` for the root, `service`, `postgres`, `bucket`, `module`, or + another resource's type), its platform ID and, for a service, its URL. The + root's ID is the Project's; every other ID is the Branch's service, database + or bucket row with the node's `logicalId`. A node with no row has no ID. It + finds the stage with the find-only lookup `destroy` uses and creates + nothing. It reads the topology, not the deploy state, so it never contends + for the deploy lease. Flags: `--stage`, `--name` and `--entry`, as on + `deploy`. An invalid stage name fails with `DEPLOY.STAGE_INVALID`; no + Project, no Branch or no topology fails with `DEPLOY.TARGET_NOT_FOUND`. + `@prisma/composer-cli` adds it to the family, because it reads Prisma Cloud + ([ADR-0053](../90-decisions/ADR-0053-dev-url-dev-stop-and-emulator-list.md)). - **`destroy`**, an operation on `@prisma/composer/control` with no command — tear a stage down (same derivation, Alchemy destroy); the target is always explicit (see § Stages and containers). Users call it from a script. diff --git a/docs/design/90-decisions/ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md b/docs/design/90-decisions/ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md index 923aa6621..2529dfc0b 100644 --- a/docs/design/90-decisions/ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md +++ b/docs/design/90-decisions/ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md @@ -2,6 +2,8 @@ Amended by [ADR-0053](ADR-0053-dev-url-dev-stop-and-emulator-list.md): the family also ships `dev url`, `dev stop` and `emulator list`, so `prisma dev` is both a command and a group. +Amended 2026-10-27: the family also ships `deploy list`, so `prisma deploy` is both a command and a group too ([ADR-0053](ADR-0053-dev-url-dev-stop-and-emulator-list.md)). + Amended 2026-10-23: neither command takes a positional entry. The entry is `entry` in the `composer` section of `prisma.config.ts` ([ADR-0049](ADR-0049-composers-configuration-is-the-composer-section-of-prisma-config.md)), and both commands take `--entry ` to override it for one run. The `entry` input of the `deploy`, `destroy`, `dev` and `log` operations is optional and overrides the config's. ## Decision diff --git a/docs/design/90-decisions/ADR-0053-dev-url-dev-stop-and-emulator-list.md b/docs/design/90-decisions/ADR-0053-dev-url-dev-stop-and-emulator-list.md index bbd080242..6b073c217 100644 --- a/docs/design/90-decisions/ADR-0053-dev-url-dev-stop-and-emulator-list.md +++ b/docs/design/90-decisions/ADR-0053-dev-url-dev-stop-and-emulator-list.md @@ -6,6 +6,8 @@ Amended by [ADR-0056](ADR-0056-prisma-dev-runs-a-service-with-its-build-adapters Amended by [ADR-0057](ADR-0057-a-nodes-address-is-its-full-path-from-the-app-name.md): `dev url` takes the full address, starting with the app name (`prisma dev url shop.gateway`). +Amended 2026-10-27: the family also ships `deploy list`. It lists every node an app has deployed on a stage, with the platform ID each one deployed to and, for a service, its URL. It reads the stage's application topology and the Branch's services, databases and buckets on Prisma Cloud. Like `emulator list`, it needs Prisma Cloud code, so `@prisma/composer-cli` adds it to the family. It takes `--stage`, `--name` and `--entry` as `deploy` does, and fails with `DEPLOY.STAGE_INVALID` for an invalid stage name and `DEPLOY.TARGET_NOT_FOUND` when the stage has nothing deployed. Its data lines are address, kind, platform ID and URL. Its JSON result is an array of `{ address, kind, platformId, url }`, with `null` for a missing value. + ## Decision Composer's command family ships three more commands and a `--detach` flag on `prisma dev`, and `@prisma/composer/control` two more operations and a `detach` option behind them: diff --git a/docs/design/90-decisions/README.md b/docs/design/90-decisions/README.md index c10e36429..c4cfab81f 100644 --- a/docs/design/90-decisions/README.md +++ b/docs/design/90-decisions/README.md @@ -71,10 +71,10 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl - [ADR-0047](ADR-0047-compute-assembly-preserves-safe-runtime-topology.md) — Compute assembly traces runtime files from the author-declared Node entry without rebundling app code, preserves only symlinks whose resolved targets remain inside the staged bundle, and installs narrowly runtime-gated bootstrap compatibility when a framework needs Node semantics. Each local mechanism is removed once the upstream Alchemy Compute provider owns the equivalent guarantee. Supersedes ADR-0005's blanket ban on symlinks and its assumption that directory output is already self-contained. - [ADR-0048](ADR-0048-prisma-cloud-resources-come-from-the-upstream-alchemy-provider.md) — The six Management-API resource families (project, database, connection, app, deployment, environment variable) are the upstream `alchemy/Prisma` provider's classes, registered in Composer's `PrismaComposer` collection; Composer defines resources only where the upstream provider has no support yet (buckets) or no Management API exists behind them. Compute binds the low-level App/Deployment/EnvironmentVariable trio (the `COMPOSER_*_ORIGIN` self-edge and ADR-0005 rule out composite `Compute`); the env→deployment ordering edge rides the deployment's `app` prop, and a deployment is replaced exactly when its artifact or environment fingerprint changes — unchanged services are reused. Legacy rows in the platform state API (retired type-ids and shapes) migrate on read — the older retired SQL state stores stay destroy-only under ADR-0045 — branch-stage databases take generated physical names, and the platform's seeded `DATABASE_URL` is left system-managed. *(Amended by ADR-0057: resource IDs are the node's full address, or the address plus `.` and a role, each declaring its previous ID with `renamedFrom`.)* - [ADR-0049](ADR-0049-composers-configuration-is-the-composer-section-of-prisma-config.md) — Composer's configuration is the `composer` section of `prisma.config.ts`, loaded by the Prisma CLI engine. The validator checks only the fields that identify extension and state descriptors, passes descriptors through as the file's own objects, rejects unknown keys, and takes the nearest declaring file's section whole so the generated stack imports exactly one file. The old `prisma-composer.config.ts` and the `configPath` pointer are refused with `CONFIG.SECTION_MISSING`, `CONFIG.FIELD_RETIRED` and `CONFIG.FILE_RETIRED`, never migrated. The validator returns the section with its declaring file, and the programmatic operations take the same `ComposerConfigSource` (`{ value, file }`) and run the same checks. The Alchemy child evaluates the whole `prisma.config.ts`. The effect pre-flight and `DEPS.EFFECT_VERSION_CONFLICT` are retired: a broken tree fails evaluation of `prisma.config.ts` with the engine's `CLI.CONFIG_UNREADABLE`. *(Amended: the alternatives section lists the rejected ways to keep a Composer-owned effect check. Amended 2026-10-23: the section takes an optional `entry`, resolved against the declaring file's directory; `--entry` or an operation's `entry` overrides it; neither gives `CONFIG.ENTRY_MISSING`.)* -- [ADR-0050](ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md) — Composer has no binary of its own: the `prisma` CLI mounts exactly two Composer commands, `prisma deploy` and `prisma dev`, mounted at the root as bare verbs on purpose rather than under a noun. `destroy` and `log` are operations on `@prisma/composer/control`, called from a script; a teardown script reads `PRISMA_SERVICE_TOKEN` and `PRISMA_WORKSPACE_ID`. Where teardown and logs belong in the `prisma` command tree is not decided; until it is, agents and users need a script for both. A `destroy` command was rejected: the CLI's destructive verb is `delete`, and no Composer command takes `--production`. Retires the `prisma-composer` binary; a CI lint keeps its name out of user-facing files. *(Amended by ADR-0053: the family also ships `dev url`, `dev stop` and `emulator list`, and `prisma dev` gains `--detach`. Amended 2026-10-23: neither command takes a positional entry; both take `--entry` over the config's `entry`.)* +- [ADR-0050](ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md) — Composer has no binary of its own: the `prisma` CLI mounts exactly two Composer commands, `prisma deploy` and `prisma dev`, mounted at the root as bare verbs on purpose rather than under a noun. `destroy` and `log` are operations on `@prisma/composer/control`, called from a script; a teardown script reads `PRISMA_SERVICE_TOKEN` and `PRISMA_WORKSPACE_ID`. Where teardown and logs belong in the `prisma` command tree is not decided; until it is, agents and users need a script for both. A `destroy` command was rejected: the CLI's destructive verb is `delete`, and no Composer command takes `--production`. Retires the `prisma-composer` binary; a CI lint keeps its name out of user-facing files. *(Amended by ADR-0053: the family also ships `dev url`, `dev stop` and `emulator list`, and `prisma dev` gains `--detach`. Amended 2026-10-23: neither command takes a positional entry; both take `--entry` over the config's `entry`. Amended 2026-10-27: the family also ships `deploy list`.)* - [ADR-0051](ADR-0051-a-nodes-address-is-its-logical-id-on-the-platform.md) — A node's address is its identity on Prisma Cloud: Composer writes it, byte for byte, as the `logicalId` of the node's one platform row (Project, App, Database, Bucket) and as the node's `logicalId` in the application topology, so the platform joins topology nodes to rows, and one node across branches, by string equality. A provision ID defaults to the node's name, so changing a name changes identity. The value is never an Alchemy resource ID, a display name or a platform `id`; App, Database and Bucket rows get it as a prop on the upstream resource that creates them, and the Project from container resolution; no separate resource writes it. Changing an address changes the entity's identity; changing a display name changes nothing. *(Amended 2026-10-16: on the app's own Project and Branch, the row holding a node's `logicalId` is that node's row, so a deploy resuming an interrupted create uses it. Superseded by ADR-0057: addresses start with the app name, and resource IDs, env keys and `serviceOrigin()` are built from them; the identity rules here are carried forward.)* - [ADR-0052](ADR-0052-composer-maps-its-nodes-to-emulator-instances-per-directory.md) — The local emulators know nothing about Composer: the Compute, Postgres and bucket emulators key their instances by ids they generate and store labels they never read. Composer records per working directory, in `.prisma-composer/dev/emulator-instances.json`, which instance backs each node of each app run there, and nothing else maps Composer's names to instances, so two directories running one app get separate services, databases and buckets. An instance keeps its port until it is deleted, because the port is in the deployment's environment and its dependents' URLs and a warm start does not redeploy; a remembered port is requested, as a preference, only when an instance is re-created. When `prisma dev` ends it stops the app's services and database servers, keeping their data. Running a different app from a directory stops the previous app's instances and keeps them; `--fresh` deletes only the current app's. Amends ADR-0041's app-name local identity. *(Amended by ADR-0057: record keys are full addresses; an older record is rewritten on the first `prisma dev`.)* -- [ADR-0053](ADR-0053-dev-url-dev-stop-and-emulator-list.md) — Composer's command family ships `dev url
` (where a node of the app `prisma dev` runs from this directory answers: a service's URL, a database's connection string, a bucket's S3 endpoint; the URL alone on stdout, piped too), `dev stop` (stops that app's services and database servers, data kept), `dev --detach` (returns once every service answers and leaves the app running until `dev stop`; the foreground stays the default) and `emulator list` (every local emulator and its instances, with labels; needs no project). `devUrl` and `devStop` are `@prisma/composer/control` operations that go through a new optional `current({ devDir })` on the local-target seam, so core never reads the target's record; `listEmulators` lives in the Prisma Cloud domain and `@prisma/composer-cli` adds its command. New codes: `DEV.NOT_STARTED_HERE`, `DEV.ADDRESS_UNKNOWN`, `DEV.NOT_RUNNING`. Amends ADR-0050 and ADR-0043. *(Amended by ADR-0056: `devUrl`'s result gains `runs`. Amended by ADR-0057: `dev url` takes the full address.)* +- [ADR-0053](ADR-0053-dev-url-dev-stop-and-emulator-list.md) — Composer's command family ships `dev url
` (where a node of the app `prisma dev` runs from this directory answers: a service's URL, a database's connection string, a bucket's S3 endpoint; the URL alone on stdout, piped too), `dev stop` (stops that app's services and database servers, data kept), `dev --detach` (returns once every service answers and leaves the app running until `dev stop`; the foreground stays the default) and `emulator list` (every local emulator and its instances, with labels; needs no project). `devUrl` and `devStop` are `@prisma/composer/control` operations that go through a new optional `current({ devDir })` on the local-target seam, so core never reads the target's record; `listEmulators` lives in the Prisma Cloud domain and `@prisma/composer-cli` adds its command. New codes: `DEV.NOT_STARTED_HERE`, `DEV.ADDRESS_UNKNOWN`, `DEV.NOT_RUNNING`. Amends ADR-0050 and ADR-0043. *(Amended by ADR-0056: `devUrl`'s result gains `runs`. Amended by ADR-0057: `dev url` takes the full address. Amended 2026-10-27: the family also ships `deploy list`, which lists every node an app has deployed on a stage with its platform ID and URL; `@prisma/composer-cli` adds it.)* - [ADR-0054](ADR-0054-raw-http-is-the-untyped-tier-of-service-connections.md) — `@prisma/composer/http` ships `rawHttp()` and `rawHttpContract` (kind `http`, compared by kind only): the untyped tier of service-to-service connections, as `rawPostgres()` is for data. The provider declares its HTTP port; there is no service key, retry or idempotency, because the far end is the app's own server. The auth module's `api` port becomes a raw HTTP port and the provider-named `auth-api` kind is removed, because contracts describe interfaces, not providers. A `Contract` declares its own communication `style`, which the application topology reads. The untyped `http({ name })` is removed. A more specific HTTP kind filling a raw slot is the intended design but is not built, because contract kinds have no parent kinds. - [ADR-0055](ADR-0055-the-postgres-binding-builds-clients-with-the-apps-options.md) — The `postgres(contract)` binding gains `clientWith(options)`, which builds a typed Prisma ORM client with the runtime's own options (less `poolOptions`), such as pgvector's runtime extension or `middleware`, and `connection`, the pool every client shares, for an app that builds its client itself. The dependency declaration takes no options: they are how the service consumes the database, not what it requires, and passing the runtime's option type through means a new option needs no Composer change. `client` is unchanged. Composer never discovers an extension package from the contract's pack ids; a missing one still fails when the client is built, with the runtime's error. Rejected: options on the declaration, extensions on the contract. Amends ADR-0022, extends ADR-0040. - [ADR-0056](ADR-0056-prisma-dev-runs-a-service-with-its-build-adapters-dev-server.md) — Under `prisma dev`, a service runs the dev server its build adapter declares, in place of its built artifact: `nextjs()` declares `next dev`; `node()` declares none unless the author writes `dev: [...]` on it. No flag. The build descriptor gains `serve(input) → DevServer | undefined` (`cwd`, `argv({ port, host })`, `display`), called before assembly; such a service is not assembled and lowers without its `Deployment` (`LowerOptions.unassembled` + the service descriptor's `reserve`), so its rows, instance, port and dependents' URLs stay. The Compute emulator runs the dev server as a *command deployment* (`{ command: { cwd, argv }, env, listens }`) on the service's instance, with the deployment's materialized env re-stashed address-free by `stashRows`, the pure function `run()` now uses, plus `PORT`/`HOST`, over the shell's env; readiness, logs, crash supervision, restart on env change, `--detach`, `dev stop` and session resume are unchanged and the dev session owns no processes. The session streams dev servers' logs inline; `prisma dev` also watches the entry and every service's authoring module; `devUrl` gains `runs`. New codes `DEV.DEV_SERVER_UNAVAILABLE`, `DEV.DEV_SERVER_UNSUPPORTED`. Amends ADR-0041 and ADR-0053; extends ADR-0005. diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index 759a66b8d..31ca2af07 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -14,6 +14,7 @@ in your code. | Deploy to production | `prisma deploy` | | Deploy an isolated environment | `prisma deploy --stage ` | | Deploy under a different app name | `prisma deploy --name demo-42` | +| See what is deployed on a stage | `prisma deploy list --stage ` (see [Listing what is deployed](#listing-what-is-deployed)) | | Remove the app and every environment | `prisma project delete --confirm ` (see [Removing an app or a stage](#removing-an-app-or-a-stage)) | | Remove one stage | the `destroy` operation (see [Removing an app or a stage](#removing-an-app-or-a-stage)) | @@ -285,6 +286,41 @@ A node that deployed but published nothing reportable is still listed, marked about your app. It's gone, replaced by the tree above. Nothing you can configure printed it and nothing depended on it. +## Listing what is deployed + +`prisma deploy list` shows every node the app has deployed on a stage, the +platform ID each one deployed to and, for a service, its URL. It reads the +stage on Prisma Cloud, so it works from any machine and does not need the +terminal that ran the deploy: + +``` +$ prisma deploy list --stage pr-42 +Address Kind Platform ID URL +storefront-auth project proj_avmiqutogcve9ojwdfb8l28z +storefront-auth.auth module +storefront-auth.auth.database postgres db_p93c9pzn1k75bdmbgtuazav4 +storefront-auth.auth.service service cps_p2wu05hfr90sknm62mk4n1ke https://p2wu05hfr90sknm62mk4n1ke.ewr.prisma.build +storefront-auth.storefront service cps_uihnu0asxk2mr4csj736tai7 https://uihnu0asxk2mr4csj736tai7.ewr.prisma.build +``` + +Without `--stage` it lists production. It finds the app by the name of the +entry's root module, so run it where you run `prisma deploy`, or pass +`--name` and `--entry` as you would to `prisma deploy`. + +The root's platform ID is the Project's. A module has no platform ID, and +nor does a resource with no row of its own on the platform, such as S3 +credentials. The list is the topology the last deploy recorded: if that +deploy stopped before a node's resource was created, the node is listed +with no platform ID. + +It fails with `DEPLOY.TARGET_NOT_FOUND` when the stage has nothing deployed, +and with `DEPLOY.STAGE_INVALID` when the stage name is not a valid git ref +name. Piped, it writes one line per node: address, kind, platform ID and URL, +with an empty field for a missing value. With `--json` the result is an +array of `{ address, kind, platformId, url }`, with `null` for a missing +value. `prisma deploy list` needs a `prisma` CLI release that includes it; +8.0.0-rc.26 does not. + ## Removing an app or a stage To remove an app with every environment, delete its Project. Its id is in the diff --git a/docs/guides/running-locally.md b/docs/guides/running-locally.md index 1b33c2c32..e1670ee2f 100644 --- a/docs/guides/running-locally.md +++ b/docs/guides/running-locally.md @@ -295,6 +295,7 @@ The rules for every line: | Command | One line per | Fields | | --- | --- | --- | | `prisma deploy` | deployed entity | node address, entity kind (`compute-service`, `postgres-database` or `bucket`), entity id, URL (empty for an entity without one) | +| `prisma deploy list` | deployed node | node address, kind (`project`, `service`, `postgres`, `bucket`, `module`, or another resource's type), platform ID, URL | | `prisma dev stop` | node it stopped | node address, node kind (`service`, `database` or `bucket`) | | `prisma dev url` | the node asked for | URL | | `prisma emulator list` | emulator instance | emulator kind (`service`, `database` or `bucket`), instance id, URL, state, node address, directory | diff --git a/packages/0-framework/3-tooling/cli/package.json b/packages/0-framework/3-tooling/cli/package.json index b196288f6..378008cf7 100644 --- a/packages/0-framework/3-tooling/cli/package.json +++ b/packages/0-framework/3-tooling/cli/package.json @@ -4,6 +4,7 @@ "type": "module", "description": "Composer's command family and its programmatic deploy, destroy, dev and log operations.", "exports": { + "./app-identity": "./dist/app-identity.mjs", "./control": "./dist/control.mjs", "./family": "./dist/family.mjs", "./report": "./dist/report.mjs", diff --git a/packages/0-framework/3-tooling/cli/src/exports/app-identity.ts b/packages/0-framework/3-tooling/cli/src/exports/app-identity.ts new file mode 100644 index 000000000..38e4ad8cb --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/exports/app-identity.ts @@ -0,0 +1,4 @@ +/** The (`./app-identity`) subpath: who the app is, from its config and entry, without Load or assemble, and the check a stage name must pass. For commands that read what an app has deployed. */ +export type { AppIdentity } from '../pipeline.ts'; +export { resolveAppIdentity } from '../pipeline.ts'; +export { validateStageName } from '../validate-stage.ts'; diff --git a/packages/0-framework/3-tooling/cli/src/family/__tests__/command-stdout.test.ts b/packages/0-framework/3-tooling/cli/src/family/__tests__/command-stdout.test.ts index f99703268..1e0b5809d 100644 --- a/packages/0-framework/3-tooling/cli/src/family/__tests__/command-stdout.test.ts +++ b/packages/0-framework/3-tooling/cli/src/family/__tests__/command-stdout.test.ts @@ -22,11 +22,14 @@ const sources = COMMAND_DIRS.flatMap((dir) => describe('command stdout', () => { test('reads every command source', () => { expect(sources.map((source) => source.name).sort()).toEqual([ + 'deploy-list.ts', 'deploy.ts', + 'deployed-nodes.ts', 'dev-stop.ts', 'dev-url.ts', 'dev.ts', 'emulator-list.ts', + 'list-deployed.ts', ]); }); diff --git a/packages/0-framework/3-tooling/cli/src/family/family.ts b/packages/0-framework/3-tooling/cli/src/family/family.ts index f95d69ad7..b9d6cdffa 100644 --- a/packages/0-framework/3-tooling/cli/src/family/family.ts +++ b/packages/0-framework/3-tooling/cli/src/family/family.ts @@ -18,8 +18,9 @@ * The family ships `deploy`, `dev`, `dev url` and `dev stop`, keyed by the * path they are meant to mount at; the host decides where each one mounts. * A host may add commands of its own through `commands` — the published - * family adds `emulator list`, which needs no project. `destroy` and `log` - * are programmatic operations in `@prisma/composer/control`, not commands. + * family adds `deploy list` and `emulator list`, which need Prisma Cloud + * code. `destroy` and `log` are programmatic operations in + * `@prisma/composer/control`, not commands. */ import { type CommandFamily, defineCommandFamily } from '@prisma/cli-engine'; import { deployWithDeps } from '../operations/deploy.ts'; diff --git a/packages/0-framework/3-tooling/cli/tsdown.config.ts b/packages/0-framework/3-tooling/cli/tsdown.config.ts index 25f76f74b..101fa933d 100644 --- a/packages/0-framework/3-tooling/cli/tsdown.config.ts +++ b/packages/0-framework/3-tooling/cli/tsdown.config.ts @@ -5,6 +5,7 @@ import { defineConfig } from '@internal/tsdown-config'; // import nothing but core's types. export default defineConfig({ entry: { + 'app-identity': 'src/exports/app-identity.ts', report: 'src/exports/render-deployment.ts', control: 'src/exports/control.ts', family: 'src/exports/family.ts', diff --git a/packages/1-prisma-cloud/0-lowering/lowering/package.json b/packages/1-prisma-cloud/0-lowering/lowering/package.json index 35efdf622..d239ee2b4 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/package.json +++ b/packages/1-prisma-cloud/0-lowering/lowering/package.json @@ -6,6 +6,7 @@ ".": "./dist/index.mjs", "./builds": "./dist/builds.mjs", "./compute": "./dist/compute.mjs", + "./deployed": "./dist/deployed.mjs", "./state": "./dist/state.mjs", "./package.json": "./package.json" }, diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/deployed.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/deployed.ts new file mode 100644 index 000000000..3b2b08225 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/deployed.ts @@ -0,0 +1,121 @@ +import * as Effect from 'effect/Effect'; +import type { ApplicationTopologyNodeBody } from './builds/application-topology.ts'; +import { type ManagementApiClient, ManagementClient } from './client.ts'; +import { resolveContainer } from './container.ts'; +import { call, callOptional } from './http.ts'; +import { collectPages } from './pagination.ts'; + +export interface ReadDeployedInput { + readonly workspaceId: string; + readonly appName: string; + /** A named stage; omit for production. */ + readonly stage?: string | undefined; +} + +export interface DeployedRow { + readonly id: string; + readonly logicalId: string | null; +} + +export interface DeployedServiceRow extends DeployedRow { + readonly appEndpointDomain: string; +} + +/** What one stage has on Prisma Cloud: its application topology and the platform rows on its Branch. With no topology (none written, or no nodes) every list is empty. */ +export interface DeployedStage { + readonly projectId: string; + readonly branchId: string; + readonly nodes: readonly Pick< + ApplicationTopologyNodeBody, + 'logicalId' | 'parentLogicalId' | 'kind' | 'type' + >[]; + readonly services: readonly DeployedServiceRow[]; + readonly databases: readonly DeployedRow[]; + readonly buckets: readonly DeployedRow[]; +} + +const program = (input: ReadDeployedInput) => + Effect.gen(function* () { + const container = yield* resolveContainer({ + workspaceId: input.workspaceId, + appName: input.appName, + ensure: false, + ...(input.stage === undefined ? {} : { stage: input.stage }), + }); + const { projectId } = container; + const branchId = container.branchId ?? container.defaultBranchId; + if (branchId === undefined) { + return yield* Effect.die(new Error(`project ${projectId} resolved no stage Branch`)); + } + const client = yield* ManagementClient; + + const topology = yield* callOptional(() => + client.GET('/v1/projects/{projectId}/branches/{branchId}/application-topology', { + params: { path: { projectId, branchId } }, + }), + ); + if (topology === undefined || topology.data.nodes.length === 0) { + const empty: DeployedStage = { + projectId, + branchId, + nodes: [], + services: [], + databases: [], + buckets: [], + }; + return empty; + } + + const query = (cursor: string | undefined) => + cursor === undefined ? { projectId, branchId } : { projectId, branchId, cursor }; + const services = yield* collectPages(`services of branch ${branchId}`, (cursor) => + call(() => client.GET('/v1/services', { params: { query: query(cursor) } })), + ); + const databases = yield* collectPages(`databases of branch ${branchId}`, (cursor) => + call(() => client.GET('/v1/databases', { params: { query: query(cursor) } })), + ); + const buckets = yield* collectPages(`buckets of branch ${branchId}`, (cursor) => + call(() => client.GET('/v1/buckets', { params: { query: query(cursor) } })), + ); + + const stage: DeployedStage = { + projectId, + branchId, + nodes: topology.data.nodes.map(({ logicalId, parentLogicalId, kind, type }) => ({ + logicalId, + parentLogicalId, + kind, + ...(type === undefined ? {} : { type }), + })), + services: services.map(({ id, logicalId, appEndpointDomain }) => ({ + id, + logicalId, + appEndpointDomain, + })), + databases: databases.map(({ id, logicalId }) => ({ id, logicalId })), + buckets: buckets.map(({ id, logicalId }) => ({ id, logicalId })), + }; + return stage; + }); + +/** + * Reads what a stage has deployed: its Project and Branch by the same + * find-only lookup `destroy` uses, the application topology the last deploy + * wrote, and the Branch's services, databases and buckets. `undefined` when + * there is no Project or no Branch. Rejects on any other Management API + * failure. + */ +export async function readDeployedStage( + client: ManagementApiClient, + input: ReadDeployedInput, +): Promise { + return Effect.runPromise( + program(input).pipe( + Effect.catchTag('ContainerNotFoundError', () => Effect.succeed(undefined)), + Effect.catchTag('PrismaApiError', (error) => + Effect.fail(new Error(`Prisma Management API error: ${error.message}.`)), + ), + Effect.provideService(ManagementClient, client), + ), + ); +} diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/exports/deployed.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/deployed.ts new file mode 100644 index 000000000..33fa16683 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/deployed.ts @@ -0,0 +1,3 @@ +/** What a deployed stage has on Prisma Cloud, for `deploy list`. */ +export * from '../deployed.ts'; +export * from '../resource-rows.ts'; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/resource-rows.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/resource-rows.ts new file mode 100644 index 000000000..762cc8833 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/resource-rows.ts @@ -0,0 +1,11 @@ +export type ResourceRowKind = 'database' | 'bucket'; + +/** The platform row each Prisma Cloud resource type deploys to. A type missing here, such as an S3 credentials resource, has no row. */ +export const RESOURCE_ROW_KINDS: ReadonlyMap = new Map< + string, + ResourceRowKind +>([ + ['postgres', 'database'], + ['raw-postgres', 'database'], + ['s3', 'bucket'], +]); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/tsdown.config.ts b/packages/1-prisma-cloud/0-lowering/lowering/tsdown.config.ts index af41f0857..27237e10b 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/tsdown.config.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/tsdown.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ entry: { index: 'src/exports/index.ts', builds: 'src/exports/builds.ts', + deployed: 'src/exports/deployed.ts', compute: 'src/exports/compute.ts', state: 'src/exports/state.ts', }, diff --git a/packages/1-prisma-cloud/1-extensions/target/src/local-target/emulators.ts b/packages/1-prisma-cloud/1-extensions/target/src/local-target/emulators.ts index 6f3c5a85a..af966bfaa 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/local-target/emulators.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/local-target/emulators.ts @@ -36,13 +36,11 @@ import { selectApp, stopAppInstances, } from '@internal/local-target'; +import { RESOURCE_ROW_KINDS } from '@internal/lowering/deployed'; import { PRISMA_CLOUD_EXTENSION_ID } from '../container.ts'; import { resourceId } from '../resource-ids.ts'; import { daemonEntry } from './daemon-entry.ts'; -const DATABASE_TYPES = new Set(['raw-postgres', 'postgres']); -const BUCKET_TYPE = 's3'; - /** * This extension's nodes that run on an emulator, in `graph.nodes` order — * dependencies first, ties in declaration order — keyed by their main @@ -53,12 +51,8 @@ function appNodes(input: LocalTargetEmulatorsInput): AppNode[] { if (n.node.kind === 'service' && n.node.extension === PRISMA_CLOUD_EXTENSION_ID) { return [{ address: resourceId(n.id), kind: 'service' }]; } - if (n.node.kind === 'resource' && DATABASE_TYPES.has(n.node.type)) { - return [{ address: resourceId(n.id), kind: 'database' }]; - } - if (n.node.kind === 'resource' && n.node.type === BUCKET_TYPE) { - return [{ address: resourceId(n.id), kind: 'bucket' }]; - } + const rowKind = n.node.kind === 'resource' ? RESOURCE_ROW_KINDS.get(n.node.type) : undefined; + if (rowKind !== undefined) return [{ address: resourceId(n.id), kind: rowKind }]; return []; }); } diff --git a/packages/9-public/composer-cli/package.json b/packages/9-public/composer-cli/package.json index 34901d373..e41cd8711 100644 --- a/packages/9-public/composer-cli/package.json +++ b/packages/9-public/composer-cli/package.json @@ -2,7 +2,7 @@ "name": "@prisma/composer-cli", "version": "0.34.0", "type": "module", - "description": "Prisma Composer's command family, which the `prisma` CLI mounts as `deploy`, `dev`, `dev url`, `dev stop` and `emulator list`.", + "description": "Prisma Composer's command family, which the `prisma` CLI mounts as `deploy`, `deploy list`, `dev`, `dev url`, `dev stop` and `emulator list`.", "exports": { "./family": "./dist/family.mjs", "./testing": "./dist/testing.mjs", @@ -20,6 +20,7 @@ }, "dependencies": { "@prisma/composer": "workspace:0.34.0", + "@prisma/management-api-sdk": "^1.80.0", "alchemy": "2.0.0-beta.83", "cross-spawn": "^7.0.6", "effect": "4.0.3", @@ -32,6 +33,7 @@ "@internal/cli": "workspace:0.34.0", "@internal/dev-emulators": "workspace:0.34.0", "@internal/foundation": "workspace:0.34.0", + "@internal/lowering": "workspace:0.34.0", "@internal/tsdown-config": "workspace:0.34.0", "@types/bun": "^1.3.13", "@prisma/cli-engine": "0.9.0", diff --git a/packages/9-public/composer-cli/src/exports/family.ts b/packages/9-public/composer-cli/src/exports/family.ts index 660b0d079..131392136 100644 --- a/packages/9-public/composer-cli/src/exports/family.ts +++ b/packages/9-public/composer-cli/src/exports/family.ts @@ -17,6 +17,7 @@ import { type CreateComposerFamilyOptions as FrameworkFamilyOptions, } from '@internal/cli/family'; import type { CommandFamily } from '@prisma/cli-engine'; +import { createDeployListCommand, type ListDeployed, listDeployed } from '../family/deploy-list.ts'; import { createEmulatorListCommand, type ListEmulators, @@ -24,18 +25,24 @@ import { } from '../family/emulator-list.ts'; export * from '@internal/cli/family'; +export type { ListDeployed } from '../family/deploy-list.ts'; +export type { DeployedNode } from '../family/deployed-nodes.ts'; export type { ListEmulators } from '../family/emulator-list.ts'; +export type { ListDeployedInput } from '../family/list-deployed.ts'; export interface CreateComposerFamilyOptions extends FrameworkFamilyOptions { /** Defaults to the real listing of this machine's emulators. */ readonly listEmulators?: ListEmulators | undefined; + /** Defaults to the real read of what the app has deployed on Prisma Cloud. */ + readonly listDeployed?: ListDeployed | undefined; } -/** Composer's command family: `deploy`, `dev`, `dev url`, `dev stop` and `emulator list`, keyed by the path each is meant to mount at. */ +/** Composer's command family: `deploy`, `deploy list`, `dev`, `dev url`, `dev stop` and `emulator list`, keyed by the path each is meant to mount at. */ export function createComposerFamily(options: CreateComposerFamilyOptions = {}): CommandFamily { return createFrameworkFamily({ ...options, commands: { + 'deploy list': createDeployListCommand(options.listDeployed ?? listDeployed), 'emulator list': createEmulatorListCommand(options.listEmulators ?? listEmulators), ...options.commands, }, diff --git a/packages/9-public/composer-cli/src/family/deploy-list.ts b/packages/9-public/composer-cli/src/family/deploy-list.ts new file mode 100644 index 000000000..10bcbe858 --- /dev/null +++ b/packages/9-public/composer-cli/src/family/deploy-list.ts @@ -0,0 +1,96 @@ +/** + * `deploy list` — every node an app has deployed on a stage, with the + * platform row each one deployed to. It reads the stage's application + * topology and the Branch's rows on Prisma Cloud, so the command is + * assembled into the family here, where both domains may be imported. + */ + +import { composerSection, dataLine, toEngineError } from '@internal/cli/family'; +import type { CliStructuredError as ComposerError } from '@internal/foundation/errors'; +import type { Result } from '@internal/foundation/result'; +import { defineCommand, flag } from '@prisma/cli-engine'; +import { notOk, ok } from '@prisma/cli-engine/protocol'; +import type { DeployedNode } from './deployed-nodes.ts'; +import type { ListDeployedInput } from './list-deployed.ts'; + +export type ListDeployed = ( + input: ListDeployedInput, +) => Promise>; + +/** Loaded on first use, so nothing of the Management API reads loads with the family. */ +export const listDeployed: ListDeployed = async (input) => + (await import('./list-deployed.ts')).listDeployed(input); + +export const createDeployListCommand = (list: ListDeployed) => + defineCommand({ + help: { + summary: 'List every node the app has deployed on a stage, with its platform ID and URL.', + description: + 'Reads the application topology the last deploy recorded for the stage, and finds ' + + 'each node’s service, database or bucket on Prisma Cloud. The root node is the ' + + 'Project. A node with nothing on the platform, such as a module, has no platform ID.', + examples: [ + '{bin} deploy list', + '{bin} deploy list --stage feat-auth', + '{bin} deploy list --json', + ], + }, + args: { + flags: { + entry: flag.string({ + brief: + 'The file whose default export is the application root module, built with ' + + 'module(...). Overrides `entry` in the `composer` section of prisma.config.ts.', + placeholder: 'path', + }), + name: flag.string({ + brief: + 'List the app deployed under this name instead of the name its root module ' + + "declares (default: the root module's name).", + placeholder: 'name', + }), + stage: flag.string({ + brief: 'List this stage instead of production (default: production).', + placeholder: 'stage', + }), + }, + }, + needs: { config: composerSection, credentials: true }, + handler: async (args, ctx) => { + const credential = await ctx.activeCredential(); + const result = await list({ + config: ctx.config, + entry: args.flags.entry, + name: args.flags.name, + stage: args.flags.stage, + cwd: ctx.cwd, + workspaceId: credential?.workspaceId, + client: ctx.api, + }); + if (!result.ok) return notOk(toEngineError(result.failure)); + const nodes = result.value; + return ok( + ctx.present( + { data: nodes }, + { + human: () => [ + { + kind: 'table', + columns: ['Address', 'Kind', 'Platform ID', 'URL'], + rows: nodes.map((node) => [ + node.address, + node.kind, + node.platformId ?? '', + node.url ?? '', + ]), + }, + ], + stdout: () => + nodes.map((node) => dataLine([node.address, node.kind, node.platformId, node.url])), + json: () => nodes, + next: () => [], + }, + ), + ); + }, + }); diff --git a/packages/9-public/composer-cli/src/family/deployed-nodes.ts b/packages/9-public/composer-cli/src/family/deployed-nodes.ts new file mode 100644 index 000000000..ea5166abd --- /dev/null +++ b/packages/9-public/composer-cli/src/family/deployed-nodes.ts @@ -0,0 +1,55 @@ +import { type DeployedStage, RESOURCE_ROW_KINDS } from '@internal/lowering/deployed'; + +/** One row of `deploy list`: a topology node and the platform row it deployed to. */ +export interface DeployedNode { + readonly address: string; + readonly kind: string; + readonly platformId: string | null; + readonly url: string | null; +} + +type TopologyNode = DeployedStage['nodes'][number]; + +function kindOf(node: TopologyNode): string { + if (node.parentLogicalId === null) return 'project'; + if (node.kind === 'resource') { + if (node.type === undefined) return 'resource'; + const rowKind = RESOURCE_ROW_KINDS.get(node.type); + if (rowKind === 'database') return 'postgres'; + return rowKind ?? node.type; + } + return node.kind; +} + +function rowId(rows: readonly { id: string; logicalId: string | null }[], logicalId: string) { + return rows.find((row) => row.logicalId === logicalId)?.id ?? null; +} + +function deployedNode(stage: DeployedStage, node: TopologyNode): DeployedNode { + const kind = kindOf(node); + const address = node.logicalId; + switch (kind) { + case 'project': + return { address, kind, platformId: stage.projectId, url: null }; + case 'service': { + const row = stage.services.find((service) => service.logicalId === address); + return { + address, + kind, + platformId: row?.id ?? null, + url: row?.appEndpointDomain ?? null, + }; + } + case 'postgres': + return { address, kind, platformId: rowId(stage.databases, address), url: null }; + case 'bucket': + return { address, kind, platformId: rowId(stage.buckets, address), url: null }; + default: + return { address, kind, platformId: null, url: null }; + } +} + +/** Every node of the stage's topology, in topology order, joined by `logicalId` to the platform row it deployed to. The root's row is the Project. */ +export function deployedNodes(stage: DeployedStage): DeployedNode[] { + return stage.nodes.map((node) => deployedNode(stage, node)); +} diff --git a/packages/9-public/composer-cli/src/family/list-deployed.ts b/packages/9-public/composer-cli/src/family/list-deployed.ts new file mode 100644 index 000000000..3f213ca4f --- /dev/null +++ b/packages/9-public/composer-cli/src/family/list-deployed.ts @@ -0,0 +1,64 @@ +import { resolveAppIdentity, validateStageName } from '@internal/cli/app-identity'; +import type { ComposerConfigSource } from '@internal/cli/control'; +import { CliStructuredError } from '@internal/foundation/errors'; +import { notOk, ok, type Result } from '@internal/foundation/result'; +import { readDeployedStage } from '@internal/lowering/deployed'; +import type { ManagementApiClient } from '@prisma/cli-engine'; +import { type DeployedNode, deployedNodes } from './deployed-nodes.ts'; + +export interface ListDeployedInput { + readonly config: ComposerConfigSource; + readonly entry: string | undefined; + readonly name: string | undefined; + readonly stage: string | undefined; + readonly cwd: string; + readonly workspaceId: string | undefined; + readonly client: ManagementApiClient; +} + +function notFound(app: string, stage: string | undefined): CliStructuredError { + return new CliStructuredError( + 'DEPLOY.TARGET_NOT_FOUND', + `Nothing deployed for ${app}${stage === undefined ? '' : `/${stage}`}.`, + { fix: 'Deploy it first.' }, + ); +} + +function lookupFailed(error: unknown): CliStructuredError { + return new CliStructuredError( + 'DEPLOY.CONTAINER_FAILED', + error instanceof Error ? error.message : String(error), + { cause: error }, + ); +} + +/** Every node the app has deployed on the stage, or `DEPLOY.TARGET_NOT_FOUND` when the stage has no Project, Branch or topology. */ +export async function listDeployed( + input: ListDeployedInput, +): Promise> { + let app: string; + try { + if (input.stage !== undefined) validateStageName(input.stage); + app = (await resolveAppIdentity(input.entry, input.name, input.cwd, input.config)).name; + } catch (error) { + if (CliStructuredError.is(error)) return notOk(error); + throw error; + } + if (input.workspaceId === undefined) { + return notOk(lookupFailed(new Error('environment variable PRISMA_WORKSPACE_ID is required.'))); + } + + let stage: Awaited>; + try { + stage = await readDeployedStage(input.client, { + workspaceId: input.workspaceId, + appName: app, + stage: input.stage, + }); + } catch (error) { + return notOk(lookupFailed(error)); + } + const nodes = stage === undefined ? [] : deployedNodes(stage); + if (nodes.length === 0) return notOk(notFound(app, input.stage)); + return ok(nodes); +} diff --git a/packages/9-public/composer-cli/test/deploy-list.test.ts b/packages/9-public/composer-cli/test/deploy-list.test.ts new file mode 100644 index 000000000..23966edbf --- /dev/null +++ b/packages/9-public/composer-cli/test/deploy-list.test.ts @@ -0,0 +1,308 @@ +/** `deploy list` through the engine, reading a fake Management API. */ +import { describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type { ManagementApiClient } from '@prisma/cli-engine'; +import { createTestCli, mintTestJwt } from '@prisma/cli-engine/testing'; +import { createComposerFamily } from '../src/exports/family.ts'; +import { listDeployed } from '../src/family/list-deployed.ts'; + +/** The app directory: the engine's config file must exist there, though the test seeds its sections. */ +const CWD = (() => { + const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'composer-deploy-list-'))); + fs.writeFileSync(path.join(dir, 'prisma.config.ts'), ''); + return dir; +})(); + +/** An entry module in the app directory whose root module is named `name`. */ +function entryNamed(name: string): string { + const file = path.join(CWD, `${name}.ts`); + fs.writeFileSync( + file, + `import { module } from ${JSON.stringify(import.meta.resolve('@prisma/composer'))};\n` + + `export default module(${JSON.stringify(name)}, {}, () => ({}));\n`, + ); + return file; +} + +const ENTRY = entryNamed('shop'); + +interface FakeApi { + readonly projects?: readonly unknown[]; + readonly branches?: readonly unknown[]; + readonly topology?: { readonly nodes: readonly unknown[] } | undefined; + readonly services?: readonly unknown[]; + readonly databases?: readonly unknown[]; + readonly buckets?: readonly unknown[]; +} + +interface Request { + readonly path: string; + readonly params: unknown; +} + +const page = (data: readonly unknown[] = []) => ({ + data, + pagination: { nextCursor: null, hasMore: false }, +}); + +function fakeClient(api: FakeApi, requests: Request[]): ManagementApiClient { + const respond = (status: number, data?: unknown) => ({ + data: status === 200 ? data : undefined, + error: status === 200 ? undefined : { message: 'not found' }, + response: new Response(null, { status }), + }); + const bodies: Record ReturnType> = { + '/v1/projects': () => respond(200, page(api.projects)), + '/v1/projects/{projectId}/branches': () => respond(200, page(api.branches)), + '/v1/projects/{projectId}/branches/{branchId}/application-topology': () => + api.topology === undefined ? respond(404) : respond(200, { data: api.topology }), + '/v1/services': () => respond(200, page(api.services)), + '/v1/databases': () => respond(200, page(api.databases)), + '/v1/buckets': () => respond(200, page(api.buckets)), + }; + return { + GET: async (route: string, options: { params: unknown }) => { + requests.push({ path: route, params: options.params }); + const body = bodies[route]; + if (body === undefined) throw new Error(`unexpected GET ${route}`); + return body(); + }, + } as unknown as ManagementApiClient; +} + +const COMPOSER_CONFIG = { + entry: ENTRY, + extensions: [{ id: 'ext-a', nodes: {} }], + state: { + extension: 'ext-a', + create: (): never => { + throw new Error('deploy list reads no state'); + }, + }, +}; + +function cliWith(api: FakeApi, requests: Request[] = []) { + const family = createComposerFamily(); + const command = family.commands['deploy list']; + if (command === undefined) throw new Error('no deploy list command'); + return createTestCli({ + commandFamilies: [family], + commands: { 'deploy list': command }, + groups: { deploy: { brief: 'Deploy the app.' } }, + config: { composer: COMPOSER_CONFIG }, + credential: { + token: mintTestJwt({ + sub: 'user_1', + workspace_id: 'ws_1', + exp: Math.floor(Date.now() / 1000) + 3600, + }), + refreshToken: undefined, + expiresAt: new Date(Date.now() + 3_600_000), + }, + managementApi: { client: fakeClient(api, requests) }, + }); +} + +const project = { + id: 'proj_1', + name: 'shop', + createdAt: '2026-10-01T00:00:00Z', + workspace: { id: 'wksp_ws_1' }, + logicalId: 'shop', +}; + +const deployed: FakeApi = { + projects: [project], + branches: [{ id: 'br_default', isDefault: true }], + topology: { + nodes: [ + { logicalId: 'shop', parentLogicalId: null, kind: 'module' }, + { logicalId: 'shop.api', parentLogicalId: 'shop', kind: 'service' }, + { logicalId: 'shop.catalog', parentLogicalId: 'shop', kind: 'module' }, + { + logicalId: 'shop.catalog.db', + parentLogicalId: 'shop.catalog', + kind: 'resource', + type: 'postgres', + }, + { logicalId: 'shop.files', parentLogicalId: 'shop', kind: 'resource', type: 's3' }, + ], + }, + services: [ + { + id: 'svc_1', + logicalId: 'shop.api', + appEndpointDomain: 'https://api.prisma.app', + branchId: 'br_default', + }, + ], + databases: [{ id: 'db_1', logicalId: 'shop.catalog.db', branchId: 'br_default' }], + buckets: [{ id: 'bkt_1', logicalId: 'shop.files', branchId: 'br_default' }], +}; + +const listed = [ + { address: 'shop', kind: 'project', platformId: 'proj_1', url: null }, + { address: 'shop.api', kind: 'service', platformId: 'svc_1', url: 'https://api.prisma.app' }, + { address: 'shop.catalog', kind: 'module', platformId: null, url: null }, + { address: 'shop.catalog.db', kind: 'postgres', platformId: 'db_1', url: null }, + { address: 'shop.files', kind: 'bucket', platformId: 'bkt_1', url: null }, +]; + +describe('deploy list', () => { + test('the published family ships it', () => { + expect(Object.keys(createComposerFamily().commands)).toContain('deploy list'); + }); + + test('lists every node of production with its platform ID and URL as the JSON result', async () => { + const requests: Request[] = []; + const cli = cliWith(deployed, requests); + + const result = await cli.run(['deploy', 'list', '--json'], { cwd: CWD }); + + expect(result.exitCode).toBe(0); + expect(result.json.at(-1)).toMatchObject({ envelope: { ok: true, result: listed } }); + expect(requests.find((r) => r.path === '/v1/services')?.params).toEqual({ + query: { projectId: 'proj_1', branchId: 'br_default' }, + }); + }); + + test('finds a named stage by its Branch', async () => { + const requests: Request[] = []; + const cli = cliWith({ ...deployed, branches: [{ id: 'br_pr42' }] }, requests); + + const result = await cli.run(['deploy', 'list', '--stage', 'pr-42', '--json'], { cwd: CWD }); + + expect(result.exitCode).toBe(0); + expect( + requests.find((r) => r.path === '/v1/projects/{projectId}/branches')?.params, + ).toMatchObject({ query: { gitName: 'pr-42' } }); + expect(requests.find((r) => r.path.endsWith('/application-topology'))?.params).toEqual({ + path: { projectId: 'proj_1', branchId: 'br_pr42' }, + }); + }); + + test('rejects an invalid --stage with DEPLOY.STAGE_INVALID before any lookup', async () => { + const requests: Request[] = []; + const cli = cliWith(deployed, requests); + + const result = await cli.run(['deploy', 'list', '--stage', 'foo..bar', '--json'], { + cwd: CWD, + }); + + expect(result.exitCode).not.toBe(0); + expect(result.json.at(-1)).toMatchObject({ + envelope: { ok: false, error: { code: 'DEPLOY.STAGE_INVALID' } }, + }); + expect(requests).toEqual([]); + }); + + test('loads the app from --entry', async () => { + const cli = cliWith({ + ...deployed, + projects: [{ ...project, id: 'proj_3', logicalId: 'shop-c' }], + }); + + const result = await cli.run(['deploy', 'list', '--entry', entryNamed('shop-c'), '--json'], { + cwd: CWD, + }); + + expect(result.exitCode).toBe(0); + expect(result.json.at(-1)).toMatchObject({ + envelope: { ok: true, result: [{ ...listed[0], platformId: 'proj_3' }, ...listed.slice(1)] }, + }); + }); + + test('looks the app up under --name', async () => { + const cli = cliWith({ + ...deployed, + projects: [{ ...project, id: 'proj_2', logicalId: 'shop-b' }], + }); + + const result = await cli.run(['deploy', 'list', '--name', 'shop-b', '--json'], { cwd: CWD }); + + expect(result.exitCode).toBe(0); + expect(result.json.at(-1)).toMatchObject({ + envelope: { ok: true, result: [{ ...listed[0], platformId: 'proj_2' }, ...listed.slice(1)] }, + }); + }); + + test('writes one tab-separated line per node to stdout: address, kind, platform ID and URL', async () => { + const cli = cliWith(deployed); + + const result = await cli.run(['deploy', 'list'], { cwd: CWD }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe( + 'shop\tproject\tproj_1\t\n' + + 'shop.api\tservice\tsvc_1\thttps://api.prisma.app\n' + + 'shop.catalog\tmodule\t\t\n' + + 'shop.catalog.db\tpostgres\tdb_1\t\n' + + 'shop.files\tbucket\tbkt_1\t\n', + ); + }); + + test('shows a table to a person', async () => { + const cli = cliWith(deployed); + + const result = await cli.run(['deploy', 'list', '--format', 'human'], { cwd: CWD }); + + expect(result.exitCode).toBe(0); + const lines = result.stderr.split('\n'); + expect(lines[0]).toMatch(/^Address +Kind +Platform ID +URL$/); + expect(lines).toContainEqual(expect.stringMatching(/^shop\.catalog\.db +postgres +db_1 +—$/)); + expect(lines).toContainEqual( + expect.stringMatching(/^shop\.api +service +svc_1 +https:\/\/api\.prisma\.app$/), + ); + }); + + test('fails like deploy when the credential names no workspace', async () => { + const requests: Request[] = []; + + const result = await listDeployed({ + config: { value: COMPOSER_CONFIG, file: path.join(CWD, 'prisma.config.ts') }, + entry: undefined, + name: undefined, + stage: undefined, + cwd: CWD, + workspaceId: undefined, + client: fakeClient(deployed, requests), + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.code).toBe('DEPLOY.CONTAINER_FAILED'); + expect(result.failure.message).toBe('environment variable PRISMA_WORKSPACE_ID is required.'); + expect(requests).toEqual([]); + }); + + test.each([ + ['no Project', { ...deployed, projects: [] }, [], 'Nothing deployed for shop.'], + [ + 'no Branch', + { ...deployed, branches: [] }, + ['--stage', 'pr-42'], + 'Nothing deployed for shop/pr-42.', + ], + ['no topology', { ...deployed, topology: undefined }, [], 'Nothing deployed for shop.'], + [ + 'a topology with no nodes', + { ...deployed, topology: { nodes: [] } }, + [], + 'Nothing deployed for shop.', + ], + ] as const)( + 'fails with DEPLOY.TARGET_NOT_FOUND when there is %s', + async (_case, api, flags, message) => { + const cli = cliWith(api); + + const result = await cli.run(['deploy', 'list', ...flags, '--json'], { cwd: CWD }); + + expect(result.exitCode).not.toBe(0); + expect(result.json.at(-1)).toMatchObject({ + envelope: { ok: false, error: { code: 'DEPLOY.TARGET_NOT_FOUND', summary: message } }, + }); + }, + ); +}); diff --git a/packages/9-public/composer-cli/test/deployed-nodes.test.ts b/packages/9-public/composer-cli/test/deployed-nodes.test.ts new file mode 100644 index 000000000..c98167679 --- /dev/null +++ b/packages/9-public/composer-cli/test/deployed-nodes.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, test } from 'bun:test'; +import type { DeployedStage } from '@internal/lowering/deployed'; +import { deployedNodes } from '../src/family/deployed-nodes.ts'; + +const empty: DeployedStage = { + projectId: 'proj_1', + branchId: 'br_1', + nodes: [], + services: [], + databases: [], + buckets: [], +}; + +describe('deployedNodes', () => { + test('the root node is the Project', () => { + expect( + deployedNodes({ + ...empty, + nodes: [{ logicalId: 'shop', parentLogicalId: null, kind: 'module' }], + }), + ).toEqual([{ address: 'shop', kind: 'project', platformId: 'proj_1', url: null }]); + }); + + test('a service is its row, with the URL a deploy prints', () => { + expect( + deployedNodes({ + ...empty, + nodes: [{ logicalId: 'shop.api', parentLogicalId: 'shop', kind: 'service' }], + services: [ + { id: 'svc_other', logicalId: 'shop.web', appEndpointDomain: 'https://web.prisma.app' }, + { id: 'svc_1', logicalId: 'shop.api', appEndpointDomain: 'https://api.prisma.app' }, + ], + }), + ).toEqual([ + { address: 'shop.api', kind: 'service', platformId: 'svc_1', url: 'https://api.prisma.app' }, + ]); + }); + + test('both Postgres types are postgres, found among the databases', () => { + expect( + deployedNodes({ + ...empty, + nodes: [ + { logicalId: 'shop.db', parentLogicalId: 'shop', kind: 'resource', type: 'postgres' }, + { + logicalId: 'shop.raw', + parentLogicalId: 'shop', + kind: 'resource', + type: 'raw-postgres', + }, + ], + databases: [ + { id: 'db_raw', logicalId: 'shop.raw' }, + { id: 'db_1', logicalId: 'shop.db' }, + ], + }), + ).toEqual([ + { address: 'shop.db', kind: 'postgres', platformId: 'db_1', url: null }, + { address: 'shop.raw', kind: 'postgres', platformId: 'db_raw', url: null }, + ]); + }); + + test('an S3 resource is a bucket, found among the buckets', () => { + expect( + deployedNodes({ + ...empty, + nodes: [{ logicalId: 'shop.files', parentLogicalId: 'shop', kind: 'resource', type: 's3' }], + buckets: [{ id: 'bkt_1', logicalId: 'shop.files' }], + }), + ).toEqual([{ address: 'shop.files', kind: 'bucket', platformId: 'bkt_1', url: null }]); + }); + + test('a module below the root has no row', () => { + expect( + deployedNodes({ + ...empty, + nodes: [{ logicalId: 'shop.catalog', parentLogicalId: 'shop', kind: 'module' }], + }), + ).toEqual([{ address: 'shop.catalog', kind: 'module', platformId: null, url: null }]); + }); + + test('another resource shows its type and has no row', () => { + expect( + deployedNodes({ + ...empty, + nodes: [ + { + logicalId: 'shop.keys', + parentLogicalId: 'shop', + kind: 'resource', + type: 's3-credentials', + }, + ], + }), + ).toEqual([{ address: 'shop.keys', kind: 's3-credentials', platformId: null, url: null }]); + }); + + test('a node whose row is missing lists with no platform ID', () => { + expect( + deployedNodes({ + ...empty, + nodes: [ + { logicalId: 'shop.api', parentLogicalId: 'shop', kind: 'service' }, + { logicalId: 'shop.db', parentLogicalId: 'shop', kind: 'resource', type: 'postgres' }, + ], + services: [ + { id: 'svc_1', logicalId: 'shop.web', appEndpointDomain: 'https://web.prisma.app' }, + ], + }), + ).toEqual([ + { address: 'shop.api', kind: 'service', platformId: null, url: null }, + { address: 'shop.db', kind: 'postgres', platformId: null, url: null }, + ]); + }); + + test('no topology lists nothing', () => { + expect(deployedNodes(empty)).toEqual([]); + }); + + test('keeps topology order', () => { + expect( + deployedNodes({ + ...empty, + nodes: [ + { logicalId: 'shop', parentLogicalId: null, kind: 'module' }, + { logicalId: 'shop.z', parentLogicalId: 'shop', kind: 'module' }, + { logicalId: 'shop.a', parentLogicalId: 'shop', kind: 'module' }, + ], + }).map((node) => node.address), + ).toEqual(['shop', 'shop.z', 'shop.a']); + }); +}); diff --git a/packages/9-public/composer-cli/test/emulator-list.test.ts b/packages/9-public/composer-cli/test/emulator-list.test.ts index 0a9b4e598..de110340f 100644 --- a/packages/9-public/composer-cli/test/emulator-list.test.ts +++ b/packages/9-public/composer-cli/test/emulator-list.test.ts @@ -53,6 +53,7 @@ describe('emulator list', () => { test('the published family ships it beside the framework commands', () => { expect(Object.keys(createComposerFamily().commands).sort()).toEqual([ 'deploy', + 'deploy list', 'dev', 'dev stop', 'dev url', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 678ede3c3..4043476ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,7 +102,7 @@ importers: version: link:../../packages/9-public/composer-prisma-cloud alchemy: specifier: 2.0.0-beta.83 - version: 2.0.0-beta.83(@effect/platform-bun@4.0.3(effect@4.0.3))(@effect/platform-node@4.0.3(effect@4.0.3)(redis@6.2.1))(@effect/vitest@4.0.3(effect@4.0.3)(vitest@5.0.4(@types/node@26.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))))(@prisma/orm-postgres@8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)))(@types/node@26.1.2)(@types/react@19.2.17)(@vercel/nft@1.10.2)(effect@4.0.3)(hono@4.12.31)(mongodb@6.21.0(@aws-sdk/credential-providers@3.1077.0))(pg@8.22.0)(prisma@8.0.0-rc.26(magicast@0.5.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))(ws@8.22.0) + version: 2.0.0-beta.83(@effect/platform-bun@4.0.3(effect@4.0.3))(@effect/platform-node@4.0.3(effect@4.0.3)(redis@6.2.1))(@effect/vitest@4.0.3(effect@4.0.3)(vitest@5.0.4(@types/node@26.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))))(@prisma/orm-postgres@8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)))(@types/node@26.1.2)(@types/react@19.2.17)(@vercel/nft@1.10.2)(effect@4.0.3)(hono@4.11.4)(mongodb@6.21.0(@aws-sdk/credential-providers@3.1077.0))(pg@8.22.0)(prisma@8.0.0-rc.26(magicast@0.5.5)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))(ws@8.22.0) effect: specifier: 4.0.3 version: 4.0.3 @@ -130,7 +130,7 @@ importers: version: link:../../packages/9-public/composer-prisma-cloud alchemy: specifier: 2.0.0-beta.83 - version: 2.0.0-beta.83(@effect/platform-bun@4.0.3(effect@4.0.3))(@effect/platform-node@4.0.3(effect@4.0.3)(redis@6.2.1))(@effect/vitest@4.0.3(effect@4.0.3)(vitest@5.0.4(@types/node@26.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))))(@prisma/orm-postgres@8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)))(@types/node@26.1.2)(@types/react@19.2.17)(@vercel/nft@1.10.2)(effect@4.0.3)(hono@4.11.4)(mongodb@6.21.0(@aws-sdk/credential-providers@3.1077.0))(pg@8.22.0)(prisma@8.0.0-rc.26(magicast@0.5.5)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))(ws@8.22.0) + version: 2.0.0-beta.83(@effect/platform-bun@4.0.3(effect@4.0.3))(@effect/platform-node@4.0.3(effect@4.0.3)(redis@6.2.1))(@effect/vitest@4.0.3(effect@4.0.3)(vitest@5.0.4(@types/node@26.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))))(@prisma/orm-postgres@8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)))(@types/node@26.1.2)(@types/react@19.2.17)(@vercel/nft@1.10.2)(effect@4.0.3)(hono@4.12.31)(mongodb@6.21.0(@aws-sdk/credential-providers@3.1077.0))(pg@8.22.0)(prisma@8.0.0-rc.26(magicast@0.5.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))(ws@8.22.0) arktype: specifier: ^2.2.3 version: 2.2.7 @@ -1393,6 +1393,9 @@ importers: '@prisma/composer': specifier: workspace:0.34.0 version: link:../composer + '@prisma/management-api-sdk': + specifier: ^1.80.0 + version: 1.80.0 alchemy: specifier: 2.0.0-beta.83 version: 2.0.0-beta.83(@effect/platform-bun@4.0.3(effect@4.0.3))(@effect/platform-node@4.0.3(effect@4.0.3)(redis@6.2.1))(@effect/vitest@4.0.3(effect@4.0.3)(vitest@5.0.4(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))))(@prisma/orm-postgres@8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)))(@types/node@26.1.1)(@types/react@19.2.17)(@vercel/nft@1.10.2)(effect@4.0.3)(hono@4.12.31)(mongodb@6.21.0(@aws-sdk/credential-providers@3.1077.0))(pg@8.22.0)(prisma@8.0.0-rc.26(magicast@0.5.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))(ws@8.22.0) @@ -1415,6 +1418,9 @@ importers: '@internal/foundation': specifier: workspace:0.34.0 version: link:../../0-framework/0-foundation/foundation + '@internal/lowering': + specifier: workspace:0.34.0 + version: link:../../1-prisma-cloud/0-lowering/lowering '@internal/tsdown-config': specifier: workspace:0.34.0 version: link:../../0-framework/0-foundation/tsdown-config diff --git a/scripts/deploy-list-check.test.ts b/scripts/deploy-list-check.test.ts new file mode 100644 index 000000000..bb13939be --- /dev/null +++ b/scripts/deploy-list-check.test.ts @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + deployListMismatches, + type ListedNode, + type ListedPlatform, + parseListed, +} from './deploy-list-check.ts'; + +const BRANCH = 'br_main'; + +const platform: ListedPlatform = { + projectId: 'proj_1', + branchId: BRANCH, + nodes: [ + { logicalId: 'shop', parentLogicalId: null, kind: 'module' }, + { logicalId: 'shop.api', parentLogicalId: 'shop', kind: 'service', type: 'compute' }, + { logicalId: 'shop.db', parentLogicalId: 'shop', kind: 'resource', type: 'raw-postgres' }, + { logicalId: 'shop.files', parentLogicalId: 'shop', kind: 'resource', type: 's3' }, + { logicalId: 'shop.creds', parentLogicalId: 'shop', kind: 'resource', type: 'credentials' }, + ], + rows: [ + { + kind: 'app', + id: 'app_1', + branchId: BRANCH, + logicalId: 'shop.api', + appEndpointDomain: 'https://api.prisma.app', + }, + { kind: 'app', id: 'app_other', branchId: 'br_other', logicalId: 'shop.api' }, + { kind: 'database', id: 'db_1', branchId: BRANCH, logicalId: 'shop.db' }, + { kind: 'bucket', id: 'bkt_1', branchId: BRANCH, logicalId: 'shop.files' }, + ], +}; + +const listed: ListedNode[] = [ + { address: 'shop', kind: 'project', platformId: 'proj_1', url: null }, + { address: 'shop.api', kind: 'service', platformId: 'app_1', url: 'https://api.prisma.app' }, + { address: 'shop.db', kind: 'postgres', platformId: 'db_1', url: null }, + { address: 'shop.files', kind: 'bucket', platformId: 'bkt_1', url: null }, + { address: 'shop.creds', kind: 'credentials', platformId: null, url: null }, +]; + +describe('deployListMismatches', () => { + it('accepts a listing that matches the topology and the rows on the stage Branch', () => { + assert.deepEqual(deployListMismatches(listed, platform), []); + }); + + it('reports a missing node', () => { + assert.deepEqual(deployListMismatches(listed.slice(0, -1), platform), [ + 'lists [shop, shop.api, shop.db, shop.files], but the topology has [shop, shop.api, shop.db, shop.files, shop.creds]', + ]); + }); + + it('reports a node out of topology order', () => { + const swapped = [listed[0], listed[2], listed[1], listed[3], listed[4]].filter( + (entry) => entry !== undefined, + ); + assert.equal(deployListMismatches(swapped, platform).length, 1); + }); + + it('reports an ID that is not the row with the node logicalId on the stage Branch', () => { + const wrong = listed.map((entry) => + entry.address === 'shop.api' ? { ...entry, platformId: 'app_other' } : entry, + ); + assert.deepEqual(deployListMismatches(wrong, platform), [ + 'shop.api lists platform ID app_other, but the platform has app_1', + ]); + }); + + it('reports a kind that is not the one its topology node implies', () => { + const wrong = listed.map((entry) => { + if (entry.address === 'shop.db') return { ...entry, kind: 'raw-postgres' }; + if (entry.address === 'shop.files') return { ...entry, kind: 'module' }; + return entry; + }); + assert.deepEqual(deployListMismatches(wrong, platform), [ + 'shop.db lists kind raw-postgres, but its topology node is a postgres', + 'shop.files lists kind module, but its topology node is a bucket', + ]); + }); + + it('reports a root whose ID is not the Project', () => { + const wrong = listed.map((entry) => + entry.address === 'shop' ? { ...entry, platformId: null } : entry, + ); + assert.deepEqual(deployListMismatches(wrong, platform), [ + 'shop lists platform ID null, but the platform has proj_1', + ]); + }); + + it('reports a service URL that is not its endpoint, or not https', () => { + const wrong = listed.map((entry) => + entry.address === 'shop.api' ? { ...entry, url: 'http://api.prisma.app' } : entry, + ); + assert.deepEqual(deployListMismatches(wrong, platform), [ + 'shop.api lists URL http://api.prisma.app, but expected https://api.prisma.app', + 'shop.api lists URL http://api.prisma.app, which is not https', + ]); + }); +}); + +describe('parseListed', () => { + it('reads the nodes from the result event', () => { + assert.deepEqual( + parseListed({ kind: 'result', envelope: { ok: true, result: listed } }), + listed, + ); + }); + + it('rejects an event with no list of nodes', () => { + assert.throws( + () => parseListed({ kind: 'result', envelope: { ok: false, error: {} } }), + /printed no list of nodes/, + ); + assert.throws(() => + parseListed({ kind: 'result', envelope: { ok: true, result: [{ address: 1 }] } }), + ); + }); +}); diff --git a/scripts/deploy-list-check.ts b/scripts/deploy-list-check.ts new file mode 100644 index 000000000..551ca8fd6 --- /dev/null +++ b/scripts/deploy-list-check.ts @@ -0,0 +1,105 @@ +/** + * Checks `deploy list --json` output against what the platform holds for the + * stage: one entry per topology node, in topology order, with the kind its + * topology node implies; the root's ID is the Project's; each service, database and bucket has the ID of the row with its + * `logicalId`; and a service's URL is its App row's endpoint, over https. + * Pure, so it is unit-tested; `e2e-verify-deploy-list.ts` feeds it. + */ + +import { type PlatformRow, rowKindOf, type TopologyNode } from './platform-identity.ts'; + +export interface ListedNode { + readonly address: string; + readonly kind: string; + readonly platformId: string | null; + readonly url: string | null; +} + +export interface ListedPlatform { + readonly projectId: string; + readonly branchId: string; + readonly nodes: readonly (TopologyNode & { readonly parentLogicalId: string | null })[]; + readonly rows: readonly PlatformRow[]; +} + +const isStringOrNull = (value: unknown): value is string | null => + value === null || typeof value === 'string'; + +function field(value: unknown, key: string): unknown { + if (typeof value !== 'object' || value === null) return undefined; + const record: Record = { ...value }; + return record[key]; +} + +const isListedNode = (value: unknown): value is ListedNode => + typeof field(value, 'address') === 'string' && + typeof field(value, 'kind') === 'string' && + isStringOrNull(field(value, 'platformId')) && + isStringOrNull(field(value, 'url')); + +/** The nodes in the `result` event `--json` prints last. */ +export function parseListed(event: unknown): ListedNode[] { + const result = field(field(event, 'envelope'), 'result'); + if (!Array.isArray(result) || !result.every(isListedNode)) { + throw new Error(`deploy list --json printed no list of nodes: ${JSON.stringify(event)}`); + } + return result; +} + +type ListedTopologyNode = ListedPlatform['nodes'][number]; + +/** The kind `deploy list` should show for a topology node. */ +export function expectedKind(node: ListedTopologyNode): string { + if (node.parentLogicalId === null) return 'project'; + if (node.kind !== 'resource') return node.kind; + const rowKind = rowKindOf(node); + if (rowKind === 'database') return 'postgres'; + return rowKind ?? node.type ?? 'resource'; +} + +/** Every way the listing disagrees with the platform; empty when it agrees. */ +export function deployListMismatches( + listed: readonly ListedNode[], + platform: ListedPlatform, +): string[] { + const problems: string[] = []; + const addresses = listed.map((entry) => entry.address); + const logicalIds = platform.nodes.map((node) => node.logicalId); + if (addresses.join('\n') !== logicalIds.join('\n')) { + problems.push( + `lists [${addresses.join(', ')}], but the topology has [${logicalIds.join(', ')}]`, + ); + } + + for (const node of platform.nodes) { + const entry = listed.find((candidate) => candidate.address === node.logicalId); + if (entry === undefined) continue; + const kind = expectedKind(node); + if (entry.kind !== kind) { + problems.push( + `${entry.address} lists kind ${entry.kind}, but its topology node is a ${kind}`, + ); + } + const rowKind = rowKindOf(node); + const row = platform.rows.find( + (candidate) => + candidate.branchId === platform.branchId && + candidate.kind === rowKind && + candidate.logicalId === node.logicalId, + ); + const platformId = node.parentLogicalId === null ? platform.projectId : (row?.id ?? null); + if (entry.platformId !== platformId) { + problems.push( + `${entry.address} lists platform ID ${String(entry.platformId)}, but the platform has ${String(platformId)}`, + ); + } + const url = rowKind === 'app' ? (row?.appEndpointDomain ?? null) : null; + if (entry.url !== url) { + problems.push(`${entry.address} lists URL ${String(entry.url)}, but expected ${String(url)}`); + } + if (entry.url !== null && !entry.url.startsWith('https://')) { + problems.push(`${entry.address} lists URL ${entry.url}, which is not https`); + } + } + return problems; +} diff --git a/scripts/e2e-verify-deploy-list.ts b/scripts/e2e-verify-deploy-list.ts new file mode 100644 index 000000000..3aacc4383 --- /dev/null +++ b/scripts/e2e-verify-deploy-list.ts @@ -0,0 +1,103 @@ +#!/usr/bin/env bun +/** + * After a real deploy, runs `deploy list --json` for the app in the current + * directory and checks its output against the topology and rows the platform + * holds for the stage. + * + * The installed `prisma` does not mount `deploy list` yet, so this mounts + * Composer's command family itself, the way the `prisma` host does, with a + * service-token credential and no OAuth login. It also declares the ORM's config + * section, so a config with an `orm` section loads. + * + * Env: STACK_NAME (the `prisma deploy --name` value), PRISMA_SERVICE_TOKEN, + * PRISMA_WORKSPACE_ID, and STAGE for a named stage (omit for production). + */ + +import { + createCli, + defineCommandFamily, + EnvironmentCredentialManager, + loadConfig, +} from '@prisma/cli-engine'; +import { createComposerFamily } from '@prisma/composer-cli/family'; +import { ormConfigSection } from '@prisma/orm-toolchain/cli'; +import { deployListMismatches, parseListed } from './deploy-list-check.ts'; +import { logPlatformState, readPlatformState } from './platform-state.ts'; + +const stackName = process.env['STACK_NAME']; +const workspaceId = process.env['PRISMA_WORKSPACE_ID']; +const stage = process.env['STAGE'] || undefined; +if (!stackName || !workspaceId) { + console.error('STACK_NAME and PRISMA_WORKSPACE_ID are required'); + process.exit(1); +} + +const apiBaseUrl = process.env['PRISMA_MANAGEMENT_API_URL']?.trim() || 'https://api.prisma.io'; + +async function runDeployList(args: readonly string[]): Promise { + const family = createComposerFamily(); + const command = family.commands['deploy list']; + if (command === undefined) throw new Error('the Composer command family has no deploy list'); + const cli = createCli({ + name: 'prisma', + version: '0.0.0', + commandFamilies: [ + family, + defineCommandFamily({ configSection: ormConfigSection, commands: {} }), + ], + groups: { deploy: { brief: 'Deploy the app.' } }, + commands: { 'deploy list': command }, + }); + + let stdout = ''; + const cwd = process.cwd(); + const exitCode = await cli.run(['deploy', 'list', ...args], { + stdout: { + write: (text) => { + stdout += text; + }, + }, + stderr: { write: (text) => process.stderr.write(text) }, + stdin: (async function* () {})(), + cwd, + env: process.env, + isTty: { stdin: false, stdout: false, stderr: false }, + exit: (code) => process.exit(code), + onSignal: () => () => {}, + loadConfig: (configPath) => loadConfig(cwd, configPath), + credentialManager: new EnvironmentCredentialManager({ env: process.env }), + managementApiClientConfig: { + clientId: '', + redirectUri: '', + apiBaseUrl, + authBaseUrl: process.env['PRISMA_AUTH_BASE_URL']?.trim() || 'https://auth.prisma.io', + }, + managementApi: { baseUrl: apiBaseUrl }, + host: { + runtime: { name: 'bun', version: Bun.version }, + platform: process.platform, + arch: process.arch, + }, + }); + console.log(`deploy list ${args.join(' ')} exited ${String(exitCode)}:\n${stdout}`); + if (exitCode !== 0) process.exit(1); + return stdout; +} + +const stdout = await runDeployList([ + '--name', + stackName, + ...(stage === undefined ? [] : ['--stage', stage]), + '--json', +]); +const listed = parseListed(JSON.parse(stdout.trim().split('\n').at(-1) ?? '')); + +const state = await readPlatformState({ workspaceId, appName: stackName, stage }); +logPlatformState(state); + +const problems = deployListMismatches(listed, state); +if (problems.length > 0) { + console.error(`deploy list disagrees with the platform:\n ${problems.join('\n ')}`); + process.exit(1); +} +console.log('deploy list shows every topology node with its platform ID and URL.'); diff --git a/scripts/e2e-verify-identity.ts b/scripts/e2e-verify-identity.ts index 275400623..3291f553f 100644 --- a/scripts/e2e-verify-identity.ts +++ b/scripts/e2e-verify-identity.ts @@ -9,17 +9,8 @@ * PRISMA_WORKSPACE_ID, and STAGE for a named stage (omit for the default one). */ -import * as Effect from 'effect/Effect'; -import * as Layer from 'effect/Layer'; -import { - layer, - ManagementClient, -} from '../packages/1-prisma-cloud/0-lowering/lowering/src/client.ts'; -import { resolveContainer } from '../packages/1-prisma-cloud/0-lowering/lowering/src/container.ts'; -import { fromEnv } from '../packages/1-prisma-cloud/0-lowering/lowering/src/credentials.ts'; -import { call, callOptional } from '../packages/1-prisma-cloud/0-lowering/lowering/src/http.ts'; -import { collectPages } from '../packages/1-prisma-cloud/0-lowering/lowering/src/pagination.ts'; -import { identityMismatches, type PlatformRow, type RowKind } from './platform-identity.ts'; +import { identityMismatches } from './platform-identity.ts'; +import { logPlatformState, readPlatformState } from './platform-state.ts'; const stackName = process.env['STACK_NAME']; const workspaceId = process.env['PRISMA_WORKSPACE_ID']; @@ -29,68 +20,12 @@ if (!stackName || !workspaceId) { process.exit(1); } -const program = Effect.gen(function* () { - const container = yield* resolveContainer({ - workspaceId, - appName: stackName, - ensure: false, - ...(stage === undefined ? {} : { stage }), - }); - const { projectId } = container; - const branchId = container.branchId ?? container.defaultBranchId; - if (branchId === undefined) { - return yield* Effect.fail(new Error(`project ${projectId} resolved no stage Branch`)); - } - const client = yield* ManagementClient; +const state = await readPlatformState({ workspaceId, appName: stackName, stage }); +logPlatformState(state); - const topology = yield* callOptional(() => - client.GET('/v1/projects/{projectId}/branches/{branchId}/application-topology', { - params: { path: { projectId, branchId } }, - }), - ); - if (topology === undefined || topology.data.nodes.length === 0) { - return yield* Effect.fail( - new Error( - `branch ${branchId} has no application topology: the deploy did not submit one (its log shows a warning about failing to "record this deploy's application topology")`, - ), - ); - } - - type ListedRow = Omit; - const query = (cursor: string | undefined) => - cursor === undefined ? { projectId } : { projectId, cursor }; - const apps: readonly ListedRow[] = yield* collectPages(`apps of project ${projectId}`, (cursor) => - call(() => client.GET('/v1/apps', { params: { query: query(cursor) } })), - ); - const databases: readonly ListedRow[] = yield* collectPages( - `databases of project ${projectId}`, - (cursor) => call(() => client.GET('/v1/databases', { params: { query: query(cursor) } })), - ); - const buckets: readonly ListedRow[] = yield* collectPages( - `buckets of project ${projectId}`, - (cursor) => call(() => client.GET('/v1/buckets', { params: { query: query(cursor) } })), - ); - const rowsOf = (kind: RowKind, listed: readonly ListedRow[]): PlatformRow[] => - listed.map(({ id, branchId, logicalId }) => ({ kind, id, branchId, logicalId })); - const rows = [ - ...rowsOf('app', apps), - ...rowsOf('database', databases), - ...rowsOf('bucket', buckets), - ]; - - const nodes = topology.data.nodes; - console.log(`Topology on branch ${branchId}:`); - for (const node of nodes) console.log(` ${node.kind} ${node.logicalId}`); - console.log(`Rows in project ${projectId}:`); - for (const row of rows) { - console.log(` ${row.kind} ${row.id} branch=${row.branchId} logicalId=${row.logicalId}`); - } - - const problems = identityMismatches(nodes, rows, branchId); - if (problems.length > 0) { - return yield* Effect.fail(new Error(`Platform identity mismatch:\n ${problems.join('\n ')}`)); - } - console.log('Every service, database and bucket row carries its topology node logicalId.'); -}); - -await Effect.runPromise(program.pipe(Effect.provide(layer().pipe(Layer.provideMerge(fromEnv()))))); +const problems = identityMismatches(state.nodes, state.rows, state.branchId); +if (problems.length > 0) { + console.error(`Platform identity mismatch:\n ${problems.join('\n ')}`); + process.exit(1); +} +console.log('Every service, database and bucket row carries its topology node logicalId.'); diff --git a/scripts/platform-identity.ts b/scripts/platform-identity.ts index 9dd9f9bfa..97b7f9624 100644 --- a/scripts/platform-identity.ts +++ b/scripts/platform-identity.ts @@ -6,6 +6,7 @@ */ import type { ApplicationTopologyNodeBody } from '../packages/1-prisma-cloud/0-lowering/lowering/src/builds/application-topology.ts'; +import { RESOURCE_ROW_KINDS } from '../packages/1-prisma-cloud/0-lowering/lowering/src/resource-rows.ts'; export type RowKind = 'app' | 'database' | 'bucket'; @@ -16,18 +17,14 @@ export interface PlatformRow { readonly id: string; readonly branchId: string | null; readonly logicalId: string | null; + /** An App row's endpoint: the full URL its service is served from. */ + readonly appEndpointDomain?: string; } -const RESOURCE_ROW_KINDS: Readonly> = { - 'raw-postgres': 'database', - postgres: 'database', - s3: 'bucket', -}; - /** The kind of platform row a topology node lowers to, or `undefined` when it has none (a module, an S3 credentials resource). */ export function rowKindOf(node: TopologyNode): RowKind | undefined { if (node.kind === 'service') return 'app'; - if (node.kind === 'resource' && node.type !== undefined) return RESOURCE_ROW_KINDS[node.type]; + if (node.kind === 'resource' && node.type !== undefined) return RESOURCE_ROW_KINDS.get(node.type); return undefined; } diff --git a/scripts/platform-state.ts b/scripts/platform-state.ts new file mode 100644 index 000000000..de396370e --- /dev/null +++ b/scripts/platform-state.ts @@ -0,0 +1,110 @@ +/** + * Reads what a deployed stage has on Prisma Cloud straight from the + * Management API, for the e2e checks: the application topology on the stage's + * Branch, and every App, Database and Bucket row of its Project. + * + * Env: PRISMA_SERVICE_TOKEN, and PRISMA_MANAGEMENT_API_URL to point elsewhere. + */ + +import * as Effect from 'effect/Effect'; +import * as Layer from 'effect/Layer'; +import type { ApplicationTopologyNodeBody } from '../packages/1-prisma-cloud/0-lowering/lowering/src/builds/application-topology.ts'; +import { + layer, + ManagementClient, +} from '../packages/1-prisma-cloud/0-lowering/lowering/src/client.ts'; +import { resolveContainer } from '../packages/1-prisma-cloud/0-lowering/lowering/src/container.ts'; +import { fromEnv } from '../packages/1-prisma-cloud/0-lowering/lowering/src/credentials.ts'; +import { call, callOptional } from '../packages/1-prisma-cloud/0-lowering/lowering/src/http.ts'; +import { collectPages } from '../packages/1-prisma-cloud/0-lowering/lowering/src/pagination.ts'; +import type { PlatformRow, RowKind } from './platform-identity.ts'; + +export interface PlatformState { + readonly projectId: string; + readonly branchId: string; + readonly nodes: readonly ApplicationTopologyNodeBody[]; + readonly rows: readonly PlatformRow[]; +} + +export interface PlatformStateInput { + readonly workspaceId: string; + readonly appName: string; + /** A named stage; omit for production. */ + readonly stage?: string | undefined; +} + +const program = (input: PlatformStateInput) => + Effect.gen(function* () { + const container = yield* resolveContainer({ + workspaceId: input.workspaceId, + appName: input.appName, + ensure: false, + ...(input.stage === undefined ? {} : { stage: input.stage }), + }); + const { projectId } = container; + const branchId = container.branchId ?? container.defaultBranchId; + if (branchId === undefined) { + return yield* Effect.fail(new Error(`project ${projectId} resolved no stage Branch`)); + } + const client = yield* ManagementClient; + + const topology = yield* callOptional(() => + client.GET('/v1/projects/{projectId}/branches/{branchId}/application-topology', { + params: { path: { projectId, branchId } }, + }), + ); + if (topology === undefined || topology.data.nodes.length === 0) { + return yield* Effect.fail( + new Error( + `branch ${branchId} has no application topology: the deploy did not submit one (its log shows a warning about failing to "record this deploy's application topology")`, + ), + ); + } + + type ListedRow = Omit; + const query = (cursor: string | undefined) => + cursor === undefined ? { projectId } : { projectId, cursor }; + const apps = yield* collectPages(`apps of project ${projectId}`, (cursor) => + call(() => client.GET('/v1/apps', { params: { query: query(cursor) } })), + ); + const databases: readonly ListedRow[] = yield* collectPages( + `databases of project ${projectId}`, + (cursor) => call(() => client.GET('/v1/databases', { params: { query: query(cursor) } })), + ); + const buckets: readonly ListedRow[] = yield* collectPages( + `buckets of project ${projectId}`, + (cursor) => call(() => client.GET('/v1/buckets', { params: { query: query(cursor) } })), + ); + const rowsOf = (kind: RowKind, listed: readonly ListedRow[]): PlatformRow[] => + listed.map(({ id, branchId, logicalId }) => ({ kind, id, branchId, logicalId })); + const rows: PlatformRow[] = [ + ...apps.map(({ id, branchId, logicalId, appEndpointDomain }) => ({ + kind: 'app' as const, + id, + branchId, + logicalId, + appEndpointDomain, + })), + ...rowsOf('database', databases), + ...rowsOf('bucket', buckets), + ]; + const state: PlatformState = { projectId, branchId, nodes: topology.data.nodes, rows }; + return state; + }); + +/** The stage's topology and rows; rejects when the stage has no Branch or no topology. */ +export function readPlatformState(input: PlatformStateInput): Promise { + return Effect.runPromise( + program(input).pipe(Effect.provide(layer().pipe(Layer.provideMerge(fromEnv())))), + ); +} + +/** Prints the topology and the rows, so a failed check shows what the platform held. */ +export function logPlatformState(state: PlatformState): void { + console.log(`Topology on branch ${state.branchId}:`); + for (const node of state.nodes) console.log(` ${node.kind} ${node.logicalId}`); + console.log(`Rows in project ${state.projectId}:`); + for (const row of state.rows) { + console.log(` ${row.kind} ${row.id} branch=${row.branchId} logicalId=${row.logicalId}`); + } +} diff --git a/skills/prisma-composer-core-concepts/SKILL.md b/skills/prisma-composer-core-concepts/SKILL.md index 9b863493b..815dba900 100644 --- a/skills/prisma-composer-core-concepts/SKILL.md +++ b/skills/prisma-composer-core-concepts/SKILL.md @@ -16,7 +16,7 @@ description: >- # Prisma Composer core concepts A **Prisma App** is a tree of typed declarations composed in TypeScript and -handed to the `prisma` CLI, which has two Composer commands: `prisma deploy` +handed to the `prisma` CLI, whose two main Composer commands are `prisma deploy` and `prisma dev`. Removing a whole app is `prisma project delete`. Removing one stage and reading logs are not commands; they are the `destroy` and `log` operations of `@prisma/composer/control`, called from a script. @@ -399,6 +399,14 @@ stage is a Branch of it, with its own running services, its own empty database, its own configuration. A stage name must be a valid git ref name; an invalid name is a hard error. +**Seeing what is deployed.** `prisma deploy list [--stage ]` lists every +node of the topology the last deploy recorded, with its kind, platform ID and, +for a service, its URL; the root's ID is the Project's, and a module has none. +It finds the app as `deploy` does (`--name`, `--entry`) and fails with +`DEPLOY.TARGET_NOT_FOUND` when the stage has nothing deployed. Piped: +address, kind, platform ID, URL per line; `--json`: `[{ address, kind, platformId, url }]`. It needs +a `prisma` CLI release that includes it; 8.0.0-rc.26 does not. + **Removing an app** with every stage is a platform command: find the Project id with `prisma project list`, then `prisma project delete --confirm `, and check `prisma project list` again to confirm it is gone. It works while the diff --git a/tsconfig.depcruise.json b/tsconfig.depcruise.json index b9e5f1d6c..4bc3160c4 100644 --- a/tsconfig.depcruise.json +++ b/tsconfig.depcruise.json @@ -49,6 +49,9 @@ "@internal/cli/report": [ "./packages/0-framework/3-tooling/cli/src/exports/render-deployment.ts" ], + "@internal/cli/app-identity": [ + "./packages/0-framework/3-tooling/cli/src/exports/app-identity.ts" + ], "@internal/cli/control": ["./packages/0-framework/3-tooling/cli/src/exports/control.ts"], "@internal/cli/family": ["./packages/0-framework/3-tooling/cli/src/exports/family.ts"], "@internal/cli/testing": ["./packages/0-framework/3-tooling/cli/src/exports/testing.ts"], @@ -64,6 +67,9 @@ "@internal/lowering/builds": [ "./packages/1-prisma-cloud/0-lowering/lowering/src/exports/builds.ts" ], + "@internal/lowering/deployed": [ + "./packages/1-prisma-cloud/0-lowering/lowering/src/exports/deployed.ts" + ], "@internal/lowering/state": [ "./packages/1-prisma-cloud/0-lowering/lowering/src/exports/state.ts" ],