| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Path from "effect/Path"; |
| 3 | import * as Redacted from "effect/Redacted"; |
| 4 | import * as Stream from "effect/Stream"; |
| 5 | import { deepEqual, isResolved } from "../Diff.ts"; |
| 6 | import * as Provider from "../Provider.ts"; |
| 7 | import { |
| 8 | DEV_TIMESTAMP, |
| 9 | attrOrString, |
| 10 | devId, |
| 11 | devProvider, |
| 12 | } from "./Internal/DevStub.ts"; |
| 13 | import * as ProviderLayer from "../Local/ProviderLayer.ts"; |
| 14 | import { Resource } from "../Resource.ts"; |
| 15 | import { sha256Object } from "../Util/sha256.ts"; |
| 16 | import { |
| 17 | type GetServiceDeploymentsResponse, |
| 18 | getServiceDeployments, |
| 19 | createServiceDeployment, |
| 20 | } from "@distilled.cloud/prisma/management"; |
| 21 | import { Retry } from "@distilled.cloud/prisma"; |
| 22 | import { |
| 23 | destroyDeployment, |
| 24 | waitForDeploymentStatus, |
| 25 | } from "./ComputeLifecycle.ts"; |
| 26 | import { executeArtifactUpload } from "./Internal/ArtifactUpload.ts"; |
| 27 | import { aggregateCleanupFailure } from "./Internal/CleanupFailure.ts"; |
| 28 | import { |
| 29 | inspectArtifactFile, |
| 30 | readArtifactFile, |
| 31 | type ArtifactFile, |
| 32 | } from "./Internal/ArtifactFile.ts"; |
| 33 | import { promoteAppObserved } from "./Internal/AppPromotion.ts"; |
| 34 | import { startDeploymentIdempotent } from "./Internal/DeploymentActions.ts"; |
| 35 | import { ensureDeploymentMembership } from "./Internal/DeploymentIdentity.ts"; |
| 36 | import { observeDeployment } from "./Internal/DeploymentObserve.ts"; |
| 37 | import { tailDeploymentLogs } from "./PrismaLogs.ts"; |
| 38 | import type { App } from "./App.ts"; |
| 39 | import type { Providers } from "./Providers.ts"; |
| 40 | import { |
| 41 | concreteIdsChanged, |
| 42 | isInputObject, |
| 43 | isPrismaDevId, |
| 44 | resolveAppId, |
| 45 | unresolvedAppIdOf, |
| 46 | } from "./Refs.ts"; |
| 47 | import type { ObservedDeployment } from "./Internal/Observed.ts"; |
| 48 | import { PrismaPaginationError } from "./Internal/Pagination.ts"; |
| 49 | |
| 50 | export const MAX_DEPLOYMENT_ARTIFACT_BYTES = 256 * 1024 * 1024; |
| 51 | |
| 52 | export interface DeploymentProps { |
| 53 | /** |
| 54 | * App ID or Prisma.App resource that owns this deployment. |
| 55 | */ |
| 56 | app: string | App; |
| 57 | /** |
| 58 | * Port mapping for the deployment. Set `http` to `null` to reset to |
| 59 | * Foundry's default port (8080); non-null values must be integers from |
| 60 | * 1 through 65535. |
| 61 | */ |
| 62 | portMapping?: { http?: number | null }; |
| 63 | /** |
| 64 | * Create the deployment by reusing the App's currently promoted artifact |
| 65 | * instead of uploading code. Requires an existing promoted deployment. |
| 66 | */ |
| 67 | skipCodeUpload?: boolean; |
| 68 | /** |
| 69 | * Path to a pre-created artifact file to upload to the pre-signed upload URL. |
| 70 | */ |
| 71 | artifactPath?: string; |
| 72 | /** |
| 73 | * Content type for artifact uploads. |
| 74 | * |
| 75 | * @default "application/octet-stream" |
| 76 | */ |
| 77 | artifactContentType?: string; |
| 78 | /** |
| 79 | * Start the deployment after it is created. |
| 80 | * |
| 81 | * @default false |
| 82 | */ |
| 83 | start?: boolean; |
| 84 | /** |
| 85 | * Promote the deployment to the App's stable endpoint after start. |
| 86 | * If `start` is omitted, enabling promotion starts the deployment first. |
| 87 | * |
| 88 | * @default false |
| 89 | */ |
| 90 | promote?: boolean; |
| 91 | /** |
| 92 | * Opaque key/value map; when any resolved value changes, a replacement |
| 93 | * deployment is planned (create-before-delete) even if the artifact is |
| 94 | * unchanged — the same contract as `AWS.ApiGateway.Deployment.triggers`. |
| 95 | * Because values here may be secrets, they are folded into the deployment |
| 96 | * fingerprint as a salted hash and persisted `Redacted` rather than |
| 97 | * compared as plaintext; plaintext never lands in state. |
| 98 | * |
| 99 | * Prisma snapshots environment variables into a deployment when the |
| 100 | * deployment is created, so changing only an environment variable's value |
| 101 | * updates the platform's variable record but never reaches the running app. |
| 102 | * Pass those values here to make such a change take effect. |
| 103 | * |
| 104 | * Members may be wrapped in `Redacted.make(secret)`; they are unwrapped only |
| 105 | * to compute the fingerprint. |
| 106 | * |
| 107 | * If the value cannot be resolved while the deploy is being planned — |
| 108 | * because it reads an attribute of another resource that the same deploy is |
| 109 | * changing — the diff cannot prove the fingerprint is unchanged, and the |
| 110 | * engine offers no later opportunity to plan a replacement. The deployment |
| 111 | * is then replaced conservatively, but only once a fingerprint has already |
| 112 | * been recorded: adding `triggers` to an existing deployment records its |
| 113 | * fingerprint through a plain update rather than forcing a replacement. |
| 114 | * |
| 115 | * Leaving `triggers` unset tracks nothing, so removing it from a |
| 116 | * deployment that had it does not trigger a replacement on its own. |
| 117 | * |
| 118 | * @example |
| 119 | * ```typescript |
| 120 | * const deployment = yield* Prisma.Deployment("web", { |
| 121 | * app, |
| 122 | * artifactPath: "./dist/app.tar.gz", |
| 123 | * triggers: { DATABASE_URL: Redacted.make(databaseUrl) }, |
| 124 | * start: true, |
| 125 | * promote: true, |
| 126 | * }); |
| 127 | * ``` |
| 128 | */ |
| 129 | triggers?: Record<string, unknown>; |
| 130 | } |
| 131 | |
| 132 | export interface Deployment extends Resource< |
| 133 | "Prisma.Deployment", |
| 134 | DeploymentProps, |
| 135 | { |
| 136 | /** |
| 137 | * Prisma deployment ID. |
| 138 | */ |
| 139 | deploymentId: string; |
| 140 | /** |
| 141 | * App ID that owns the deployment. |
| 142 | */ |
| 143 | appId: string; |
| 144 | /** |
| 145 | * Foundry version ID returned by the Prisma Management API. |
| 146 | */ |
| 147 | foundryVersionId: string; |
| 148 | /** |
| 149 | * Current deployment status, when observed. |
| 150 | */ |
| 151 | status: string | undefined; |
| 152 | /** |
| 153 | * Preview endpoint domain for the deployment. |
| 154 | */ |
| 155 | previewDomain: string | null | undefined; |
| 156 | /** |
| 157 | * Hash of the artifact bytes uploaded for this deployment, when Alchemy |
| 158 | * uploaded an artifact. |
| 159 | */ |
| 160 | artifactHash?: string; |
| 161 | /** |
| 162 | * Salted fingerprint of the resolved `triggers` inputs this deployment |
| 163 | * was created for. Held `Redacted` so the inputs stay out of plaintext |
| 164 | * state; absent when `triggers` is unset. |
| 165 | */ |
| 166 | triggersHash?: Redacted.Redacted<string>; |
| 167 | /** |
| 168 | * Stable App endpoint domain after promotion. |
| 169 | */ |
| 170 | appEndpointDomain: string | undefined; |
| 171 | /** |
| 172 | * ISO timestamp when the deployment was created, when observed. |
| 173 | */ |
| 174 | createdAt: string | undefined; |
| 175 | }, |
| 176 | never, |
| 177 | Providers |
| 178 | > {} |
| 179 | |
| 180 | /** |
| 181 | * A Prisma deployment owned by an App. |
| 182 | * |
| 183 | * This is the low-level resource: it can upload or reuse an artifact, start it, |
| 184 | * and promote it, but it does not provide `Prisma.Compute`'s preview/stable |
| 185 | * health checks or automatic rollback. Prefer `Prisma.Compute` for production |
| 186 | * application deployments. |
| 187 | * |
| 188 | * Prisma's create-deployment API currently exposes neither an idempotency key |
| 189 | * nor a caller-defined natural key. After a crash that loses state immediately |
| 190 | * after creation, Alchemy deliberately does not adopt the App's latest |
| 191 | * deployment: doing so could take ownership of an unrelated deployment. When |
| 192 | * persisted state contains a Foundry version ID, refresh may safely recover the |
| 193 | * matching deployment. |
| 194 | * |
| 195 | * ### Creating a Deployment |
| 196 | * **Example:** Fork the currently promoted artifact |
| 197 | * ```typescript |
| 198 | * const deployment = yield* Prisma.Deployment("web-v2", { |
| 199 | * app: app.appId, |
| 200 | * skipCodeUpload: true, |
| 201 | * start: true, |
| 202 | * promote: true, |
| 203 | * }); |
| 204 | * ``` |
| 205 | * |
| 206 | * **Example:** Upload a prebuilt artifact |
| 207 | * ```typescript |
| 208 | * const deployment = yield* Prisma.Deployment("web-v3", { |
| 209 | * app: app.appId, |
| 210 | * artifactPath: "./dist/app.tar.gz", |
| 211 | * artifactContentType: "application/gzip", |
| 212 | * start: true, |
| 213 | * promote: true, |
| 214 | * }); |
| 215 | * ``` |
| 216 | * |
| 217 | * @resource |
| 218 | * @product Compute |
| 219 | */ |
| 220 | export const Deployment = Resource<Deployment>("Prisma.Deployment"); |
| 221 | |
| 222 | // Distilled emits the cursor-paginated list operations as plain ops, so |
| 223 | // callers walk `pagination` themselves (see `src/Neon/Project.ts`). |
| 224 | const listAppDeployments = (appId: string) => |
| 225 | Effect.gen(function* () { |
| 226 | const deployments: GetServiceDeploymentsResponse["data"][number][] = []; |
| 227 | let cursor: string | undefined; |
| 228 | while (true) { |
| 229 | const page = yield* getServiceDeployments( |
| 230 | cursor === undefined |
| 231 | ? { serviceId: appId, limit: 100 } |
| 232 | : { serviceId: appId, limit: 100, cursor }, |
| 233 | ); |
| 234 | deployments.push(...page.data); |
| 235 | const nextCursor = page.pagination.nextCursor; |
| 236 | if (!page.pagination.hasMore) break; |
| 237 | if (nextCursor === null) { |
| 238 | return yield* Effect.fail( |
| 239 | new PrismaPaginationError({ |
| 240 | message: |
| 241 | "Invalid Prisma Management API pagination response from getServiceDeployments: hasMore was true without a non-empty nextCursor", |
| 242 | }), |
| 243 | ); |
| 244 | } |
| 245 | cursor = nextCursor; |
| 246 | } |
| 247 | return deployments; |
| 248 | }); |
| 249 | |
| 250 | const findDeployment = (appId: string, foundryVersionId: string | undefined) => |
| 251 | foundryVersionId === undefined |
| 252 | ? Effect.succeed(undefined) |
| 253 | : listAppDeployments(appId).pipe( |
| 254 | Effect.flatMap((deployments) => { |
| 255 | const matches = deployments.filter( |
| 256 | (deployment) => deployment.foundryVersionId === foundryVersionId, |
| 257 | ); |
| 258 | return matches.length > 1 |
| 259 | ? Effect.fail( |
| 260 | new Error( |
| 261 | `Prisma returned multiple deployments with Foundry version ID '${foundryVersionId}' for App '${appId}'; refusing an ambiguous recovery match.`, |
| 262 | ), |
| 263 | ) |
| 264 | : Effect.succeed(matches[0]); |
| 265 | }), |
| 266 | ); |
| 267 | |
| 268 | const attrsFrom = ( |
| 269 | deployment: ObservedDeployment, |
| 270 | appId: string, |
| 271 | extra?: { |
| 272 | artifactHash?: string; |
| 273 | triggersHash?: Redacted.Redacted<string>; |
| 274 | appEndpointDomain?: string; |
| 275 | }, |
| 276 | ): Deployment["Attributes"] => ({ |
| 277 | deploymentId: deployment.id, |
| 278 | appId, |
| 279 | foundryVersionId: deployment.foundryVersionId, |
| 280 | status: deployment.status, |
| 281 | previewDomain: deployment.previewDomain, |
| 282 | artifactHash: extra?.artifactHash, |
| 283 | triggersHash: extra?.triggersHash, |
| 284 | appEndpointDomain: extra?.appEndpointDomain, |
| 285 | createdAt: deployment.createdAt, |
| 286 | }); |
| 287 | |
| 288 | export interface ReadUploadArtifactInput { |
| 289 | artifact?: string | Uint8Array; |
| 290 | artifactPath?: string; |
| 291 | output?: "bytes" | "file"; |
| 292 | } |
| 293 | |
| 294 | export function readUploadArtifact( |
| 295 | input: ReadUploadArtifactInput & { readonly output: "file" }, |
| 296 | ): Effect.Effect<ArtifactFile | undefined, Error, Path.Path>; |
| 297 | export function readUploadArtifact( |
| 298 | input: ReadUploadArtifactInput & { readonly output?: "bytes" }, |
| 299 | ): Effect.Effect<Uint8Array | undefined, Error, Path.Path>; |
| 300 | export function readUploadArtifact( |
| 301 | input: ReadUploadArtifactInput, |
| 302 | ): Effect.Effect<Uint8Array | ArtifactFile | undefined, Error, Path.Path>; |
| 303 | export function readUploadArtifact(input: ReadUploadArtifactInput) { |
| 304 | return readUploadArtifactInternal(input); |
| 305 | } |
| 306 | |
| 307 | const readUploadArtifactInternal = Effect.fn(function* ( |
| 308 | input: ReadUploadArtifactInput, |
| 309 | ) { |
| 310 | if (input.artifact !== undefined && input.artifactPath !== undefined) { |
| 311 | return yield* Effect.fail( |
| 312 | new Error("artifact and artifactPath are mutually exclusive."), |
| 313 | ); |
| 314 | } |
| 315 | if (input.output === "file" && input.artifact !== undefined) { |
| 316 | return yield* Effect.fail( |
| 317 | new Error("File-backed artifact output requires artifactPath."), |
| 318 | ); |
| 319 | } |
| 320 | if (input.artifactPath !== undefined) { |
| 321 | const path = yield* Path.Path; |
| 322 | const resolved = path.resolve(input.artifactPath); |
| 323 | const artifact = yield* inspectArtifactFile( |
| 324 | resolved, |
| 325 | MAX_DEPLOYMENT_ARTIFACT_BYTES, |
| 326 | ); |
| 327 | if (input.output === "file") { |
| 328 | return artifact; |
| 329 | } |
| 330 | return yield* readArtifactFile(artifact); |
| 331 | } |
| 332 | if (input.artifact !== undefined) { |
| 333 | const artifact = input.artifact; |
| 334 | const bytes = yield* Effect.sync(() => |
| 335 | typeof artifact === "string" |
| 336 | ? new TextEncoder().encode(artifact) |
| 337 | : artifact, |
| 338 | ); |
| 339 | return yield* validateDeploymentArtifactBytes(bytes); |
| 340 | } |
| 341 | return undefined; |
| 342 | }); |
| 343 | |
| 344 | export const validateDeploymentArtifactBytes = ( |
| 345 | artifact: Uint8Array, |
| 346 | maxBytes = MAX_DEPLOYMENT_ARTIFACT_BYTES, |
| 347 | ) => |
| 348 | !Number.isSafeInteger(maxBytes) || maxBytes <= 0 |
| 349 | ? Effect.fail( |
| 350 | new Error("Artifact maxBytes must be a positive safe integer."), |
| 351 | ) |
| 352 | : maxBytes > MAX_DEPLOYMENT_ARTIFACT_BYTES |
| 353 | ? Effect.fail( |
| 354 | new Error( |
| 355 | `Artifact maxBytes must not exceed the hard limit of ${MAX_DEPLOYMENT_ARTIFACT_BYTES}.`, |
| 356 | ), |
| 357 | ) |
| 358 | : artifact.byteLength === 0 |
| 359 | ? Effect.fail( |
| 360 | new Error("Prisma deployment artifact must be non-empty."), |
| 361 | ) |
| 362 | : artifact.byteLength > maxBytes |
| 363 | ? Effect.fail( |
| 364 | new Error( |
| 365 | `Prisma deployment artifact exceeds the ${maxBytes} byte upload safety limit.`, |
| 366 | ), |
| 367 | ) |
| 368 | : Effect.succeed(artifact); |
| 369 | |
| 370 | const artifactHashOf = Effect.fn(function* (props: DeploymentProps) { |
| 371 | const artifact = yield* readUploadArtifact({ |
| 372 | artifactPath: props.artifactPath, |
| 373 | output: "file", |
| 374 | }); |
| 375 | if (artifact === undefined) return undefined; |
| 376 | return yield* sha256Object({ |
| 377 | artifact: artifact.sha256, |
| 378 | contentType: props.artifactContentType ?? "application/octet-stream", |
| 379 | }); |
| 380 | }); |
| 381 | |
| 382 | /** |
| 383 | * Domain separator so a `triggers` fingerprint is never the bare SHA-256 of |
| 384 | * a secret value, which would otherwise be comparable against a precomputed |
| 385 | * digest of a guessed value. |
| 386 | */ |
| 387 | const TRIGGERS_HASH_SALT = "alchemy/Prisma.Deployment/triggers/v1"; |
| 388 | |
| 389 | const unwrapRedacted = (value: unknown): unknown => { |
| 390 | if (Redacted.isRedacted(value)) return unwrapRedacted(Redacted.value(value)); |
| 391 | if (Array.isArray(value)) return value.map(unwrapRedacted); |
| 392 | if (value !== null && typeof value === "object") { |
| 393 | return Object.fromEntries( |
| 394 | Object.entries(value).map(([key, item]) => [key, unwrapRedacted(item)]), |
| 395 | ); |
| 396 | } |
| 397 | return value; |
| 398 | }; |
| 399 | |
| 400 | const triggersHashOf = (triggers: unknown) => |
| 401 | sha256Object({ |
| 402 | salt: TRIGGERS_HASH_SALT, |
| 403 | triggers: unwrapRedacted(triggers) ?? null, |
| 404 | }); |
| 405 | |
| 406 | const persistedTriggersHash = ( |
| 407 | value: Redacted.Redacted<string> | string | undefined, |
| 408 | ) => (Redacted.isRedacted(value) ? Redacted.value(value) : value); |
| 409 | |
| 410 | /** |
| 411 | * Whether the declared `triggers` inputs differ from the fingerprint the |
| 412 | * running deployment was created for. |
| 413 | * |
| 414 | * Only `diff` can plan a replacement — `reconcile` has no way to ask for one — |
| 415 | * so an input that is still unresolved while planning is treated as changed. |
| 416 | * That is only reached when another resource in the same deploy owns the |
| 417 | * value and is itself changing, in which case the value most likely changed |
| 418 | * too; the cost of being wrong is one extra create-before-delete replacement. |
| 419 | * Without a recorded fingerprint there is nothing to compare against, so the |
| 420 | * first deploy that declares `triggers` records it through the engine's |
| 421 | * default update instead of replacing the deployment. |
| 422 | */ |
| 423 | const triggersChanged = Effect.fn(function* ( |
| 424 | triggers: unknown, |
| 425 | persisted: Redacted.Redacted<string> | string | undefined, |
| 426 | ) { |
| 427 | const recorded = persistedTriggersHash(persisted); |
| 428 | if (triggers === undefined || recorded === undefined) return false; |
| 429 | if (!isResolved({ triggers })) return true; |
| 430 | return (yield* triggersHashOf(triggers)) !== recorded; |
| 431 | }); |
| 432 | |
| 433 | export const uploadArtifact = ( |
| 434 | uploadUrl: string, |
| 435 | artifact: Uint8Array | ArtifactFile, |
| 436 | contentType: string, |
| 437 | ) => |
| 438 | Effect.gen(function* () { |
| 439 | if (artifact instanceof Uint8Array) { |
| 440 | yield* validateDeploymentArtifactBytes(artifact); |
| 441 | } else if ( |
| 442 | artifact.size <= 0 || |
| 443 | artifact.size > MAX_DEPLOYMENT_ARTIFACT_BYTES |
| 444 | ) { |
| 445 | return yield* Effect.fail( |
| 446 | new Error( |
| 447 | `Prisma deployment artifact exceeds the ${MAX_DEPLOYMENT_ARTIFACT_BYTES} byte upload safety limit.`, |
| 448 | ), |
| 449 | ); |
| 450 | } |
| 451 | yield* Effect.try({ |
| 452 | try: () => { |
| 453 | const parsed = new URL(uploadUrl); |
| 454 | if ( |
| 455 | parsed.protocol !== "https:" || |
| 456 | parsed.username.length > 0 || |
| 457 | parsed.password.length > 0 |
| 458 | ) { |
| 459 | throw new Error("invalid upload URL"); |
| 460 | } |
| 461 | }, |
| 462 | catch: () => |
| 463 | new Error("Prisma artifact upload URL must be credential-free HTTPS."), |
| 464 | }); |
| 465 | yield* executeArtifactUpload(uploadUrl, artifact, contentType); |
| 466 | }); |
| 467 | |
| 468 | const ProviderLive = () => |
| 469 | Provider.effect( |
| 470 | Deployment, |
| 471 | Effect.gen(function* () { |
| 472 | return { |
| 473 | stables: ["deploymentId"], |
| 474 | // App deletion cascades deployments. AppProvider is the single nuke |
| 475 | // enumerator so a deployment is never deleted twice during teardown. |
| 476 | list: () => Effect.succeed([]), |
| 477 | diff: Effect.fn(function* ({ olds, news, output }) { |
| 478 | if (!isInputObject(news)) return undefined; |
| 479 | if (isPrismaDevId(output?.deploymentId)) { |
| 480 | return { action: "update" } as const; |
| 481 | } |
| 482 | // `deploymentId` is stable and Foundry's failed status is terminal. |
| 483 | // Route drift recovery through the engine's create-before-delete |
| 484 | // replacement lifecycle instead of trying to restart the failed |
| 485 | // generation or changing a stable ID during an update. |
| 486 | if (output?.status === "failed") { |
| 487 | return { action: "replace" } as const; |
| 488 | } |
| 489 | // Checked before the resolved-content early return below: a |
| 490 | // deferred diff falls back to an update, which reuses the running |
| 491 | // deployment and would never apply the changed inputs. |
| 492 | if (yield* triggersChanged(news.triggers, output?.triggersHash)) { |
| 493 | return { action: "replace" } as const; |
| 494 | } |
| 495 | const replacementContent = { |
| 496 | portMapping: news.portMapping, |
| 497 | skipCodeUpload: news.skipCodeUpload, |
| 498 | artifactPath: news.artifactPath, |
| 499 | artifactContentType: news.artifactContentType, |
| 500 | }; |
| 501 | if (!isResolved(replacementContent)) return undefined; |
| 502 | const resolvedReplacementContent = replacementContent as Pick< |
| 503 | DeploymentProps, |
| 504 | | "portMapping" |
| 505 | | "skipCodeUpload" |
| 506 | | "artifactPath" |
| 507 | | "artifactContentType" |
| 508 | >; |
| 509 | const oldAppId = output?.appId ?? unresolvedAppIdOf(olds.app); |
| 510 | const newAppId = isResolved(news.app) |
| 511 | ? unresolvedAppIdOf(news.app) |
| 512 | : undefined; |
| 513 | const oldArtifactHash = output?.artifactHash; |
| 514 | const newArtifactHash = yield* artifactHashOf({ |
| 515 | app: olds.app, |
| 516 | ...resolvedReplacementContent, |
| 517 | }); |
| 518 | const appChanged = concreteIdsChanged(oldAppId, newAppId); |
| 519 | if ( |
| 520 | appChanged || |
| 521 | !deepEqual( |
| 522 | resolvedReplacementContent.portMapping ?? {}, |
| 523 | olds.portMapping ?? {}, |
| 524 | ) || |
| 525 | (resolvedReplacementContent.skipCodeUpload ?? false) !== |
| 526 | (olds.skipCodeUpload ?? false) || |
| 527 | resolvedReplacementContent.artifactPath !== olds.artifactPath || |
| 528 | resolvedReplacementContent.artifactContentType !== |
| 529 | olds.artifactContentType || |
| 530 | (newArtifactHash !== undefined && |
| 531 | newArtifactHash !== oldArtifactHash) |
| 532 | ) { |
| 533 | return { action: "replace" } as const; |
| 534 | } |
| 535 | const updateProps = { |
| 536 | start: news.start, |
| 537 | promote: news.promote, |
| 538 | }; |
| 539 | if (!isResolved(updateProps)) return undefined; |
| 540 | const resolvedUpdateProps = updateProps as Pick< |
| 541 | DeploymentProps, |
| 542 | "start" | "promote" |
| 543 | >; |
| 544 | if ( |
| 545 | (resolvedUpdateProps.start ?? false) || |
| 546 | (resolvedUpdateProps.promote ?? false) |
| 547 | ) { |
| 548 | // Reconcile asserted lifecycle state on every deploy so external |
| 549 | // stops and App routing drift are repaired. |
| 550 | return { action: "update" } as const; |
| 551 | } |
| 552 | if ( |
| 553 | (resolvedUpdateProps.start ?? false) !== (olds.start ?? false) || |
| 554 | (resolvedUpdateProps.promote ?? false) !== (olds.promote ?? false) |
| 555 | ) { |
| 556 | return { action: "update" } as const; |
| 557 | } |
| 558 | return undefined; |
| 559 | }), |
| 560 | read: Effect.fn(function* ({ output, olds }) { |
| 561 | if (isPrismaDevId(output?.deploymentId)) return undefined; |
| 562 | const appId = |
| 563 | output?.appId && !isPrismaDevId(output.appId) |
| 564 | ? output.appId |
| 565 | : yield* resolveAppId(olds.app); |
| 566 | const savedDeployment = output?.deploymentId |
| 567 | ? yield* observeDeployment(output.deploymentId).pipe( |
| 568 | Effect.catchTag("NotFound", () => Effect.succeed(undefined)), |
| 569 | ) |
| 570 | : undefined; |
| 571 | const listed = savedDeployment |
| 572 | ? undefined |
| 573 | : yield* findDeployment(appId, output?.foundryVersionId); |
| 574 | const deployment = |
| 575 | savedDeployment ?? |
| 576 | (listed ? yield* observeDeployment(listed.id) : undefined); |
| 577 | if (savedDeployment) { |
| 578 | yield* ensureDeploymentMembership(appId, savedDeployment); |
| 579 | } |
| 580 | return deployment ? attrsFrom(deployment, appId, output) : undefined; |
| 581 | }), |
| 582 | reconcile: Effect.fn(function* ({ news, output }) { |
| 583 | if ( |
| 584 | news.portMapping?.http !== undefined && |
| 585 | news.portMapping.http !== null && |
| 586 | (!Number.isInteger(news.portMapping.http) || |
| 587 | news.portMapping.http < 1 || |
| 588 | news.portMapping.http > 65_535) |
| 589 | ) { |
| 590 | return yield* Effect.fail( |
| 591 | new Error( |
| 592 | "portMapping.http must be an integer between 1 and 65535.", |
| 593 | ), |
| 594 | ); |
| 595 | } |
| 596 | if ( |
| 597 | !(news.skipCodeUpload ?? false) && |
| 598 | news.artifactPath === undefined |
| 599 | ) { |
| 600 | return yield* Effect.fail( |
| 601 | new Error( |
| 602 | "Prisma.Deployment requires artifactPath or skipCodeUpload: true.", |
| 603 | ), |
| 604 | ); |
| 605 | } |
| 606 | if ( |
| 607 | (news.skipCodeUpload ?? false) && |
| 608 | news.artifactPath !== undefined |
| 609 | ) { |
| 610 | return yield* Effect.fail( |
| 611 | new Error("skipCodeUpload cannot be combined with artifactPath."), |
| 612 | ); |
| 613 | } |
| 614 | if ((news.promote ?? false) && news.start === false) { |
| 615 | return yield* Effect.fail( |
| 616 | new Error("promote cannot be combined with start: false."), |
| 617 | ); |
| 618 | } |
| 619 | const artifact = yield* readUploadArtifact({ |
| 620 | artifactPath: news.artifactPath, |
| 621 | output: "file", |
| 622 | }); |
| 623 | const artifactHash = |
| 624 | artifact === undefined |
| 625 | ? output?.artifactHash |
| 626 | : yield* sha256Object({ |
| 627 | artifact: artifact.sha256, |
| 628 | contentType: |
| 629 | news.artifactContentType ?? "application/octet-stream", |
| 630 | }); |
| 631 | const triggersHash = |
| 632 | news.triggers === undefined |
| 633 | ? undefined |
| 634 | : Redacted.make(yield* triggersHashOf(news.triggers)); |
| 635 | const appId = yield* resolveAppId(news.app); |
| 636 | const deploymentId = isPrismaDevId(output?.deploymentId) |
| 637 | ? undefined |
| 638 | : output?.deploymentId; |
| 639 | let deployment: ObservedDeployment | undefined = deploymentId |
| 640 | ? yield* observeDeployment(deploymentId).pipe( |
| 641 | Effect.catchTag("NotFound", () => Effect.succeed(undefined)), |
| 642 | ) |
| 643 | : undefined; |
| 644 | if (deployment) { |
| 645 | yield* ensureDeploymentMembership(appId, deployment); |
| 646 | if (deployment.status === "failed") { |
| 647 | return yield* Effect.fail( |
| 648 | new Error( |
| 649 | `Prisma deployment '${deployment.id}' is in terminal status 'failed' and must be replaced before reconcile; refusing to restart it or change the stable deploymentId during an update.`, |
| 650 | ), |
| 651 | ); |
| 652 | } |
| 653 | } |
| 654 | let createdDeploymentId: string | undefined; |
| 655 | const cleanupCreatedDeploymentOnFailure = ( |
| 656 | failedDeploymentId: string, |
| 657 | error: unknown, |
| 658 | ) => |
| 659 | createdDeploymentId === failedDeploymentId |
| 660 | ? destroyDeployment(failedDeploymentId).pipe( |
| 661 | Effect.catch((cleanupError) => |
| 662 | Effect.fail( |
| 663 | aggregateCleanupFailure( |
| 664 | "deployment", |
| 665 | failedDeploymentId, |
| 666 | `/v1/deployments/${failedDeploymentId}`, |
| 667 | error, |
| 668 | cleanupError, |
| 669 | ), |
| 670 | ), |
| 671 | ), |
| 672 | Effect.andThen(() => Effect.fail(error)), |
| 673 | ) |
| 674 | : Effect.fail(error); |
| 675 | |
| 676 | if (!deployment) { |
| 677 | const created = yield* createServiceDeployment({ |
| 678 | serviceId: appId, |
| 679 | ...(news.portMapping === undefined |
| 680 | ? {} |
| 681 | : { portMapping: news.portMapping }), |
| 682 | ...(news.skipCodeUpload === undefined |
| 683 | ? {} |
| 684 | : { skipCodeUpload: news.skipCodeUpload }), |
| 685 | }).pipe( |
| 686 | // A replayed create would make a second deployment; the retry |
| 687 | // policy cannot see the request, so opt out explicitly. |
| 688 | Retry.none, |
| 689 | Effect.map((response) => response.data), |
| 690 | ); |
| 691 | createdDeploymentId = created.id; |
| 692 | if (artifact !== undefined && !created.uploadUrl) { |
| 693 | return yield* cleanupCreatedDeploymentOnFailure( |
| 694 | created.id, |
| 695 | new Error( |
| 696 | "Prisma deployment creation did not return an upload URL.", |
| 697 | ), |
| 698 | ); |
| 699 | } |
| 700 | if (created.uploadUrl && artifact !== undefined) { |
| 701 | yield* uploadArtifact( |
| 702 | created.uploadUrl, |
| 703 | artifact, |
| 704 | news.artifactContentType ?? "application/octet-stream", |
| 705 | ).pipe( |
| 706 | Effect.catch((error) => |
| 707 | cleanupCreatedDeploymentOnFailure(created.id, error), |
| 708 | ), |
| 709 | ); |
| 710 | } |
| 711 | deployment = yield* observeDeployment(created.id).pipe( |
| 712 | Effect.catchTag("NotFound", () => |
| 713 | Effect.succeed({ |
| 714 | id: created.id, |
| 715 | foundryVersionId: created.foundryVersionId, |
| 716 | status: "new", |
| 717 | previewDomain: null, |
| 718 | createdAt: undefined, |
| 719 | }), |
| 720 | ), |
| 721 | Effect.catch((error) => |
| 722 | cleanupCreatedDeploymentOnFailure(created.id, error), |
| 723 | ), |
| 724 | ); |
| 725 | } |
| 726 | if (!deployment) { |
| 727 | return yield* Effect.fail( |
| 728 | new Error( |
| 729 | "Prisma deployment could not be resolved after creation.", |
| 730 | ), |
| 731 | ); |
| 732 | } |
| 733 | |
| 734 | let appEndpointDomain = output?.appEndpointDomain; |
| 735 | const shouldStart = news.start ?? news.promote ?? false; |
| 736 | if (shouldStart) { |
| 737 | const currentDeployment = deployment; |
| 738 | const currentDeploymentId = currentDeployment.id; |
| 739 | deployment = yield* Effect.gen(function* () { |
| 740 | if ( |
| 741 | currentDeployment.status !== "running" && |
| 742 | currentDeployment.status !== "provisioning" |
| 743 | ) { |
| 744 | const started = yield* startDeploymentIdempotent( |
| 745 | currentDeployment.id, |
| 746 | ); |
| 747 | if (started) { |
| 748 | return yield* waitForDeploymentStatus( |
| 749 | currentDeployment.id, |
| 750 | "running", |
| 751 | ).pipe( |
| 752 | Effect.map((running) => ({ |
| 753 | ...running, |
| 754 | previewDomain: started.previewDomain, |
| 755 | })), |
| 756 | ); |
| 757 | } |
| 758 | } |
| 759 | return yield* waitForDeploymentStatus( |
| 760 | currentDeployment.id, |
| 761 | "running", |
| 762 | ); |
| 763 | }).pipe( |
| 764 | Effect.catch((error) => |
| 765 | cleanupCreatedDeploymentOnFailure(currentDeploymentId, error), |
| 766 | ), |
| 767 | ); |
| 768 | } |
| 769 | if (news.promote ?? false) { |
| 770 | appEndpointDomain = yield* Effect.gen(function* () { |
| 771 | // Promotion is deliberately replayed even when the control-plane |
| 772 | // record already names this deployment. The endpoint operation also |
| 773 | // repairs provider routing and custom-domain assignment drift. |
| 774 | const promoted = yield* promoteAppObserved(appId, deployment.id); |
| 775 | return promoted.appEndpointDomain; |
| 776 | }); |
| 777 | } |
| 778 | |
| 779 | return attrsFrom(deployment, appId, { |
| 780 | artifactHash, |
| 781 | triggersHash, |
| 782 | appEndpointDomain, |
| 783 | }); |
| 784 | }), |
| 785 | delete: Effect.fn(function* ({ output }) { |
| 786 | if (isPrismaDevId(output.deploymentId)) return; |
| 787 | const deployment = yield* observeDeployment(output.deploymentId).pipe( |
| 788 | Effect.catchTag("NotFound", () => Effect.succeed(undefined)), |
| 789 | ); |
| 790 | if (!deployment) return; |
| 791 | yield* ensureDeploymentMembership(output.appId, deployment); |
| 792 | yield* destroyDeployment(output.deploymentId); |
| 793 | }), |
| 794 | tail: ({ output }) => |
| 795 | output.deploymentId |
| 796 | ? tailDeploymentLogs(output.deploymentId) |
| 797 | : Stream.empty, |
| 798 | }; |
| 799 | }), |
| 800 | ); |
| 801 | |
| 802 | const ProviderLocal = () => |
| 803 | devProvider(Deployment, ["deploymentId"], ({ id, news }) => ({ |
| 804 | deploymentId: devId("deployment", id), |
| 805 | appId: attrOrString(news.app, "appId"), |
| 806 | foundryVersionId: devId("foundry-version", id), |
| 807 | status: "new", |
| 808 | previewDomain: undefined, |
| 809 | artifactHash: undefined, |
| 810 | triggersHash: undefined, |
| 811 | appEndpointDomain: undefined, |
| 812 | createdAt: DEV_TIMESTAMP, |
| 813 | })); |
| 814 | |
| 815 | export const DeploymentProvider = () => |
| 816 | ProviderLayer.dual(Deployment, { |
| 817 | local: () => ProviderLocal(), |
| 818 | live: () => ProviderLive(), |
| 819 | }); |