diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 000000000..8b38c6370 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,15 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "dev", + "runtimeExecutable": "sh", + "runtimeArgs": [ + "-c", + "cd website && COMPOSER_PORT=\"${PORT:-3000}\" pnpm run dev" + ], + "port": 3000, + "autoPort": true + } + ] +} diff --git a/.drive/projects/build-reporting/design-notes.md b/.drive/projects/build-reporting/design-notes.md new file mode 100644 index 000000000..6b39fc04d --- /dev/null +++ b/.drive/projects/build-reporting/design-notes.md @@ -0,0 +1,93 @@ +# Design notes: Composer reports its builds to Prisma Cloud + +> **This is a chronological log, not a reference.** It records what was believed at each point, including claims later corrected further down. For the current design, read [spec.md](spec.md) and [topology-design.md](topology-design.md); come here only for the reasoning behind a decision. + +Design review of the incoming brief, 2026-08-12. Verified against pdp-control-plane PR #4855 (`feat/builds-api` branch) and the Composer deploy pipeline. Records what the review found, including where the brief and the reviewer were wrong. + +## Where the brief was wrong + +**`PRISMA_WORKSPACE_ID` is not part of this contract.** The brief says `fromEnv` in `credentials.ts` loads both it and `PRISMA_SERVICE_TOKEN`. It loads only the token. The builds routes take the workspace from the token and reject a token spanning several workspaces, so the workspace id is never sent. + +**`commitSha` and `branchName` are required, and Composer reads no git.** The brief does not mention either field. Both are `min(1)` on `POST /v1/builds`, and Composer has no git-reading code anywhere. This collides directly with the brief's hard requirement that a laptop deploy always creates a build: a deploy from a directory that is not a git checkout can satisfy neither field. + +**The SDK cannot make these calls — resolved before this shipped.** The brief says to reuse `createManagementApiClient` rather than adding a second HTTP path. At review time the installed `@prisma/management-api-sdk` was 1.50.0 and exposed only `/v1/builds/{buildId}/logs`. The stack then merged and 1.60.0 shipped with all three endpoints, so the temporary hand-written module was deleted and the brief's instruction holds as written. The release also closed a pre-existing gap nobody had chased: the hosted-state lease and scope endpoints were missing too, which is why `lowering` carried seven type errors on a clean tree. It typechecks now. + +Two things worth keeping from that episode. The worktree was also half-installed — `@prisma/orm-*` was absent from `node_modules` entirely, which produced a second set of "pre-existing" typecheck, build and invariant-test failures that had nothing to do with the SDK. A plain `pnpm install` cleared them. When a repository appears to have a large baseline of failures, check that it is fully installed before concluding anything about the code. + +And once the SDK was in place, the request and response shapes were **derived** from its generated `operations` type rather than restated. A hand-kept copy of a contract someone else owns drifts silently; a derived one breaks the build. The derivation was verified with a compiled probe in both directions — that each alias accepts its real values and rejects invented ones — because a type that collapses to `never` or widens to `any` typechecks just as quietly as a correct one. + +**End-of-run resource reporting cannot meet the brief's own goal.** The brief presents incremental versus end-of-run as a judgement call about crash resilience. It is not close. Descriptors emit exactly three entity kinds — `postgres-database`, `bucket`, `compute-service` — against eight platform resource types, and `deployment`, the type that makes the platform maintain the deployment-to-build link and the implied app row, is not an entity at all. The Alchemy providers do cover the vocabulary. Reporting from `report()` could never cover more than three of eight, whatever its crash behaviour. + +## Where the review was wrong + +The first review claimed a build's project **cannot** be set after creation and offered three ways to work around it. Will challenged this, correctly. + +The schema draws exactly the distinction he pointed at: + +```prisma +workspace Workspace @relation(..., onDelete: Cascade) +workspaceId String // required +project Project? @relation(..., onDelete: SetNull) +projectId String? // nullable, mutable +branch Branch? @relation(..., onDelete: SetNull) +branchId String? // nullable, mutable +appId String? @map("computeServiceId") // nullable, no relation +``` + +`workspaceId` is required and cascades — genuinely hard to change, and it never needs to be, since it comes from the token. The three anchors are ordinary nullable foreign keys that already null themselves when their target is deleted. `verifyBuildAnchors` is already a standalone function taking a `Pick` of the create input, reusable from a PATCH handler unchanged. + +So the constraint is that `UpdateBuildInputSchema` lists five fields and these are not among them. An omission in an unmerged PR, not a property of the model. The correct response is to amend the PR, which was handed to that PR's author with two details attached: merge the row's existing anchors before verifying, or the mutual-agreement checks silently pass; and make it fill-only, matching the never-weaken rule the same PR already applies to resource actions. + +**Outcome: the amendment landed in full.** The merged `UpdateBuildInputSchema` takes all three references, fill-only, verified against what the build already carries, with a 409 when a recorded value would change and a no-op when the same value is re-sent. `deployedUrl` was added on the same terms — the secondary ask that was flagged as decline-able — which is what lets a Composer build show a working link in the Console. + +**Correction (2026-08-13, operator).** Two claims above overstated the project reference's weight, and the vocabulary was wrong. A Build's required scope is its **workspace**, which comes from the token; `projectId`/`branchId`/`appId` are optional foreign keys that only add narrower views. The Console's builds pages (pdp #4860) list at workspace level as well as project level, so a build without a project reference is still visible — it just does not appear in the project-filtered view. And the platform's own "anchor" wording is outdated; these are plain optional fks. The code now says `attach`/`refsOf` and nothing says anchor. + +## Constraints discovered in the Composer pipeline + +**The apply runs in a child process.** `prisma-composer deploy` generates `.prisma-composer/alchemy.run.ts` and shells out to `alchemy`. The `report` hook fires inside that child, not the CLI. This is why the build id must travel through the environment, and why the JSON summary already exists as a cross-process file protocol. + +**`report` is synchronous and returns `void`.** It cannot await HTTP calls. Anything requiring async work belongs in `runStackPipeline` in the parent, which is already async and where every failure path funnels through one place. + +**The project is created before the apply.** `container.ensure()` runs in the parent, at step 3 of the pipeline, well before alchemy starts. That is where composer#103's orphaned project comes from, and it is why the build has to be created before the pipeline runs rather than after the project id is known. + +**Named failure causes exist but stop at the apply boundary.** `DEPLOY.BUILD_REQUIRED`, `DEPLOY.CONTAINER_FAILED`, `DEPLOY.SCOPE_MISSING`, `DEPLOY.PREFLIGHT_FAILED`, `DEPLOY.STACK_WRITE_FAILED`, `DEPLOY.ENGINE_FAILED`, `DEPLOY.TEARDOWN_FAILED`, `DEPLOY.CONTAINER_REMOVE_FAILED`. The brief is right that these are what `failingStep` is for. What it does not say is that every failure *inside* the apply — the interesting ones — collapses into `DEPLOY.ENGINE_FAILED` with the message "alchemy deploy exited with status N". The report will be accurate and nearly useless for diagnosing a failed apply. Improving that is separate work and worth its own ticket. + +**The result file is deleted after every run.** Deliberately, so resource ids and URLs do not accumulate on disk. Any user-facing JSON output must be a separate path that survives, without reintroducing that accumulation for people who did not ask for it. + +**The framework may not import Prisma Cloud, so reporting reaches the CLI through a new extension hook.** `architecture.config.json` gives the framework domain `mayImportFrom: []`, and the CLI is framework tooling. The reporting client cannot live there. The existing seam for exactly this is the extension descriptor, which already carries `container`, `preflight` and `teardown` hooks the CLI calls generically — so `reporter` joins them: core defines the vocabulary, the CLI drives the lifecycle, and Prisma Cloud supplies the implementation. + +**The extension package may not read the environment or import a node builtin, which decided where the reporter lives.** `packages/1-prisma-cloud/1-extensions/target` ships into runtime surfaces, and two of its own tests enforce this: invariant 4 asserts an exact per-file list of `process.env` uses, and invariant 5 bans every `node:` import across the package. Reading git and the deploy shell is precisely what a reporter does, so the session lives in `0-lowering/lowering/src/builds/` — which already uses `node:fs`, `node:os` and `node:crypto` freely — and the extension keeps only a five-line adapter supplying the one thing the lowering side cannot know: how to read its own container. + +**The repository's cast counter treats an import alias as a cast.** `import { buildReporter as reportBuilds }` raised `lint:casts` by one. Renaming the export removed it. Worth knowing before someone spends time hunting for a type assertion that was never there. + +## How two reporters of one CI run converge + +Checked against `origin/main` because it decides where the GitHub-env reading belongs. + +The platform owns the dedup key and nothing else. `sourceEventIdForRun(workspaceId, runIdentity)` in `packages/interactors/src/compute/build.ts` returns `github::::`, and `Build.sourceEventId` carries a unique constraint, so any two reporters that supply the same `runIdentity` land on one `Build` row rather than two. `createBuild` looks the key up before inserting and treats a duplicate-key error as "already recorded", so the race is closed on both sides. + +What the platform does **not** do is work out that identity for you. It reads no GitHub environment variable anywhere outside its own CI scripts — the four fields arrive in the `POST` body, and a reporter that omits `runIdentity` gets a fresh build every call plus no repository link, since `gitRepoId` is resolved from `runIdentity.repositoryId`. So deriving the identity from `GITHUB_REPOSITORY_ID` / `GITHUB_RUN_ID` / `GITHUB_RUN_ATTEMPT` is the reporter's job, and `builds/run-identity.ts` is the right home for it. There is no shared helper to reuse and nothing to keep in step beyond those three variable names. + +The consequence worth remembering: **Composer and the GitHub Action must derive the identity identically, or one run produces two builds.** Passing a build id down side-steps it entirely, which is why that path exists — but the fallback only converges because both sides read the same three variables. + +~~Separately, a platform webhook build and a CI-run build for the same push do *not* converge.~~ **Superseded 2026-08-13:** pdp #4877 makes the git webhook correlate `workflow_run` events for `prisma-deploy.yml` into the same Build row via the run's `sourceEventId`, and it marks the build `queued` the moment GitHub accepts the run — before any user code executes. So the webhook and the CI run now converge on one row, and the webhook is usually the first reporter. This strengthens the identity requirement: Composer's derivation must match not only the Action's but the webhook's. + +## Invocation cases + +Walked through with Will. The brief covers the first and third; the rest were found during review. + +| Case | `PRISMA_BUILD_ID` | Behaviour | +| --- | --- | --- | +| Laptop | absent | Create, `source: "cli"`, no run identity. Not idempotent — each retry is a new build. Failed local commands create build records; accepted noise. | +| Generic CI on GitHub Actions | absent | Create, `source: "ci"`, **with** a run identity from `GITHUB_REPOSITORY_ID` / `GITHUB_RUN_ID` / `GITHUB_RUN_ATTEMPT`. Buys idempotency and resolves `gitRepoId`. The brief conflates this with the laptop case. | +| Prisma GitHub Action | present | Join. Never re-POST, never overwrite `externalLogUrl`, never touch `source`. The path that most needs the anchor amendment. | +| Direct `alchemy deploy` of the generated file | absent | Documented path for isolating CLI bugs from Alchemy bugs. No parent process, so no build. Skip and log. | +| `destroy` | either | Only source of the `deleted` action, but `phase` has no teardown value and `source` cannot distinguish it. Out of the first cut. | +| `dev` | n/a | Local providers, no Prisma Cloud, no token. Never reports. | +| Programmatic `deploy()` | either | Behaves as laptop or CI per environment. Worth a test — a host application might already hold a build id. | +| Process killed | either | No terminal report; the build stays running forever. Signal handling covers Ctrl-C and SIGTERM only. Since CI cancellation is common, this is the steady state, not a defect. | + +## Follow-ups worth their own tickets + +- A failed apply reports `DEPLOY.ENGINE_FAILED` and nothing else. Structuring the child's failure so the real cause survives to the parent would make `failingStep` and `errorMessage` genuinely useful. +- ~~`deployedUrl` is on the build response but not settable via PATCH~~ — superseded: the fill-only amendment (recorded above) shipped `deployedUrl`, and the reporter sets it for single-service apps. diff --git a/.drive/projects/build-reporting/plan.md b/.drive/projects/build-reporting/plan.md new file mode 100644 index 000000000..1caf8b588 --- /dev/null +++ b/.drive/projects/build-reporting/plan.md @@ -0,0 +1,114 @@ +# Plan: Composer reports its builds to Prisma Cloud + +> **Status, 2026-08-12: all three slices are implemented in one pull request** at the operator's direction, rather than the three separate PRs planned below. The slice descriptions stand as the record of what each part does. Everything below the rule is historical; the current status is the sections above it: dependencies cleared, definition of done met (verified live 2026-08-14). + +## Where the code landed + +| Part | Location | +| --- | --- | +| The `reporter` extension hook | [app-config.ts](../../../packages/0-framework/1-core/core/src/control/app-config.ts) | +| Lifecycle driving, run report | [execute-deploy-destroy.ts](../../../packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts), [run-report.ts](../../../packages/0-framework/3-tooling/cli/src/run-report.ts) | +| Build endpoints, git identity, the session | `packages/1-prisma-cloud/0-lowering/lowering/src/builds/` | +| Resource reporting through the state store | [state-store.ts](../../../packages/1-prisma-cloud/0-lowering/lowering/src/builds/state-store.ts), wired in [state/layer.ts](../../../packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts) | +| The extension's adapter | [reporting/reporter.ts](../../../packages/1-prisma-cloud/1-extensions/target/src/reporting/reporter.ts) | + +The session lives on the lowering side rather than beside the extension that registers it because the extension package may read no environment and import no node builtin (its own invariants 4 and 5), and reading git and the deploy shell is exactly what a reporter does. + +## Dependencies — all cleared, 2026-08-12 + +The pdp-control-plane stack merged and the API is in production. `@prisma/management-api-sdk` 1.60.0 carries every endpoint, so the temporary hand-written client is gone and the pin moved to `^1.60.0`. The anchor amendment landed in full, plus `deployedUrl`. + +Nothing is blocked. What remains before the definition of done is met is one real deploy observed in the Console. + +## Follow-up design, 2026-08-13 + +The topology design is settled; the canonical spec lives in pdp-control-plane at `projects/branch-topology/spec.md` (see [topology-design.md](topology-design.md) for the pointer). Composer's follow-up slices, from its plan: (1) keep the authored graph — boundary ports and pre-dereference edges — in core's `Graph` (`load-module.ts` + `graph-types.ts`, additive); (2) the pre-apply topology submission through the `ExtensionDescriptor` seam once the endpoint exists, best-effort like all reporting; (3) stamp `logicalId` (the node's full address, already on `LowerContext.address`) on every typed row the providers create — Database, Bucket, Service — once the platform accepts the column; (4) retire `--report` once the Action reads the platform; (5) reject `$out` as a user-declared port name. + +**Identity adoption (2026-08-13, operator direction; shipped).** Composer treats the module name as the project's `logicalId` and every node address as that node's `logicalId` — the same identity the topology submits. Project-level resolution by that identity landed via [PR #230](https://github.com/prisma/composer/pull/230), and the field shipped platform-side as `logicalId` (the slug→logicalId rename happened before the window closed); composer main sends and matches `logicalId` today. The broader domain model — Build Run, Versions, branch-scoped resources, the topology content hash — is pdp ADR-012 ([pdp#4902](https://github.com/prisma/pdp-control-plane/pull/4902)); when the Build Run widening ships, composer's resource reporting gains per-link outcomes and the topology submission records its content hash on the run. The address form is settled and verified against core (`load-module.ts`): the root scope's children get bare, unprefixed addresses (`auth.api`, not `shop.auth.api`); the root node's address is its own name, which is also the Project's `logicalId`. Stamp and submit addresses exactly as the graph declares them. + +--- + +## Slice A — the JSON report the Action reads + +**Why first.** It depends on nothing, and it is what unblocks Action development in parallel with everything else. + +**Starting point.** Most of the mechanism exists. `DeploymentSummary` in [deployment-summary.ts](../../../packages/0-framework/3-tooling/cli/src/deployment-summary.ts) is already written by the report hook inside the alchemy child, to a per-run file named by `PRISMA_COMPOSER_DEPLOYMENT_RESULT_FILE`, and read back by the parent. It is an internal cross-process protocol, not a public contract. + +**What changes.** + +- A version field, so the Action can depend on the shape. +- The failure cause on the failure path. Today the file is written only by the report hook, which runs at the end of a successful apply — a failed deploy produces no file at all. The parent must write one carrying the error code and message. +- A user-facing way to ask for it: a flag or an env-var-named path, distinct from the internal per-run file the parent already deletes in its `finally`. +- Documentation of the shape, and a test that fails when it changes incompatibly. + +**Care needed.** The internal file is deleted after every run, including on failure, specifically so resource ids and URLs do not accumulate on disk. The user-facing output must be a separate path that survives, without reintroducing that accumulation for people who did not ask for it. + +**Verification.** Deploy an example app, parse the emitted file, assert the preview URL is present. Force a failure, assert the file exists and names the cause. + +--- + +## Slice B — the build's lifecycle + +**Scope.** Git identity, create-or-join, progress reporting, terminal reporting, and the guarantee that none of it can fail a deploy. + +**Where it lives.** `runStackPipeline` in [execute-deploy-destroy.ts](../../../packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts), which runs in the CLI's own process where async work is easy and where every failure path already funnels through one place. Reporting wraps it rather than threading through it. + +**Sequence.** + +1. Resolve git identity. No git, no reporting (D4). +2. Join `PRISMA_BUILD_ID`, or create a build — `source: "ci"` with a `runIdentity` under GitHub Actions, `source: "cli"` otherwise (D5). Created before the pipeline runs, so a bootstrap failure that orphans a project (composer#103) is recorded. +3. `phase: deploy`, `state: running`. +4. Fill `projectId` and `branchId` once `container.ensure()` resolves them — pending the anchor amendment. +5. Terminal `state`, `failingStep` and `errorMessage` on every exit path, including thrown errors and signals. + +**Care needed.** + +- Failures are values here, not throws — `runStackPipeline` returns a `Result` — but non-structured errors still rethrow, so the wrapper needs both branches plus a `finally`. +- `failingStep` truncates at 500 characters, `errorMessage` at 5000. +- Every mid-apply failure collapses into `DEPLOY.ENGINE_FAILED` with the message "alchemy deploy exited with status N". The report will be honest but coarse, and nobody should expect the Console to explain why an apply failed. Improving that is separate work. +- The signal handler for Ctrl-C and SIGTERM must not delay process exit if the platform is unresponsive. + +**Verification.** Unit tests against a fake API for each path: joined build, created build, each failure code, and a platform that refuses every call. One end-to-end deploy once the routes are live. + +--- + +## Slice C — resource reporting + +**Scope.** One `PUT` per platform resource, reported as the run touches it, intercepted at the state layer (D2). + +**Where it lives.** The state layer in `packages/1-prisma-cloud/0-lowering/lowering/src/state/`. It is alchemy's stock `makeHttpStateStore`, so this wraps the `State` service rather than modifying a store we own. It reads `PRISMA_BUILD_ID` from the environment (D3) and does nothing when it is absent (D9). + +**The mapping.** Composer's Alchemy resources onto the platform's eight types: + +| Alchemy resource | Platform type | Action | +| --- | --- | --- | +| `Prisma.Project` | `project` | `created` when this run created it; **not reported when adopted** | +| `Prisma.Database` | `database` | `created` / `acted_on` | +| `Prisma.Connection` | `service_key` | `created` / `acted_on` | +| `Prisma.Bucket` | `bucket` | `created` / `acted_on` | +| `Prisma.ComputeService` | `app` | `created` / `acted_on` | +| `Prisma.Deployment` | `deployment` | `created` — also makes the platform attach the deployment and record the app | +| `Prisma.EnvironmentVariable` | `config_variable` | `created` / `acted_on` | +| `Prisma.BucketKey` | — | not reported; no platform type corresponds | +| `PgWarm` and any non-Prisma resource | — | not reported | + +Branch has no Alchemy resource of its own; it is resolved by the container, so it is reported only as the build's anchor, not as a touched resource. + +**Care needed.** + +- State holds a record for every resource in the stack, including ones this run left untouched. `acted_on` is still correct for those — the run reconciled them — but an adopted project must be excluded, per the rule that resolving is not acting. +- Reports must not serialise behind each other, or a large apply pays a round trip per resource. +- The resource must already exist in the workspace or the `PUT` returns 404. Reporting therefore has to happen after the resource is created, not before. +- The route caps its body small and rate-limits at the `high` tier, sized for exactly this burst. + +**Verification.** Unit tests over the mapping table, including the adopted-project exclusion. An end-to-end deploy asserting the reported set matches the resources the run actually created, once the routes are live. + +--- + +## Open decisions + +Recorded in `spec.md` as D4, D5, D6 and D7, all taken by the orchestrator and all reversible. If any is wrong, the cheapest time to say so is before slice B starts. + +## Not verifiable yet — superseded + +This section predates the API shipping. The stack merged, the API is in production, and the live deploy was observed (see the status header): the definition of done is met. diff --git a/.drive/projects/build-reporting/spec.md b/.drive/projects/build-reporting/spec.md new file mode 100644 index 000000000..f68c8f69a --- /dev/null +++ b/.drive/projects/build-reporting/spec.md @@ -0,0 +1,70 @@ +# Project: Composer reports its builds to Prisma Cloud + +> Status: **implemented** on [PR #227](https://github.com/prisma/composer/pull/227); the platform API is in production. The definition of done is met — verified live 2026-08-14, build `bld_cqdzjjlja99nmcd4f27bn69g`. The follow-up topology design is in [topology-design.md](topology-design.md). The discussion history behind every decision here is in [design-notes.md](design-notes.md) — a log, not a reference. + +## The design + +Every `prisma-composer deploy` records itself on the platform as a **Build**: that a run happened, how far it got, how it ended, and which platform resources it touched. Reporting is observability — it can never fail a deploy. + +Four pieces, and where each lives: + +1. **A `reporter` hook on `ExtensionDescriptor`** ([app-config.ts](../../../packages/0-framework/1-core/core/src/control/app-config.ts)), driven generically by the CLI: `begin` after the graph loads and *before* containers resolve (so the failure that orphans a freshly created project, composer#103, is still recorded), `attach` once the Project/Branch exist, `finish` on every exit path — success, structured failure, thrown defect, SIGINT/SIGTERM. Core defines the vocabulary and knows nothing about Builds; the CLI drives the lifecycle and makes no HTTP calls; the Prisma Cloud extension supplies the implementation. This indirection is forced by the architecture: the framework domain may import nothing (`architecture.config.json`), so reporting reaches the CLI only through the extension seam. +2. **The reporting session** in `packages/1-prisma-cloud/0-lowering/lowering/src/builds/` — build create/join, git identity, progress and terminal PATCHes, all through the generated `@prisma/management-api-sdk` client with every request/response shape *derived* from its `operations` types (a hand-kept copy of someone else's contract drifts; a derived one breaks the build). It lives in `lowering` rather than beside the extension because the extension package may read no environment and import no node builtin (its invariants 4 and 5) — which is most of what a reporter does. The extension keeps a five-line adapter supplying the one thing lowering cannot know: how to read its own container. +3. **Resource reporting through the state store** ([state-store.ts](../../../packages/1-prisma-cloud/0-lowering/lowering/src/builds/state-store.ts), wired in [state/layer.ts](../../../packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts)): each platform resource is reported as Alchemy converges it, fired without blocking the apply and drained before the deploy lease releases. The state store is the interception point because every resource write passes through it whichever provider performed it; the deploy's own report hook sees only three entity kinds against the platform's eight resource types. +4. **The run report** ([run-report.ts](../../../packages/0-framework/3-tooling/cli/src/run-report.ts)): `--report ` or `PRISMA_COMPOSER_REPORT_FILE` writes a versioned JSON file with the app, its entities, preview URLs, and the failure cause — written on failure too. **Transitional**: it dies once the GitHub Action reads Build + topology + Versions from the platform (topology-design.md R4). + +How a run gets its Build: join the id from `--build-id` or `PRISMA_BUILD_ID` (flag wins — passed deliberately for that step; empty string is unset on both), else create one — `source: "ci"` with a GitHub run identity when inside GitHub Actions, `source: "cli"` otherwise. The id reaches the apply child through `PRISMA_BUILD_ID`, injected like the other pipeline env. + +## Requirements, and what satisfies them + +| # | Requirement | Satisfied by | +| --- | --- | --- | +| FR1 | Join an existing build or create one; a deploy that reports nothing is invisible, so creating is a requirement, not a fallback. | The join/create logic above; both channels exist because a runner exporting one id per job wants the variable, a job deploying several stages wants the per-step flag. | +| FR2 | Report progress and outcome, with Composer's error codes as the `failingStep` vocabulary and human detail in `errorMessage`. | `begin` PATCHes `phase: deploy, state: running`; `finish` PATCHes the terminal state on every exit path; codes truncate to the platform's 500/5000 caps. | +| FR3 | Report the resources the run acted on: `acted_on` default, `created` only when this run created it, `deleted` on teardown; never report what was merely resolved. | The state-store interceptor; terminal statuses map created→`created`, updated→`acted_on`, deleting→`deleted`; adopted resources are excluded by the `adopting` flag on the state record. | +| FR4 | The Action can consume the outcome as data. | Today: the run-report JSON. Target state: the platform (topology-design.md R4); the file is transitional. | +| FR5 | Reporting never fails a deploy, including a platform unreachable all run. | Every API call warns and returns; the CLI additionally swallows anything a reporter throws; the drain never rejects. | + +Non-functional: one credential path (`PRISMA_SERVICE_TOKEN`, workspace from the token — `PRISMA_WORKSPACE_ID` is not part of this contract); no change to what a deploy provisions or in what order; resource reports must not serialise behind each other. + +## Decisions + +Ids are stable (they are referenced from design-notes.md and plan.md); the order here is logical, not chronological. + +**Architecture** + +- **D10 — reporting reaches the CLI through the `reporter` extension hook, and the session lives on the lowering side.** Both placements forced by machine-checked rules; see The design. +- **D2 — resource reporting is incremental, intercepted at the state layer.** Not primarily for crash resilience: end-of-run reporting from the deploy's report hook could never cover more than three of the platform's eight resource types, and misses `deployment` — the type that makes the platform maintain the build↔app link — entirely. +- **D3 — the build id reaches the apply through the environment** (`PRISMA_BUILD_ID` on the alchemy child), so the child reads one variable whether Composer created the build or CI did. + +**Semantics** + +- **D1 — Composer only ever reports `phase: deploy`.** Composer never builds the user's code (composer ADR-0005); `build` belongs to whoever ran the build. +- **D4 — no git metadata means no report.** `commitSha`/`branchName` are required fields; placeholders would sit in a workspace's history permanently. Prefer `GITHUB_SHA`/`GITHUB_REF_NAME`, fall back to git, skip with a warning outside a checkout. +- **D5 — GitHub Actions runs report `source: "ci"` with a run identity** from `GITHUB_REPOSITORY_ID`/`GITHUB_RUN_ID`/`GITHUB_RUN_ATTEMPT` — that is what buys create-idempotency and the repository link. The platform owns the dedup key (`sourceEventIdForRun`) but reads no GitHub environment itself, so Composer, the Action, and the platform webhook must derive the identity from the same three variables or one run produces multiple builds. +- **D11 — the build records `appId`/`deployedUrl` only when the run deployed exactly one compute service.** The columns hold one value each; picking a service arbitrarily would imply it was the app's address. Multi-service apps lose nothing — every service is reported through the resources endpoint. +- **D8 — a killed process leaves a permanently `running` build, accepted.** Nothing sweeps builds; SIGINT/SIGTERM are caught (with a 1.5s budget so Ctrl-C never hangs), SIGKILL and torn-down runners cannot be. + +**Scope** + +- **D7 — `destroy` is not reported.** Only source of the `deleted` action, but `phase` has no teardown value and `source` cannot distinguish it — it would render as a deploy that deleted everything. +- **D9 — a direct `alchemy deploy` of the generated stack file reports nothing** (no CLI parent, no build; the state store skips resource reporting when `PRISMA_BUILD_ID` is absent). +- `prisma-composer dev` never reports — local providers, no token. +- **D6 — resolved.** The hand-written HTTP client that bridged the pre-SDK gap was deleted the day SDK 1.60.0 shipped; shapes are now derived from the SDK. + +## Platform contract notes + +The authoritative surface is `services/management-api/routes/v1/builds/` in pdp-control-plane (see its `docs/prisma-next-in-mgmt-api/builds.builds-surface.md`). Points this design leans on: the workspace comes from the token, never the body; `projectId`/`branchId`/`appId`/`deployedUrl` are fill-only on PATCH (409 on a genuine change, no-op on re-send) — which is why the `attach` call is separate, so a disagreeing creator costs those fields alone; resource reporting is an idempotent upsert whose action never weakens; reporting a created `deployment` also attaches it to the build and records the app, in one transaction. + +## Non-goals + +Publishing the GitHub Action; platform-side work beyond what topology-design.md names; the Alchemy state-store migration; Composer's auth rework. + +## Definition of done + +- A deploy against Prisma Cloud produces a build with the right phase, outcome and resources. ✓ **Verified live 2026-08-14**: `bld_cqdzjjlja99nmcd4f27bn69g` (dev workspace, storage example) — `source: cli`, `phase: deploy`, `state: succeeded`, correct branch and commit, project attached via the fill-only PATCH, `appId`/`deployedUrl` correctly absent (two services, D11), and 21 resource rows all `created`: 2 apps, 2 deployments, 1 database, 1 service_key, 15 config_variables. Two observations from the live run, neither blocking: the Project itself appears as no resource row, because the hosted flow creates it through the container step rather than the state store (the build's `projectId` carries the association); and `branchId` stays null on a default-stage deploy, since only named stages carry a branch id into `attach` — attaching the default Branch id is a possible refinement. +- A failing deploy shows its named cause and human detail. ✓ (fake-API and pipeline tests) +- A reporting outage does not fail the deploy. ✓ +- The JSON report is emitted, versioned, parseable, written on failure. ✓ +- No git → deploys normally, reports nothing, says why. ✓ +- `dev` and direct `alchemy deploy` report nothing. ✓ diff --git a/.drive/projects/build-reporting/topology-design.md b/.drive/projects/build-reporting/topology-design.md new file mode 100644 index 000000000..d2f38f281 --- /dev/null +++ b/.drive/projects/build-reporting/topology-design.md @@ -0,0 +1,5 @@ +# The topology design moved + +The canonical design lives in **pdp-control-plane** at `projects/branch-topology/spec.md` (branch `claude/branch-topology-design`), with its division of work in `plan.md` beside it. Composer's own work items from that plan are mirrored in this project's [plan.md](plan.md) follow-up section. + +This file is a pointer, not a mirror — the mirror kept drifting and the design has one home now. diff --git a/architecture.config.json b/architecture.config.json index d232068e1..0878263cc 100644 --- a/architecture.config.json +++ b/architecture.config.json @@ -348,6 +348,12 @@ "layer": "extensions", "plane": "control" }, + { + "glob": "packages/1-prisma-cloud/1-extensions/target/src/reporting/**", + "domain": "prisma-cloud", + "layer": "extensions", + "plane": "control" + }, { "glob": "packages/1-prisma-cloud/1-extensions/target/src/local-target/**", "domain": "prisma-cloud", diff --git a/packages/0-framework/1-core/core/src/control/app-config.ts b/packages/0-framework/1-core/core/src/control/app-config.ts index 4ed69d11b..be83b9db2 100644 --- a/packages/0-framework/1-core/core/src/control/app-config.ts +++ b/packages/0-framework/1-core/core/src/control/app-config.ts @@ -11,6 +11,7 @@ import type { ApplicationDescriptor, AssembleInput, Bundle, + DeployedEntity, Lowering, ProvisionerDescriptor, ServiceLowering, @@ -27,6 +28,8 @@ export { containerEnvVarName, deserializeContainers, } from '../container-transport.ts'; +/** Re-exported because `RunOutcome` hands them to a reporter — reading this surface must not also require the deploy one. */ +export type { DeployedEntity } from './deploy.ts'; /** * One extension's control-plane registry: everything the deploy pipeline may @@ -74,6 +77,12 @@ export interface ExtensionDescriptor { * in container-transport.ts. */ readonly container?: ContainerDescriptor; + /** + * Deploy-run reporting. The CLI begins a session after the graph is loaded + * and before containers are resolved, and finishes it on every exit path. + * An extension without one reports nothing, which is the default. + */ + readonly reporter?: ReporterDescriptor; /** * The extension's LOCAL TARGET counterpart (ADR-0041; naming, operator * 2026-07-23 — "dev" names the user-facing feature only, the seam takes @@ -120,6 +129,92 @@ export interface TeardownInput { readonly stage: string | undefined; } +/** The deploy context handed to `ReporterDescriptor.begin`. `C` erases to `unknown` at the framework boundary, exactly as on `PreflightInput`. */ +export interface ReportBeginInput { + /** The resolved application name. */ + readonly appName: string; + /** The stage name (`--stage`), or `undefined` for the default stage. */ + readonly stage: string | undefined; + /** The directory the deploy command was run from — where a reporter reads repository metadata. */ + readonly cwd: string; + /** + * An existing report record this deploy is one part of, when whatever + * invoked Composer created one first — a CI job that opens the record, runs + * several steps against it, and closes it afterwards. Opaque to core: only + * the reporter knows what record the id names, and a reporter that receives + * one joins it instead of creating its own. + * + * Takes precedence over any equivalent the reporter reads from the + * environment, because it was passed deliberately. + */ + readonly reportId: string | undefined; + /** What the caller has already authenticated, exactly as `preflight` and the container lifecycle receive it. Present means the reporter must not build a client from the environment. */ + readonly credentials?: ContainerCredentials | undefined; +} + +/** The deploy context handed to `RunReporter.attach`, once containers exist. */ +export interface ReportAttachInput { + /** The calling extension's own resolved container; `undefined` when it declares no container descriptor. Narrow with the extension's guard. */ + readonly container: ContainerInstance | undefined; +} + +/** How a run ended, as a reporter sees it. */ +export interface RunOutcome { + readonly ok: boolean; + /** The run was interrupted (the engine settled a Ctrl-C or a termination signal) — a kind of not-ok that is not a failure. Only meaningful when `ok` is false. */ + readonly cancelled: boolean; + /** The failing step's name — the deploy's own error code. `undefined` when the run succeeded. */ + readonly failingStep: string | undefined; + /** Human-readable detail. `undefined` when the run succeeded. */ + readonly errorMessage: string | undefined; + /** + * Everything the run's nodes became on the deployment target, flattened. + * Core does not interpret a `kind` and neither should the CLI — a reporter + * reads the kinds its own extension emits and ignores the rest. Empty when + * the run failed before producing a report. + */ + readonly entities: readonly DeployedEntity[]; +} + +/** + * One run's reporting session. Every method is best-effort by contract: + * reporting is observability, never a step of the deploy, so an + * implementation logs its own failures and resolves rather than rejecting. + * The CLI does not catch, and will not fail a deploy over a report. + */ +export interface RunReporter { + /** + * Extra environment for the alchemy child, so reporting that happens + * inside the apply can find the run this session belongs to. Read once, + * after `attach`, and merged into the child's environment. + */ + childEnv(): Readonly>; + /** Called once the extension's own container is resolved, before any stack file is written — the moment the run's project and branch first exist to be referenced. */ + attach(input: ReportAttachInput): Promise; + /** Called exactly once, on every exit path including a thrown error. */ + finish(outcome: RunOutcome): Promise; +} + +/** + * Deploy-run reporting — how an extension records that a deploy happened, + * how far it got, and how it ended. The CLI begins a session after the app's + * graph is loaded and before its containers are resolved, so a failure while + * creating them is still reported, and finishes it on every exit path. + * + * Deploy only: `destroy` has no reportable shape on the Prisma Cloud side + * (its build phases name a deploy), so the CLI does not run this hook there. + */ +export interface ReporterDescriptor { + /** + * Start a session, or return `undefined` when there is nothing to report + * against (no credentials, no repository). Never throws. METHOD SYNTAX + * REQUIRED, like `preflight`: the framework hands over the erased + * `ReportBeginInput`, and a reporter that types the input against + * its own client type only assigns here through method bivariance. + */ + begin(input: ReportBeginInput): Promise; +} + /** The extension's LOCAL TARGET counterpart (ADR-0041) — the local-target variant OF ExtensionDescriptor, hence the full qualifier. An extension without one is not local-target-capable (cannot back the "dev" feature). */ export interface LocalTargetDescriptor { /** Local providers for the SAME resource types this extension's lowering emits. Receives the app identity — unlike deploy's env-arg-free `providers()`, local providers are emulator clients and must know which app they provision for. */ diff --git a/packages/0-framework/3-tooling/cli/src/__tests__/run-report.test.ts b/packages/0-framework/3-tooling/cli/src/__tests__/run-report.test.ts new file mode 100644 index 000000000..eda86aa4a --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/__tests__/run-report.test.ts @@ -0,0 +1,124 @@ +import { afterEach, 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 { DeploymentSummary } from '../deployment-summary.ts'; +import { + RUN_REPORT_VERSION, + resolveRunReportPath, + toRunReport, + writeRunReport, +} from '../run-report.ts'; + +const summary = { + app: 'storefront', + nodes: [ + { + address: 'storefront.web', + entities: [{ kind: 'compute-service', id: 'app_1', url: 'https://web.example' }], + }, + ], +} satisfies DeploymentSummary; + +const tempDirs: string[] = []; + +function tempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'run-report-')); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('toRunReport', () => { + test('a successful run carries the app, its nodes, and no failure', () => { + const report = toRunReport({ summary, stage: 'preview', failure: undefined }); + + expect(report).toEqual({ + version: RUN_REPORT_VERSION, + outcome: 'succeeded', + app: 'storefront', + stage: 'preview', + nodes: summary.nodes, + failure: null, + }); + }); + + test('a failed run before any summary still names its cause', () => { + const report = toRunReport({ + summary: undefined, + stage: undefined, + failure: { code: 'DEPLOY.PREFLIGHT_FAILED', message: 'STRIPE_KEY is not set for preview.' }, + }); + + expect(report.outcome).toBe('failed'); + expect(report.failure).toEqual({ + code: 'DEPLOY.PREFLIGHT_FAILED', + message: 'STRIPE_KEY is not set for preview.', + }); + }); + + test('absent scalars are null rather than missing, so a consumer can read them unguarded', () => { + const report = toRunReport({ summary: undefined, stage: undefined, failure: undefined }); + + expect(Object.keys(report).sort()).toEqual([ + 'app', + 'failure', + 'nodes', + 'outcome', + 'stage', + 'version', + ]); + expect(report.app).toBeNull(); + expect(report.stage).toBeNull(); + expect(report.nodes).toEqual([]); + }); +}); + +describe('resolveRunReportPath', () => { + test('the flag wins over the environment variable', () => { + expect(resolveRunReportPath('flag.json', 'env.json', '/work')).toBe('/work/flag.json'); + }); + + test('the environment variable applies when no flag was passed', () => { + expect(resolveRunReportPath(undefined, 'env.json', '/work')).toBe('/work/env.json'); + }); + + test('an absolute path is left alone', () => { + expect(resolveRunReportPath('/elsewhere/out.json', undefined, '/work')).toBe( + '/elsewhere/out.json', + ); + }); + + test('neither asked for means no report is written', () => { + expect(resolveRunReportPath(undefined, undefined, '/work')).toBeUndefined(); + expect(resolveRunReportPath('', '', '/work')).toBeUndefined(); + }); +}); + +describe('writeRunReport', () => { + test('writes parseable JSON, creating the parent directory', () => { + const target = path.join(tempDir(), 'nested', 'run.json'); + const report = toRunReport({ summary, stage: undefined, failure: undefined }); + + expect(writeRunReport(target, report)).toBe(true); + expect(JSON.parse(fs.readFileSync(target, 'utf8'))).toEqual(report); + }); + + test('a path that cannot be written reports the failure instead of throwing', () => { + const dir = tempDir(); + // The parent is a file, so no directory can be created under it. + fs.writeFileSync(path.join(dir, 'blocked'), ''); + + expect( + writeRunReport( + path.join(dir, 'blocked', 'run.json'), + toRunReport({ summary, stage: undefined, failure: undefined }), + ), + ).toBe(false); + }); +}); diff --git a/packages/0-framework/3-tooling/cli/src/exports/control.ts b/packages/0-framework/3-tooling/cli/src/exports/control.ts index 89791ba01..b59bdd139 100644 --- a/packages/0-framework/3-tooling/cli/src/exports/control.ts +++ b/packages/0-framework/3-tooling/cli/src/exports/control.ts @@ -30,3 +30,5 @@ export type { LogAttached, LogEvent, LogInput, LogLine } from '../operations/log export { log } from '../operations/log.ts'; export type { ExecutionDiagnostics, ServiceEndpoint } from '../operations/shared.ts'; export { executionDiagnostics } from '../operations/shared.ts'; +export type { RunReport, RunReportFailure } from '../run-report.ts'; +export { RUN_REPORT_FILE_ENV, RUN_REPORT_VERSION } from '../run-report.ts'; diff --git a/packages/0-framework/3-tooling/cli/src/family/commands/deploy.ts b/packages/0-framework/3-tooling/cli/src/family/commands/deploy.ts index 2cbad3d70..a21f891bf 100644 --- a/packages/0-framework/3-tooling/cli/src/family/commands/deploy.ts +++ b/packages/0-framework/3-tooling/cli/src/family/commands/deploy.ts @@ -33,6 +33,22 @@ export const createDeployCommand = (operations: ComposerOperations) => brief: 'Deploy scope to target; omit for production.', placeholder: 'stage', }), + report: flag.string({ + brief: + "Write the deploy's outcome as JSON to this path — resources, preview URLs, and " + + 'the failure cause. Also settable as PRISMA_COMPOSER_REPORT_FILE.', + placeholder: 'path', + }), + // Named for what a user reads in their target's console — a build — + // not for this CLI's own `build` (a service's build adapter, + // ADR-0005). Nothing else on this surface takes a build id. + buildId: flag.string({ + brief: + 'Join the deploy record your CI already created rather than letting the target ' + + 'create one. Each target also reads its own environment variable for this; the ' + + 'flag wins.', + placeholder: 'id', + }), }, }, needs: { config: composerSection, credentials: 'child' }, @@ -45,6 +61,8 @@ export const createDeployCommand = (operations: ComposerOperations) => name: args.flags.name, stage: args.flags.stage, cwd: ctx.cwd, + reportPath: args.flags.report, + reportId: args.flags.buildId, }, operationDeps({ alchemy, diff --git a/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts b/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts index 32c185d4b..913d32418 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts @@ -105,7 +105,7 @@ function fakeContainerDescriptor( } function fakeConfig( - hooks: Partial> = {}, + hooks: Partial> = {}, containerOpts: Parameters[0] = {}, ): PrismaAppConfig { return { @@ -124,6 +124,7 @@ function fakeConfig( container: fakeContainerDescriptor(containerOpts), ...(hooks.teardown !== undefined ? { teardown: hooks.teardown } : {}), ...(hooks.preflight !== undefined ? { preflight: hooks.preflight } : {}), + ...(hooks.reporter !== undefined ? { reporter: hooks.reporter } : {}), }, { id: 'fixture-build', nodes: { node: { kind: 'build', assemble: unused } } }, ], @@ -1360,3 +1361,249 @@ describe('log()', () => { expect(events).toEqual(['daemon went away']); }); }); + +describe('deploy-run reporting', () => { + interface ReporterLog { + readonly events: string[]; + readonly reporter: NonNullable; + } + + /** Records the lifecycle the operations drive, and optionally fails at one of its steps. */ + function recordingReporter( + opts: { readonly throwOn?: 'begin' | 'attach' | 'finish'; readonly none?: boolean } = {}, + ): ReporterLog { + const events: string[] = []; + const fail = (step: string) => { + if (opts.throwOn === step) throw new Error(`reporting broke at ${step}`); + }; + return { + events, + reporter: { + begin: async (input) => { + events.push( + `begin:${input.appName}:${input.stage ?? 'default'}:${input.reportId ?? 'no-id'}:` + + `${input.credentials === undefined ? 'no-credentials' : 'credentials'}`, + ); + fail('begin'); + if (opts.none === true) return undefined; + return { + childEnv: () => ({ FAKE_BUILD_ID: 'bld_1' }), + attach: async (attachInput) => { + events.push(`attach:${attachInput.container === undefined ? 'none' : 'container'}`); + fail('attach'); + }, + finish: async (outcome) => { + events.push( + outcome.ok + ? 'finish:ok' + : outcome.cancelled + ? 'finish:cancelled' + : `finish:failed:${outcome.failingStep ?? 'unnamed'}`, + ); + fail('finish'); + }, + }; + }, + }, + }; + } + + /** The reporting wrappers warn on a broken reporter, so these do not run inside silently(). */ + const quietly = async (run: () => Promise): Promise => { + const warnSpy = spyOn(console, 'warn').mockImplementation(() => {}); + try { + return await run(); + } finally { + warnSpy.mockRestore(); + } + }; + + test('a successful deploy is begun, attached, and finished, and its build reaches the apply child', async () => { + const app = makeAppDir('reported-app'); + const log = recordingReporter(); + const invocations: AlchemyInvocation[] = []; + + const result = await silently(() => + deployWithDeps( + { entry: app.entryPath, stage: 'ci-1', cwd: app.dir }, + { + config: fakeConfig({ reporter: log.reporter }), + runAssembler: fakeAssembler, + alchemy: async (invocation) => { + invocations.push(invocation); + return { exitCode: 0, signal: null }; + }, + }, + ), + ); + + expect(result.ok).toBe(true); + expect(log.events).toEqual([ + 'begin:reported-app:ci-1:no-id:no-credentials', + 'attach:container', + 'finish:ok', + ]); + expect(invocations[0]?.env['FAKE_BUILD_ID']).toBe('bld_1'); + }); + + test('the engine credentials and the --build-id both reach begin', async () => { + const app = makeAppDir('threaded-app'); + const log = recordingReporter({ none: true }); + + await silently(() => + deployWithDeps( + { entry: app.entryPath, stage: 'ci-2', cwd: app.dir, reportId: 'bld_from_ci' }, + { + config: fakeConfig({ reporter: log.reporter }), + runAssembler: fakeAssembler, + alchemy: async () => ({ exitCode: 0, signal: null }), + credentials: { workspaceId: 'ws-1', client: {} }, + }, + ), + ); + + expect(log.events).toEqual(['begin:threaded-app:ci-2:bld_from_ci:credentials']); + }); + + test('a container failure is still reported — the session opens before containers', async () => { + const app = makeAppDir(); + const log = recordingReporter(); + const config = fakeConfig({ reporter: log.reporter }); + const extension = config.extensions[0]; + if (extension?.container === undefined) throw new Error('fixture must declare a container'); + const refusing: PrismaAppConfig = { + ...config, + extensions: [ + { + ...extension, + container: { + ...extension.container, + ensure: () => Promise.reject(new Error('the platform refused to create the project')), + }, + }, + ...config.extensions.slice(1), + ], + }; + + const result = await silently(() => + deployWithDeps( + { entry: app.entryPath, stage: 'ci-3', cwd: app.dir }, + { + config: refusing, + runAssembler: fakeAssembler, + alchemy: async () => ({ exitCode: 0, signal: null }), + }, + ), + ); + + expect(result.ok).toBe(false); + expect(log.events).toEqual([ + 'begin:fixture-app:ci-3:no-id:no-credentials', + 'finish:failed:DEPLOY.CONTAINER_FAILED', + ]); + }); + + test('an interrupted converge finishes the session as cancelled, not failed', async () => { + const app = makeAppDir(); + const log = recordingReporter(); + + const result = await silently(() => + deployWithDeps( + { entry: app.entryPath, stage: 'ci-4', cwd: app.dir }, + { + config: fakeConfig({ reporter: log.reporter }), + runAssembler: fakeAssembler, + alchemy: async () => ({ exitCode: null, signal: 'SIGINT' }), + }, + ), + ); + + expect(result.ok).toBe(false); + expect(log.events.at(-1)).toBe('finish:cancelled'); + }); + + test('a reporter that throws at any step never fails the deploy', async () => { + for (const step of ['begin', 'attach', 'finish'] as const) { + const app = makeAppDir(); + const log = recordingReporter({ throwOn: step }); + + const result = await quietly(() => + deployWithDeps( + { entry: app.entryPath, stage: 'ci-5', cwd: app.dir }, + { + config: fakeConfig({ reporter: log.reporter }), + runAssembler: fakeAssembler, + alchemy: async () => ({ exitCode: 0, signal: null }), + }, + ), + ); + + expect({ step, ok: result.ok }).toEqual({ step, ok: true }); + } + }); + + test('destroy is not reported', async () => { + const app = makeAppDir(); + const log = recordingReporter(); + + await silently(() => + destroyWithDeps( + { + entry: app.entryPath, + target: { kind: 'stage', stage: 'staging' }, + onEvent: undefined, + cwd: app.dir, + }, + { + config: fakeConfig({ reporter: log.reporter }), + runAssembler: fakeAssembler, + alchemy: async () => ({ exitCode: 0, signal: null }), + }, + ), + ); + + expect(log.events).toEqual([]); + }); + + test('--report writes the outcome, on success and on failure', async () => { + const app = makeAppDir('reported-json'); + const target = path.join(app.dir, 'out', 'run.json'); + + const ok = await silently(() => + deployWithDeps( + { entry: app.entryPath, stage: 'ci-6', cwd: app.dir, reportPath: target }, + { + config: fakeConfig(), + runAssembler: fakeAssembler, + alchemy: async () => ({ exitCode: 0, signal: null }), + }, + ), + ); + expect(ok.ok).toBe(true); + expect(JSON.parse(fs.readFileSync(target, 'utf8'))).toMatchObject({ + version: 1, + outcome: 'succeeded', + stage: 'ci-6', + failure: null, + }); + + const failedTarget = path.join(app.dir, 'failed.json'); + const failed = await silently(() => + deployWithDeps( + { entry: app.entryPath, stage: 'ci-7', cwd: app.dir, reportPath: failedTarget }, + { + config: fakeConfig({ + preflight: () => Promise.reject(new Error('STRIPE_KEY is missing')), + }), + runAssembler: fakeAssembler, + alchemy: async () => ({ exitCode: 0, signal: null }), + }, + ), + ); + expect(failed.ok).toBe(false); + expect(JSON.parse(fs.readFileSync(failedTarget, 'utf8'))).toMatchObject({ + outcome: 'failed', + failure: { code: 'DEPLOY.PREFLIGHT_FAILED' }, + }); + }); +}); diff --git a/packages/0-framework/3-tooling/cli/src/operations/deploy.ts b/packages/0-framework/3-tooling/cli/src/operations/deploy.ts index af193f628..8c819b9aa 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/deploy.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/deploy.ts @@ -20,6 +20,20 @@ export interface DeployInput { readonly stage?: string | undefined; /** Defaults to process.cwd(); the directory `.prisma-composer/` and `.alchemy` state live under. */ readonly cwd?: string | undefined; + /** + * Where to write the run report — the deploy's outcome as JSON, for a tool + * that consumes a deploy rather than watches one. Relative paths resolve + * against `cwd`. Absent falls back to `PRISMA_COMPOSER_REPORT_FILE`, and + * absent from both writes no report. + */ + readonly reportPath?: string | undefined; + /** + * An existing report record this deploy belongs to — the `--build-id` flag's + * slot. A CI job that opens the record before invoking Composer passes the + * id here, and the target's reporter joins that record instead of creating + * one. Absent falls back to whatever the target reads from the environment. + */ + readonly reportId?: string | undefined; } export interface DeploySuccess { diff --git a/packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts b/packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts index 050f28f91..d4c6d9373 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts @@ -8,7 +8,12 @@ import { randomUUID } from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; -import type { ContainerInstance } from '@internal/core/config'; +import type { + ContainerCredentials, + ContainerInstance, + ReporterDescriptor, + RunReporter, +} from '@internal/core/config'; import { containerEnv } from '@internal/core/config'; import { CliStructuredError } from '@internal/foundation/errors'; import { notOk, ok, okVoid, type Result } from '@internal/foundation/result'; @@ -20,6 +25,12 @@ import { import { GENERATED_STACK_RELATIVE_PATH, writeStackFile } from '../generate-stack.ts'; import { type PipelineDeps, type PipelineResult, runPipeline } from '../pipeline.ts'; import { type AlchemyOutcome, alchemyInvocation, spawnAlchemy } from '../run-alchemy.ts'; +import { + RUN_REPORT_FILE_ENV, + resolveRunReportPath, + toRunReport, + writeRunReport, +} from '../run-report.ts'; import { validateStageName } from '../validate-stage.ts'; import type { DeployInput, DeploySuccess } from './deploy.ts'; import type { DestroyEvent, DestroyInput } from './destroy.ts'; @@ -40,6 +51,8 @@ interface StackPipelineOptions { readonly cwd: string; readonly onEvent: ((event: DestroyEvent) => void) | undefined; readonly deps: OperationDeps; + /** Deploy only: an existing report record to join, from `--build-id`. */ + readonly reportId: string | undefined; } export async function executeDeploy( @@ -54,7 +67,23 @@ export async function executeDeploy( cwd, onEvent: undefined, deps, + reportId: input.reportId, }); + + const reportPath = resolveRunReportPath(input.reportPath, process.env[RUN_REPORT_FILE_ENV], cwd); + if (reportPath !== undefined) { + writeRunReport( + reportPath, + toRunReport({ + summary: outcome.ok ? outcome.value : undefined, + stage: input.stage, + failure: outcome.ok + ? undefined + : { code: outcome.failure.code, message: outcome.failure.message }, + }), + ); + } + if (!outcome.ok) return outcome; return ok({ summary: outcome.value }); } @@ -71,17 +100,176 @@ export async function executeDestroy( cwd, onEvent: input.onEvent, deps, + reportId: undefined, }); if (!outcome.ok) return outcome; return okVoid(); } +/** A live reporting session, kept beside the extension that owns it so `attach` can hand back that extension's own container. */ +interface ExtensionReporter { + readonly extensionId: ExtensionId; + readonly reporter: RunReporter; +} + +/** + * Opens a session per extension that declares a reporter. A `begin` that + * throws costs that extension its reporting and nothing else — the deploy + * has not started, and refusing to run it because an observer failed would + * invert the relationship. + */ +async function beginReporters( + extensions: readonly { readonly id: ExtensionId; readonly reporter?: ReporterDescriptor }[], + context: { + readonly appName: string; + readonly stage: string | undefined; + readonly cwd: string; + readonly reportId: string | undefined; + readonly credentials: ContainerCredentials | undefined; + }, +): Promise { + const opened = await Promise.all( + extensions.map(async (extension) => { + if (extension.reporter === undefined) return undefined; + try { + const reporter = await extension.reporter.begin(context); + return reporter === undefined ? undefined : { extensionId: extension.id, reporter }; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + console.warn(`\nCould not start deploy reporting for ${extension.id}: ${detail}`); + return undefined; + } + }), + ); + return opened.filter((entry) => entry !== undefined); +} + +/** Hands each session its own extension's resolved container, so it can attach the run to what that container names. */ +async function attachReporters( + reporters: readonly ExtensionReporter[], + containers: ReadonlyMap, +): Promise { + await Promise.all( + reporters.map(async ({ extensionId, reporter }) => { + try { + await reporter.attach({ container: containers.get(extensionId) }); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + console.warn(`\nCould not attach this deploy to its project for ${extensionId}: ${detail}`); + } + }), + ); +} + +/** Every session's contribution to the alchemy child's environment, so reporting that happens inside the apply can find the run. */ +function reporterChildEnv(reporters: readonly ExtensionReporter[]): Record { + const env: Record = {}; + for (const { extensionId, reporter } of reporters) { + try { + Object.assign(env, reporter.childEnv()); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + console.warn( + `\nCould not pass deploy reporting into the apply for ${extensionId}: ${detail}`, + ); + } + } + return env; +} + +/** `failingStep` is capped at 500 by the platform and `errorMessage` at 5000; truncating here keeps a long message from costing the whole report. */ +function truncate(value: string, limit: number): string { + return value.length <= limit ? value : `${value.slice(0, limit - 1)}…`; +} + +/** An interrupted converge (the engine settled a Ctrl-C) — reported as `cancelled`, never as `failed`. */ +function wasInterrupted(failure: CliStructuredError): boolean { + return typeof failure.meta?.['signal'] === 'string'; +} + +/** + * Ends every reporting session, whatever the run did. Sessions never reject + * by contract, but a buggy one must not turn a converged deploy into a + * failure — so this swallows anyway, and reports each session independently + * so one bad implementation cannot silence another. + */ +async function finishReporters( + reporters: readonly ExtensionReporter[], + outcome: { + readonly ok: boolean; + readonly cancelled: boolean; + readonly code?: string; + readonly message?: string; + readonly summary?: DeploymentSummary | undefined; + }, +): Promise { + // Flattened, not per node: a reporter reads the kinds its own extension + // emits, and which node produced one is the CLI's presentation concern. + const entities = outcome.summary?.nodes.flatMap((node) => node.entities) ?? []; + await Promise.all( + reporters.map(async ({ reporter }) => { + try { + await reporter.finish({ + ok: outcome.ok, + cancelled: outcome.cancelled, + failingStep: outcome.code === undefined ? undefined : truncate(outcome.code, 500), + errorMessage: outcome.message === undefined ? undefined : truncate(outcome.message, 5000), + entities, + }); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + console.warn(`\nCould not report this deploy's outcome: ${detail}`); + } + }), + ); +} + +/** + * Owns the reporting sessions around the pipeline: the inner run opens them + * once it knows which extensions are configured, and this closes them on + * every exit path — a returned failure, a success, or a thrown defect. + * Nothing here can change what the pipeline returns. + */ +async function runStackPipeline( + action: 'deploy' | 'destroy', + opts: StackPipelineOptions, +): Promise> { + const reporters: ExtensionReporter[] = []; + let outcome: Result; + try { + outcome = await runStackPipelineInner(action, opts, reporters); + } catch (error) { + // A defect, not a structured failure — still the end of the run, and the + // only chance to record that it ended at all. + await finishReporters(reporters, { + ok: false, + cancelled: false, + code: 'DEPLOY.UNEXPECTED', + message: error instanceof Error ? error.message : String(error), + }); + throw error; + } + await finishReporters( + reporters, + outcome.ok + ? { ok: true, cancelled: false, summary: outcome.value } + : { + ok: false, + cancelled: wasInterrupted(outcome.failure), + code: outcome.failure.code, + message: outcome.failure.message, + }, + ); + return outcome; +} + /** The pipeline both actions share: validate, resolve containers, preflight, * write the stack file, run alchemy against it, then the destroy-only * teardown/removal suffix. The value is only ever a summary for deploy. */ -async function runStackPipeline( +async function runStackPipelineInner( action: 'deploy' | 'destroy', opts: StackPipelineOptions, + reporters: ExtensionReporter[], ): Promise> { const { entry, name, stage, cwd, onEvent, deps } = opts; @@ -130,6 +318,22 @@ async function runStackPipeline( pipeline = await runPipeline(entry, name, cwd, pipelineDeps, onAssembleError); const { config, graph, name: resolvedName } = pipeline; + // Open reporting BEFORE containers are resolved: creating them is the + // step that can leave a project behind with nothing recording why + // (composer#103), so a session that started afterwards would miss the + // one failure it most needs to describe. + if (action === 'deploy') { + reporters.push( + ...(await beginReporters(config.extensions, { + appName: resolvedName, + stage, + cwd, + reportId: opts.reportId, + credentials: deps.credentials, + })), + ); + } + // Resolve each extension's own container (e.g. Prisma Cloud's Project + // named-stage Branch) via its own descriptor — deploy ensures (creates if // absent), destroy locates only — after assembly succeeds, so a deploy @@ -162,6 +366,10 @@ async function runStackPipeline( } } + // Containers exist now, so each session can attach its run to the + // Project/Branch its extension resolved. + await attachReporters(reporters, containers); + // The Alchemy stage is never left to Alchemy's own default (`dev_$USER` // — machine-dependent, the TML-3157 incident): the state-owning extension's // container (same selection as core's resolveStateLayer) pins it, else an @@ -255,7 +463,10 @@ async function runStackPipeline( cwd, stage: alchemyStage, containerEnv: containerEnv(containers), - env: { [DEPLOYMENT_RESULT_FILE_ENV]: resultFilePath }, + env: { + ...reporterChildEnv(reporters), + [DEPLOYMENT_RESULT_FILE_ENV]: resultFilePath, + }, }), ); } catch (error) { diff --git a/packages/0-framework/3-tooling/cli/src/run-report.ts b/packages/0-framework/3-tooling/cli/src/run-report.ts new file mode 100644 index 000000000..a98863dd4 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/run-report.ts @@ -0,0 +1,93 @@ +/** + * The run report: one deploy's outcome as JSON, for tools that consume a + * deploy rather than watch one — the Prisma GitHub Action reads it to build a + * pull-request comment carrying preview links. + * + * Deliberately separate from `deployment-summary.ts`. That file is a private + * carrier between the alchemy child and this process, written to a + * per-run path the parent deletes in a `finally` so resource ids and URLs do + * not accumulate on disk. This one is written where the operator asked for + * it, survives the run, is written on the failure path too, and carries a + * version so a consumer can depend on its shape. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { DeployedNodeSummary, DeploymentSummary } from './deployment-summary.ts'; + +/** Bump when a change would break a consumer that reads the current shape. */ +export const RUN_REPORT_VERSION = 1; + +/** Names the file to write the run report to, when `--report` is not passed. */ +export const RUN_REPORT_FILE_ENV = 'PRISMA_COMPOSER_REPORT_FILE'; + +export interface RunReportFailure { + /** The deploy's own error code, e.g. `DEPLOY.PREFLIGHT_FAILED`. */ + readonly code: string; + readonly message: string; +} + +/** + * Every field is always present, and absent scalars are `null` rather than + * omitted — a consumer can read `report.failure` without first testing + * whether the key exists. + */ +export interface RunReport { + readonly version: number; + readonly outcome: 'succeeded' | 'failed'; + /** Null when the run failed before it produced a deployment summary. */ + readonly app: string | null; + /** Null for the default stage. */ + readonly stage: string | null; + readonly nodes: readonly DeployedNodeSummary[]; + readonly failure: RunReportFailure | null; +} + +export interface RunReportInput { + readonly summary: DeploymentSummary | undefined; + readonly stage: string | undefined; + readonly failure: RunReportFailure | undefined; +} + +export function toRunReport(input: RunReportInput): RunReport { + return { + version: RUN_REPORT_VERSION, + outcome: input.failure === undefined ? 'succeeded' : 'failed', + app: input.summary?.app ?? null, + stage: input.stage ?? null, + nodes: input.summary?.nodes ?? [], + failure: input.failure ?? null, + }; +} + +/** + * The path to write to: the `--report` flag first, then the env var. Relative + * paths resolve against the deploy's cwd. `undefined` means no report was + * asked for, which is the common case and writes nothing. + */ +export function resolveRunReportPath( + flag: string | undefined, + env: string | undefined, + cwd: string, +): string | undefined { + const requested = flag !== undefined && flag.length > 0 ? flag : env; + if (requested === undefined || requested.length === 0) return undefined; + return path.resolve(cwd, requested); +} + +/** + * Writes the report, creating the parent directory if needed. A write failure + * warns and returns false rather than failing a deploy that already + * converged — but it is never silent, because the operator asked for this + * file and a consumer is waiting on it. + */ +export function writeRunReport(filePath: string, report: RunReport): boolean { + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(report, null, 2)}\n`); + return true; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + console.warn(`\nCould not write the run report to ${filePath}: ${detail}`); + return false; + } +} diff --git a/packages/1-prisma-cloud/0-lowering/lowering/package.json b/packages/1-prisma-cloud/0-lowering/lowering/package.json index 0decefca7..600e6c4ca 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/package.json +++ b/packages/1-prisma-cloud/0-lowering/lowering/package.json @@ -5,6 +5,7 @@ "exports": { ".": "./dist/index.mjs", "./buckets": "./dist/buckets.mjs", + "./builds": "./dist/builds.mjs", "./compute": "./dist/compute.mjs", "./postgres": "./dist/postgres.mjs", "./state": "./dist/state.mjs", @@ -19,7 +20,7 @@ "dependencies": { "@internal/core": "workspace:0.6.0", "@internal/foundation": "workspace:0.6.0", - "@prisma/management-api-sdk": "^1.57.0", + "@prisma/management-api-sdk": "^1.60.0", "alchemy": "2.0.0-beta.67", "effect": "4.0.0-beta.103" }, diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/build-reporter.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/build-reporter.test.ts new file mode 100644 index 000000000..a7e7ffdce --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/build-reporter.test.ts @@ -0,0 +1,366 @@ +import { describe, expect, test } from 'bun:test'; +import type { ContainerInstance } from '@internal/core/config'; +import type { BuildsApi, CreateBuildBody, UpdateBuildBody } from '../builds/api.ts'; +import { type BuildContainerRefs, buildReporter } from '../builds/reporter.ts'; + +const ENV = { + PRISMA_SERVICE_TOKEN: 'token', + GITHUB_SHA: 'a'.repeat(40), + GITHUB_REF_NAME: 'main', +}; + +interface Recorded { + creates: CreateBuildBody[]; + updates: { buildId: string; body: UpdateBuildBody }[]; +} + +/** `createdId` is explicit, never defaulted — a default would swallow the `undefined` the "platform refused" case depends on. */ +function fakeApi(createdId: string | undefined): { api: BuildsApi; recorded: Recorded } { + const recorded: Recorded = { creates: [], updates: [] }; + return { + recorded, + api: { + create: async (body) => { + recorded.creates.push(body); + return createdId; + }, + update: async (buildId, body) => { + recorded.updates.push({ buildId, body }); + return true; + }, + reportResource: async () => true, + }, + }; +} + +/** Stands in for the extension's own container; the reporter only ever sees it through `refsOf`. */ +const CONTAINER = { projectId: 'proj_1', branchId: 'branch_1' }; +const refsOf = (container: ContainerInstance): BuildContainerRefs => + container as unknown as BuildContainerRefs; + +const begin = ( + api: BuildsApi, + env: Record = ENV, + warn: (message: string) => void = () => {}, + reportId: string | undefined = undefined, +) => + buildReporter({ api, env, warn, refsOf }).begin({ + appName: 'storefront', + stage: undefined, + cwd: import.meta.dir, + reportId, + }); + +describe('buildReporter', () => { + test('creates a build and marks it running in the deploy phase', async () => { + const { api, recorded } = fakeApi('bld_new'); + + const session = await begin(api); + await session?.finish({ + ok: true, + cancelled: false, + failingStep: undefined, + errorMessage: undefined, + entities: [], + }); + + expect(recorded.creates).toEqual([ + { source: 'cli', commitSha: 'a'.repeat(40), branchName: 'main' }, + ]); + // `deploy`, never `build`: Composer does not build the user's code. + expect(recorded.updates[0]).toEqual({ + buildId: 'bld_new', + body: { phase: 'deploy', state: 'running' }, + }); + }); + + test('joins the build the Action created instead of creating a second one', async () => { + const { api, recorded } = fakeApi('bld_new'); + + const session = await begin(api, { ...ENV, PRISMA_BUILD_ID: 'bld_from_action' }); + await session?.finish({ + ok: true, + cancelled: false, + failingStep: undefined, + errorMessage: undefined, + entities: [], + }); + + expect(recorded.creates).toEqual([]); + expect(recorded.updates.every((u) => u.buildId === 'bld_from_action')).toBe(true); + // The creator's own link to its logs is never overwritten. + expect(recorded.updates.some((u) => 'externalLogUrl' in u.body)).toBe(false); + }); + + test('joins the build named on the command line, without the environment variable', async () => { + const { api, recorded } = fakeApi('bld_new'); + + const session = await begin(api, ENV, () => {}, 'bld_from_flag'); + await session?.finish({ + ok: true, + cancelled: false, + failingStep: undefined, + errorMessage: undefined, + entities: [], + }); + + expect(recorded.creates).toEqual([]); + expect(recorded.updates.every((u) => u.buildId === 'bld_from_flag')).toBe(true); + expect(session?.childEnv()).toEqual({ PRISMA_BUILD_ID: 'bld_from_flag' }); + }); + + test('the command line beats the environment — a job-wide variable does not override one step', async () => { + const { api, recorded } = fakeApi('bld_new'); + + const session = await begin( + api, + { ...ENV, PRISMA_BUILD_ID: 'bld_from_env' }, + () => {}, + 'bld_from_flag', + ); + await session?.finish({ + ok: true, + cancelled: false, + failingStep: undefined, + errorMessage: undefined, + entities: [], + }); + + expect(recorded.updates.every((u) => u.buildId === 'bld_from_flag')).toBe(true); + }); + + test('an empty build id is how a shell spells unset, so the build is created', async () => { + const { api, recorded } = fakeApi('bld_new'); + + const session = await begin(api, { ...ENV, PRISMA_BUILD_ID: '' }, () => {}, ''); + await session?.finish({ + ok: true, + cancelled: false, + failingStep: undefined, + errorMessage: undefined, + entities: [], + }); + + expect(recorded.creates).toHaveLength(1); + expect(recorded.updates.every((u) => u.buildId === 'bld_new')).toBe(true); + }); + + test('passes the build id into the apply, so the state store reports against it', async () => { + const { api } = fakeApi('bld_new'); + + const session = await begin(api); + expect(session?.childEnv()).toEqual({ PRISMA_BUILD_ID: 'bld_new' }); + await session?.finish({ + ok: true, + cancelled: false, + failingStep: undefined, + errorMessage: undefined, + entities: [], + }); + }); + + test('attaches the project and branch on their own, so a rejection costs only those fields', async () => { + const { api, recorded } = fakeApi('bld_new'); + + const session = await begin(api); + await session?.attach({ container: CONTAINER as unknown as ContainerInstance }); + await session?.finish({ + ok: true, + cancelled: false, + failingStep: undefined, + errorMessage: undefined, + entities: [], + }); + + expect(recorded.updates[1]).toEqual({ + buildId: 'bld_new', + body: { projectId: 'proj_1', branchId: 'branch_1' }, + }); + }); + + test('an extension with no container has nothing to attach and sends nothing', async () => { + const { api, recorded } = fakeApi('bld_new'); + + const session = await begin(api); + await session?.attach({ container: undefined }); + await session?.finish({ + ok: true, + cancelled: false, + failingStep: undefined, + errorMessage: undefined, + entities: [], + }); + + expect(recorded.updates.some((u) => 'projectId' in u.body)).toBe(false); + }); + + test('a failed run reports its named cause and the detail', async () => { + const { api, recorded } = fakeApi('bld_new'); + + const session = await begin(api); + await session?.finish({ + ok: false, + cancelled: false, + failingStep: 'DEPLOY.PREFLIGHT_FAILED', + errorMessage: 'STRIPE_KEY is not set for preview.', + entities: [], + }); + + expect(recorded.updates.at(-1)).toEqual({ + buildId: 'bld_new', + body: { + state: 'failed', + failingStep: 'DEPLOY.PREFLIGHT_FAILED', + errorMessage: 'STRIPE_KEY is not set for preview.', + }, + }); + }); + + test('a run that deployed one service records the app and where it can be reached', async () => { + const { api, recorded } = fakeApi('bld_new'); + + const session = await begin(api); + await session?.finish({ + ok: true, + cancelled: false, + failingStep: undefined, + errorMessage: undefined, + entities: [ + { kind: 'postgres-database', id: 'db_1' }, + { kind: 'compute-service', id: 'app_1', url: 'https://storefront.example' }, + ], + }); + + expect(recorded.updates.at(-1)?.body).toEqual({ + state: 'succeeded', + appId: 'app_1', + deployedUrl: 'https://storefront.example', + }); + }); + + test('a run that deployed several services records neither — there is no one answer to record', async () => { + const { api, recorded } = fakeApi('bld_new'); + + const session = await begin(api); + await session?.finish({ + ok: true, + cancelled: false, + failingStep: undefined, + errorMessage: undefined, + entities: [ + { kind: 'compute-service', id: 'app_1', url: 'https://web.example' }, + { kind: 'compute-service', id: 'app_2', url: 'https://api.example' }, + ], + }); + + expect(recorded.updates.at(-1)?.body).toEqual({ state: 'succeeded' }); + }); + + test('a service with no public address still records the app it deployed', async () => { + const { api, recorded } = fakeApi('bld_new'); + + const session = await begin(api); + await session?.finish({ + ok: true, + cancelled: false, + failingStep: undefined, + errorMessage: undefined, + entities: [{ kind: 'compute-service', id: 'app_1' }], + }); + + expect(recorded.updates.at(-1)?.body).toEqual({ state: 'succeeded', appId: 'app_1' }); + }); + + test('finishing twice reports once — the signal path and the normal path cannot both land', async () => { + const { api, recorded } = fakeApi('bld_new'); + + const session = await begin(api); + await session?.finish({ + ok: true, + cancelled: false, + failingStep: undefined, + errorMessage: undefined, + entities: [], + }); + await session?.finish({ + ok: false, + cancelled: false, + failingStep: 'X.Y', + errorMessage: 'second', + entities: [], + }); + + expect(recorded.updates.filter((u) => u.body.state !== 'running')).toHaveLength(1); + }); + + test('no service token means no session, and no complaint — the deploy fails for its own reason', async () => { + const warnings: string[] = []; + const session = await buildReporter({ + env: {}, + warn: (m) => warnings.push(m), + refsOf, + }).begin({ + appName: 'storefront', + stage: undefined, + cwd: import.meta.dir, + reportId: undefined, + }); + + expect(session).toBeUndefined(); + expect(warnings).toEqual([]); + }); + + test('a run with no commit or branch says so, and reports nothing', async () => { + const warnings: string[] = []; + const { api, recorded } = fakeApi('bld_new'); + const session = await buildReporter({ + api, + env: { PRISMA_SERVICE_TOKEN: 'token' }, + warn: (m) => warnings.push(m), + refsOf, + }).begin({ appName: 'storefront', stage: undefined, cwd: '/', reportId: undefined }); + + expect(session).toBeUndefined(); + expect(recorded.creates).toEqual([]); + expect(warnings.join('\n')).toContain('no commit and branch'); + }); + + test('a build the platform would not create ends the session rather than reporting into nothing', async () => { + const { api, recorded } = fakeApi(undefined); + + expect(await begin(api)).toBeUndefined(); + expect(recorded.updates).toEqual([]); + }); + + test('a platform that rejects the create ends the session without throwing', async () => { + const warnings: string[] = []; + const api: BuildsApi = { + create: () => Promise.reject(new Error('the platform is unavailable')), + update: async () => true, + reportResource: async () => true, + }; + + expect(await begin(api, ENV, (m) => warnings.push(m))).toBeUndefined(); + expect(warnings.join('\n')).toContain('the platform is unavailable'); + }); + + test('a rejected terminal update does not reject finish', async () => { + const warnings: string[] = []; + const api: BuildsApi = { + create: async () => 'bld_new', + update: () => Promise.reject(new Error('the platform is unavailable')), + reportResource: async () => true, + }; + + const session = await begin(api, ENV, (m) => warnings.push(m)); + // begin PATCHes running and already tolerates the rejection; finish must too. + await session?.finish({ + ok: true, + cancelled: false, + failingStep: undefined, + errorMessage: undefined, + entities: [], + }); + expect(warnings.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/build-resources.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/build-resources.test.ts new file mode 100644 index 000000000..c106c8b8a --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/build-resources.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, test } from 'bun:test'; +import type { BuildResourceAction, BuildResourceType, BuildsApi } from '../builds/api.ts'; +import { reportableResource, resourceReporter } from '../builds/resources.ts'; + +const state = (over: Record) => ({ + resourceType: 'Prisma.Database', + status: 'created', + attr: { id: 'db_1' }, + ...over, +}); + +interface Reported { + buildId: string; + type: BuildResourceType; + id: string; + action: BuildResourceAction; +} + +function fakeApi(): { api: BuildsApi; reported: Reported[]; settle: () => void } { + const reported: Reported[] = []; + const pending: (() => void)[] = []; + return { + reported, + settle: () => { + for (const resolve of pending.splice(0)) resolve(); + }, + api: { + create: async () => undefined, + update: async () => true, + reportResource: (buildId, type, id, action) => { + reported.push({ buildId, type, id, action }); + return new Promise((resolve) => pending.push(() => resolve(true))); + }, + }, + }; +} + +describe('reportableResource', () => { + test('maps each Prisma Cloud resource onto its platform type', () => { + const cases: readonly [string, string, BuildResourceType][] = [ + ['Prisma.Project', 'id', 'project'], + ['Prisma.Database', 'id', 'database'], + ['Prisma.Connection', 'id', 'service_key'], + ['Prisma.Bucket', 'id', 'bucket'], + ['Prisma.ComputeService', 'id', 'app'], + ['Prisma.EnvironmentVariable', 'id', 'config_variable'], + ]; + + for (const [resourceType, idField, expected] of cases) { + expect(reportableResource(state({ resourceType, attr: { [idField]: 'x_1' } }))).toEqual({ + type: expected, + id: 'x_1', + action: 'created', + }); + } + }); + + test('a deployment keys its platform id as deploymentId, not id', () => { + expect( + reportableResource( + state({ resourceType: 'Prisma.Deployment', attr: { deploymentId: 'dep_1' } }), + ), + ).toEqual({ type: 'deployment', id: 'dep_1', action: 'created' }); + + // An `id` on a deployment is some other identifier and must not be reported as one. + expect( + reportableResource(state({ resourceType: 'Prisma.Deployment', attr: { id: 'wrong' } })), + ).toBeUndefined(); + }); + + test('a reconcile that changed nothing is still an action on the resource', () => { + expect(reportableResource(state({ status: 'updated' }))?.action).toBe('acted_on'); + }); + + test('a resource being removed is reported as deleted', () => { + expect(reportableResource(state({ status: 'deleting' }))?.action).toBe('deleted'); + }); + + test('an in-progress status is not reported — the terminal write follows it', () => { + for (const status of ['creating', 'updating', 'replacing', 'replaced']) { + expect(reportableResource(state({ status }))).toBeUndefined(); + } + }); + + test('an adopted resource is not reported: this run resolved it, it did not act on it', () => { + expect(reportableResource(state({ status: 'updated', adopting: true }))).toBeUndefined(); + }); + + test('resources with no platform type are not reported', () => { + for (const resourceType of ['Prisma.BucketKey', 'PrismaCloud.ServiceKey', 'PgWarm']) { + expect(reportableResource(state({ resourceType }))).toBeUndefined(); + } + }); + + test('tasks, malformed records, and missing ids are not reported', () => { + expect(reportableResource(state({ kind: 'action' }))).toBeUndefined(); + expect(reportableResource(state({ attr: undefined }))).toBeUndefined(); + expect(reportableResource(state({ attr: { id: '' } }))).toBeUndefined(); + expect(reportableResource(state({ resourceType: 42 }))).toBeUndefined(); + expect(reportableResource(undefined)).toBeUndefined(); + expect(reportableResource('nonsense')).toBeUndefined(); + }); +}); + +describe('resourceReporter', () => { + test('reports each resource once, however often its state is written', () => { + const { api, reported, settle } = fakeApi(); + const reporter = resourceReporter(api, 'bld_1'); + + reporter.observe(state({})); + reporter.observe(state({})); + reporter.observe(state({ status: 'updated' })); + + settle(); + expect(reported).toEqual([ + { buildId: 'bld_1', type: 'database', id: 'db_1', action: 'created' }, + // A different action on the same resource is a different claim, so it is sent. + { buildId: 'bld_1', type: 'database', id: 'db_1', action: 'acted_on' }, + ]); + }); + + test('observing does not wait for the report — an apply never blocks on the Console', () => { + const { api, reported, settle } = fakeApi(); + const reporter = resourceReporter(api, 'bld_1'); + + reporter.observe(state({})); + // The request is already away while nothing has resolved it. + expect(reported).toHaveLength(1); + settle(); + }); + + test('drain waits for every report already started', async () => { + const { api, settle } = fakeApi(); + const reporter = resourceReporter(api, 'bld_1'); + + reporter.observe(state({})); + let drained = false; + const draining = reporter.drain().then(() => { + drained = true; + }); + + await Promise.resolve(); + expect(drained).toBe(false); + + settle(); + await draining; + expect(drained).toBe(true); + }); + + test('a report that fails does not make drain reject', async () => { + const failing: BuildsApi = { + create: async () => undefined, + update: async () => true, + reportResource: () => Promise.reject(new Error('platform is down')), + }; + const reporter = resourceReporter(failing, 'bld_1'); + + reporter.observe(state({})); + await reporter.drain(); + }); + + test('a report that never settles is abandoned at the deadline instead of hanging the drain', async () => { + const never: BuildsApi = { + create: async () => undefined, + update: async () => true, + reportResource: () => new Promise(() => {}), + }; + const warnings: string[] = []; + const reporter = resourceReporter(never, 'bld_1', (m) => warnings.push(m), 50); + + reporter.observe(state({})); + await reporter.drain(); + + expect(warnings.join('\n')).toContain('Abandoned 1 in-flight resource report'); + }); +}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/run-identity.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/run-identity.test.ts new file mode 100644 index 000000000..1c8da285a --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/run-identity.test.ts @@ -0,0 +1,133 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { resolveRunIdentity } from '../builds/run-identity.ts'; + +/** A directory outside any checkout, so the git fallback genuinely finds nothing. */ +const dirs: string[] = []; +function nonRepoDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'run-identity-')); + dirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +const actions = { + GITHUB_ACTIONS: 'true', + GITHUB_SHA: 'a'.repeat(40), + GITHUB_REF_NAME: 'main', + GITHUB_REPOSITORY: 'prisma/composer', + GITHUB_REPOSITORY_ID: '12345', + GITHUB_RUN_ID: '99', + GITHUB_RUN_ATTEMPT: '2', +}; + +describe('resolveRunIdentity', () => { + test('a GitHub Actions run is named, so its build dedupes and links to the repository', () => { + const identity = resolveRunIdentity(nonRepoDir(), actions); + + expect(identity).toEqual({ + source: 'ci', + commitSha: 'a'.repeat(40), + branchName: 'main', + runIdentity: { provider: 'github', repositoryId: '12345', runId: '99', runAttempt: 2 }, + externalLogUrl: 'https://github.com/prisma/composer/actions/runs/99/attempts/2', + }); + }); + + test('a pull-request run reports the head branch, not the synthetic merge ref', () => { + const identity = resolveRunIdentity(nonRepoDir(), { + ...actions, + GITHUB_REF_NAME: '123/merge', + GITHUB_HEAD_REF: 'feat/build-reporting', + }); + + expect(identity?.branchName).toBe('feat/build-reporting'); + }); + + test('a run whose identity is not all digits is reported as cli, never with a half identity', () => { + for (const broken of [ + { GITHUB_REPOSITORY_ID: 'prisma:composer' }, + { GITHUB_RUN_ID: 'abc' }, + { GITHUB_RUN_ATTEMPT: 'first' }, + { GITHUB_REPOSITORY_ID: '' }, + ]) { + const identity = resolveRunIdentity(nonRepoDir(), { ...actions, ...broken }); + expect(identity?.source).toBe('cli'); + expect(identity?.runIdentity).toBeUndefined(); + expect(identity?.externalLogUrl).toBeUndefined(); + } + }); + + test('a run attempt defaults to the first when the variable is absent', () => { + const { GITHUB_RUN_ATTEMPT: _omitted, ...rest } = actions; + expect(resolveRunIdentity(nonRepoDir(), rest)?.runIdentity?.runAttempt).toBe(1); + }); + + test('outside GitHub Actions the run is cli, even with the other variables set', () => { + const identity = resolveRunIdentity(nonRepoDir(), { ...actions, GITHUB_ACTIONS: undefined }); + + expect(identity?.source).toBe('cli'); + expect(identity?.runIdentity).toBeUndefined(); + }); + + test('no commit and no branch means no identity — nothing is invented to fill a required field', () => { + expect(resolveRunIdentity(nonRepoDir(), {})).toBeUndefined(); + }); + + test('a commit with no branch is still not enough', () => { + expect(resolveRunIdentity(nonRepoDir(), { GITHUB_SHA: 'a'.repeat(40) })).toBeUndefined(); + }); + + test('a git checkout resolves its commit and branch through git', () => { + // A purpose-built repo, not the host checkout: CI checks out pull + // requests at a detached HEAD, where "no branch" is the correct answer. + const dir = nonRepoDir(); + const git = (...args: string[]) => + execFileSync('git', args, { cwd: dir, stdio: ['ignore', 'pipe', 'ignore'] }); + git('init', '--initial-branch=trunk'); + git( + '-c', + 'user.email=t@example.com', + '-c', + 'user.name=t', + 'commit', + '--allow-empty', + '--no-gpg-sign', + '-m', + 'x', + ); + + const identity = resolveRunIdentity(dir, {}); + + expect(identity?.source).toBe('cli'); + expect(identity?.commitSha).toMatch(/^[0-9a-f]{40}$/); + expect(identity?.branchName).toBe('trunk'); + }); + + test('a detached HEAD has no branch to report, so there is no identity', () => { + const dir = nonRepoDir(); + const git = (...args: string[]) => + execFileSync('git', args, { cwd: dir, stdio: ['ignore', 'pipe', 'ignore'] }); + git('init', '--initial-branch=trunk'); + git( + '-c', + 'user.email=t@example.com', + '-c', + 'user.name=t', + 'commit', + '--allow-empty', + '--no-gpg-sign', + '-m', + 'x', + ); + git('checkout', '--detach', 'HEAD'); + + expect(resolveRunIdentity(dir, {})).toBeUndefined(); + }); +}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/builds/api.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/builds/api.ts new file mode 100644 index 000000000..38e9596da --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/builds/api.ts @@ -0,0 +1,159 @@ +/** + * The Management API's build-reporting endpoints, over the generated SDK + * client — the same client every other call in this package uses. + * + * Nothing here throws. Reporting a build is observability, never a step of a + * deploy, so a failed call warns and the caller carries on with a value that + * says the report did not land. That is the whole reason these three calls + * are wrapped rather than made directly: one place decides what a failure + * costs, and the answer is always "nothing". + */ +import type { operations } from '@prisma/management-api-sdk'; +import type { ManagementApiClient } from '../client.ts'; + +/** + * Every shape below is DERIVED from the generated client, never restated. The + * platform owns this contract, and a hand-kept copy would drift silently the + * next time it moves — the whole reason to wait for the SDK rather than keep + * transcribing the routes. + */ +type JsonBody = O extends { requestBody?: { content: { 'application/json': infer B } } } + ? B + : never; + +/** + * What a reporter sends to create a build. `runIdentity` is what makes + * creation idempotent — two reporters watching the same run converge on one + * build instead of racing to create two — and it is also what resolves the + * build's repository, so a build reported without one is never linked to the + * repository it came from. + */ +export type CreateBuildBody = JsonBody; + +/** + * Progress on a build. `projectId`, `branchId`, `appId` and `deployedUrl` are + * fill-only: a reporter that resolves them partway through a deploy sets them + * here, re-sending a recorded value is accepted, and changing one is a 409. + */ +export type UpdateBuildBody = JsonBody; + +type ResourcePath = + operations['putV1BuildsByBuildIdResourcesByResourceTypeByResourceId']['parameters']['path']; + +export type BuildResourceType = ResourcePath['resourceType']; +export type BuildResourceAction = NonNullable< + JsonBody +>['action']; + +/** Reportable `BuildSource` — the platform rejects the three that name its own surfaces. */ +export type BuildSource = NonNullable['source']; +export type BuildPhase = NonNullable['phase']>; +export type BuildState = NonNullable['state']>; +export type BuildRunIdentity = NonNullable['runIdentity']>; + +export interface BuildsApi { + /** The created or joined build's id, or `undefined` when the report did not land. */ + create(body: CreateBuildBody): Promise; + update(buildId: string, body: UpdateBuildBody): Promise; + reportResource( + buildId: string, + resourceType: BuildResourceType, + resourceId: string, + action: BuildResourceAction, + ): Promise; +} + +export interface BuildsApiOptions { + readonly client: ManagementApiClient; + /** Where a failed report goes. Injected so tests can assert on it and callers can route it. */ + readonly warn: (message: string) => void; +} + +/** + * Reporting is observability, so no call may stall the deploy: every request + * carries this deadline, and an expired one is warned and dropped like any + * other failure. + */ +const REPORT_DEADLINE_MS = 10_000; + +/** What openapi-fetch hands back from every call. */ +interface ClientResult { + readonly data?: unknown; + readonly error?: unknown; + readonly response: Response; +} + +export function buildsApi(options: BuildsApiOptions): BuildsApi { + const { client, warn } = options; + + /** Runs one call, turning every failure — transport or refusal — into a warning and `undefined`. */ + const send = async ( + call: () => Promise, + describe: string, + ): Promise => { + let result: R; + try { + result = await call(); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + warn(`Could not reach Prisma Cloud to ${describe}: ${detail}`); + return undefined; + } + + if (!result.response.ok) { + // The platform's own error envelope. It never carries the token — that + // rides the header — so quoting it back is safe, and it is usually the + // only way to tell a rejected field from a missing permission. + const detail = result.error === undefined ? '' : `: ${JSON.stringify(result.error)}`; + warn(`Prisma Cloud refused to ${describe} (HTTP ${String(result.response.status)})${detail}`); + return undefined; + } + + // A 204 carries no body; `{}` still means the call landed. + return result.data ?? {}; + }; + + return { + async create(body) { + const created = await send( + () => client.POST('/v1/builds', { body, signal: AbortSignal.timeout(REPORT_DEADLINE_MS) }), + 'record this deploy', + ); + if (created === undefined) return undefined; + // Typed by the generated client, so this reads the id rather than + // hunting for it — but a 2xx with no body is still possible on the wire. + const id = created.data?.id; + if (id === undefined || id.length === 0) { + warn('Prisma Cloud recorded this deploy but returned no build id.'); + return undefined; + } + return id; + }, + + async update(id, body) { + const payload = await send( + () => + client.PATCH('/v1/builds/{buildId}', { + params: { path: { buildId: id } }, + body, + signal: AbortSignal.timeout(REPORT_DEADLINE_MS), + }), + "update this deploy's build", + ); + return payload !== undefined; + }, + + async reportResource(id, resourceType, resourceId, action) { + const payload = await send( + () => + client.PUT('/v1/builds/{buildId}/resources/{resourceType}/{resourceId}', { + params: { path: { buildId: id, resourceType, resourceId } }, + body: { action }, + signal: AbortSignal.timeout(REPORT_DEADLINE_MS), + }), + `record the ${resourceType} this deploy ${action === 'acted_on' ? 'acted on' : action}`, + ); + return payload !== undefined; + }, + }; +} diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/builds/reporter.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/builds/reporter.ts new file mode 100644 index 000000000..bedee97fc --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/builds/reporter.ts @@ -0,0 +1,240 @@ +/** + * Reports a deploy to Prisma Cloud as a `Build`, so the Console can show + * deploy history for apps the platform never built. + * + * The session opens before the CLI resolves containers, which is deliberate: + * creating them is the step that can leave a Project behind with nothing + * recording why (composer#103), and a session opened afterwards would miss + * the one failure it most needs to describe. + * + * Nothing here can fail a deploy. Every call goes through `BuildsApi`, which + * warns and returns rather than throwing, and the one place that could still + * throw — reading git — is wrapped. A deploy that converged is a deploy that + * succeeded, whatever the Console was told. + * + * This module installs NO signal handlers: the CLI engine is the sole signal + * owner (its detector test enforces it). Cancellation reaches this reporter + * as `RunOutcome.cancelled` on the ordinary `finish` path. + * + * Lives here rather than beside the extension that registers it because the + * extension package ships into runtime surfaces and may import no node + * builtin and read no environment (its invariants 4 and 5). Reading git and + * the deploy shell is exactly what this does, so it belongs on the lowering + * side; the extension keeps only the one thing this cannot know, which is how + * to read its own container. + */ +import type { + ContainerInstance, + DeployedEntity, + ReportAttachInput, + ReportBeginInput, + ReporterDescriptor, + RunOutcome, + RunReporter, +} from '@internal/core/config'; +import { createManagementApiClient } from '@prisma/management-api-sdk'; +import { MANAGEMENT_API_ORIGIN, type ManagementApiClient } from '../client.ts'; +import { type BuildsApi, buildsApi, type UpdateBuildBody } from './api.ts'; +import { BUILD_ID_ENV } from './resources.ts'; +import { resolveRunIdentity } from './run-identity.ts'; + +/** An empty string is how a shell spells "unset", so it must not be mistaken for a build id. */ +const nonEmpty = (value: string | undefined): string | undefined => + value !== undefined && value.length > 0 ? value : undefined; + +/** The Build row's project and branch references, read out of the reporting extension's own container. */ +export interface BuildContainerRefs { + readonly projectId: string; + readonly branchId: string | undefined; +} + +export interface BuildReporterOptions { + /** Narrows the extension's container to the project/branch references this build should carry. */ + readonly refsOf: (container: ContainerInstance) => BuildContainerRefs; + readonly origin?: string; + readonly env?: Readonly>; + readonly warn?: (message: string) => void; + /** Injected by tests; the real one talks to the Management API. */ + readonly api?: BuildsApi; +} + +export function buildReporter(options: BuildReporterOptions): ReporterDescriptor { + return { + // Typed against this platform's client; assigns into the erased + // `begin(ReportBeginInput)` through method bivariance. + // + // `begin` is documented never to throw, and this catch is what makes the + // claim true of THIS reporter rather than a favour the CLI does it: an + // injected api that rejects, or any other defect here, costs the session + // and one warning, never the deploy. + begin: async (input: ReportBeginInput) => { + try { + return await beginSession(input, options); + } catch (error) { + const warn = options.warn ?? ((message: string) => console.warn(message)); + warn( + `Could not start build reporting: ${error instanceof Error ? error.message : String(error)}`, + ); + return undefined; + } + }, + }; +} + +async function beginSession( + input: ReportBeginInput, + options: BuildReporterOptions, +): Promise { + const env = options.env ?? process.env; + const warn = options.warn ?? ((message: string) => console.warn(message)); + + // The caller's already-authenticated client, when the engine supplied one + // — "present means the extension must not build its own from the + // environment" (ContainerCredentials). The env token is the standalone + // fallback, for hosts driving `@prisma/composer/control` without an engine. + const injected = input.credentials?.client; + const token = env['PRISMA_SERVICE_TOKEN']; + if ( + options.api === undefined && + injected === undefined && + (token === undefined || token.length === 0) + ) { + // Not worth a line: a deploy without any credential fails immediately + // afterwards for a reason the operator will see. + return undefined; + } + + const identity = resolveRunIdentity(input.cwd, env); + if (identity === undefined) { + warn( + `\nNot recording this deploy in Prisma Cloud: ${input.cwd} has no commit and branch to ` + + 'report it under. Deploy from a git checkout to see it in the Console.', + ); + return undefined; + } + + // Its own client rather than the shared `ManagementClient` service: this + // runs in the CLI process, before any Effect layer is built, and needs + // nothing from one. + const api = + options.api ?? + buildsApi({ + client: + injected ?? + createManagementApiClient({ + token: token ?? '', + baseUrl: options.origin ?? MANAGEMENT_API_ORIGIN, + }), + warn, + }); + + // A build id from either channel means something upstream — the Prisma + // GitHub Action, or a workflow driving the CLI directly — already created + // the build and this run is one part of it. Joining means never re-creating + // it, and never overwriting what the creator knows better: its source, and + // the link to its own logs. + // + // `--build-id` beats PRISMA_BUILD_ID because it was passed deliberately: a + // runner that exports the variable for a whole job, then names a different + // build for one step, means the step. + const joined = nonEmpty(input.reportId) ?? nonEmpty(env[BUILD_ID_ENV]); + const buildId = + joined !== undefined + ? joined + : await api.create({ + source: identity.source, + commitSha: identity.commitSha, + branchName: identity.branchName, + ...(identity.runIdentity !== undefined ? { runIdentity: identity.runIdentity } : {}), + ...(identity.externalLogUrl !== undefined + ? { externalLogUrl: identity.externalLogUrl } + : {}), + }); + + if (buildId === undefined) return undefined; + + // `deploy`, always. Composer does not build the user's code — ADR-0005 + // leaves that to them — so `build` names a phase this tool never runs. + // Whoever did build reports that phase itself. + await api.update(buildId, { phase: 'deploy', state: 'running' }); + + return session(api, buildId, options.refsOf, warn); +} + +/** + * The app this run deployed and where it can be reached — but only when the + * run deployed exactly one compute service. + * + * `Build.appId` and `Build.deployedUrl` are each one value, and an app with + * several services has no single answer. Picking the first would put an + * arbitrary service's address in the Console and quietly imply it was the + * app's. Single-service apps are the common case and get a working link; + * multi-service apps get neither, and their services are all reported through + * the resources endpoint regardless. + * + * Both fields are fill-only, so this is safe to send on a build whose creator + * already set them to the same values, and a genuine disagreement is a 409 + * the caller logs. + */ +function deployedApp(entities: readonly DeployedEntity[]): UpdateBuildBody { + const services = entities.filter((entity) => entity.kind === 'compute-service'); + const only = services.length === 1 ? services[0] : undefined; + if (only === undefined) return {}; + return { + appId: only.id, + ...(only.url !== undefined ? { deployedUrl: only.url } : {}), + }; +} + +function session( + api: BuildsApi, + buildId: string, + refsOf: (container: ContainerInstance) => BuildContainerRefs, + warn: (message: string) => void, +): RunReporter { + let finished = false; + + return { + childEnv: () => ({ [BUILD_ID_ENV]: buildId }), + + /** + * Attaches the build to the Project and Branch this deploy resolved. + * Sent on its own rather than folded into the progress update above: + * the fields are fill-only, so keeping them in their own call means a + * 409 from a disagreeing creator costs these references alone. + */ + async attach(input: ReportAttachInput): Promise { + if (input.container === undefined) return; + const { projectId, branchId } = refsOf(input.container); + await api.update(buildId, { + projectId, + ...(branchId !== undefined ? { branchId } : {}), + }); + }, + + async finish(outcome: RunOutcome): Promise { + if (finished) return; + finished = true; + // `cancelled` is the user interrupting, not a deploy that went wrong — + // and a SIGKILLed run reports nothing at all, leaving the build + // permanently `running` (nothing sweeps builds, by platform design). + try { + await api.update(buildId, { + state: outcome.ok ? 'succeeded' : outcome.cancelled ? 'cancelled' : 'failed', + ...(outcome.failingStep !== undefined && !outcome.cancelled + ? { failingStep: outcome.failingStep } + : {}), + ...(outcome.errorMessage !== undefined && !outcome.cancelled + ? { errorMessage: outcome.errorMessage } + : {}), + ...deployedApp(outcome.entities), + }); + } catch (error) { + // Same contract as begin: finish never rejects, whatever the api does. + warn( + `Could not report this deploy's outcome: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }, + }; +} diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/builds/resources.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/builds/resources.ts new file mode 100644 index 000000000..6bdb254cd --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/builds/resources.ts @@ -0,0 +1,182 @@ +/** + * Which platform resources a deploy touched, reported as it touches them. + * + * The interception point is the state store rather than the deploy's report + * hook, because the report hook cannot see this. A descriptor's + * `DeployedEntity` vocabulary is three kinds wide (`postgres-database`, + * `bucket`, `compute-service`) against the platform's eight resource types, + * and the most valuable of those — `deployment`, which makes the platform + * attach the deployment to the build and record the app it went into — is + * not an entity at all. Every resource write passes through the state store + * whichever provider performed it, so that is where the full set is visible. + * + * Reporting as the run goes also survives a crash: a deploy that dies partway + * still leaves a record of what it had already created. + */ +import type { BuildResourceAction, BuildResourceType, BuildsApi } from './api.ts'; + +/** Names the build a run belongs to. Set by the CLI on the alchemy child, or by a CI runner that created the build itself. */ +export const BUILD_ID_ENV = 'PRISMA_BUILD_ID'; + +interface PlatformResource { + readonly type: BuildResourceType; + /** The attribute holding the platform's own id — `Deployment` does not call it `id`. */ + readonly idField: string; +} + +/** + * Alchemy resource type → platform resource type. + * + * Two of this package's resources are deliberately absent. `Prisma.BucketKey` + * has no platform resource type to map onto. `PrismaCloud.ServiceKey` is a + * value this deploy mints locally, not a platform resource at all — the + * platform's `service_key` is what `Prisma.Connection` creates. + * + * Anything not listed — `PgWarm`, and every resource another extension + * contributes — is not a Prisma Cloud resource and is not reported. + */ +const PLATFORM_RESOURCES: Readonly> = { + 'Prisma.Project': { type: 'project', idField: 'id' }, + 'Prisma.Database': { type: 'database', idField: 'id' }, + 'Prisma.Connection': { type: 'service_key', idField: 'id' }, + 'Prisma.Bucket': { type: 'bucket', idField: 'id' }, + 'Prisma.ComputeService': { type: 'app', idField: 'id' }, + 'Prisma.Deployment': { type: 'deployment', idField: 'deploymentId' }, + 'Prisma.EnvironmentVariable': { type: 'config_variable', idField: 'id' }, +}; + +/** + * Terminal status → what the run did to the resource. The intermediate + * statuses (`creating`, `updating`, `replacing`) are skipped: they say work + * started, not that it landed, and the terminal write follows immediately. + * + * `updated` maps to `acted_on` rather than to nothing, because a reconcile + * that changed no field is still this run acting on that resource — a + * migration against an untouched database is an action on it. + */ +const ACTION_BY_STATUS: Readonly> = { + created: 'created', + updated: 'acted_on', + deleting: 'deleted', +}; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +export interface ReportableResource { + readonly type: BuildResourceType; + readonly id: string; + readonly action: BuildResourceAction; +} + +/** + * What a persisted state record says this run did, or `undefined` when it + * says nothing reportable. Reads defensively: the record crosses a wire and + * carries resources from every extension, not only this one's. + */ +export function reportableResource(value: unknown): ReportableResource | undefined { + if (!isRecord(value)) return undefined; + // Tasks are persisted alongside resources under the same key space. + if (value['kind'] === 'action') return undefined; + + // An adopted resource existed before this run and this run only resolved + // it. Resolving is not acting, so it is not reported. Read off the record + // rather than off the type: the flag is declared on the mid-adoption state + // and persists until the first reconcile after it succeeds. + if (value['adopting'] === true) return undefined; + + const resourceType = value['resourceType']; + if (typeof resourceType !== 'string') return undefined; + const mapping = PLATFORM_RESOURCES[resourceType]; + if (mapping === undefined) return undefined; + + const status = value['status']; + const action = typeof status === 'string' ? ACTION_BY_STATUS[status] : undefined; + if (action === undefined) return undefined; + + const attr = value['attr']; + if (!isRecord(attr)) return undefined; + const id = attr[mapping.idField]; + if (typeof id !== 'string' || id.length === 0) return undefined; + + return { type: mapping.type, id, action }; +} + +/** + * Sends resource reports without making the deploy wait for them, and lets + * the caller wait once at the end. + * + * Awaiting each report inline would add a round trip per resource to an + * apply that already talks to the same API for the resource itself. Firing + * without ever waiting would let the process exit with reports in flight. + * So: fire immediately, keep the promises, and drain them from the state + * layer's finalizer. + */ +export interface ResourceReporter { + /** Called for every state write. Cheap and synchronous — it starts a request at most, never waits for one. */ + observe(value: unknown): void; + /** Waits for every report already started. Never rejects. */ + drain(): Promise; +} + +/** + * The most a drain will wait, total. Every real report already carries the + * api layer's per-request deadline, so this never fires for the shipped + * `BuildsApi`; it is the backstop that keeps the state layer's finalizer — + * and therefore the deploy lease release — bounded against any implementation. + */ +const DRAIN_DEADLINE_MS = 15_000; + +export function resourceReporter( + api: BuildsApi, + buildId: string, + warn: (message: string) => void = (message) => console.warn(message), + drainDeadlineMs: number = DRAIN_DEADLINE_MS, +): ResourceReporter { + const inFlight = new Set>(); + // A resource is written more than once per run (creating, then created). + // The API's upsert makes a repeat harmless, but there is no reason to + // spend the round trip. + const reported = new Set(); + + return { + observe(value) { + const resource = reportableResource(value); + if (resource === undefined) return; + + const key = `${resource.type}:${resource.id}:${resource.action}`; + if (reported.has(key)) return; + reported.add(key); + + const sent = api + .reportResource(buildId, resource.type, resource.id, resource.action) + .finally(() => inFlight.delete(sent)); + inFlight.add(sent); + }, + + async drain() { + const deadline = Date.now() + drainDeadlineMs; + // Reports started while draining join the same wait — a state write can + // land between the snapshot and the await. + while (inFlight.size > 0) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + warn( + `Abandoned ${inFlight.size} in-flight resource report(s) after ${drainDeadlineMs}ms.`, + ); + return; + } + await Promise.race([ + Promise.allSettled([...inFlight]), + new Promise((resolve) => setTimeout(resolve, remaining).unref?.()), + ]); + if (Date.now() >= deadline && inFlight.size > 0) { + warn( + `Abandoned ${inFlight.size} in-flight resource report(s) after ${drainDeadlineMs}ms.`, + ); + return; + } + } + }, + }; +} diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/builds/run-identity.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/builds/run-identity.ts new file mode 100644 index 000000000..b3e758f98 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/builds/run-identity.ts @@ -0,0 +1,115 @@ +/** + * Who is deploying, and from what commit — the identity a build is reported + * under. + * + * `commitSha` and `branchName` are required by the platform, and Composer has + * no other reason to read git, so this is the only place it does. A deploy + * from a directory that is not a git checkout has neither, and is reported + * not at all rather than reported with placeholder values: the Console keeps + * whatever it is told, and "unknown" would sit in a workspace's deploy + * history permanently. + */ +import { execFileSync } from 'node:child_process'; +import type { BuildRunIdentity, BuildSource } from './api.ts'; + +export interface RunIdentity { + readonly source: BuildSource; + readonly commitSha: string; + readonly branchName: string; + /** Present only in a CI run this can name; what makes creation idempotent and links the build to its repository. */ + readonly runIdentity: BuildRunIdentity | undefined; + /** Where the run's own logs live, in the system that ran it. */ + readonly externalLogUrl: string | undefined; +} + +/** Environment this reads, narrowed to what it uses. */ +export type RunEnvironment = Readonly>; + +const DIGITS = /^[0-9]+$/; + +const nonEmpty = (value: string | undefined): string | undefined => + value !== undefined && value.length > 0 ? value : undefined; + +function git(args: readonly string[], cwd: string): string | undefined { + try { + const out = execFileSync('git', [...args], { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5_000, + }); + return nonEmpty(out.trim()); + } catch { + // Not a checkout, no git on PATH, or a repository with no commits yet. + return undefined; + } +} + +/** + * The GitHub Actions run this is executing inside, when all three parts are + * present and are the digits the platform's dedup key requires. Partial or + * malformed input yields nothing rather than half an identity: the key joins + * its parts with `:`, so a part that is not digits could let two different + * runs spell the same key. + */ +function githubRunIdentity(env: RunEnvironment): BuildRunIdentity | undefined { + if (env['GITHUB_ACTIONS'] !== 'true') return undefined; + + const repositoryId = nonEmpty(env['GITHUB_REPOSITORY_ID']); + const runId = nonEmpty(env['GITHUB_RUN_ID']); + const attempt = nonEmpty(env['GITHUB_RUN_ATTEMPT']) ?? '1'; + + if (repositoryId === undefined || !DIGITS.test(repositoryId)) return undefined; + if (runId === undefined || !DIGITS.test(runId)) return undefined; + if (!DIGITS.test(attempt)) return undefined; + + const runAttempt = Number.parseInt(attempt, 10); + if (!Number.isInteger(runAttempt) || runAttempt < 1) return undefined; + + return { provider: 'github', repositoryId, runId, runAttempt }; +} + +function githubRunUrl(env: RunEnvironment): string | undefined { + const server = nonEmpty(env['GITHUB_SERVER_URL']) ?? 'https://github.com'; + const repository = nonEmpty(env['GITHUB_REPOSITORY']); + const runId = nonEmpty(env['GITHUB_RUN_ID']); + if (repository === undefined || runId === undefined) return undefined; + const attempt = nonEmpty(env['GITHUB_RUN_ATTEMPT']) ?? '1'; + return `${server}/${repository}/actions/runs/${runId}/attempts/${attempt}`; +} + +/** + * The branch this ran on. Inside a pull-request workflow `GITHUB_REF_NAME` is + * the synthetic merge ref (`123/merge`), so the head branch is preferred — + * it is the name a person would recognise in the Console. + */ +function branchName(env: RunEnvironment, cwd: string): string | undefined { + const fromEnv = nonEmpty(env['GITHUB_HEAD_REF']) ?? nonEmpty(env['GITHUB_REF_NAME']); + if (fromEnv !== undefined) return fromEnv; + const head = git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd); + // A detached HEAD reports "HEAD", which names no branch. + return head === 'HEAD' ? undefined : head; +} + +/** + * The identity to report this run under, or `undefined` when there is not + * enough to report one honestly. + */ +export function resolveRunIdentity(cwd: string, env: RunEnvironment): RunIdentity | undefined { + const commitSha = nonEmpty(env['GITHUB_SHA']) ?? git(['rev-parse', 'HEAD'], cwd); + const branch = branchName(env, cwd); + if (commitSha === undefined || branch === undefined) return undefined; + + const runIdentity = githubRunIdentity(env); + return { + // `ci` whenever the run can be named, `cli` otherwise. A named run is + // what buys idempotency across retries and links the build to its + // repository, so claiming `ci` without one would describe the run less + // accurately, not more. + source: runIdentity === undefined ? 'cli' : 'ci', + commitSha, + branchName: branch, + runIdentity, + externalLogUrl: runIdentity === undefined ? undefined : githubRunUrl(env), + }; +} diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/builds/state-store.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/builds/state-store.ts new file mode 100644 index 000000000..61fab79c0 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/builds/state-store.ts @@ -0,0 +1,44 @@ +/** + * Wraps a state store so every resource it records is also reported to the + * build this run belongs to. The store's behaviour is unchanged: reporting + * hangs off a successful write and can only ever add a request. + */ +import type { PersistedState, StateService } from 'alchemy/State'; +import * as Effect from 'effect/Effect'; +import type { BuildsApi } from './api.ts'; +import { type ResourceReporter, resourceReporter } from './resources.ts'; + +export interface ReportingStateStore { + readonly store: StateService; + readonly reporter: ResourceReporter; +} + +/** + * Reports on the way out of a successful `set`, never before it: a write that + * failed leaves the resource unrecorded here, and claiming otherwise would + * make the build's provenance a guess. + */ +export function withResourceReporting( + inner: StateService, + api: BuildsApi, + buildId: string, + warn?: (message: string) => void, +): ReportingStateStore { + const reporter = resourceReporter(api, buildId, warn); + + const store: StateService = { + ...inner, + set(request: { + stack: string; + stage: string; + fqn: string; + value: V; + }) { + return inner + .set(request) + .pipe(Effect.tap(() => Effect.sync(() => reporter.observe(request.value)))); + }, + }; + + return { store, reporter }; +} diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/exports/builds.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/builds.ts new file mode 100644 index 000000000..935705119 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/builds.ts @@ -0,0 +1,20 @@ +/** Build reporting: the Management API's build endpoints, and the resource mapping the state store reports through. */ +export type { + BuildPhase, + BuildResourceAction, + BuildResourceType, + BuildRunIdentity, + BuildSource, + BuildState, + BuildsApi, + BuildsApiOptions, + CreateBuildBody, + UpdateBuildBody, +} from '../builds/api.ts'; +export { buildsApi } from '../builds/api.ts'; +export type { BuildContainerRefs, BuildReporterOptions } from '../builds/reporter.ts'; +export { buildReporter } from '../builds/reporter.ts'; +export type { ReportableResource, ResourceReporter } from '../builds/resources.ts'; +export { BUILD_ID_ENV, reportableResource, resourceReporter } from '../builds/resources.ts'; +export type { RunIdentity } from '../builds/run-identity.ts'; +export { resolveRunIdentity } from '../builds/run-identity.ts'; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/exports/index.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/index.ts index 8d401324a..0118c6f54 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/exports/index.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/index.ts @@ -6,6 +6,7 @@ */ export { layer as managementClientLayer, + MANAGEMENT_API_ORIGIN, type ManagementApiClient, ManagementClient, } from '../client.ts'; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts index ef4188209..5e5f945e0 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts @@ -5,6 +5,9 @@ import * as Layer from 'effect/Layer'; import * as Redacted from 'effect/Redacted'; import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient'; import * as HttpClientRequest from 'effect/unstable/http/HttpClientRequest'; +import { buildsApi } from '../builds/api.ts'; +import { BUILD_ID_ENV } from '../builds/resources.ts'; +import { withResourceReporting } from '../builds/state-store.ts'; import * as client from '../client.ts'; import { resolveDefaultBranchId } from '../container.ts'; import * as credentials from '../credentials.ts'; @@ -114,7 +117,33 @@ export const stateLayerAgainst = ( id: 'prisma-postgres', }).pipe(Effect.provide(FetchHttpClient.layer)); - return Effect.succeed(service); + // Report what this run touches to the build it belongs to, when there + // is one. There is none when nothing created a build — a direct + // `alchemy deploy` of the generated stack file, which runs with no CLI + // parent — and reporting resources against no build is impossible, so + // the store is used unwrapped and the deploy is unaffected. + const buildId = process.env[BUILD_ID_ENV]; + if (buildId === undefined || buildId.length === 0) { + return Effect.succeed(service); + } + + const { store, reporter } = withResourceReporting( + service, + buildsApi({ + // The same client the lease and the scope probe already use. + client: mgmt, + warn: (message) => { + console.warn(message); + }, + }), + buildId, + ); + // Before the lease is released, so the run is still the stage's owner + // while its last reports land. Never fails: a report that did not + // arrive must not become a deploy that did not finish. + yield* Effect.addFinalizer(() => Effect.promise(() => reporter.drain())); + + return Effect.succeed(store); }).pipe(Effect.provide(dependencies)), ).pipe(Layer.orDie, Layer.merge(redactLeaseHeader)); }; 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 c681a314d..a2095f4a9 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', buckets: 'src/exports/buckets.ts', + builds: 'src/exports/builds.ts', compute: 'src/exports/compute.ts', postgres: 'src/exports/postgres.ts', state: 'src/exports/state.ts', diff --git a/packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts b/packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts index a6724aab9..b2960f972 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts @@ -36,6 +36,7 @@ import { PgWarmProvider } from '../pg-warm-resource.ts'; import { PnMigrationProvider } from '../pn-migration-resource.ts'; import { type PrismaCloudPreflightInput, runPreflight } from '../preflight.ts'; import { RESERVED_PROVIDER_PARAMS } from '../provider-params.ts'; +import { prismaCloudReporter } from '../reporting/reporter.ts'; import { S3CredentialsProvider } from '../s3-credentials-resource.ts'; import type { ProviderParamEntry } from '../serializer.ts'; import { STREAMS_API_KEY } from '../streams-keys.ts'; @@ -346,6 +347,11 @@ export const prismaCloud = (opts: PrismaCloudOptions = {}): ExtensionDescriptor // arrives as `unknown` and the call below stops compiling. preflight: (input: PrismaCloudPreflightInput) => runPreflight(input), + // Records the deploy as a Build so it appears in the Console, and passes + // the build's id into the apply so the state store can report what the + // run touched. Deploy only — the CLI does not run this for destroy. + reporter: prismaCloudReporter(), + // No teardown: deploy state lives behind the platform state API, scoped // to the stage's Branch — deleting the Branch/Project deletes it // platform-side. diff --git a/packages/1-prisma-cloud/1-extensions/target/src/reporting/reporter.ts b/packages/1-prisma-cloud/1-extensions/target/src/reporting/reporter.ts new file mode 100644 index 000000000..a5ce41e9f --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/reporting/reporter.ts @@ -0,0 +1,19 @@ +/** + * This extension's deploy-run reporter: the session itself lives in + * `@internal/lowering/builds`, which may read git and the deploy shell — + * this package may do neither (invariants 4 and 5). All that is left here is + * the one thing the lowering side cannot know: how to read this extension's + * own container. + */ +import type { ReporterDescriptor } from '@internal/core/config'; +import * as Builds from '@internal/lowering/builds'; +import { prismaCloudContainerOf } from '../container.ts'; + +export function prismaCloudReporter(): ReporterDescriptor { + return Builds.buildReporter({ + refsOf: (container) => { + const { projectId, branchId } = prismaCloudContainerOf(container); + return { projectId, branchId }; + }, + }); +} diff --git a/packages/9-public/composer-prisma-cloud/package.json b/packages/9-public/composer-prisma-cloud/package.json index 1354f4173..5ca04157d 100644 --- a/packages/9-public/composer-prisma-cloud/package.json +++ b/packages/9-public/composer-prisma-cloud/package.json @@ -43,7 +43,7 @@ "@effect/platform-node": "4.0.0-beta.103", "@effect/platform-node-shared": "4.0.0-beta.103", "@prisma/composer": "workspace:0.6.0", - "@prisma/management-api-sdk": "^1.57.0", + "@prisma/management-api-sdk": "^1.60.0", "@prisma/orm-toolchain": "8.0.0-rc.1", "@standard-schema/spec": "^1.1.0", "alchemy": "2.0.0-beta.67", diff --git a/packages/9-public/composer/package.json b/packages/9-public/composer/package.json index 4f31e40f8..86eda68bf 100644 --- a/packages/9-public/composer/package.json +++ b/packages/9-public/composer/package.json @@ -37,7 +37,7 @@ "c12": "^3.3.4", "effect": "4.0.0-beta.103", "esbuild": "^0.28.1", - "@prisma/management-api-sdk": "^1.57.0" + "@prisma/management-api-sdk": "^1.60.0" }, "devDependencies": { "@effect/vitest": "4.0.0-beta.103", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 730de1918..eb8d2d675 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -837,8 +837,8 @@ importers: specifier: workspace:0.6.0 version: link:../../../0-framework/0-foundation/foundation '@prisma/management-api-sdk': - specifier: ^1.57.0 - version: 1.57.0 + specifier: ^1.60.0 + version: 1.60.0 alchemy: specifier: 2.0.0-beta.67 version: 2.0.0-beta.67(patch_hash=b4f53c9c951c28d72ce7373ac88b38bd85718e114e5001fa7b2a8f7f869d6fac)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) @@ -1171,8 +1171,8 @@ importers: packages/9-public/composer: dependencies: '@prisma/management-api-sdk': - specifier: ^1.57.0 - version: 1.57.0 + specifier: ^1.60.0 + version: 1.60.0 '@standard-schema/spec': specifier: ^1.1.0 version: 1.1.0 @@ -1284,8 +1284,8 @@ importers: specifier: workspace:0.6.0 version: link:../composer '@prisma/management-api-sdk': - specifier: ^1.57.0 - version: 1.57.0 + specifier: ^1.60.0 + version: 1.60.0 '@prisma/orm-toolchain': specifier: 8.0.0-rc.1 version: 8.0.0-rc.1(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)) @@ -2514,8 +2514,8 @@ packages: '@prisma/management-api-sdk@1.55.0': resolution: {integrity: sha512-WuDDOhxOHfROGY7QAU6wtlbWR0diNwfsQw1epQkhalvgOUe/JzIjxqpBpQzMA54zFVlMmcnT6OEiQfwCIKiRjA==} - '@prisma/management-api-sdk@1.57.0': - resolution: {integrity: sha512-NrTnL41BKj1XEs0sLsSUuxG6t4n1Qh1JldFnNpeLgTdTasfXYech4u8bCcfInxKkkPvFwDM759wpb9cSySK1Fw==} + '@prisma/management-api-sdk@1.60.0': + resolution: {integrity: sha512-hJZdr1NIF4uoc/WjYs2cyHmR/GkykY47uHMGjpXN2o1syXBNJNZoudH8yIagrWK/6adGRCbvgjdTo6q1y86zYg==} '@prisma/orm-family-sql@8.0.0-rc.1': resolution: {integrity: sha512-uVku752/K0DWOZrg0X65hDZXYj38k9ZOd0HNAn3HrmMIa0dajiF9zAC1spxBtkXnpc2smVTwPPsPfPY0EwhaFQ==} @@ -6658,7 +6658,7 @@ snapshots: dependencies: openapi-fetch: 0.14.0 - '@prisma/management-api-sdk@1.57.0': + '@prisma/management-api-sdk@1.60.0': dependencies: openapi-fetch: 0.14.0 diff --git a/tsconfig.depcruise.json b/tsconfig.depcruise.json index 734117e21..22bb81afc 100644 --- a/tsconfig.depcruise.json +++ b/tsconfig.depcruise.json @@ -59,6 +59,9 @@ "@internal/lowering/compute": [ "./packages/1-prisma-cloud/0-lowering/lowering/src/exports/compute.ts" ], + "@internal/lowering/builds": [ + "./packages/1-prisma-cloud/0-lowering/lowering/src/exports/builds.ts" + ], "@internal/lowering/state": [ "./packages/1-prisma-cloud/0-lowering/lowering/src/exports/state.ts" ],