diff --git a/docs/design/05-prisma-cloud/alchemy-lowering.md b/docs/design/05-prisma-cloud/alchemy-lowering.md index 6d5321f6..6804e321 100644 --- a/docs/design/05-prisma-cloud/alchemy-lowering.md +++ b/docs/design/05-prisma-cloud/alchemy-lowering.md @@ -166,10 +166,10 @@ flowchart TB DBs[(Database storefront-db)] --> Cs[Connection] -- url --> EVs["EnvironmentVariable(STOREFRONT_DB_URL)"] Sa[App auth] --> Da[Deployment_a] Ss[App storefront] --> Ds[Deployment_s] - EVa -- id ref --> Da - EVs -- id ref --> Ds + EVa -- resource ref --> Da + EVs -- resource ref --> Ds Da -- appEndpointDomain --> EVu["EnvironmentVariable(STOREFRONT_AUTH_URL)"] - EVu -- id ref --> Ds + EVu -- resource ref --> Ds end ``` @@ -181,7 +181,7 @@ How the pieces map: `Database → Connection`, whose url is written as that service's **explicitly named** variable — the same `serialize` path as any other config value. - **The connection** lowers to two edges: the producer's endpoint domain flows - into a named `EnvironmentVariable`, and that variable's **id flows into the + into a named `EnvironmentVariable`, and that **whole resource flows into the consumer's `Deployment`** through its `app` prop. - Every `EnvironmentVariable` a Deployment boots with is threaded into its `app` — database URLs and connection URLs alike — so the deployment @@ -196,14 +196,17 @@ deployment-create call literally contains the materialized env map, so the environment is genuinely an input to a deployment (see the [config lifecycle](pdp-data-model.md#the-config-lifecycle--what-is-resolved-when)). The edge's job is **ordering**: the variable write completes before -deployment-create, so the first deployment boots with a complete environment. +deployment-create, so new and replacement deployments boot with the completed +configuration updates. Without it the two race — the failure documented as PRO-211 in `gotchas.md`. **Why the edge rides `app`.** Upstream's `Prisma.Deployment` has no `environment` prop (Composer's deleted one did). Alchemy derives its dependency graph from the resource references a prop's *value* is built from, so the -descriptor builds `app` as an Output over the app id AND every variable's id, -resolving to the app id itself: the graph gains the edges. It cannot ride +descriptor builds `app` as an Output over the app id AND every whole variable +resource, resolving to the app id itself: the graph gains the edges. A persisted +variable ID can resolve before its pending value update finishes; the whole +resource keeps that update as a dependency. The edge cannot ride `artifactPath` (or any of upstream's other replacement-block props): the diff reads that block as one unit and gives no opinion the moment any member is unresolved — and a brand-new variable's reference IS unresolved at plan time — diff --git a/docs/design/90-decisions/ADR-0048-prisma-cloud-resources-come-from-the-upstream-alchemy-provider.md b/docs/design/90-decisions/ADR-0048-prisma-cloud-resources-come-from-the-upstream-alchemy-provider.md index 851596d9..846b80d5 100644 --- a/docs/design/90-decisions/ADR-0048-prisma-cloud-resources-come-from-the-upstream-alchemy-provider.md +++ b/docs/design/90-decisions/ADR-0048-prisma-cloud-resources-come-from-the-upstream-alchemy-provider.md @@ -52,7 +52,7 @@ What the trio costs: `Compute`'s preview/stable health checks and automatic roll ## The ordering edge rides the `app` prop -Environment rows must be written before the deployment is created, because the platform snapshots the branch environment into a deployment at create. Upstream's `Deployment` has no prop for that dependency, so Composer builds the edge into the `app` prop: an Output over the app id *and* every environment row's id, resolving to the app id (`lowering/src/compute/deployment-edge.ts`). Alchemy derives its graph from the resource references inside prop values, so every row is scheduled first. +Environment rows must be written before the deployment is created, because the platform snapshots the branch environment into a deployment at create. Upstream's `Deployment` has no prop for that dependency, so Composer builds the edge into the `app` prop: an Output over the app id *and* every whole environment resource, resolving to the app id (`lowering/src/compute/deployment-edge.ts`). A persisted variable ID is stable and can resolve while its value update is still pending; depending on that ID loses the ordering edge. Whole-resource references preserve the dependency on completion of the writes, including updates to existing input documents. Alchemy derives its graph from those resource references inside prop values, so every row is scheduled first. The edge must not ride `artifactPath`. Upstream's diff reads `{portMapping, skipCodeUpload, artifactPath, artifactContentType}` as one block and offers no opinion when any member is unresolved — and a brand-new environment row is always unresolved at plan time. The consequence of getting this wrong is severe and quiet: the artifact comparison never runs, the engine falls back to a plain update, and the reconcile keeps the running deployment while recording the new artifact's fingerprint as deployed — a code change silently never ships, and every later deploy agrees it already did. The `app` prop sits outside that block and tolerates being unresolved. `compute/__tests__/deployment-edge.test.ts` drives upstream's real diff and real Output machinery and fails if the edge ever moves back. diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index ce58a457..76d920f8 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -63,6 +63,11 @@ Re-deploying any environment is idempotent — it updates the resources in place. A stage name must be a valid git ref name (`git check-ref-format`); an invalid name is a hard error, never a silent rename. +Compute deployments capture their environment when they are created. Composer +waits for its environment-variable updates to finish before creating a +deployment, including updates to an existing input document. Later variable +updates do not change an already-created deployment's environment. + After a deploy, each service is a Compute service in the Project; its public URL is its service endpoint domain — printed when the deploy finishes, and also shown in the Console. diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deployment-environment-lifecycle.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deployment-environment-lifecycle.test.ts new file mode 100644 index 00000000..c01fa692 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deployment-environment-lifecycle.test.ts @@ -0,0 +1,235 @@ +import { expect, test } from 'bun:test'; +import * as Output from 'alchemy/Output'; +import * as Prisma from 'alchemy/Prisma'; +import * as Provider from 'alchemy/Provider'; +import { inMemoryState } from 'alchemy/State/InMemoryState'; +import * as Core from 'alchemy/Test/Core'; +import * as Deferred from 'effect/Deferred'; +import * as Effect from 'effect/Effect'; +import * as Exit from 'effect/Exit'; +import * as Fiber from 'effect/Fiber'; +import * as Layer from 'effect/Layer'; +import * as Redacted from 'effect/Redacted'; +import { appAfterEnvironment } from '../deployment-edge.ts'; + +const scenarios: { + name: string; + input: string; + addVariable?: boolean; + repairDrift?: boolean; + failUpdate?: boolean; +}[] = [ + { name: 'changed existing input', input: '{"required":"new"}' }, + { name: 'new required field', input: '{"required":"old","added":"new"}' }, + { name: 'unchanged input repaired after drift', input: '{"required":"old"}', repairDrift: true }, + { name: 'changed, unchanged, and new variables', input: '{"required":"new"}', addVariable: true }, + { name: 'failed input update', input: '{"required":"new"}', failUpdate: true }, +]; + +test.each(scenarios)( + 'deployment snapshots completed environment writes: $name', + async ({ input, addVariable = false, repairDrift = false, failUpdate = false }) => { + const variables = new Map(); + const values = new Map(); + const deployments = new Map(); + const snapshots: Record[] = []; + const events: string[] = []; + const updateStarted = Deferred.makeUnsafe(); + const releaseUpdate = Deferred.makeUnsafe(); + let generation = 0; + + const client = { + listEnvironmentVariables: ({ key } = {}) => + Effect.sync(() => + [...variables.values()].filter((variable) => !key || variable.key === key), + ), + getEnvironmentVariable: (id) => Effect.sync(() => variables.get(id)!), + createEnvironmentVariable: (props) => + Effect.sync(() => { + const variable: Prisma.Types.EnvironmentVariable = { + id: `var-${props.key}`, + type: 'environment-variable', + url: `https://api.example.test/environment-variables/${props.key}`, + projectId: props.projectId, + branchId: props.branchId ?? null, + class: props.class, + key: props.key, + valueKid: 'test-key', + isManagedBySystem: false, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + variables.set(variable.id, variable); + values.set(variable.key, props.value); + events.push(`write:${variable.key}`); + return variable; + }), + updateEnvironmentVariable: (id, props) => + Effect.gen(function* () { + const variable = variables.get(id)!; + if (variable.key === 'COMPOSER_INPUT') { + events.push('input-update-started'); + yield* Deferred.succeed(updateStarted, undefined); + yield* Deferred.await(releaseUpdate); + if (failUpdate) { + events.push('input-update-failed'); + return yield* Effect.die(new Error('input update failed')); + } + } + const updated = { ...variable, updatedAt: '2026-01-02T00:00:00.000Z' }; + variables.set(id, updated); + values.set(variable.key, props.value); + events.push(`write:${variable.key}`); + return updated; + }), + listAppDeployments: () => Effect.sync(() => [...deployments.values()]), + getDeployment: (id) => Effect.sync(() => deployments.get(id)!), + createAppDeployment: () => + Effect.sync(() => { + const id = `deployment-${++generation}`; + const deployment: Prisma.Types.Deployment = { + id, + type: 'deployment', + url: `https://api.example.test/deployments/${id}`, + foundryVersionId: `version-${generation}`, + status: 'new', + previewDomain: null, + createdAt: '2026-01-01T00:00:00.000Z', + }; + deployments.set(id, deployment); + snapshots.push(Object.fromEntries(values)); + events.push('deployment-snapshot'); + return { ...deployment, uploadUrl: null }; + }), + deleteDeployment: (id) => + Effect.sync(() => { + events.push(`delete:${id}`); + deployments.delete(id); + }), + } satisfies Pick< + Prisma.PrismaManagementClient, + | 'listEnvironmentVariables' + | 'getEnvironmentVariable' + | 'createEnvironmentVariable' + | 'updateEnvironmentVariable' + | 'listAppDeployments' + | 'getDeployment' + | 'createAppDeployment' + | 'deleteDeployment' + >; + + const providers = Layer.effect( + Prisma.Providers, + Provider.collection([Prisma.EnvironmentVariable, Prisma.Deployment]), + ).pipe( + Layer.provideMerge( + Layer.mergeAll(Prisma.EnvironmentVariableProvider(), Prisma.DeploymentProvider()), + ), + Layer.provide( + Layer.succeed(Prisma.PrismaClient, client as unknown as Prisma.PrismaManagementClient), + ), + ); + const options = { providers, state: inMemoryState(), dev: false }; + const stack = Core.scratchStack(options, 'deployment-input-order'); + + const program = (inputValue: string, release: number, includeNewVariable: boolean) => + Effect.gen(function* () { + const desired = { + COMPOSER_INPUT: inputValue, + UNCHANGED: 'keep-me', + ...(includeNewVariable ? { NEW_VARIABLE: 'new-value' } : {}), + }; + const environment: Prisma.EnvironmentVariable[] = []; + for (const [key, value] of Object.entries(desired)) { + environment.push( + yield* Prisma.EnvironmentVariable(key, { + project: 'project-1', + class: 'preview', + branchId: 'branch-1', + key, + value: Redacted.make(value), + }), + ); + } + const deployment = yield* Prisma.Deployment('deployment', { + app: appAfterEnvironment(Output.asOutput('app-1'), environment), + skipCodeUpload: true, + start: false, + // Environment values alone must replace the deployment. The drift case + // models a separate replacement reason while the desired input is unchanged. + triggers: { + ...Object.fromEntries( + Object.entries(desired).map(([key, value]) => [key, Redacted.make(value)]), + ), + ...(repairDrift ? { release } : {}), + }, + }); + return { deployment }; + }); + + await Core.run( + Effect.gen(function* () { + yield* stack.deploy(program('{"required":"old"}', 1, false)); + if (repairDrift) values.set('COMPOSER_INPUT', 'out-of-band-drift'); + events.length = 0; + + const plan = yield* stack.plan(program(input, 2, addVariable)); + const deploymentPlan = plan.resources['deployment']!; + expect(deploymentPlan.action).toBe('replace'); + if (deploymentPlan.action !== 'replace') throw new Error('Expected deployment replacement'); + expect(plan.resources['COMPOSER_INPUT']!.action).toBe('update'); + expect(plan.resources['UNCHANGED']!.action).toBe('update'); + if (addVariable) expect(plan.resources['NEW_VARIABLE']!.action).toBe('create'); + + const deploymentFiber = yield* Effect.forkChild( + stack.deploy(program(input, 2, addVariable)), + ); + yield* Deferred.await(updateStarted); + + expect(snapshots).toHaveLength(1); + expect(deployments.has('deployment-1')).toBe(true); + + yield* Deferred.succeed(releaseUpdate, undefined); + const result = yield* Effect.exit(Fiber.join(deploymentFiber)); + + if (failUpdate) { + expect(Exit.isFailure(result)).toBe(true); + expect(events).toContain('input-update-failed'); + expect(snapshots).toHaveLength(1); + expect([...deployments.keys()]).toEqual(['deployment-1']); + expect(events.some((event) => event.startsWith('delete:'))).toBe(false); + return; + } + + expect(Exit.isSuccess(result)).toBe(true); + expect(snapshots).toHaveLength(2); + + expect(snapshots[1]).toEqual({ + COMPOSER_INPUT: input, + UNCHANGED: 'keep-me', + ...(addVariable ? { NEW_VARIABLE: 'new-value' } : {}), + }); + expect(Object.keys(Output.upstreamAny(deploymentPlan.props)).sort()).toEqual( + ['COMPOSER_INPUT', 'UNCHANGED', ...(addVariable ? ['NEW_VARIABLE'] : [])].sort(), + ); + expect(events.indexOf('input-update-started')).toBeLessThan( + events.indexOf('write:COMPOSER_INPUT'), + ); + for (const key of [ + 'COMPOSER_INPUT', + 'UNCHANGED', + ...(addVariable ? ['NEW_VARIABLE'] : []), + ]) { + expect(events).toContain(`write:${key}`); + expect(events.indexOf(`write:${key}`)).toBeLessThan( + events.indexOf('deployment-snapshot'), + ); + } + + yield* stack.deploy(program(input, 2, addVariable)); + expect(snapshots).toHaveLength(2); + }), + options, + ); + }, +); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/deployment-edge.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/deployment-edge.ts index 058408a6..3311cb9f 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/deployment-edge.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/deployment-edge.ts @@ -3,7 +3,8 @@ * materializes rows into a deployment at create time and never re-reads them * (PRO-211). Alchemy schedules only on resource references inside prop * values, and upstream's `Prisma.Deployment` has no environment prop, so the - * edge rides `app`: every variable's id threads through it, and the platform + * edge rides `app`: whole variable resources preserve their update edges; + * stable variable IDs alone are folded away during planning. The platform * still receives the app id. `app` is the ONLY safe prop — upstream's diff * treats `{portMapping, skipCodeUpload, artifactPath, artifactContentType}` * as one block and returns "no opinion" if any is unresolved (a brand-new @@ -26,6 +27,6 @@ export const appAfterEnvironment = ( // built FROM, never inside the function, so an app referenced only by // the closure would leave the deployment with no edge to its own app. Output.flatMap( - Output.all(app, ...environment.map((variable) => variable.environmentVariableId)), + Output.all(app, ...environment.map((variable) => Output.of(variable))), () => app, ); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-resources.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-resources.test.ts index 004b07bd..643b11b3 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-resources.test.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-resources.test.ts @@ -28,6 +28,7 @@ import { type StateService, } from 'alchemy/State'; import { PlatformServices } from 'alchemy/Util/PlatformServices'; +import * as ConfigProvider from 'effect/ConfigProvider'; import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; import * as Redacted from 'effect/Redacted'; @@ -35,8 +36,6 @@ import { stateLayerAgainst } from '../layer.ts'; import { migrateLegacyResourceState } from '../legacy-resources.ts'; import { FakeStateApi } from './fake-state-api.ts'; -process.env['PRISMA_SERVICE_TOKEN'] ??= 'test-service-token'; - const DIRECT_URL = 'postgres://user:pass@db.prisma.io:5432/postgres'; const legacyDatabaseRow = (): CreatedResourceState => ({ @@ -1034,7 +1033,14 @@ describe('state round-trip of legacy rows through the hosted state layer', () => Effect.gen(function* () { const service = yield* yield* State; return yield* use(service).pipe(Effect.orDie); - }).pipe(Effect.provide(layer)) as Effect.Effect, + }).pipe( + Effect.provide(layer), + // Do not depend on when another test first snapshots the process environment. + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnvRecord({ PRISMA_SERVICE_TOKEN: 'test-service-token' }), + ), + ) as Effect.Effect, ); }; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/state-api.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/state-api.test.ts index 5235f63e..7801ef8f 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/state-api.test.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/state-api.test.ts @@ -8,6 +8,7 @@ import { State, type StateService, } from 'alchemy/State'; +import * as ConfigProvider from 'effect/ConfigProvider'; import * as Effect from 'effect/Effect'; import * as Fiber from 'effect/Fiber'; import * as Layer from 'effect/Layer'; @@ -26,8 +27,6 @@ import { } from '../lease.ts'; import { FakeStateApi } from './fake-state-api.ts'; -process.env['PRISMA_SERVICE_TOKEN'] = 'test-service-token'; - const PROJECT_ID = 'proj-1'; const BRANCH_ID = 'br-1'; const STACK = 'demo-stack'; @@ -312,7 +311,14 @@ describe('prismaStateLayer against the platform state API', () => { Effect.gen(function* () { const service = yield* yield* State; return yield* use(service).pipe(Effect.orDie); - }).pipe(Effect.provide(layer)) as Effect.Effect, + }).pipe( + Effect.provide(layer), + // Do not depend on when another test first snapshots the process environment. + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnvRecord({ PRISMA_SERVICE_TOKEN: 'test-service-token' }), + ), + ) as Effect.Effect, ); };