| 1 | import * as apprunner from "@distilled.cloud/aws/apprunner"; |
| 2 | import * as logs from "@distilled.cloud/aws/cloudwatch-logs"; |
| 3 | import * as ecr from "@distilled.cloud/aws/ecr"; |
| 4 | import * as iam from "@distilled.cloud/aws/iam"; |
| 5 | import type { Region } from "@distilled.cloud/aws/Region"; |
| 6 | import * as Data from "effect/Data"; |
| 7 | import type * as Duration from "effect/Duration"; |
| 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as Option from "effect/Option"; |
| 10 | import * as Redacted from "effect/Redacted"; |
| 11 | import * as Schedule from "effect/Schedule"; |
| 12 | import * as Stream from "effect/Stream"; |
| 13 | import type * as rolldown from "rolldown"; |
| 14 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 15 | import { AlchemyContext } from "../../AlchemyContext.ts"; |
| 16 | import * as Bundle from "../../Bundle/Bundle.ts"; |
| 17 | import { |
| 18 | findCwdForBundle, |
| 19 | getStableContextDir, |
| 20 | resolveMainPath, |
| 21 | } from "../../Bundle/TempRoot.ts"; |
| 22 | import { isResolved } from "../../Diff.ts"; |
| 23 | import { Docker } from "../../Docker/Docker.ts"; |
| 24 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 25 | import { Platform, type Main, type PlatformProps } from "../../Platform.ts"; |
| 26 | import * as Provider from "../../Provider.ts"; |
| 27 | import { Resource, type ResourceBinding } from "../../Resource.ts"; |
| 28 | import { |
| 29 | createHostRuntimeContext, |
| 30 | type HostRuntimeContext, |
| 31 | type ServerHost, |
| 32 | } from "../../Server/Process.ts"; |
| 33 | import { Stack } from "../../Stack.ts"; |
| 34 | import { |
| 35 | createInternalTags, |
| 36 | createTagsList, |
| 37 | hasAlchemyTags, |
| 38 | hasTags, |
| 39 | } from "../../Tags.ts"; |
| 40 | import { toSeconds } from "../../Util/Duration.ts"; |
| 41 | import type { Credentials } from "../Credentials.ts"; |
| 42 | import { buildAndPushEcrImage } from "../ECR/Image.ts"; |
| 43 | import { AWSEnvironment } from "../Environment.ts"; |
| 44 | import type { PolicyStatement } from "../IAM/Policy.ts"; |
| 45 | import type { Providers } from "../Providers.ts"; |
| 46 | import { |
| 47 | readAppRunnerTags, |
| 48 | syncAppRunnerTags, |
| 49 | toWireTags, |
| 50 | } from "./internal.ts"; |
| 51 | |
| 52 | /** |
| 53 | * Container image source for an App Runner service. |
| 54 | */ |
| 55 | export interface ServiceImageRepository { |
| 56 | /** |
| 57 | * Image identifier: a private ECR image URI |
| 58 | * (`{account}.dkr.ecr.{region}.amazonaws.com/{repo}:{tag}`) or a public |
| 59 | * ECR image (`public.ecr.aws/{alias}/{repo}:{tag}`). |
| 60 | */ |
| 61 | imageIdentifier: string; |
| 62 | /** |
| 63 | * Repository type. `ECR` (private, requires `accessRoleArn`) or |
| 64 | * `ECR_PUBLIC` (public gallery images, no access role). |
| 65 | */ |
| 66 | imageRepositoryType: "ECR" | "ECR_PUBLIC"; |
| 67 | /** |
| 68 | * Port the application listens on. |
| 69 | * @default "8080" |
| 70 | */ |
| 71 | port?: string; |
| 72 | /** |
| 73 | * Command App Runner runs to start the container. Overrides the image's |
| 74 | * default start command. |
| 75 | */ |
| 76 | startCommand?: string; |
| 77 | /** |
| 78 | * Environment variables available to the running service. |
| 79 | */ |
| 80 | runtimeEnvironmentVariables?: Record<string, string>; |
| 81 | /** |
| 82 | * Secrets exposed as environment variables. Values are Secrets Manager |
| 83 | * secret ARNs or SSM parameter ARNs (the instance role must be able to |
| 84 | * read them). |
| 85 | */ |
| 86 | runtimeEnvironmentSecrets?: Record<string, string>; |
| 87 | } |
| 88 | |
| 89 | /** |
| 90 | * Compute resources for each instance of the service. |
| 91 | */ |
| 92 | export interface ServiceInstanceConfiguration { |
| 93 | /** |
| 94 | * CPU units per instance: `"256"` (0.25 vCPU), `"512"`, `"1024"`, |
| 95 | * `"2048"`, or `"4096"`. The `"0.25 vCPU"`-style forms are also |
| 96 | * accepted. |
| 97 | * @default "1024" |
| 98 | */ |
| 99 | cpu?: string; |
| 100 | /** |
| 101 | * Memory per instance in MB: `"512"`, `"1024"`, `"2048"`, `"3072"`, |
| 102 | * `"4096"`, `"6144"`, `"8192"`, `"10240"`, or `"12288"`. The |
| 103 | * `"2 GB"`-style forms are also accepted. |
| 104 | * @default "2048" |
| 105 | */ |
| 106 | memory?: string; |
| 107 | /** |
| 108 | * IAM role assumed by the running service (analogous to an ECS task |
| 109 | * role). Required when the app calls AWS APIs or reads |
| 110 | * `runtimeEnvironmentSecrets`. For the Effect-native form (`main`), |
| 111 | * Alchemy provisions and manages this role automatically. |
| 112 | */ |
| 113 | instanceRoleArn?: string; |
| 114 | } |
| 115 | |
| 116 | /** |
| 117 | * Health check App Runner performs against the service. |
| 118 | */ |
| 119 | export interface ServiceHealthCheckConfiguration { |
| 120 | /** |
| 121 | * Health check protocol. |
| 122 | * @default "TCP" |
| 123 | */ |
| 124 | protocol?: "TCP" | "HTTP"; |
| 125 | /** |
| 126 | * URL path for HTTP health checks. |
| 127 | * @default "/" |
| 128 | */ |
| 129 | path?: string; |
| 130 | /** |
| 131 | * Time between health checks, e.g. `"5 seconds"` or |
| 132 | * `Duration.seconds(5)` (1-20 seconds on the wire). |
| 133 | * @default "5 seconds" |
| 134 | */ |
| 135 | interval?: Duration.Input; |
| 136 | /** |
| 137 | * Time to wait for a response, e.g. `"2 seconds"` or |
| 138 | * `Duration.seconds(2)` (1-20 seconds on the wire). |
| 139 | * @default "2 seconds" |
| 140 | */ |
| 141 | timeout?: Duration.Input; |
| 142 | /** |
| 143 | * Consecutive successful checks before the target is healthy (1-20). |
| 144 | * @default 1 |
| 145 | */ |
| 146 | healthyThreshold?: number; |
| 147 | /** |
| 148 | * Consecutive failed checks before the target is unhealthy (1-20). |
| 149 | * @default 5 |
| 150 | */ |
| 151 | unhealthyThreshold?: number; |
| 152 | } |
| 153 | |
| 154 | /** |
| 155 | * Network settings for inbound and outbound service traffic. |
| 156 | */ |
| 157 | export interface ServiceNetworkConfiguration { |
| 158 | /** |
| 159 | * Outbound traffic routing. `DEFAULT` egresses through App Runner; |
| 160 | * `VPC` routes through the VPC connector named by `vpcConnectorArn`. |
| 161 | * @default "DEFAULT" |
| 162 | */ |
| 163 | egressType?: "DEFAULT" | "VPC"; |
| 164 | /** |
| 165 | * ARN of the App Runner VPC connector for `egressType: "VPC"`. |
| 166 | */ |
| 167 | vpcConnectorArn?: string; |
| 168 | /** |
| 169 | * Whether the service is reachable from the public internet. Set to |
| 170 | * false to only allow access from a VPC ingress connection. |
| 171 | * @default true |
| 172 | */ |
| 173 | isPubliclyAccessible?: boolean; |
| 174 | /** |
| 175 | * IP address type for the public endpoint. |
| 176 | * @default "IPV4" |
| 177 | */ |
| 178 | ipAddressType?: "IPV4" | "DUAL_STACK"; |
| 179 | } |
| 180 | |
| 181 | /** |
| 182 | * Observability (tracing) settings for the service. |
| 183 | */ |
| 184 | export interface ServiceObservabilityProps { |
| 185 | /** |
| 186 | * Whether observability (X-Ray tracing) is enabled for the service. |
| 187 | */ |
| 188 | observabilityEnabled: boolean; |
| 189 | /** |
| 190 | * ARN of the App Runner observability configuration to use. Required |
| 191 | * when `observabilityEnabled` is true. |
| 192 | */ |
| 193 | observabilityConfigurationArn?: string; |
| 194 | } |
| 195 | |
| 196 | export interface ServiceProps extends PlatformProps { |
| 197 | /** |
| 198 | * Name of the service. Must be 4-40 characters. If omitted, a |
| 199 | * deterministic physical name is generated. Changing the name replaces |
| 200 | * the service. |
| 201 | */ |
| 202 | serviceName?: string; |
| 203 | /** |
| 204 | * Container image source for the service (low-level form). Required |
| 205 | * unless `main` is given. Code-repository (GitHub) sources are not |
| 206 | * supported — they require an App Runner Connection whose handshake is |
| 207 | * completed manually in the console. |
| 208 | */ |
| 209 | imageRepository?: ServiceImageRepository; |
| 210 | /** |
| 211 | * Module entrypoint for an Effect-native service (typically |
| 212 | * `import.meta.url` from an inline Effect program). Alchemy bundles the |
| 213 | * program, builds a container image, pushes it to a managed ECR |
| 214 | * repository, and provisions the instance/access IAM roles — mutually |
| 215 | * exclusive with a caller-supplied `imageRepository`. |
| 216 | */ |
| 217 | main?: string; |
| 218 | /** |
| 219 | * Named export to load from `main`. |
| 220 | * @default "default" |
| 221 | */ |
| 222 | handler?: string; |
| 223 | /** |
| 224 | * HTTP port the Effect-native program listens on (App Runner injects it |
| 225 | * as `PORT`). Only used with `main`; the low-level form configures |
| 226 | * `imageRepository.port` instead. |
| 227 | * @default 3000 |
| 228 | */ |
| 229 | port?: number; |
| 230 | /** |
| 231 | * Additional environment variables for the Effect-native container. |
| 232 | * Non-string values are JSON-encoded. |
| 233 | */ |
| 234 | env?: Record<string, any>; |
| 235 | /** |
| 236 | * Bundler configuration for the Effect-native entrypoint. Unused |
| 237 | * code is tree-shaken. `effect`, alchemy, and `@distilled.cloud` are |
| 238 | * marked pure so unused parts prune more aggressively. List extra |
| 239 | * packages with `pure.packages`, or disable with `pure: false`. |
| 240 | */ |
| 241 | build?: Bundle.BundleConfig; |
| 242 | /** |
| 243 | * Docker image build for the Effect-native form: optional full |
| 244 | * `dockerfile`. When omitted, Alchemy generates a Dockerfile for the |
| 245 | * bundled `index.mjs`. |
| 246 | */ |
| 247 | docker?: { |
| 248 | /** |
| 249 | * Base image when Alchemy generates the Dockerfile. |
| 250 | * @default public.ecr.aws/docker/library/bun:1 |
| 251 | */ |
| 252 | base?: string; |
| 253 | /** Full Dockerfile content (replaces generated Dockerfile). */ |
| 254 | dockerfile?: string; |
| 255 | }; |
| 256 | /** |
| 257 | * Whether App Runner automatically deploys new image versions pushed to |
| 258 | * the (private ECR only) repository. |
| 259 | * @default false |
| 260 | */ |
| 261 | autoDeploymentsEnabled?: boolean; |
| 262 | /** |
| 263 | * IAM role App Runner assumes to pull from private ECR (must trust |
| 264 | * `build.apprunner.amazonaws.com`). Required for |
| 265 | * `imageRepositoryType: "ECR"`, forbidden for `ECR_PUBLIC`. For the |
| 266 | * Effect-native form (`main`), Alchemy provisions and manages this role |
| 267 | * automatically. |
| 268 | */ |
| 269 | accessRoleArn?: string; |
| 270 | /** |
| 271 | * CPU, memory, and instance role for the running service. |
| 272 | */ |
| 273 | instanceConfiguration?: ServiceInstanceConfiguration; |
| 274 | /** |
| 275 | * Health check configuration. |
| 276 | */ |
| 277 | healthCheckConfiguration?: ServiceHealthCheckConfiguration; |
| 278 | /** |
| 279 | * Inbound/outbound network configuration. |
| 280 | */ |
| 281 | networkConfiguration?: ServiceNetworkConfiguration; |
| 282 | /** |
| 283 | * ARN of an App Runner auto scaling configuration. Defaults to the |
| 284 | * account's default configuration. |
| 285 | */ |
| 286 | autoScalingConfigurationArn?: string; |
| 287 | /** |
| 288 | * Observability (X-Ray tracing) configuration, referencing an |
| 289 | * `AppRunner.ObservabilityConfiguration`. |
| 290 | */ |
| 291 | observabilityConfiguration?: ServiceObservabilityProps; |
| 292 | /** |
| 293 | * Customer-managed KMS key ARN for encrypting stored copies of the |
| 294 | * image and configuration. Changing the key replaces the service. |
| 295 | * @default AWS-owned key |
| 296 | */ |
| 297 | kmsKeyArn?: string; |
| 298 | /** |
| 299 | * Keep the CloudWatch log groups App Runner auto-creates for the service |
| 300 | * (`/aws/apprunner/{serviceName}/{serviceId}/application` and |
| 301 | * `.../service`) when the service is deleted. |
| 302 | * |
| 303 | * By default they are deleted along with the service — App Runner itself |
| 304 | * leaves them behind, so every deploy+destroy cycle would otherwise |
| 305 | * strand a pair. Set this when the logs must outlive the service (an |
| 306 | * incident post-mortem, a compliance retention window). The kept groups |
| 307 | * are inert: their names embed the service id, so no future service ever |
| 308 | * writes to them, and nothing but a manual delete (or |
| 309 | * `alchemy unsafe nuke`) removes them. |
| 310 | * |
| 311 | * @default false |
| 312 | */ |
| 313 | retainLogGroups?: boolean; |
| 314 | /** |
| 315 | * User-defined tags for the service. |
| 316 | */ |
| 317 | tags?: Record<string, string>; |
| 318 | } |
| 319 | |
| 320 | export interface Service extends Resource< |
| 321 | "AWS.AppRunner.Service", |
| 322 | ServiceProps, |
| 323 | { |
| 324 | /** |
| 325 | * Name of the App Runner service. |
| 326 | */ |
| 327 | serviceName: string; |
| 328 | /** |
| 329 | * ARN of the service. |
| 330 | */ |
| 331 | serviceArn: string; |
| 332 | /** |
| 333 | * ID of the service. |
| 334 | */ |
| 335 | serviceId: string; |
| 336 | /** |
| 337 | * Default HTTPS endpoint of the service (`xxxx.awsapprunner.com`). |
| 338 | */ |
| 339 | serviceUrl: string | undefined; |
| 340 | /** |
| 341 | * Current status of the service (e.g. `RUNNING`, `OPERATION_IN_PROGRESS`). |
| 342 | */ |
| 343 | status: string; |
| 344 | /** |
| 345 | * The full URI of the container image the service runs (Effect-native |
| 346 | * form only). |
| 347 | */ |
| 348 | imageUri: string | undefined; |
| 349 | /** |
| 350 | * The name of the managed ECR repository holding the built image |
| 351 | * (Effect-native form only). |
| 352 | */ |
| 353 | repositoryName: string | undefined; |
| 354 | /** |
| 355 | * The URI of the managed ECR repository (Effect-native form only). |
| 356 | */ |
| 357 | repositoryUri: string | undefined; |
| 358 | /** |
| 359 | * The ARN of the managed instance role (Effect-native form only). |
| 360 | */ |
| 361 | instanceRoleArn: string | undefined; |
| 362 | /** |
| 363 | * The name of the managed instance role (Effect-native form only). |
| 364 | */ |
| 365 | instanceRoleName: string | undefined; |
| 366 | /** |
| 367 | * The ARN of the managed ECR access role (Effect-native form only). |
| 368 | */ |
| 369 | accessRoleArn: string | undefined; |
| 370 | /** |
| 371 | * The name of the managed ECR access role (Effect-native form only). |
| 372 | */ |
| 373 | accessRoleName: string | undefined; |
| 374 | /** |
| 375 | * The content hash of the bundled application code (Effect-native |
| 376 | * form only). |
| 377 | */ |
| 378 | codeHash: string | undefined; |
| 379 | }, |
| 380 | { |
| 381 | /** Environment variables injected into the service's containers. */ |
| 382 | env?: Record<string, any>; |
| 383 | /** IAM policy statements attached to the managed instance role. */ |
| 384 | policyStatements?: PolicyStatement[]; |
| 385 | }, |
| 386 | Providers |
| 387 | > {} |
| 388 | |
| 389 | export type ServiceServices = |
| 390 | | Credentials |
| 391 | | Region |
| 392 | | ServerHost |
| 393 | | AWSEnvironment; |
| 394 | |
| 395 | export type ServiceShape = Main<ServiceServices>; |
| 396 | |
| 397 | export interface ServiceRuntimeContext extends HostRuntimeContext { |
| 398 | readonly Type: "AWS.AppRunner.Service"; |
| 399 | } |
| 400 | |
| 401 | /** |
| 402 | * An AWS App Runner service — the zero-infrastructure way to run a |
| 403 | * container behind an HTTPS endpoint: App Runner provisions, |
| 404 | * load-balances, scales, and patches the fleet for you. Service creation |
| 405 | * and deletion are asynchronous and take several minutes; the provider |
| 406 | * waits (bounded) for operations to settle. |
| 407 | * |
| 408 | * `Service` is a Platform: alongside the low-level container-image form |
| 409 | * (`imageRepository`), it supports Effect-native implementations — an |
| 410 | * inline Effect HTTP program that Alchemy bundles, containerizes, pushes |
| 411 | * to a managed ECR repository, and deploys, provisioning the instance and |
| 412 | * ECR access roles automatically. Capability bindings (e.g. DynamoDB |
| 413 | * `GetItem`) attach IAM policy statements to the managed instance role. |
| 414 | * ### Creating a Service |
| 415 | * **Example:** Public ECR Image |
| 416 | * ```typescript |
| 417 | * const service = yield* AppRunner.Service("Hello", { |
| 418 | * imageRepository: { |
| 419 | * imageIdentifier: "public.ecr.aws/aws-containers/hello-app-runner:latest", |
| 420 | * imageRepositoryType: "ECR_PUBLIC", |
| 421 | * port: "8000", |
| 422 | * }, |
| 423 | * instanceConfiguration: { cpu: "256", memory: "512" }, |
| 424 | * }); |
| 425 | * // service.serviceUrl -> "xxxxxxxx.us-west-2.awsapprunner.com" |
| 426 | * ``` |
| 427 | * |
| 428 | * **Example:** Private ECR Image with Access Role |
| 429 | * ```typescript |
| 430 | * const service = yield* AppRunner.Service("Api", { |
| 431 | * imageRepository: { |
| 432 | * imageIdentifier: `${repository.repositoryUri}:latest`, |
| 433 | * imageRepositoryType: "ECR", |
| 434 | * port: "8080", |
| 435 | * runtimeEnvironmentVariables: { NODE_ENV: "production" }, |
| 436 | * }, |
| 437 | * accessRoleArn: accessRole.roleArn, |
| 438 | * autoDeploymentsEnabled: true, |
| 439 | * }); |
| 440 | * ``` |
| 441 | * |
| 442 | * ### Effect-Native Services |
| 443 | * **Example:** Inline Effect HTTP Program |
| 444 | * ```typescript |
| 445 | * export default class Api extends AppRunner.Service<Api>()( |
| 446 | * "Api", |
| 447 | * { |
| 448 | * main: import.meta.url, |
| 449 | * port: 3000, |
| 450 | * instanceConfiguration: { cpu: "256", memory: "512" }, |
| 451 | * }, |
| 452 | * Effect.gen(function* () { |
| 453 | * return { |
| 454 | * fetch: Effect.gen(function* () { |
| 455 | * const request = yield* HttpServerRequest; |
| 456 | * return HttpServerResponse.text("hello from app runner"); |
| 457 | * }), |
| 458 | * }; |
| 459 | * }), |
| 460 | * ) {} |
| 461 | * ``` |
| 462 | * |
| 463 | * ### Bundling & Tree-shaking |
| 464 | * `main` is bundled with rolldown at deploy time. Unused code is |
| 465 | * tree-shaken. `effect`, alchemy, and `@distilled.cloud` are marked |
| 466 | * pure so unused parts prune more aggressively. Your app is not |
| 467 | * marked pure. |
| 468 | * |
| 469 | * **Example:** Mark additional packages as pure |
| 470 | * Only list packages with no top-level side effects. |
| 471 | * ```typescript |
| 472 | * { |
| 473 | * main: import.meta.url, |
| 474 | * build: { |
| 475 | * pure: { packages: ["my-lib", "@my-scope/*"] }, |
| 476 | * }, |
| 477 | * } |
| 478 | * ``` |
| 479 | * |
| 480 | * **Example:** Turn it off |
| 481 | * ```typescript |
| 482 | * { |
| 483 | * main: import.meta.url, |
| 484 | * build: { pure: false }, |
| 485 | * } |
| 486 | * ``` |
| 487 | * |
| 488 | * ### Scaling and Networking |
| 489 | * **Example:** Custom Auto Scaling and VPC Egress |
| 490 | * ```typescript |
| 491 | * const service = yield* AppRunner.Service("Api", { |
| 492 | * imageRepository: { |
| 493 | * imageIdentifier: "public.ecr.aws/aws-containers/hello-app-runner:latest", |
| 494 | * imageRepositoryType: "ECR_PUBLIC", |
| 495 | * port: "8000", |
| 496 | * }, |
| 497 | * autoScalingConfigurationArn: scaling.autoScalingConfigurationArn, |
| 498 | * networkConfiguration: { |
| 499 | * egressType: "VPC", |
| 500 | * vpcConnectorArn: connector.vpcConnectorArn, |
| 501 | * }, |
| 502 | * }); |
| 503 | * ``` |
| 504 | * |
| 505 | * @resource |
| 506 | */ |
| 507 | export const Service: Platform< |
| 508 | Service, |
| 509 | ServiceServices, |
| 510 | ServiceShape, |
| 511 | ServiceRuntimeContext |
| 512 | > = Platform("AWS.AppRunner.Service", { |
| 513 | createRuntimeContext: createHostRuntimeContext("AWS.AppRunner.Service") as ( |
| 514 | id: string, |
| 515 | ) => ServiceRuntimeContext, |
| 516 | }); |
| 517 | |
| 518 | class AppRunnerServiceNotSettled extends Data.TaggedError( |
| 519 | "AppRunnerServiceNotSettled", |
| 520 | )<{ |
| 521 | readonly serviceArn: string; |
| 522 | readonly status: string; |
| 523 | }> {} |
| 524 | |
| 525 | /** |
| 526 | * Case-insensitive status comparison — App Runner returns lowercase |
| 527 | * statuses for some resource types despite documenting uppercase. |
| 528 | */ |
| 529 | const statusIs = (status: string | undefined, expected: string): boolean => |
| 530 | status?.toUpperCase() === expected; |
| 531 | |
| 532 | /** Normalize the vCPU-style CPU forms to the numeric wire echo. */ |
| 533 | const normalizeCpu = (cpu: string): string => |
| 534 | ({ |
| 535 | "0.25 vCPU": "256", |
| 536 | "0.5 vCPU": "512", |
| 537 | "1 vCPU": "1024", |
| 538 | "2 vCPU": "2048", |
| 539 | "4 vCPU": "4096", |
| 540 | })[cpu] ?? cpu; |
| 541 | |
| 542 | /** Normalize the GB-style memory forms to the numeric wire echo. */ |
| 543 | const normalizeMemory = (memory: string): string => |
| 544 | ({ |
| 545 | "0.5 GB": "512", |
| 546 | "1 GB": "1024", |
| 547 | "2 GB": "2048", |
| 548 | "3 GB": "3072", |
| 549 | "4 GB": "4096", |
| 550 | "6 GB": "6144", |
| 551 | "8 GB": "8192", |
| 552 | "10 GB": "10240", |
| 553 | "12 GB": "12288", |
| 554 | })[memory] ?? memory; |
| 555 | |
| 556 | /** Unwrap distilled's sensitive-string decoding to a plain value. */ |
| 557 | const plain = ( |
| 558 | value: string | Redacted.Redacted<string> | undefined, |
| 559 | ): string | undefined => |
| 560 | value === undefined || typeof value === "string" |
| 561 | ? value |
| 562 | : Redacted.value(value); |
| 563 | |
| 564 | const plainRecord = ( |
| 565 | record: |
| 566 | | { [key: string]: string | Redacted.Redacted<string> | undefined } |
| 567 | | undefined, |
| 568 | ): Record<string, string> => |
| 569 | Object.fromEntries( |
| 570 | Object.entries(record ?? {}).flatMap(([key, value]) => { |
| 571 | const v = plain(value); |
| 572 | return v === undefined ? [] : [[key, v]]; |
| 573 | }), |
| 574 | ); |
| 575 | |
| 576 | const sameRecord = ( |
| 577 | a: Record<string, string>, |
| 578 | b: Record<string, string>, |
| 579 | ): boolean => { |
| 580 | const aKeys = Object.keys(a).sort(); |
| 581 | const bKeys = Object.keys(b).sort(); |
| 582 | return ( |
| 583 | aKeys.length === bKeys.length && |
| 584 | aKeys.every((k, i) => k === bKeys[i] && a[k] === b[k]) |
| 585 | ); |
| 586 | }; |
| 587 | |
| 588 | /** Props with the source resolved (either given or built from `main`). */ |
| 589 | interface ResolvedServiceProps extends ServiceProps { |
| 590 | imageRepository: ServiceImageRepository; |
| 591 | } |
| 592 | |
| 593 | const toWireSource = ( |
| 594 | props: ResolvedServiceProps, |
| 595 | ): apprunner.SourceConfiguration => ({ |
| 596 | ImageRepository: { |
| 597 | ImageIdentifier: props.imageRepository.imageIdentifier, |
| 598 | ImageRepositoryType: props.imageRepository.imageRepositoryType, |
| 599 | ImageConfiguration: { |
| 600 | Port: props.imageRepository.port, |
| 601 | StartCommand: props.imageRepository.startCommand, |
| 602 | RuntimeEnvironmentVariables: |
| 603 | props.imageRepository.runtimeEnvironmentVariables, |
| 604 | RuntimeEnvironmentSecrets: |
| 605 | props.imageRepository.runtimeEnvironmentSecrets, |
| 606 | }, |
| 607 | }, |
| 608 | AutoDeploymentsEnabled: props.autoDeploymentsEnabled, |
| 609 | AuthenticationConfiguration: props.accessRoleArn |
| 610 | ? { AccessRoleArn: props.accessRoleArn } |
| 611 | : undefined, |
| 612 | }); |
| 613 | |
| 614 | const toWireInstance = ( |
| 615 | instance: ServiceInstanceConfiguration | undefined, |
| 616 | ): apprunner.InstanceConfiguration | undefined => |
| 617 | instance === undefined |
| 618 | ? undefined |
| 619 | : { |
| 620 | Cpu: instance.cpu, |
| 621 | Memory: instance.memory, |
| 622 | InstanceRoleArn: instance.instanceRoleArn, |
| 623 | }; |
| 624 | |
| 625 | const toWireHealthCheck = ( |
| 626 | healthCheck: ServiceHealthCheckConfiguration | undefined, |
| 627 | ): apprunner.HealthCheckConfiguration | undefined => |
| 628 | healthCheck === undefined |
| 629 | ? undefined |
| 630 | : { |
| 631 | Protocol: healthCheck.protocol, |
| 632 | Path: healthCheck.path, |
| 633 | Interval: toSeconds(healthCheck.interval), |
| 634 | Timeout: toSeconds(healthCheck.timeout), |
| 635 | HealthyThreshold: healthCheck.healthyThreshold, |
| 636 | UnhealthyThreshold: healthCheck.unhealthyThreshold, |
| 637 | }; |
| 638 | |
| 639 | const toWireNetwork = ( |
| 640 | network: ServiceNetworkConfiguration | undefined, |
| 641 | ): apprunner.NetworkConfiguration | undefined => |
| 642 | network === undefined |
| 643 | ? undefined |
| 644 | : { |
| 645 | EgressConfiguration: |
| 646 | network.egressType !== undefined || |
| 647 | network.vpcConnectorArn !== undefined |
| 648 | ? { |
| 649 | EgressType: network.egressType, |
| 650 | VpcConnectorArn: network.vpcConnectorArn, |
| 651 | } |
| 652 | : undefined, |
| 653 | IngressConfiguration: |
| 654 | network.isPubliclyAccessible !== undefined |
| 655 | ? { IsPubliclyAccessible: network.isPubliclyAccessible } |
| 656 | : undefined, |
| 657 | IpAddressType: network.ipAddressType, |
| 658 | }; |
| 659 | |
| 660 | const toWireObservability = ( |
| 661 | observability: ServiceObservabilityProps | undefined, |
| 662 | ): apprunner.ServiceObservabilityConfiguration | undefined => |
| 663 | observability === undefined |
| 664 | ? undefined |
| 665 | : { |
| 666 | ObservabilityEnabled: observability.observabilityEnabled, |
| 667 | ObservabilityConfigurationArn: |
| 668 | observability.observabilityConfigurationArn, |
| 669 | }; |
| 670 | |
| 671 | /** |
| 672 | * True when any user-specified aspect of the source configuration differs |
| 673 | * from the observed one. Only fields the user actually specified are |
| 674 | * compared — the service fills in defaults that must not trigger |
| 675 | * spurious deployments. |
| 676 | */ |
| 677 | const sourceDrifted = ( |
| 678 | news: ResolvedServiceProps, |
| 679 | observed: apprunner.SourceConfiguration | undefined, |
| 680 | ): boolean => { |
| 681 | const image = observed?.ImageRepository; |
| 682 | const config = image?.ImageConfiguration; |
| 683 | const desired = news.imageRepository; |
| 684 | return ( |
| 685 | image?.ImageIdentifier !== desired.imageIdentifier || |
| 686 | image?.ImageRepositoryType !== desired.imageRepositoryType || |
| 687 | (desired.port !== undefined && config?.Port !== desired.port) || |
| 688 | (desired.startCommand !== undefined && |
| 689 | plain(config?.StartCommand) !== desired.startCommand) || |
| 690 | (desired.runtimeEnvironmentVariables !== undefined && |
| 691 | !sameRecord( |
| 692 | plainRecord(config?.RuntimeEnvironmentVariables), |
| 693 | desired.runtimeEnvironmentVariables, |
| 694 | )) || |
| 695 | (desired.runtimeEnvironmentSecrets !== undefined && |
| 696 | !sameRecord( |
| 697 | plainRecord(config?.RuntimeEnvironmentSecrets), |
| 698 | desired.runtimeEnvironmentSecrets, |
| 699 | )) || |
| 700 | (news.autoDeploymentsEnabled !== undefined && |
| 701 | observed?.AutoDeploymentsEnabled !== news.autoDeploymentsEnabled) || |
| 702 | (news.accessRoleArn !== undefined && |
| 703 | observed?.AuthenticationConfiguration?.AccessRoleArn !== |
| 704 | news.accessRoleArn) |
| 705 | ); |
| 706 | }; |
| 707 | |
| 708 | const instanceDrifted = ( |
| 709 | desired: ServiceInstanceConfiguration | undefined, |
| 710 | observed: apprunner.InstanceConfiguration | undefined, |
| 711 | ): boolean => |
| 712 | desired !== undefined && |
| 713 | ((desired.cpu !== undefined && |
| 714 | normalizeCpu(observed?.Cpu ?? "") !== normalizeCpu(desired.cpu)) || |
| 715 | (desired.memory !== undefined && |
| 716 | normalizeMemory(observed?.Memory ?? "") !== |
| 717 | normalizeMemory(desired.memory)) || |
| 718 | (desired.instanceRoleArn !== undefined && |
| 719 | observed?.InstanceRoleArn !== desired.instanceRoleArn)); |
| 720 | |
| 721 | const healthCheckDrifted = ( |
| 722 | desired: ServiceHealthCheckConfiguration | undefined, |
| 723 | observed: apprunner.HealthCheckConfiguration | undefined, |
| 724 | ): boolean => |
| 725 | desired !== undefined && |
| 726 | ((desired.protocol !== undefined && |
| 727 | observed?.Protocol !== desired.protocol) || |
| 728 | (desired.path !== undefined && observed?.Path !== desired.path) || |
| 729 | (desired.interval !== undefined && |
| 730 | observed?.Interval !== toSeconds(desired.interval)) || |
| 731 | (desired.timeout !== undefined && |
| 732 | observed?.Timeout !== toSeconds(desired.timeout)) || |
| 733 | (desired.healthyThreshold !== undefined && |
| 734 | observed?.HealthyThreshold !== desired.healthyThreshold) || |
| 735 | (desired.unhealthyThreshold !== undefined && |
| 736 | observed?.UnhealthyThreshold !== desired.unhealthyThreshold)); |
| 737 | |
| 738 | const networkDrifted = ( |
| 739 | desired: ServiceNetworkConfiguration | undefined, |
| 740 | observed: apprunner.NetworkConfiguration | undefined, |
| 741 | ): boolean => |
| 742 | desired !== undefined && |
| 743 | ((desired.egressType !== undefined && |
| 744 | observed?.EgressConfiguration?.EgressType !== desired.egressType) || |
| 745 | (desired.vpcConnectorArn !== undefined && |
| 746 | observed?.EgressConfiguration?.VpcConnectorArn !== |
| 747 | desired.vpcConnectorArn) || |
| 748 | (desired.isPubliclyAccessible !== undefined && |
| 749 | observed?.IngressConfiguration?.IsPubliclyAccessible !== |
| 750 | desired.isPubliclyAccessible) || |
| 751 | (desired.ipAddressType !== undefined && |
| 752 | observed?.IpAddressType !== desired.ipAddressType)); |
| 753 | |
| 754 | const observabilityDrifted = ( |
| 755 | desired: ServiceObservabilityProps | undefined, |
| 756 | observed: apprunner.ServiceObservabilityConfiguration | undefined, |
| 757 | ): boolean => |
| 758 | desired !== undefined && |
| 759 | ((observed?.ObservabilityEnabled ?? false) !== desired.observabilityEnabled || |
| 760 | (desired.observabilityConfigurationArn !== undefined && |
| 761 | // App Runner echoes the resolved (revision-qualified) ARN; compare |
| 762 | // on the revision-less name partial so both forms converge. |
| 763 | !(observed?.ObservabilityConfigurationArn ?? "").startsWith( |
| 764 | desired.observabilityConfigurationArn.split("/").slice(0, 2).join("/"), |
| 765 | ))); |
| 766 | |
| 767 | /** Attributes only produced by the Effect-native (`main`) form. */ |
| 768 | interface PlatformAttributes { |
| 769 | imageUri: string | undefined; |
| 770 | repositoryName: string | undefined; |
| 771 | repositoryUri: string | undefined; |
| 772 | instanceRoleArn: string | undefined; |
| 773 | instanceRoleName: string | undefined; |
| 774 | accessRoleArn: string | undefined; |
| 775 | accessRoleName: string | undefined; |
| 776 | codeHash: string | undefined; |
| 777 | } |
| 778 | |
| 779 | const emptyPlatformAttributes: PlatformAttributes = { |
| 780 | imageUri: undefined, |
| 781 | repositoryName: undefined, |
| 782 | repositoryUri: undefined, |
| 783 | instanceRoleArn: undefined, |
| 784 | instanceRoleName: undefined, |
| 785 | accessRoleArn: undefined, |
| 786 | accessRoleName: undefined, |
| 787 | codeHash: undefined, |
| 788 | }; |
| 789 | |
| 790 | /** |
| 791 | * The two CloudWatch log groups App Runner auto-creates for a service: |
| 792 | * `.../application` (the container's stdout/stderr) and `.../service` |
| 793 | * (App Runner's own deployment and health-check events). |
| 794 | * |
| 795 | * Both names embed the AWS-generated service id, so a pair belongs to |
| 796 | * exactly one service instance — it can never be shared with, or reused |
| 797 | * by, another service. |
| 798 | */ |
| 799 | const logGroupNamesFor = (serviceName: string, serviceId: string) => [ |
| 800 | `/aws/apprunner/${serviceName}/${serviceId}/application`, |
| 801 | `/aws/apprunner/${serviceName}/${serviceId}/service`, |
| 802 | ]; |
| 803 | |
| 804 | /** |
| 805 | * Delete one of a service's auto-created log groups, idempotently. |
| 806 | * |
| 807 | * `deleteService` does NOT remove them, so without this every |
| 808 | * deploy+destroy cycle strands a pair (matching how the Lambda and ECS |
| 809 | * providers reap `/aws/lambda/{name}` and their task log group). |
| 810 | * |
| 811 | * App Runner can flush a final batch of events after the service itself |
| 812 | * is gone, silently re-creating a just-deleted group, so the delete is |
| 813 | * followed by a bounded observe→delete convergence loop. A group that |
| 814 | * never reappears — the common case — costs a single extra describe. |
| 815 | * Reaping is auxiliary to an already-completed service deletion, so a |
| 816 | * group that survives the budget warns rather than failing the destroy. |
| 817 | */ |
| 818 | const reapLogGroup = Effect.fn(function* (logGroupName: string) { |
| 819 | const deleteGroup = logs.deleteLogGroup({ logGroupName }).pipe( |
| 820 | Effect.retry({ |
| 821 | while: (error) => |
| 822 | error._tag === "OperationAbortedException" || |
| 823 | error._tag === "ServiceUnavailableException", |
| 824 | schedule: Schedule.max([ |
| 825 | Schedule.exponential("250 millis"), |
| 826 | Schedule.recurs(6), |
| 827 | ]), |
| 828 | }), |
| 829 | Effect.catchTag("ResourceNotFoundException", () => Effect.void), |
| 830 | Effect.timeoutOrElse({ |
| 831 | duration: "30 seconds", |
| 832 | orElse: () => |
| 833 | Effect.logWarning( |
| 834 | `Timed out deleting App Runner log group ${logGroupName}`, |
| 835 | ), |
| 836 | }), |
| 837 | ); |
| 838 | |
| 839 | // A describe that cannot complete in time is not deletion proof — |
| 840 | // assume the group is still present and let the bounded loop converge. |
| 841 | const observeGroup = logs |
| 842 | .describeLogGroups({ logGroupNamePrefix: logGroupName, limit: 1 }) |
| 843 | .pipe( |
| 844 | Effect.map((response) => |
| 845 | (response.logGroups ?? []).some( |
| 846 | (group) => group.logGroupName === logGroupName, |
| 847 | ), |
| 848 | ), |
| 849 | Effect.timeoutOrElse({ |
| 850 | duration: "15 seconds", |
| 851 | orElse: () => |
| 852 | Effect.logWarning( |
| 853 | `Timed out observing App Runner log group ${logGroupName} — assuming still present`, |
| 854 | ).pipe(Effect.as(true)), |
| 855 | }), |
| 856 | ); |
| 857 | |
| 858 | yield* deleteGroup; |
| 859 | |
| 860 | // Re-reaps at t=0s/5s/…/30s, each attempt idempotent. |
| 861 | const stillPresent = yield* Effect.gen(function* () { |
| 862 | const present = yield* observeGroup; |
| 863 | if (present) yield* deleteGroup; |
| 864 | return present; |
| 865 | }).pipe( |
| 866 | Effect.repeat({ |
| 867 | schedule: Schedule.spaced("5 seconds"), |
| 868 | until: (present) => !present, |
| 869 | times: 6, |
| 870 | }), |
| 871 | ); |
| 872 | |
| 873 | if (stillPresent) { |
| 874 | yield* Effect.logWarning( |
| 875 | `App Runner log group ${logGroupName} kept reappearing after delete`, |
| 876 | ); |
| 877 | } |
| 878 | }); |
| 879 | |
| 880 | export const ServiceProvider = () => |
| 881 | Provider.effect( |
| 882 | Service, |
| 883 | Effect.gen(function* () { |
| 884 | const stack = yield* Stack; |
| 885 | const docker = yield* Docker; |
| 886 | const { dotAlchemy } = yield* AlchemyContext; |
| 887 | const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin; |
| 888 | |
| 889 | const alchemyEnv = { |
| 890 | ALCHEMY_STACK_NAME: stack.name, |
| 891 | ALCHEMY_STAGE: stack.stage, |
| 892 | ALCHEMY_PHASE: "runtime", |
| 893 | }; |
| 894 | |
| 895 | const toName = (id: string, props: Partial<ServiceProps>) => |
| 896 | props.serviceName |
| 897 | ? Effect.succeed(props.serviceName) |
| 898 | : createPhysicalName({ id, maxLength: 40 }); |
| 899 | |
| 900 | const createRoleName = (id: string, suffix: string) => |
| 901 | createPhysicalName({ id: `${id}-${suffix}`, maxLength: 64 }); |
| 902 | |
| 903 | const createRepositoryName = (id: string) => |
| 904 | createPhysicalName({ |
| 905 | id: `${id}-repo`, |
| 906 | maxLength: 256, |
| 907 | lowercase: true, |
| 908 | }); |
| 909 | |
| 910 | /** |
| 911 | * Ensure an IAM role trusted by the given App Runner principal exists |
| 912 | * (creates on miss, adopts on race when it carries our tags). |
| 913 | */ |
| 914 | const ensureRole = Effect.fn(function* ({ |
| 915 | id, |
| 916 | roleName, |
| 917 | principal, |
| 918 | managedPolicyArns, |
| 919 | }: { |
| 920 | id: string; |
| 921 | roleName: string; |
| 922 | principal: string; |
| 923 | managedPolicyArns?: string[]; |
| 924 | }) { |
| 925 | const tags = yield* createInternalTags(id); |
| 926 | const role = yield* iam |
| 927 | .createRole({ |
| 928 | RoleName: roleName, |
| 929 | AssumeRolePolicyDocument: JSON.stringify({ |
| 930 | Version: "2012-10-17", |
| 931 | Statement: [ |
| 932 | { |
| 933 | Effect: "Allow", |
| 934 | Principal: { Service: principal }, |
| 935 | Action: "sts:AssumeRole", |
| 936 | }, |
| 937 | ], |
| 938 | }), |
| 939 | Tags: createTagsList(tags), |
| 940 | }) |
| 941 | .pipe( |
| 942 | Effect.catchTag("EntityAlreadyExistsException", () => |
| 943 | iam.getRole({ RoleName: roleName }).pipe( |
| 944 | Effect.filterOrFail( |
| 945 | (existing) => hasTags(tags, existing.Role?.Tags), |
| 946 | () => |
| 947 | new Error( |
| 948 | `Role '${roleName}' already exists and is not managed by alchemy`, |
| 949 | ), |
| 950 | ), |
| 951 | ), |
| 952 | ), |
| 953 | ); |
| 954 | for (const policyArn of managedPolicyArns ?? []) { |
| 955 | yield* iam |
| 956 | .attachRolePolicy({ RoleName: roleName, PolicyArn: policyArn }) |
| 957 | .pipe(Effect.catchTag("LimitExceededException", () => Effect.void)); |
| 958 | } |
| 959 | return role.Role!.Arn!; |
| 960 | }); |
| 961 | |
| 962 | /** Ensure the managed ECR repository for the built image exists. */ |
| 963 | const ensureRepository = Effect.fn(function* ({ |
| 964 | repositoryName, |
| 965 | tags, |
| 966 | }: { |
| 967 | repositoryName: string; |
| 968 | tags: Record<string, string>; |
| 969 | }) { |
| 970 | const created = yield* ecr |
| 971 | .createRepository({ |
| 972 | repositoryName, |
| 973 | imageTagMutability: "MUTABLE", |
| 974 | imageScanningConfiguration: { scanOnPush: true }, |
| 975 | tags: Object.entries(tags).map(([Key, Value]) => ({ Key, Value })), |
| 976 | }) |
| 977 | .pipe( |
| 978 | Effect.catchTag("RepositoryAlreadyExistsException", () => |
| 979 | Effect.gen(function* () { |
| 980 | const existing = yield* ecr.describeRepositories({ |
| 981 | repositoryNames: [repositoryName], |
| 982 | }); |
| 983 | return { repository: existing.repositories?.[0] }; |
| 984 | }), |
| 985 | ), |
| 986 | ); |
| 987 | const repository = created.repository; |
| 988 | if (!repository?.repositoryUri) { |
| 989 | return yield* Effect.fail( |
| 990 | new Error(`Failed to resolve ECR repository '${repositoryName}'`), |
| 991 | ); |
| 992 | } |
| 993 | return { repositoryUri: repository.repositoryUri }; |
| 994 | }); |
| 995 | |
| 996 | /** |
| 997 | * Attach binding-declared IAM policy statements to the managed |
| 998 | * instance role and collect binding-declared environment variables. |
| 999 | */ |
| 1000 | const attachBindings = Effect.fn(function* ({ |
| 1001 | roleName, |
| 1002 | policyName, |
| 1003 | bindings, |
| 1004 | }: { |
| 1005 | roleName: string; |
| 1006 | policyName: string; |
| 1007 | bindings: ResourceBinding<Service["Binding"]>[]; |
| 1008 | }) { |
| 1009 | const activeBindings = bindings.filter( |
| 1010 | ( |
| 1011 | binding: ResourceBinding<Service["Binding"]> & { action?: string }, |
| 1012 | ) => binding.action !== "delete", |
| 1013 | ); |
| 1014 | |
| 1015 | const env = activeBindings |
| 1016 | .map((binding) => binding?.data?.env) |
| 1017 | .reduce((acc, value) => ({ ...acc, ...value }), {}); |
| 1018 | |
| 1019 | const policyStatements = activeBindings.flatMap( |
| 1020 | (binding) => |
| 1021 | binding?.data?.policyStatements?.map((statement) => ({ |
| 1022 | ...statement, |
| 1023 | Sid: statement.Sid?.replace(/[^A-Za-z0-9]+/gi, ""), |
| 1024 | })) ?? [], |
| 1025 | ); |
| 1026 | |
| 1027 | if (policyStatements.length > 0) { |
| 1028 | yield* iam.putRolePolicy({ |
| 1029 | RoleName: roleName, |
| 1030 | PolicyName: policyName, |
| 1031 | PolicyDocument: JSON.stringify({ |
| 1032 | Version: "2012-10-17", |
| 1033 | Statement: policyStatements, |
| 1034 | }), |
| 1035 | }); |
| 1036 | } else { |
| 1037 | yield* iam |
| 1038 | .deleteRolePolicy({ RoleName: roleName, PolicyName: policyName }) |
| 1039 | .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void)); |
| 1040 | } |
| 1041 | |
| 1042 | return env; |
| 1043 | }); |
| 1044 | |
| 1045 | /** Bundle the Effect-native program into container-ready files. */ |
| 1046 | const bundleProgram = Effect.fn(function* (props: ServiceProps) { |
| 1047 | const handler = props.handler ?? "default"; |
| 1048 | const realMain = yield* resolveMainPath(props.main!); |
| 1049 | const cwd = yield* findCwdForBundle(realMain); |
| 1050 | |
| 1051 | const buildBundle = Effect.fn(function* ( |
| 1052 | entry: string, |
| 1053 | plugins?: rolldown.RolldownPluginOption, |
| 1054 | ) { |
| 1055 | return yield* Bundle.build( |
| 1056 | { |
| 1057 | ...props.build?.input, |
| 1058 | input: entry, |
| 1059 | cwd, |
| 1060 | platform: "node", |
| 1061 | // The container runs on `bun`; keep `bun`/`bun:*` external (the |
| 1062 | // runtime provides them) and resolve the `bun` export condition |
| 1063 | // so `@effect/platform-bun` picks its Bun implementations. |
| 1064 | external: [ |
| 1065 | "bun", |
| 1066 | "bun:*", |
| 1067 | ...((props.build?.input?.external as string[] | undefined) ?? |
| 1068 | []), |
| 1069 | ], |
| 1070 | resolve: { |
| 1071 | conditionNames: [...Bundle.BUN_CONDITION_NAMES], |
| 1072 | ...props.build?.input?.resolve, |
| 1073 | }, |
| 1074 | plugins: [props.build?.input?.plugins, plugins], |
| 1075 | }, |
| 1076 | { |
| 1077 | ...props.build?.output, |
| 1078 | format: "esm", |
| 1079 | sourcemap: props.build?.output?.sourcemap ?? false, |
| 1080 | minify: props.build?.output?.minify ?? false, |
| 1081 | entryFileNames: "index.mjs", |
| 1082 | }, |
| 1083 | props.build, |
| 1084 | ); |
| 1085 | }); |
| 1086 | |
| 1087 | const bundleOutput = props.isExternal |
| 1088 | ? yield* buildBundle(realMain) |
| 1089 | : yield* buildBundle( |
| 1090 | realMain, |
| 1091 | virtualEntryPlugin( |
| 1092 | (importPath) => ` |
| 1093 | import { bootstrap } from "alchemy/Runtime/Bootstrap/AppRunner"; |
| 1094 | import { ${handler} as entrypoint } from ${JSON.stringify(importPath)}; |
| 1095 | |
| 1096 | await bootstrap(entrypoint); |
| 1097 | `, |
| 1098 | ), |
| 1099 | ); |
| 1100 | |
| 1101 | // Return every emitted file (entry + shared chunks). Dynamic imports |
| 1102 | // in the Bun HTTP server / AWS SDK split into chunks; dropping any of |
| 1103 | // them crashes the container with `Cannot find module './chunk-X.js'`. |
| 1104 | const files = bundleOutput.files.map((file) => ({ |
| 1105 | path: file.path, |
| 1106 | content: |
| 1107 | typeof file.content === "string" |
| 1108 | ? new TextEncoder().encode(file.content) |
| 1109 | : file.content, |
| 1110 | })); |
| 1111 | |
| 1112 | return { files, hash: bundleOutput.hash }; |
| 1113 | }); |
| 1114 | |
| 1115 | /** Build + push the container image for the bundled program. */ |
| 1116 | const buildAndPushImage = Effect.fn(function* ({ |
| 1117 | id, |
| 1118 | repositoryUri, |
| 1119 | hash, |
| 1120 | files, |
| 1121 | props, |
| 1122 | port, |
| 1123 | }: { |
| 1124 | id: string; |
| 1125 | repositoryUri: string; |
| 1126 | hash: string; |
| 1127 | files: { path: string; content: Uint8Array<ArrayBufferLike> }[]; |
| 1128 | props: ServiceProps; |
| 1129 | port: number; |
| 1130 | }) { |
| 1131 | const realMain = yield* resolveMainPath(props.main!); |
| 1132 | const contextDir = yield* getStableContextDir( |
| 1133 | realMain, |
| 1134 | dotAlchemy, |
| 1135 | `${id}-image`, |
| 1136 | ); |
| 1137 | const imageUri = `${repositoryUri}:${hash}`; |
| 1138 | |
| 1139 | const generatedDockerfile = (() => { |
| 1140 | const base = |
| 1141 | props.docker?.base ?? "public.ecr.aws/docker/library/bun:1"; |
| 1142 | return [ |
| 1143 | `FROM ${base}`, |
| 1144 | `WORKDIR /app`, |
| 1145 | `COPY index.mjs /app/index.mjs`, |
| 1146 | // Copy any additional rolldown chunks (`chunk-XXX.js`, ...). |
| 1147 | // Non-trivial bundles always emit at least one; minimal bundles |
| 1148 | // emit none and the COPY no-ops. |
| 1149 | `COPY *.js /app/`, |
| 1150 | `ENV PORT=${String(port)}`, |
| 1151 | `EXPOSE ${String(port)}`, |
| 1152 | `ENTRYPOINT ["bun", "/app/index.mjs"]`, |
| 1153 | ].join("\n"); |
| 1154 | })(); |
| 1155 | |
| 1156 | const dockerfile = props.docker?.dockerfile ?? generatedDockerfile; |
| 1157 | |
| 1158 | yield* docker.materialize({ |
| 1159 | context: contextDir, |
| 1160 | dockerfile, |
| 1161 | // Entry chunk becomes `index.mjs`; all other chunks keep their |
| 1162 | // emitted `*.js` names so the entry's relative imports resolve. |
| 1163 | files: files.map((file, index) => ({ |
| 1164 | path: index === 0 ? "index.mjs" : file.path, |
| 1165 | content: file.content, |
| 1166 | })), |
| 1167 | }); |
| 1168 | // App Runner only runs x86_64 images — always build linux/amd64 |
| 1169 | // (cross-built via emulation on ARM64 hosts). |
| 1170 | return yield* buildAndPushEcrImage(docker, { |
| 1171 | imageUri, |
| 1172 | context: contextDir, |
| 1173 | platform: "linux/amd64", |
| 1174 | }); |
| 1175 | }); |
| 1176 | |
| 1177 | /** Describe a service; a missing or DELETED service reads as absent. */ |
| 1178 | const readService = Effect.fn(function* (arn: string) { |
| 1179 | const response = yield* apprunner |
| 1180 | .describeService({ ServiceArn: arn }) |
| 1181 | .pipe( |
| 1182 | Effect.catchTag("ResourceNotFoundException", () => |
| 1183 | Effect.succeed(undefined), |
| 1184 | ), |
| 1185 | ); |
| 1186 | const service = response?.Service; |
| 1187 | return service === undefined || statusIs(service.Status, "DELETED") |
| 1188 | ? undefined |
| 1189 | : service; |
| 1190 | }); |
| 1191 | |
| 1192 | /** Find a live service by name (list has no name filter). */ |
| 1193 | const findByName = Effect.fn(function* (name: string) { |
| 1194 | const summary = yield* apprunner.listServices.pages({}).pipe( |
| 1195 | Stream.map((page) => page.ServiceSummaryList ?? []), |
| 1196 | Stream.flattenIterable, |
| 1197 | Stream.filter( |
| 1198 | (s) => |
| 1199 | s.ServiceName === name && |
| 1200 | !statusIs(s.Status, "DELETED") && |
| 1201 | s.ServiceArn !== undefined, |
| 1202 | ), |
| 1203 | Stream.runHead, |
| 1204 | Effect.map(Option.getOrUndefined), |
| 1205 | ); |
| 1206 | if (!summary?.ServiceArn) return undefined; |
| 1207 | return yield* readService(summary.ServiceArn); |
| 1208 | }); |
| 1209 | |
| 1210 | // Create/update/delete are asynchronous; App Runner reports |
| 1211 | // OPERATION_IN_PROGRESS while converging. Service provisioning |
| 1212 | // typically takes 3-5 minutes; budget ~10 min (60 * 10s). |
| 1213 | const waitForSettled = Effect.fn(function* (arn: string) { |
| 1214 | return yield* readService(arn).pipe( |
| 1215 | Effect.flatMap((service) => |
| 1216 | service !== undefined && |
| 1217 | statusIs(service.Status, "OPERATION_IN_PROGRESS") |
| 1218 | ? Effect.fail( |
| 1219 | new AppRunnerServiceNotSettled({ |
| 1220 | serviceArn: arn, |
| 1221 | status: service.Status, |
| 1222 | }), |
| 1223 | ) |
| 1224 | : Effect.succeed(service), |
| 1225 | ), |
| 1226 | Effect.retry({ |
| 1227 | while: (e) => e instanceof AppRunnerServiceNotSettled, |
| 1228 | schedule: Schedule.max([ |
| 1229 | Schedule.fixed("10 seconds"), |
| 1230 | Schedule.recurs(60), |
| 1231 | ]), |
| 1232 | }), |
| 1233 | ); |
| 1234 | }); |
| 1235 | |
| 1236 | // Deletion is asynchronous too; wait until the service is gone so |
| 1237 | // dependencies (auto scaling configurations, VPC connectors) can be |
| 1238 | // deleted right after. |
| 1239 | const waitUntilGone = Effect.fn(function* (arn: string) { |
| 1240 | yield* readService(arn).pipe( |
| 1241 | Effect.flatMap((service) => { |
| 1242 | if (service === undefined) return Effect.void; |
| 1243 | if (statusIs(service.Status, "DELETE_FAILED")) { |
| 1244 | return Effect.fail( |
| 1245 | new Error( |
| 1246 | `App Runner service '${arn}' failed to delete (status: DELETE_FAILED)`, |
| 1247 | ), |
| 1248 | ); |
| 1249 | } |
| 1250 | return Effect.fail( |
| 1251 | new AppRunnerServiceNotSettled({ |
| 1252 | serviceArn: arn, |
| 1253 | status: service.Status, |
| 1254 | }), |
| 1255 | ); |
| 1256 | }), |
| 1257 | Effect.retry({ |
| 1258 | while: (e) => e instanceof AppRunnerServiceNotSettled, |
| 1259 | schedule: Schedule.max([ |
| 1260 | Schedule.fixed("10 seconds"), |
| 1261 | Schedule.recurs(60), |
| 1262 | ]), |
| 1263 | }), |
| 1264 | ); |
| 1265 | }); |
| 1266 | |
| 1267 | const toAttrs = ( |
| 1268 | service: apprunner.Service, |
| 1269 | platform: PlatformAttributes, |
| 1270 | ): Service["Attributes"] => ({ |
| 1271 | serviceName: service.ServiceName, |
| 1272 | serviceArn: service.ServiceArn, |
| 1273 | serviceId: service.ServiceId, |
| 1274 | serviceUrl: service.ServiceUrl, |
| 1275 | status: service.Status, |
| 1276 | ...platform, |
| 1277 | }); |
| 1278 | |
| 1279 | const platformAttributesOf = ( |
| 1280 | output: Service["Attributes"] | undefined, |
| 1281 | ): PlatformAttributes => |
| 1282 | output === undefined |
| 1283 | ? emptyPlatformAttributes |
| 1284 | : { |
| 1285 | imageUri: output.imageUri, |
| 1286 | repositoryName: output.repositoryName, |
| 1287 | repositoryUri: output.repositoryUri, |
| 1288 | instanceRoleArn: output.instanceRoleArn, |
| 1289 | instanceRoleName: output.instanceRoleName, |
| 1290 | accessRoleArn: output.accessRoleArn, |
| 1291 | accessRoleName: output.accessRoleName, |
| 1292 | codeHash: output.codeHash, |
| 1293 | }; |
| 1294 | |
| 1295 | return { |
| 1296 | stables: [ |
| 1297 | "serviceName", |
| 1298 | "serviceArn", |
| 1299 | "serviceId", |
| 1300 | "repositoryName", |
| 1301 | "repositoryUri", |
| 1302 | "instanceRoleArn", |
| 1303 | "instanceRoleName", |
| 1304 | "accessRoleArn", |
| 1305 | "accessRoleName", |
| 1306 | ], |
| 1307 | |
| 1308 | diff: Effect.fn(function* ({ id, olds, news }) { |
| 1309 | if (!isResolved(news)) return undefined; |
| 1310 | if ( |
| 1311 | (yield* toName(id, olds ?? {})) !== (yield* toName(id, news ?? {})) |
| 1312 | ) { |
| 1313 | return { action: "replace" } as const; |
| 1314 | } |
| 1315 | // The encryption key is create-only. |
| 1316 | if ( |
| 1317 | (news?.kmsKeyArn ?? undefined) !== (olds?.kmsKeyArn ?? undefined) |
| 1318 | ) { |
| 1319 | return { action: "replace" } as const; |
| 1320 | } |
| 1321 | }), |
| 1322 | |
| 1323 | read: Effect.fn(function* ({ id, olds, output }) { |
| 1324 | const service = output?.serviceArn |
| 1325 | ? yield* readService(output.serviceArn) |
| 1326 | : yield* findByName(yield* toName(id, olds ?? {})); |
| 1327 | if (service === undefined) return undefined; |
| 1328 | const attrs = toAttrs(service, platformAttributesOf(output)); |
| 1329 | const tags = yield* readAppRunnerTags(attrs.serviceArn); |
| 1330 | return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs); |
| 1331 | }), |
| 1332 | |
| 1333 | reconcile: Effect.fn(function* ({ |
| 1334 | id, |
| 1335 | news, |
| 1336 | bindings, |
| 1337 | output, |
| 1338 | session, |
| 1339 | }) { |
| 1340 | const name = output?.serviceName ?? (yield* toName(id, news)); |
| 1341 | const internalTags = yield* createInternalTags(id); |
| 1342 | const desiredTags = { ...internalTags, ...news.tags }; |
| 1343 | |
| 1344 | const activeBindings = ( |
| 1345 | bindings as (ResourceBinding<Service["Binding"]> & { |
| 1346 | action?: string; |
| 1347 | })[] |
| 1348 | ).filter((binding) => binding.action !== "delete"); |
| 1349 | |
| 1350 | // Effect-native form: bundle the program, build + push the image, |
| 1351 | // and provision the managed IAM roles + ECR repository, then fall |
| 1352 | // through to the same observe/ensure/sync flow with the derived |
| 1353 | // source configuration. |
| 1354 | let effective: ServiceProps = news; |
| 1355 | let platformAttributes = emptyPlatformAttributes; |
| 1356 | if (news.main !== undefined) { |
| 1357 | const instanceRoleName = |
| 1358 | output?.instanceRoleName ?? |
| 1359 | (yield* createRoleName(id, "instance-role")); |
| 1360 | const accessRoleName = |
| 1361 | output?.accessRoleName ?? |
| 1362 | (yield* createRoleName(id, "access-role")); |
| 1363 | const policyName = yield* createPhysicalName({ |
| 1364 | id: `${id}-policy`, |
| 1365 | maxLength: 128, |
| 1366 | }); |
| 1367 | const repositoryName = |
| 1368 | output?.repositoryName ?? (yield* createRepositoryName(id)); |
| 1369 | |
| 1370 | // Ensure roles + repository. Each helper is idempotent (creates |
| 1371 | // on miss, adopts on race) so the same sequence runs on initial |
| 1372 | // create, adoption, or update. |
| 1373 | const instanceRoleArn = |
| 1374 | output?.instanceRoleArn ?? |
| 1375 | (yield* ensureRole({ |
| 1376 | id, |
| 1377 | roleName: instanceRoleName, |
| 1378 | principal: "tasks.apprunner.amazonaws.com", |
| 1379 | })); |
| 1380 | const accessRoleArn = |
| 1381 | output?.accessRoleArn ?? |
| 1382 | (yield* ensureRole({ |
| 1383 | id, |
| 1384 | roleName: accessRoleName, |
| 1385 | principal: "build.apprunner.amazonaws.com", |
| 1386 | managedPolicyArns: [ |
| 1387 | "arn:aws:iam::aws:policy/service-role/AWSAppRunnerServicePolicyForECRAccess", |
| 1388 | ], |
| 1389 | })); |
| 1390 | |
| 1391 | const bindingEnv = yield* attachBindings({ |
| 1392 | roleName: instanceRoleName, |
| 1393 | policyName, |
| 1394 | bindings, |
| 1395 | }); |
| 1396 | |
| 1397 | const { repositoryUri } = |
| 1398 | output?.repositoryUri && output?.repositoryName === repositoryName |
| 1399 | ? { repositoryUri: output.repositoryUri } |
| 1400 | : yield* ensureRepository({ |
| 1401 | repositoryName, |
| 1402 | tags: desiredTags, |
| 1403 | }); |
| 1404 | |
| 1405 | const port = news.port ?? 3000; |
| 1406 | const { files, hash } = yield* bundleProgram(news); |
| 1407 | const imageUri = yield* buildAndPushImage({ |
| 1408 | id, |
| 1409 | repositoryUri, |
| 1410 | hash, |
| 1411 | files, |
| 1412 | props: news, |
| 1413 | port, |
| 1414 | }); |
| 1415 | |
| 1416 | // App Runner env values must be strings — JSON-encode the rest. |
| 1417 | const runtimeEnvironmentVariables = Object.fromEntries( |
| 1418 | Object.entries({ |
| 1419 | ...bindingEnv, |
| 1420 | ...alchemyEnv, |
| 1421 | ...news.env, |
| 1422 | }).map(([key, value]) => [ |
| 1423 | key, |
| 1424 | typeof value === "string" ? value : JSON.stringify(value), |
| 1425 | ]), |
| 1426 | ); |
| 1427 | |
| 1428 | effective = { |
| 1429 | ...news, |
| 1430 | imageRepository: { |
| 1431 | imageIdentifier: imageUri, |
| 1432 | imageRepositoryType: "ECR", |
| 1433 | port: String(port), |
| 1434 | runtimeEnvironmentVariables, |
| 1435 | }, |
| 1436 | accessRoleArn, |
| 1437 | autoDeploymentsEnabled: news.autoDeploymentsEnabled ?? false, |
| 1438 | instanceConfiguration: { |
| 1439 | ...news.instanceConfiguration, |
| 1440 | instanceRoleArn, |
| 1441 | }, |
| 1442 | }; |
| 1443 | platformAttributes = { |
| 1444 | imageUri, |
| 1445 | repositoryName, |
| 1446 | repositoryUri, |
| 1447 | instanceRoleArn, |
| 1448 | instanceRoleName, |
| 1449 | accessRoleArn, |
| 1450 | accessRoleName, |
| 1451 | codeHash: hash, |
| 1452 | }; |
| 1453 | } else if ( |
| 1454 | activeBindings.some( |
| 1455 | (binding) => |
| 1456 | (binding.data?.policyStatements?.length ?? 0) > 0 || |
| 1457 | Object.keys(binding.data?.env ?? {}).length > 0, |
| 1458 | ) |
| 1459 | ) { |
| 1460 | return yield* Effect.fail( |
| 1461 | new Error( |
| 1462 | `App Runner service '${name}' received capability bindings but uses ` + |
| 1463 | "the low-level `imageRepository` form. Bindings attach IAM policies " + |
| 1464 | "and environment variables to the Alchemy-managed instance role, " + |
| 1465 | "which only exists for Effect-native services (`main`). Either use " + |
| 1466 | "the Effect-native form or grant the permissions on your own " + |
| 1467 | "`instanceConfiguration.instanceRoleArn`.", |
| 1468 | ), |
| 1469 | ); |
| 1470 | } |
| 1471 | |
| 1472 | if (effective.imageRepository === undefined) { |
| 1473 | return yield* Effect.fail( |
| 1474 | new Error( |
| 1475 | `App Runner service '${name}' needs either \`imageRepository\` ` + |
| 1476 | "(container-image form) or `main` (Effect-native form)", |
| 1477 | ), |
| 1478 | ); |
| 1479 | } |
| 1480 | const desired = effective as ResolvedServiceProps; |
| 1481 | |
| 1482 | // 1. Observe — cloud state is authoritative; output is only an |
| 1483 | // identifier cache. |
| 1484 | let observed = output?.serviceArn |
| 1485 | ? yield* readService(output.serviceArn) |
| 1486 | : undefined; |
| 1487 | if (observed === undefined) { |
| 1488 | observed = yield* findByName(name); |
| 1489 | } |
| 1490 | |
| 1491 | // 2. Ensure — create if missing. A freshly-minted access role can |
| 1492 | // take a few seconds to become assumable by App Runner |
| 1493 | // (InvalidRequestException "Error in assuming access role") — |
| 1494 | // retry through that IAM-propagation window (bounded). |
| 1495 | if (observed === undefined) { |
| 1496 | const created = yield* apprunner |
| 1497 | .createService({ |
| 1498 | ServiceName: name, |
| 1499 | SourceConfiguration: toWireSource(desired), |
| 1500 | InstanceConfiguration: toWireInstance( |
| 1501 | desired.instanceConfiguration, |
| 1502 | ), |
| 1503 | HealthCheckConfiguration: toWireHealthCheck( |
| 1504 | desired.healthCheckConfiguration, |
| 1505 | ), |
| 1506 | NetworkConfiguration: toWireNetwork( |
| 1507 | desired.networkConfiguration, |
| 1508 | ), |
| 1509 | AutoScalingConfigurationArn: |
| 1510 | desired.autoScalingConfigurationArn, |
| 1511 | ObservabilityConfiguration: toWireObservability( |
| 1512 | desired.observabilityConfiguration, |
| 1513 | ), |
| 1514 | EncryptionConfiguration: desired.kmsKeyArn |
| 1515 | ? { KmsKey: desired.kmsKeyArn } |
| 1516 | : undefined, |
| 1517 | Tags: toWireTags(desiredTags), |
| 1518 | }) |
| 1519 | .pipe( |
| 1520 | Effect.retry({ |
| 1521 | while: (e): boolean => e._tag === "InvalidRequestException", |
| 1522 | schedule: Schedule.max([ |
| 1523 | Schedule.fixed("5 seconds"), |
| 1524 | Schedule.recurs(12), |
| 1525 | ]), |
| 1526 | }), |
| 1527 | ); |
| 1528 | observed = created.Service; |
| 1529 | } |
| 1530 | |
| 1531 | // Creation and prior updates run asynchronously — wait (bounded) |
| 1532 | // for the service to settle before mutating it. |
| 1533 | const settled = yield* waitForSettled(observed.ServiceArn); |
| 1534 | if (settled === undefined) { |
| 1535 | return yield* Effect.fail( |
| 1536 | new Error( |
| 1537 | `App Runner service '${name}' disappeared while reconciling`, |
| 1538 | ), |
| 1539 | ); |
| 1540 | } |
| 1541 | observed = settled; |
| 1542 | if (statusIs(observed.Status, "CREATE_FAILED")) { |
| 1543 | return yield* Effect.fail( |
| 1544 | new Error( |
| 1545 | `App Runner service '${name}' failed to create (status: CREATE_FAILED). ` + |
| 1546 | "Check the service's event and application logs in CloudWatch.", |
| 1547 | ), |
| 1548 | ); |
| 1549 | } |
| 1550 | |
| 1551 | // 3. Sync — compare each user-specified aspect against OBSERVED |
| 1552 | // state and send a single updateService with only the drifted |
| 1553 | // groups. |
| 1554 | const update: Omit<apprunner.UpdateServiceRequest, "ServiceArn"> = {}; |
| 1555 | if (sourceDrifted(desired, observed.SourceConfiguration)) { |
| 1556 | update.SourceConfiguration = toWireSource(desired); |
| 1557 | } |
| 1558 | if ( |
| 1559 | instanceDrifted( |
| 1560 | desired.instanceConfiguration, |
| 1561 | observed.InstanceConfiguration, |
| 1562 | ) |
| 1563 | ) { |
| 1564 | update.InstanceConfiguration = toWireInstance( |
| 1565 | desired.instanceConfiguration, |
| 1566 | ); |
| 1567 | } |
| 1568 | if ( |
| 1569 | healthCheckDrifted( |
| 1570 | desired.healthCheckConfiguration, |
| 1571 | observed.HealthCheckConfiguration, |
| 1572 | ) |
| 1573 | ) { |
| 1574 | update.HealthCheckConfiguration = toWireHealthCheck( |
| 1575 | desired.healthCheckConfiguration, |
| 1576 | ); |
| 1577 | } |
| 1578 | if ( |
| 1579 | networkDrifted( |
| 1580 | desired.networkConfiguration, |
| 1581 | observed.NetworkConfiguration, |
| 1582 | ) |
| 1583 | ) { |
| 1584 | update.NetworkConfiguration = toWireNetwork( |
| 1585 | desired.networkConfiguration, |
| 1586 | ); |
| 1587 | } |
| 1588 | if ( |
| 1589 | observabilityDrifted( |
| 1590 | desired.observabilityConfiguration, |
| 1591 | observed.ObservabilityConfiguration, |
| 1592 | ) |
| 1593 | ) { |
| 1594 | update.ObservabilityConfiguration = toWireObservability( |
| 1595 | desired.observabilityConfiguration, |
| 1596 | ); |
| 1597 | } |
| 1598 | if ( |
| 1599 | desired.autoScalingConfigurationArn !== undefined && |
| 1600 | observed.AutoScalingConfigurationSummary |
| 1601 | ?.AutoScalingConfigurationArn !== |
| 1602 | desired.autoScalingConfigurationArn |
| 1603 | ) { |
| 1604 | update.AutoScalingConfigurationArn = |
| 1605 | desired.autoScalingConfigurationArn; |
| 1606 | } |
| 1607 | |
| 1608 | if (Object.keys(update).length > 0) { |
| 1609 | yield* apprunner.updateService({ |
| 1610 | ServiceArn: observed.ServiceArn, |
| 1611 | ...update, |
| 1612 | }); |
| 1613 | const updated = yield* waitForSettled(observed.ServiceArn); |
| 1614 | if (updated === undefined) { |
| 1615 | return yield* Effect.fail( |
| 1616 | new Error( |
| 1617 | `App Runner service '${name}' disappeared while updating`, |
| 1618 | ), |
| 1619 | ); |
| 1620 | } |
| 1621 | observed = updated; |
| 1622 | } |
| 1623 | |
| 1624 | // 3b. Sync tags — diff against OBSERVED cloud tags. |
| 1625 | yield* syncAppRunnerTags(observed.ServiceArn, desiredTags); |
| 1626 | |
| 1627 | // 4. Return fresh attributes. |
| 1628 | yield* session.note(name); |
| 1629 | return toAttrs(observed, platformAttributes); |
| 1630 | }), |
| 1631 | |
| 1632 | delete: Effect.fn(function* ({ olds, output, force }) { |
| 1633 | // A service mid-operation rejects deletion with |
| 1634 | // InvalidStateException — wait (bounded) for it to settle first. |
| 1635 | const observed = yield* waitForSettled(output.serviceArn); |
| 1636 | if (observed !== undefined) { |
| 1637 | yield* apprunner |
| 1638 | .deleteService({ ServiceArn: output.serviceArn }) |
| 1639 | .pipe( |
| 1640 | Effect.catchTag("ResourceNotFoundException", () => Effect.void), |
| 1641 | // Already deleting — deletion is in progress. |
| 1642 | Effect.catchTag("InvalidStateException", () => Effect.void), |
| 1643 | ); |
| 1644 | yield* waitUntilGone(output.serviceArn); |
| 1645 | } |
| 1646 | |
| 1647 | // Effect-native form: also clean up the managed ECR repository and |
| 1648 | // IAM roles so destroying the service leaves zero leftovers. |
| 1649 | if (output.repositoryName !== undefined) { |
| 1650 | yield* ecr |
| 1651 | .deleteRepository({ |
| 1652 | repositoryName: output.repositoryName, |
| 1653 | force: true, |
| 1654 | }) |
| 1655 | .pipe( |
| 1656 | Effect.catchTag( |
| 1657 | "RepositoryNotFoundException", |
| 1658 | () => Effect.void, |
| 1659 | ), |
| 1660 | ); |
| 1661 | } |
| 1662 | for (const roleName of [ |
| 1663 | output.instanceRoleName, |
| 1664 | output.accessRoleName, |
| 1665 | ]) { |
| 1666 | if (roleName === undefined) continue; |
| 1667 | yield* iam.listRolePolicies.items({ RoleName: roleName }).pipe( |
| 1668 | Stream.mapEffect((policyName) => |
| 1669 | iam |
| 1670 | .deleteRolePolicy({ |
| 1671 | RoleName: roleName, |
| 1672 | PolicyName: policyName, |
| 1673 | }) |
| 1674 | .pipe( |
| 1675 | Effect.catchTag("NoSuchEntityException", () => Effect.void), |
| 1676 | ), |
| 1677 | ), |
| 1678 | Stream.runDrain, |
| 1679 | // The role may already be gone (delete re-run / race) — |
| 1680 | // treat a missing role as "no policies" so delete is |
| 1681 | // idempotent. |
| 1682 | Effect.catchTag("NoSuchEntityException", () => Effect.void), |
| 1683 | ); |
| 1684 | yield* iam.listAttachedRolePolicies |
| 1685 | .items({ RoleName: roleName }) |
| 1686 | .pipe( |
| 1687 | Stream.mapEffect((policy) => |
| 1688 | iam |
| 1689 | .detachRolePolicy({ |
| 1690 | RoleName: roleName, |
| 1691 | PolicyArn: policy.PolicyArn!, |
| 1692 | }) |
| 1693 | .pipe( |
| 1694 | Effect.catchTag( |
| 1695 | "NoSuchEntityException", |
| 1696 | () => Effect.void, |
| 1697 | ), |
| 1698 | ), |
| 1699 | ), |
| 1700 | Stream.runDrain, |
| 1701 | Effect.catchTag("NoSuchEntityException", () => Effect.void), |
| 1702 | ); |
| 1703 | yield* iam |
| 1704 | .deleteRole({ RoleName: roleName }) |
| 1705 | .pipe( |
| 1706 | Effect.catchTag("NoSuchEntityException", () => Effect.void), |
| 1707 | ); |
| 1708 | } |
| 1709 | |
| 1710 | // Last, because the reap can sit through App Runner's final log |
| 1711 | // flush: an interruption during that window must not strand the |
| 1712 | // repository or the roles, which cost money and block re-creates. |
| 1713 | // |
| 1714 | // `retainLogGroups` keeps them; account-wide teardown (`force`) |
| 1715 | // overrides that opt-out, since nuke's contract is to leave |
| 1716 | // nothing behind. Nuke also enumerates from the cloud, so `olds` |
| 1717 | // carries Attributes rather than Props there and the flag reads |
| 1718 | // as unset anyway. |
| 1719 | if (!olds.retainLogGroups || force) { |
| 1720 | for (const logGroupName of logGroupNamesFor( |
| 1721 | output.serviceName, |
| 1722 | output.serviceId, |
| 1723 | )) { |
| 1724 | yield* reapLogGroup(logGroupName); |
| 1725 | } |
| 1726 | } |
| 1727 | }), |
| 1728 | |
| 1729 | list: () => |
| 1730 | apprunner.listServices.pages({}).pipe( |
| 1731 | Stream.runCollect, |
| 1732 | Effect.map((chunk) => |
| 1733 | Array.from(chunk) |
| 1734 | .flatMap((page) => page.ServiceSummaryList ?? []) |
| 1735 | .flatMap((s) => |
| 1736 | s.Status !== undefined && |
| 1737 | !statusIs(s.Status, "DELETED") && |
| 1738 | s.ServiceName !== undefined && |
| 1739 | s.ServiceArn !== undefined && |
| 1740 | s.ServiceId !== undefined |
| 1741 | ? [ |
| 1742 | { |
| 1743 | serviceName: s.ServiceName, |
| 1744 | serviceArn: s.ServiceArn, |
| 1745 | serviceId: s.ServiceId, |
| 1746 | serviceUrl: s.ServiceUrl, |
| 1747 | status: s.Status, |
| 1748 | ...emptyPlatformAttributes, |
| 1749 | }, |
| 1750 | ] |
| 1751 | : [], |
| 1752 | ), |
| 1753 | ), |
| 1754 | ), |
| 1755 | }; |
| 1756 | }), |
| 1757 | ); |