1import * as apprunner from "@distilled.cloud/aws/apprunner";
2import * as logs from "@distilled.cloud/aws/cloudwatch-logs";
3import * as ecr from "@distilled.cloud/aws/ecr";
4import * as iam from "@distilled.cloud/aws/iam";
5import type { Region } from "@distilled.cloud/aws/Region";
6import * as Data from "effect/Data";
7import type * as Duration from "effect/Duration";
8import * as Effect from "effect/Effect";
9import * as Option from "effect/Option";
10import * as Redacted from "effect/Redacted";
11import * as Schedule from "effect/Schedule";
12import * as Stream from "effect/Stream";
13import type * as rolldown from "rolldown";
14import { Unowned } from "../../AdoptPolicy.ts";
15import { AlchemyContext } from "../../AlchemyContext.ts";
16import * as Bundle from "../../Bundle/Bundle.ts";
17import {
18 findCwdForBundle,
19 getStableContextDir,
20 resolveMainPath,
21} from "../../Bundle/TempRoot.ts";
22import { isResolved } from "../../Diff.ts";
23import { Docker } from "../../Docker/Docker.ts";
24import { createPhysicalName } from "../../PhysicalName.ts";
25import { Platform, type Main, type PlatformProps } from "../../Platform.ts";
26import * as Provider from "../../Provider.ts";
27import { Resource, type ResourceBinding } from "../../Resource.ts";
28import {
29 createHostRuntimeContext,
30 type HostRuntimeContext,
31 type ServerHost,
32} from "../../Server/Process.ts";
33import { Stack } from "../../Stack.ts";
34import {
35 createInternalTags,
36 createTagsList,
37 hasAlchemyTags,
38 hasTags,
39} from "../../Tags.ts";
40import { toSeconds } from "../../Util/Duration.ts";
41import type { Credentials } from "../Credentials.ts";
42import { buildAndPushEcrImage } from "../ECR/Image.ts";
43import { AWSEnvironment } from "../Environment.ts";
44import type { PolicyStatement } from "../IAM/Policy.ts";
45import type { Providers } from "../Providers.ts";
46import {
47 readAppRunnerTags,
48 syncAppRunnerTags,
49 toWireTags,
50} from "./internal.ts";
51
52/**
53 * Container image source for an App Runner service.
54 */
55export 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 */
92export 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 */
119export 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 */
157export 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 */
184export 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
196export 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
320export 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
389export type ServiceServices =
390 | Credentials
391 | Region
392 | ServerHost
393 | AWSEnvironment;
394
395export type ServiceShape = Main<ServiceServices>;
396
397export 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 */
507export 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
518class 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 */
529const statusIs = (status: string | undefined, expected: string): boolean =>
530 status?.toUpperCase() === expected;
531
532/** Normalize the vCPU-style CPU forms to the numeric wire echo. */
533const 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. */
543const 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. */
557const plain = (
558 value: string | Redacted.Redacted<string> | undefined,
559): string | undefined =>
560 value === undefined || typeof value === "string"
561 ? value
562 : Redacted.value(value);
563
564const 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
576const 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`). */
589interface ResolvedServiceProps extends ServiceProps {
590 imageRepository: ServiceImageRepository;
591}
592
593const 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
614const 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
625const 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
639const 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
660const 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 */
677const 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
708const 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
721const 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
738const 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
754const 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. */
768interface 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
779const 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 */
799const 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 */
818const 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
880export 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) => `
1093import { bootstrap } from "alchemy/Runtime/Bootstrap/AppRunner";
1094import { ${handler} as entrypoint } from ${JSON.stringify(importPath)};
1095
1096await 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 );