1import * as logs from "@distilled.cloud/aws/cloudwatch-logs";
2import * as ec2 from "@distilled.cloud/aws/ec2";
3import * as ecs from "@distilled.cloud/aws/ecs";
4import * as elbv2 from "@distilled.cloud/aws/elastic-load-balancing-v2";
5import * as iam from "@distilled.cloud/aws/iam";
6import * as route53 from "@distilled.cloud/aws/route-53";
7import type { Region } from "@distilled.cloud/aws/Region";
8import * as Data from "effect/Data";
9import type * as Duration from "effect/Duration";
10import * as Effect from "effect/Effect";
11import * as Option from "effect/Option";
12import * as Schedule from "effect/Schedule";
13import * as Stream from "effect/Stream";
14import type { HttpClient } from "effect/http/HttpClient";
15import { deepEqual, isResolved } from "../../Diff.ts";
16import * as Namespace from "../../Namespace.ts";
17import * as Output from "../../Output.ts";
18import { createPhysicalName } from "../../PhysicalName.ts";
19import { Platform, type Main, type PlatformProps } from "../../Platform.ts";
20import * as Provider from "../../Provider.ts";
21import { Resource } from "../../Resource.ts";
22import type { HostRuntimeContext, ServerHost } from "../../Server/Process.ts";
23import { Stack } from "../../Stack.ts";
24import { createInternalTags, diffTags } from "../../Tags.ts";
25import { toMillis, toSeconds, toWireSeconds } from "../../Util/Duration.ts";
26import { Certificate } from "../ACM/Certificate.ts";
27import { ScalableTarget } from "../ApplicationAutoScaling/ScalableTarget.ts";
28import { ScalingPolicy } from "../ApplicationAutoScaling/ScalingPolicy.ts";
29import { Service as CloudMapService } from "../CloudMap/Service.ts";
30import type { Credentials } from "../Credentials.ts";
31import { findPublicHostedZoneId } from "../Route53/HostedZoneLookup.ts";
32import { Record as Route53Record } from "../Route53/Record.ts";
33import {
34 SecurityGroup,
35 type SecurityGroupId,
36 type SecurityGroupRuleData,
37} from "../EC2/SecurityGroup.ts";
38import type { SubnetId } from "../EC2/Subnet.ts";
39import type { VpcId } from "../EC2/Vpc.ts";
40import type { ListenerAction, ListenerRuleCondition } from "../ELBv2/common.ts";
41import { Listener } from "../ELBv2/Listener.ts";
42import { ListenerRule } from "../ELBv2/ListenerRule.ts";
43import { LoadBalancer } from "../ELBv2/LoadBalancer.ts";
44import { TargetGroup, type TargetGroupArn } from "../ELBv2/TargetGroup.ts";
45import {
46 makeBunBootstrap,
47 makeImageSource,
48 type BundledImageSource,
49 type DockerfileImageSource,
50 type ImageSourceLike,
51 type RegistryImageSource,
52} from "../ECR/ImageSource.ts";
53import { AWSEnvironment, type AccountID } from "../Environment.ts";
54import type { RegionID } from "../Region.ts";
55import type { Providers } from "../Providers.ts";
56import type { ClusterArn } from "./Cluster.ts";
57import {
58 attachTaskBindings,
59 createContainerRuntimeContext,
60 createTaskRoleIfNotExists,
61 deleteTaskDefinitionInfrastructure,
62 ensureTaskExecutionRole,
63 ensureTaskLogGroup,
64 reapSupersededTaskDefinitionRevision,
65 registerTaskDefinitionRevision,
66 syncEnvironmentFilesPolicy,
67 syncTaskDefinitionTags,
68 taskImagePlatform,
69 type TaskBindingContract,
70 type TaskDefinitionConfig,
71} from "./Task.ts";
72
73export type ServiceName = string;
74export type ServiceArn =
75 `arn:aws:ecs:${RegionID}:${AccountID}:service/${string}/${ServiceName}`;
76
77export const isService = (value: any): value is Service => {
78 return (
79 typeof value === "object" &&
80 value !== null &&
81 "Type" in value &&
82 value.Type === "AWS.ECS.Service"
83 );
84};
85
86// ───────────────────────────────────────────────────────────────────────────
87// Managed load balancer (owned + shared) — types
88// ───────────────────────────────────────────────────────────────────────────
89
90/** ALB (layer-7) listener protocols. */
91export type ServiceApplicationProtocol = "http" | "https";
92
93/** NLB (layer-4) listener protocols. */
94export type ServiceNetworkProtocol = "tcp" | "udp" | "tcp_udp" | "tls";
95
96/**
97 * Listener protocols supported by managed service ingress. `http`/`https`
98 * compose an Application Load Balancer; `tcp`/`udp`/`tcp_udp`/`tls` compose a
99 * Network Load Balancer. Mixing the two families in one service is a typed
100 * error ({@link MixedLoadBalancerProtocols}).
101 */
102export type ServiceListenerProtocol =
103 | ServiceApplicationProtocol
104 | ServiceNetworkProtocol;
105
106const NETWORK_PROTOCOLS: ReadonlySet<string> = new Set([
107 "tcp",
108 "udp",
109 "tcp_udp",
110 "tls",
111]);
112
113/** A `"80/http"`-style port/protocol spec. */
114export type ServiceListenSpec = `${number}/${ServiceListenerProtocol}`;
115
116/**
117 * A routing rule for the service's managed load balancer. Conditions are FLAT
118 * on the rule (no `conditions: {}` wrapper) and are AND-ed together; a rule
119 * with no conditions matches every request (`path: "/*"` on a shared
120 * listener, or becomes the owned listener's default action).
121 */
122export interface ServiceLoadBalancerRule {
123 /**
124 * Where the rule listens. A `"80/http"`-style string means the service OWNS
125 * the listener (and its ALB); an `ELBv2.Listener` reference means the rule
126 * attaches to that existing (shared) listener. Omitting it falls back to
127 * the config-level `listener` (shared) or the service's default owned
128 * listener. Mixing owned strings and shared references within one service
129 * is a typed error ({@link MixedListenerOwnership}).
130 */
131 listen?: ServiceListenSpec | Listener;
132 /**
133 * Forward matched requests to the container at this port/protocol. A target
134 * group is created per distinct `forward` + `container` pair.
135 * @default the main container's port
136 */
137 forward?: ServiceListenSpec;
138 /**
139 * Redirect matched requests (HTTP 301) to this port/protocol. Mutually
140 * exclusive with {@link forward}.
141 */
142 redirect?: ServiceListenSpec;
143 /**
144 * Name of the container receiving traffic — the main container by default,
145 * or a sidecar's container name.
146 */
147 container?: string;
148 /** Match on the request path (`*` and `?` wildcards). */
149 path?: string | string[];
150 /** Match on the `Host` header (`*` and `?` wildcards). */
151 host?: string | string[];
152 /** Match on a named HTTP header. */
153 header?: { name: string; values: string[] };
154 /** Match on query-string key/value pairs. */
155 query?: { key?: string; value: string }[];
156 /**
157 * The rule's evaluation priority (1–50000, lower first). When omitted, a
158 * deterministic priority is derived by hashing the rule's namespaced
159 * logical id — stable across deploys and distinct across services. On a
160 * live collision the deploy fails with a typed
161 * `ListenerRulePriorityInUse` error naming the priority; set an explicit
162 * `priority` to resolve it (the engine never probes for free slots).
163 */
164 priority?: number;
165}
166
167/** Object form of the {@link ServiceLoadBalancerConfig.domain} prop. */
168export interface ServiceDomainConfig {
169 /** Domain name pointed at the owned load balancer, e.g. `api.example.com`. */
170 name: string;
171 /**
172 * Additional domain names aliased to the load balancer. Each alias gets
173 * its own Route 53 alias records and (when the certificate is composed) a
174 * subject alternative name on the ACM certificate.
175 */
176 aliases?: string[];
177 /**
178 * ARN of an existing ACM certificate (in the service's region) for the
179 * HTTPS/TLS listener. When omitted, a DNS-validated `AWS.ACM.Certificate`
180 * is composed in the matching Route 53 hosted zone.
181 */
182 cert?: string;
183}
184
185/**
186 * Per-target-group health-check overrides, keyed by the target's
187 * `"{port}/{protocol}"` spec (see {@link ServiceLoadBalancerConfig.health}).
188 */
189export interface ServiceTargetHealthCheck {
190 /** Health-check path (HTTP/HTTPS checks). */
191 path?: string;
192 /** Approximate interval between checks, e.g. `"15 seconds"`. */
193 interval?: Duration.Input;
194 /** Time to wait for a response, e.g. `"5 seconds"`. */
195 timeout?: Duration.Input;
196 /** Consecutive successes before a target is healthy. */
197 healthyThreshold?: number;
198 /** Consecutive failures before a target is unhealthy. */
199 unhealthyThreshold?: number;
200 /** HTTP codes counted as healthy, e.g. `"200-299"`. */
201 successCodes?: string;
202}
203
204/** Object form of the {@link ServicePropsBase.loadBalancer} prop. */
205export interface ServiceLoadBalancerConfig {
206 /**
207 * Default (shared) listener for rules that omit `listen`. Referencing an
208 * `ELBv2.Listener` means the service only creates target groups and
209 * listener rules — the ALB and listener belong to whoever composed them.
210 */
211 listener?: Listener;
212 /** Routing rules. Defaults to a single catch-all (`path: "/*"`) rule when a `listener` is referenced. */
213 rules?: ServiceLoadBalancerRule[];
214 /**
215 * Owned-only: whether the composed ALB is internet-facing (`true`, the
216 * default) or internal (`false`). A typed error on shared listeners.
217 */
218 public?: boolean;
219 /**
220 * Owned-only: point a custom domain at the composed load balancer.
221 *
222 * A matching Route 53 hosted zone must exist (looked up by walking the
223 * domain's labels); alias A + AAAA records are composed for the domain and
224 * every alias. Unless {@link ServiceDomainConfig.cert} supplies an
225 * existing certificate ARN, a DNS-validated `AWS.ACM.Certificate` is
226 * composed in the service's region and attached to the HTTPS listener
227 * (the default listener becomes `443/https`). The service `url` prefers
228 * the domain.
229 */
230 domain?: string | ServiceDomainConfig;
231 /**
232 * Per-target-group health-check overrides, keyed by the target's
233 * `"{port}/{protocol}"` spec — the rule's `forward` spec, or
234 * `"{containerPort}/http"` (`/tcp` for network load balancers) for the
235 * default target group. Overrides the fast-converge defaults
236 * (10s interval / 2 healthy / 2 unhealthy). A key matching no target
237 * group is a typed error ({@link ServiceHealthTargetNotFound}).
238 */
239 health?: Record<string, ServiceTargetHealthCheck>;
240}
241
242/**
243 * @internal Resolved managed-ingress wiring computed by the Service factory's
244 * composition step — never set by hand. Carries the composed (or shared)
245 * ELBv2 child-resource outputs into the core service provider.
246 */
247export interface ServiceManagedIngress {
248 /** Whether the service owns its ALB or shares a foreign listener. */
249 kind: "owned" | "shared";
250 /** ARN of the (owned or shared) load balancer, for URL derivation. */
251 loadBalancerArn?: string;
252 /** ARN of the primary listener. */
253 listenerArn?: string;
254 /** Port of the primary listener. */
255 listenerPort?: number;
256 /** Protocol of the primary listener (`HTTP` / `HTTPS`). */
257 listenerProtocol?: string;
258 /** Id of the composed managed security group, when no `securityGroups` were supplied. */
259 securityGroupId?: string;
260 /** Custom domain pointed at the owned load balancer (URL derivation prefers it). */
261 domain?: string;
262 /** Target groups to wire into the ECS service definition. */
263 targets: {
264 /** ARN of the composed target group. */
265 targetGroupArn: string;
266 /** Container port receiving traffic. Defaults to the main container's port. */
267 containerPort?: number;
268 /** Container name receiving traffic. Defaults to the main container. */
269 container?: string;
270 }[];
271}
272
273/** Owned `"80/http"` listen strings mixed with shared `ELBv2.Listener` references in one service. */
274export class MixedListenerOwnership extends Data.TaggedError(
275 "MixedListenerOwnership",
276)<{
277 readonly serviceId: string;
278 readonly message: string;
279}> {}
280
281/** A rule declared both `forward` and `redirect` (mutually exclusive). */
282export class ServiceRuleActionConflict extends Data.TaggedError(
283 "ServiceRuleActionConflict",
284)<{
285 readonly serviceId: string;
286 readonly ruleIndex: number;
287 readonly message: string;
288}> {}
289
290/** A `listen`/`forward`/`redirect` spec used a protocol outside the supported set. */
291export class UnsupportedListenerProtocol extends Data.TaggedError(
292 "UnsupportedListenerProtocol",
293)<{
294 readonly serviceId: string;
295 readonly spec: string;
296 readonly message: string;
297}> {}
298
299/** An owned `https` listener was requested without a `certificateArn`. */
300export class MissingListenerCertificate extends Data.TaggedError(
301 "MissingListenerCertificate",
302)<{
303 readonly serviceId: string;
304 readonly spec: string;
305 readonly message: string;
306}> {}
307
308/** An owned-only option (e.g. `public`) was set while sharing a foreign listener. */
309export class OwnedOnlyLoadBalancerOption extends Data.TaggedError(
310 "OwnedOnlyLoadBalancerOption",
311)<{
312 readonly serviceId: string;
313 readonly option: string;
314 readonly message: string;
315}> {}
316
317/** A rule has neither its own `listen` nor a config-level default `listener`. */
318export class MissingRuleListener extends Data.TaggedError(
319 "MissingRuleListener",
320)<{
321 readonly serviceId: string;
322 readonly ruleIndex: number;
323 readonly message: string;
324}> {}
325
326/** Application (`http`/`https`) and network (`tcp`/`udp`/`tls`/`tcp_udp`) protocols mixed in one service. */
327export class MixedLoadBalancerProtocols extends Data.TaggedError(
328 "MixedLoadBalancerProtocols",
329)<{
330 readonly serviceId: string;
331 readonly message: string;
332}> {}
333
334/** A network (NLB) rule used a feature NLB listeners don't support (conditions, `redirect`, shared listeners, or a missing/duplicate `listen`). */
335export class NetworkListenerRuleUnsupported extends Data.TaggedError(
336 "NetworkListenerRuleUnsupported",
337)<{
338 readonly serviceId: string;
339 readonly ruleIndex: number;
340 readonly message: string;
341}> {}
342
343/** No public Route 53 hosted zone matches the requested `domain`. */
344export class ServiceHostedZoneNotFound extends Data.TaggedError(
345 "ServiceHostedZoneNotFound",
346)<{
347 readonly serviceId: string;
348 readonly domainName: string;
349 readonly message: string;
350}> {}
351
352/** A `health` key matched none of the service's composed target groups. */
353export class ServiceHealthTargetNotFound extends Data.TaggedError(
354 "ServiceHealthTargetNotFound",
355)<{
356 readonly serviceId: string;
357 readonly key: string;
358 readonly message: string;
359}> {}
360
361/** `scaling.requestCount` needs a managed (owned or shared) target group to track. */
362export class RequestCountScalingRequiresLoadBalancer extends Data.TaggedError(
363 "RequestCountScalingRequiresLoadBalancer",
364)<{
365 readonly serviceId: string;
366 readonly message: string;
367}> {}
368
369/** An ECS service did not converge to the requested deployment before timeout. */
370export class ServiceDidNotStabilize extends Data.TaggedError(
371 "ServiceDidNotStabilize",
372)<{
373 readonly clusterArn: string;
374 readonly serviceName: string;
375 readonly expectedTaskDefinitionArn: string | undefined;
376 readonly status: string | undefined;
377 readonly desiredCount: number | undefined;
378 readonly runningCount: number | undefined;
379 readonly pendingCount: number | undefined;
380 readonly deploymentCount: number;
381 readonly unhealthyTargetCount: number;
382 readonly message: string;
383}> {}
384
385/**
386 * Derive a deterministic listener-rule priority (1–50000) from the rule's
387 * namespaced logical id (FNV-1a 32-bit). Stable across deploys for the same
388 * id and distinct for distinct ids; on a live `PriorityInUse` collision the
389 * deploy fails with a typed error rather than probing for a free slot.
390 */
391export const deriveRulePriority = (namespacedLogicalId: string): number => {
392 let hash = 0x811c9dc5;
393 for (let i = 0; i < namespacedLogicalId.length; i++) {
394 hash ^= namespacedLogicalId.charCodeAt(i);
395 hash = Math.imul(hash, 0x01000193) >>> 0;
396 }
397 return (hash % 50000) + 1;
398};
399
400/**
401 * Autoscaling configuration for the service's desired count. Composes an
402 * `AWS.ApplicationAutoScaling.ScalableTarget` plus one target-tracking
403 * `ScalingPolicy` per declared metric under the service's namespace.
404 */
405export interface ServiceScalingConfig {
406 /**
407 * Minimum number of running tasks Application Auto Scaling may scale in to.
408 * @default 1
409 */
410 min?: number;
411 /**
412 * Maximum number of running tasks Application Auto Scaling may scale out to.
413 * @default `min`
414 */
415 max?: number;
416 /** Target average CPU utilization (percent) to track. */
417 cpuUtilization?: number;
418 /** Target average memory utilization (percent) to track. */
419 memoryUtilization?: number;
420 /**
421 * Target `ALBRequestCountPerTarget` to track. Requires a managed load
422 * balancer target group ({@link ServicePropsBase.loadBalancer}) — a typed
423 * error ({@link RequestCountScalingRequiresLoadBalancer}) otherwise.
424 */
425 requestCount?: number;
426 /** Cooldown after a scale-in activity, e.g. `"5 minutes"`. */
427 scaleInCooldown?: Duration.Input;
428 /** Cooldown after a scale-out activity, e.g. `"1 minute"`. */
429 scaleOutCooldown?: Duration.Input;
430}
431
432/**
433 * Fargate capacity split for the service. `"spot"` runs everything on
434 * `FARGATE_SPOT`; the object form weights on-demand (`fargate`) against
435 * `spot` capacity. Normalized into
436 * {@link ServicePropsBase.capacityProviderStrategy}. The cluster must have
437 * the `FARGATE` / `FARGATE_SPOT` capacity providers associated (see
438 * `AWS.ECS.Cluster`'s `capacityProviders` prop).
439 */
440export type ServiceCapacityConfig =
441 | "spot"
442 | {
443 /** On-demand Fargate share. */
444 fargate?: {
445 /** Baseline task count placed on this provider before weights apply. */
446 base?: number;
447 /** Relative share of tasks placed on this provider. */
448 weight: number;
449 };
450 /** Fargate Spot share. */
451 spot?: {
452 /** Baseline task count placed on this provider before weights apply. */
453 base?: number;
454 /** Relative share of tasks placed on this provider. */
455 weight: number;
456 };
457 };
458
459/**
460 * Cloud Map service-discovery registration. Composes an
461 * `AWS.CloudMap.Service` in the given namespace and wires it into the ECS
462 * service's `serviceRegistries`.
463 */
464export interface ServiceRegistryConfig {
465 /**
466 * The Cloud Map namespace to register in — an
467 * `AWS.CloudMap.PrivateDnsNamespace` or `HttpNamespace` (anything with a
468 * `namespaceId`).
469 */
470 namespace: { namespaceId: string };
471 /**
472 * SRV port to publish. When set, the composed Cloud Map service uses SRV
473 * records; otherwise A records (DNS namespaces) or API-only discovery
474 * (HTTP namespaces).
475 */
476 port?: number;
477}
478
479/** Container-level health check (Docker `HEALTHCHECK` shape) for the primary container. */
480export interface ServiceContainerHealthCheck {
481 /**
482 * The check command, e.g.
483 * `["CMD-SHELL", "curl -f http://localhost/ || exit 1"]`.
484 */
485 command: string[];
486 /** Seconds-granularity period between checks, e.g. `"30 seconds"`. */
487 interval?: Duration.Input;
488 /** Time to wait for a check before counting it failed, e.g. `"5 seconds"`. */
489 timeout?: Duration.Input;
490 /** Consecutive failures before the container is unhealthy. */
491 retries?: number;
492 /** Grace period before failed checks count, e.g. `"10 seconds"`. */
493 startPeriod?: Duration.Input;
494}
495
496/** Retention policy for the service's auto-created CloudWatch log group. */
497export interface ServiceLoggingConfig {
498 /**
499 * How long to retain logs, e.g. `"2 weeks"`, or `"forever"` to clear the
500 * retention policy. Rounded up to the nearest CloudWatch-supported
501 * retention. When omitted the log group's existing retention is left
502 * untouched (new log groups default to never-expire).
503 */
504 retention?: Duration.Input | "forever";
505}
506
507/** EFS volume sugar for {@link ImageOwningServicePropsBase.volumes}. */
508export interface ServiceEfsVolume {
509 /**
510 * The EFS file system to mount — an `AWS.EFS.FileSystem` (anything with a
511 * `fileSystemId`), or `{ fileSystem, accessPoint }` to mount through an
512 * `AWS.EFS.AccessPoint`. Transit encryption is always enabled.
513 */
514 efs:
515 | { fileSystemId: string }
516 | {
517 /** The EFS file system. */
518 fileSystem: { fileSystemId: string };
519 /** Optional access point to mount through. */
520 accessPoint?: { accessPointId: string };
521 };
522 /** Container path to mount the file system at, e.g. `"/mnt/data"`. */
523 path: string;
524}
525
526export interface ServicePropsBase extends PlatformProps {
527 /**
528 * ECS cluster that will own the service.
529 */
530 cluster: ClusterArn | { clusterArn: ClusterArn };
531
532 /**
533 * Name of the ECS service.
534 * If omitted, a unique name will be generated.
535 *
536 * Changing this replaces the service (delete-first).
537 */
538 serviceName?: string;
539
540 /**
541 * Desired number of running tasks. Updated in place.
542 * @default 1
543 */
544 desiredCount?: number;
545
546 /**
547 * VPC that hosts the service networking and optional public ingress.
548 * When omitted (together with {@link subnets}), the account's default VPC
549 * is used.
550 */
551 vpcId?: string;
552
553 /**
554 * Subnets used by the service's awsvpc network configuration. Updated in
555 * place via `updateService`. When omitted (together with {@link vpcId}),
556 * the default VPC's per-AZ default subnets are used.
557 */
558 subnets?: string[];
559
560 /**
561 * Security groups attached to the service ENIs and any Alchemy-managed
562 * load balancer. When omitted and {@link loadBalancer} is set, Alchemy
563 * provisions (and owns) a security group that admits the listener port
564 * from anywhere and the container port from within the group.
565 */
566 securityGroups?: string[];
567
568 /**
569 * Whether the service ENIs should receive public IPs.
570 * @default false — but `true` when networking defaulted to the default
571 * VPC (public subnets need a public IP to pull images without a NAT).
572 */
573 assignPublicIp?: boolean;
574
575 /**
576 * Launch type for the service. Mutually exclusive with
577 * {@link capacityProviderStrategy}. Switching between launch type and
578 * capacity-provider strategy replaces the service.
579 * @default "FARGATE"
580 */
581 launchType?: ecs.LaunchType;
582
583 /**
584 * Capacity provider strategy for the service (e.g. `FARGATE`/`FARGATE_SPOT`
585 * weights, or a custom ASG-backed provider). Mutually exclusive with
586 * {@link launchType}. Switching to/from a launch type replaces the service;
587 * weight/base changes apply in place.
588 */
589 capacityProviderStrategy?: ecs.CapacityProviderStrategyItem[];
590
591 /**
592 * Fargate capacity sugar: `"spot"` runs every task on `FARGATE_SPOT`, or
593 * weight on-demand against spot capacity. Normalized into
594 * {@link capacityProviderStrategy} (which it must not be combined with).
595 * The cluster needs the Fargate capacity providers associated.
596 */
597 capacity?: ServiceCapacityConfig;
598
599 /**
600 * Autoscale the service's desired count: composes an Application Auto
601 * Scaling scalable target (bounded by `min`/`max`) plus a target-tracking
602 * policy per declared metric (`cpuUtilization`, `memoryUtilization`,
603 * `requestCount`). While set, deploys stop pinning `desiredCount` so the
604 * autoscaler's decisions survive redeploys.
605 */
606 scaling?: ServiceScalingConfig;
607
608 /**
609 * Register the service in an AWS Cloud Map namespace: composes an
610 * `AWS.CloudMap.Service` and wires it into the ECS service's
611 * {@link serviceRegistries}.
612 */
613 serviceRegistry?: ServiceRegistryConfig;
614
615 /**
616 * Load balancer target groups to wire to the service. **User-supplied** —
617 * Alchemy does NOT create these. Each entry references an existing ELBv2
618 * target group (or CLB) plus the container/port that receives traffic.
619 * Updated in place for rolling deployments.
620 *
621 * For an Alchemy-managed public ALB instead, set {@link loadBalancer} to
622 * `true`.
623 */
624 loadBalancers?: ecs.LoadBalancer[];
625
626 /**
627 * Cloud Map service registries (service discovery) to associate with the
628 * service.
629 */
630 serviceRegistries?: ecs.ServiceRegistry[];
631
632 /**
633 * Managed load balancing for the service, composed as REAL
634 * `AWS.ELBv2.LoadBalancer` / `Listener` / `TargetGroup` / `ListenerRule`
635 * child resources under the service's namespace:
636 *
637 * - `true` — the service OWNS a public ALB with a single HTTP listener
638 * forwarding to the container port. The `url` attribute is populated
639 * from the ALB's DNS name.
640 * - an `ELBv2.Listener` reference — the listener (and its ALB) are SHARED:
641 * the service only creates a target group and a catch-all
642 * (`path: "/*"`) listener rule on that listener. Destroying the service
643 * removes its rules and target groups; the shared listener/ALB are never
644 * touched.
645 * - an object — `listener` (default shared listener) + `rules` (path/host/
646 * header/query routing, `forward`/`redirect` actions) + `public`
647 * (owned-only; `false` composes an internal ALB). Rules with
648 * `"80/http"`-style `listen` strings make the service own the ALB and
649 * those listeners; `ELBv2.Listener` references share existing ones.
650 * Mixing both in one service is a typed error.
651 *
652 * Migration note: services deployed before composed ingress recorded their
653 * inline-created ALB/TG/listener/security group in the service's own
654 * attributes. The first deploy under the composed shape performs a
655 * breaking redeploy — new composed resources are created and the legacy
656 * inline infrastructure is reaped (deleted) so nothing is stranded.
657 * @default false
658 */
659 loadBalancer?: boolean | Listener | ServiceLoadBalancerConfig;
660
661 /**
662 * Legacy alias for {@link loadBalancer}.
663 * @deprecated use `loadBalancer: true`
664 */
665 public?: boolean;
666
667 /**
668 * @internal Resolved managed-ingress wiring computed by the Service
669 * factory's composition step. Never set by hand.
670 */
671 ingress?: ServiceManagedIngress;
672
673 /**
674 * Listener port for generated public ingress.
675 * @default 80 when `certificateArn` is omitted, otherwise 443
676 */
677 listenerPort?: number;
678
679 /**
680 * ACM certificate ARN for HTTPS public ingress.
681 * When provided, the generated listener uses HTTPS.
682 */
683 certificateArn?: string;
684
685 /**
686 * Target group health check path for public HTTP services.
687 * @default "/"
688 */
689 healthCheckPath?: string;
690
691 /**
692 * Fargate platform version for the service. Updated in place.
693 */
694 platformVersion?: string;
695
696 /**
697 * Raw ECS deployment configuration (rolling update percentages, circuit
698 * breaker, deployment strategy, alarms). Updated in place.
699 */
700 deploymentConfiguration?: ecs.DeploymentConfiguration;
701
702 /**
703 * Maximum time to wait for an ECS deployment, old-task drain, and target
704 * health to converge before failing the resource operation. The provider
705 * hard-caps this at 30 minutes so polling always remains bounded.
706 * @default "10 minutes"
707 */
708 deploymentStabilizationTimeout?: Duration.Input;
709
710 /**
711 * Deployment controller (`ECS`, `CODE_DEPLOY`, `EXTERNAL`). The controller
712 * type is immutable — changing it replaces the service.
713 */
714 deploymentController?: ecs.DeploymentController;
715
716 /**
717 * Placement constraints (`distinctInstance` / `memberOf`). Updated in place.
718 */
719 placementConstraints?: ecs.PlacementConstraint[];
720
721 /**
722 * Placement strategy (`random` / `spread` / `binpack`). Updated in place.
723 */
724 placementStrategy?: ecs.PlacementStrategy[];
725
726 /**
727 * Scheduling strategy. `REPLICA` runs and maintains `desiredCount` copies;
728 * `DAEMON` runs one task per eligible instance. Immutable — changing it
729 * replaces the service.
730 * @default "REPLICA"
731 */
732 schedulingStrategy?: ecs.SchedulingStrategy;
733
734 /**
735 * Whether to enable ECS Exec on the service tasks. Updated in place.
736 * @default false
737 */
738 enableExecuteCommand?: boolean;
739
740 /**
741 * Whether to enable ECS managed tags. Immutable post-create.
742 * @default true
743 */
744 enableECSManagedTags?: boolean;
745
746 /**
747 * How to propagate tags to tasks (`TASK_DEFINITION`, `SERVICE`, `NONE`).
748 * Updated in place.
749 */
750 propagateTags?: ecs.PropagateTags;
751
752 /**
753 * Availability zone rebalancing behavior. Updated in place.
754 */
755 availabilityZoneRebalancing?: ecs.AvailabilityZoneRebalancing;
756
757 /**
758 * ECS Service Connect configuration. Updated in place.
759 */
760 serviceConnectConfiguration?: ecs.ServiceConnectConfiguration;
761
762 /**
763 * Service-managed volume configurations. Updated in place.
764 */
765 volumeConfigurations?: ecs.ServiceVolumeConfiguration[];
766
767 /**
768 * IAM role for the ELB integration (only for non-awsvpc / CLB services).
769 * Immutable — changing it replaces the service.
770 */
771 role?: string;
772
773 /**
774 * Grace period before ECS starts evaluating target health checks, e.g.
775 * `"30 seconds"` or `Duration.seconds(30)`. Rounded to whole seconds on
776 * the wire. Updated in place.
777 */
778 healthCheckGracePeriod?: Duration.Input;
779
780 /**
781 * User-defined tags to apply to the ECS service and generated ingress
782 * resources. Reconciled in place against observed service tags.
783 */
784 tags?: Record<string, string>;
785}
786
787/**
788 * Deploy an existing `AWS.ECS.Task`'s definition as a service (shared
789 * image/roles/config; the Service adds `desiredCount` / load balancing /
790 * deployment configuration).
791 */
792export interface TaskReferenceServiceProps extends ServicePropsBase {
793 /**
794 * Bundled ECS task to run for each service replica: the runtime-facing
795 * subset of `AWS.ECS.Task` attributes the service needs to deploy and
796 * wire load balancer traffic (a full `Task` satisfies it structurally).
797 */
798 task: {
799 /**
800 * Registered task definition ARN to deploy.
801 */
802 taskDefinitionArn: string;
803 /**
804 * Container name inside the task definition that should receive traffic.
805 */
806 containerName: string;
807 /**
808 * Container port that the service should expose and forward traffic to.
809 */
810 port: number;
811 };
812}
813
814/**
815 * Image-owning base: the Service synthesizes its own task definition
816 * (roles, log group, ECR repository, image) from the shared
817 * {@link TaskDefinitionConfig} surface.
818 */
819export interface ImageOwningServicePropsBase
820 extends
821 ServicePropsBase,
822 Omit<TaskDefinitionConfig, "placementConstraints" | "volumes"> {
823 /**
824 * Task definition placement constraints (`memberOf` expressions) for the
825 * synthesized task definition. (`placementConstraints` on the service
826 * itself remains the service-level ECS placement constraint list.)
827 */
828 taskPlacementConstraints?: ecs.TaskDefinitionPlacementConstraint[];
829
830 /**
831 * Secrets injected into the primary container as environment variables:
832 * env-var name → SSM Parameter Store parameter ARN or Secrets Manager
833 * secret ARN. Wired as container `secrets` (`valueFrom`), with the
834 * execution role granted `ssm:GetParameters` /
835 * `secretsmanager:GetSecretValue` on exactly the referenced ARNs.
836 */
837 secrets?: Record<string, string>;
838
839 /**
840 * Retention for the auto-created CloudWatch log group, e.g.
841 * `{ retention: "2 weeks" }` or `{ retention: "forever" }`.
842 */
843 logging?: ServiceLoggingConfig;
844
845 /**
846 * Container-level health check (Docker `HEALTHCHECK`) for the primary
847 * container, e.g.
848 * `{ command: ["CMD-SHELL", "curl -f http://localhost/ || exit 1"] }`.
849 */
850 healthCheck?: ServiceContainerHealthCheck;
851
852 /**
853 * Task-level data volumes. Accepts raw {@link ecs.Volume} entries
854 * (referenced by the container via `mountPoints`) or the EFS sugar
855 * `{ efs, path }`, which composes the volume AND mounts it at `path` on
856 * the primary container with transit encryption enabled.
857 */
858 volumes?: (ecs.Volume | ServiceEfsVolume)[];
859}
860
861/** Bundle an inline Effect program (`main`) into the service's image. */
862export interface BundledServiceProps
863 extends ImageOwningServicePropsBase, BundledImageSource {}
864/** Build the user's own Dockerfile into the service's image. */
865export interface DockerfileServiceProps
866 extends ImageOwningServicePropsBase, DockerfileImageSource {}
867/** Run a pre-built registry image, mirrored into ECR. */
868export interface ImageServiceProps
869 extends ImageOwningServicePropsBase, RegistryImageSource {}
870
871/**
872 * Service props — either reference an existing task definition (`task:`) or
873 * own the image via exactly one of `main` / `context` / `image` (the
874 * Service then synthesizes its own task definition).
875 */
876export type ServiceProps =
877 | TaskReferenceServiceProps
878 | BundledServiceProps
879 | DockerfileServiceProps
880 | ImageServiceProps;
881
882export interface Service extends Resource<
883 "AWS.ECS.Service",
884 ServiceProps,
885 {
886 /**
887 * ARN of the ECS service.
888 */
889 serviceArn: ServiceArn;
890
891 /**
892 * Name of the ECS service.
893 */
894 serviceName: ServiceName;
895
896 /**
897 * ARN of the cluster that owns the service.
898 */
899 clusterArn: ClusterArn;
900
901 /**
902 * Task definition revision currently deployed by the service.
903 */
904 taskDefinitionArn: string;
905
906 /**
907 * ECS service status such as `ACTIVE` or `DRAINING`.
908 */
909 status: string;
910
911 /**
912 * URL of the service through its managed ingress. Owned load balancers
913 * use the composed ALB's DNS name; shared listeners derive it from the
914 * foreign listener's ALB DNS + protocol/port (best-effort — undefined
915 * when not derivable).
916 */
917 url?: string;
918
919 /**
920 * ARN of the load balancer serving the service's managed ingress (the
921 * composed ALB when owned; the shared listener's ALB otherwise).
922 */
923 loadBalancerArn?: string;
924
925 /**
926 * ARN of the first managed target group, when `loadBalancer` is set.
927 */
928 targetGroupArn?: string;
929
930 /**
931 * ARN of the primary listener, when `loadBalancer` is set.
932 */
933 listenerArn?: string;
934
935 /**
936 * Id of the Alchemy-managed security group composed for managed ingress
937 * (only when `loadBalancer` is set and no `securityGroups` supplied).
938 */
939 securityGroupId?: string;
940
941 /**
942 * @internal Marks attrs written by the composed-ingress shape ("owned" |
943 * "shared"). Absent on legacy state rows whose ALB/TG/listener were
944 * created inline by the provider — the marker gates the migration reap
945 * and the legacy delete path.
946 */
947 ingressKind?: "owned" | "shared";
948
949 /** Family of the synthesized task definition (image-owning form only). */
950 taskFamily?: string;
951 /** Name of the primary container (image-owning form only). */
952 containerName?: string;
953 /** Container port receiving traffic (image-owning form only). */
954 port?: number;
955 /** Image URI the synthesized task definition runs. */
956 imageUri?: string;
957 /** ECR repository name holding the service's image. */
958 repositoryName?: string;
959 /** ECR repository URI holding the service's image. */
960 repositoryUri?: string;
961 /** ARN of the synthesized task role. */
962 taskRoleArn?: string;
963 /** Name of the synthesized task role. */
964 taskRoleName?: string;
965 /** ARN of the synthesized execution role. */
966 executionRoleArn?: string;
967 /** Name of the synthesized execution role. */
968 executionRoleName?: string;
969 /** CloudWatch log group of the synthesized task definition. */
970 logGroupName?: string;
971 /** ARN of the CloudWatch log group. */
972 logGroupArn?: string;
973 /** Content hash of the service's container image. */
974 code?: {
975 /** Content hash of the service's container image. */
976 hash: string;
977 };
978 },
979 TaskBindingContract,
980 Providers
981> {}
982
983export type ServiceServices =
984 | Credentials
985 | Region
986 | ServerHost
987 | AWSEnvironment;
988
989/**
990 * The impl shape for an effectful `Service`: a long-running server returning
991 * `{ fetch }` (plus optional RPC methods).
992 */
993export type ServiceShape = Main<ServiceServices>;
994
995export interface ServiceRuntimeContext extends HostRuntimeContext {
996 readonly Type: "AWS.ECS.Service";
997}
998
999/**
1000 * An ECS service: N copies of a container kept alive, optionally behind a
1001 * load balancer.
1002 *
1003 * The service's image comes from one of four sources:
1004 *
1005 * - `image` — run a pre-built registry image, mirrored into ECR.
1006 * - `context` — build your own Dockerfile.
1007 * - `main` — bundle an inline Effect program (servers return `{ fetch }`).
1008 * - `task:` — deploy an existing `AWS.ECS.Task`'s definition; the Service
1009 * adds `desiredCount` / load balancing / deployment configuration.
1010 *
1011 * With any of the first three the Service synthesizes its own task
1012 * definition (task + execution roles, log group, ECR repository).
1013 * `loadBalancer: true` wires a public ALB + target group + listener and
1014 * populates the `url` attribute. When `vpcId`/`subnets` are omitted the
1015 * account's default VPC (and its per-AZ subnets) is used.
1016 *
1017 * Most configuration is updated **in place** via `updateService`
1018 * (desiredCount, task definition, network, deployment config, placement,
1019 * exec, load balancers, tags). Only truly-immutable aspects — `serviceName`,
1020 * `cluster`, launchType↔capacityProviderStrategy switch, `deploymentController`
1021 * type, `schedulingStrategy`, `enableECSManagedTags`, `role` — replace the
1022 * service.
1023 * ### Creating Services
1024 * **Example:** Remote Image Behind a Load Balancer
1025 * ```typescript
1026 * const nginx = yield* Service("Edge", {
1027 * cluster,
1028 * image: "public.ecr.aws/nginx/nginx:1.27",
1029 * port: 80,
1030 * desiredCount: 2,
1031 * loadBalancer: true, // ALB + target group + listener wiring
1032 * });
1033 * nginx.url; // http://<alb-dns-name>
1034 * ```
1035 *
1036 * **Example:** Run an Existing Task's Definition
1037 * ```typescript
1038 * const api = yield* Service("Api", {
1039 * cluster,
1040 * task: apiTask, // shared image/roles/config; Service adds
1041 * desiredCount: 2, // desiredCount / LB / deployment config
1042 * loadBalancer: true,
1043 * });
1044 * ```
1045 *
1046 * **Example:** Inline Effect Server
1047 * ```typescript
1048 * const api = yield* Service(
1049 * "Api",
1050 * { cluster, main: import.meta.url, port: 3000, desiredCount: 2, cpu: 256, memory: 512 },
1051 * Effect.gen(function* () {
1052 * const putItem = yield* AWS.DynamoDB.PutItem(table);
1053 * return {
1054 * fetch: Effect.gen(function* () {
1055 * return yield* HttpServerResponse.json({ ok: true });
1056 * }),
1057 * };
1058 * }).pipe(Effect.provide(AWS.DynamoDB.PutItemHttp)),
1059 * );
1060 * ```
1061 *
1062 * ### Bundling & Tree-shaking
1063 * `main` is bundled with rolldown at deploy time. Unused code is
1064 * tree-shaken. `effect`, alchemy, and `@distilled.cloud` are marked
1065 * pure so unused parts prune more aggressively. Your app is not
1066 * marked pure.
1067 *
1068 * **Example:** Mark additional packages as pure
1069 * Only list packages with no top-level side effects.
1070 * ```typescript
1071 * {
1072 * main: import.meta.url,
1073 * build: {
1074 * pure: { packages: ["my-lib", "@my-scope/*"] },
1075 * },
1076 * }
1077 * ```
1078 *
1079 * **Example:** Turn it off
1080 * ```typescript
1081 * {
1082 * main: import.meta.url,
1083 * build: { pure: false },
1084 * }
1085 * ```
1086 *
1087 * ### Shared Load Balancers
1088 * **Example:** Two Services Sharing One Listener
1089 * ```typescript
1090 * // The ALB + listener are stack-level resources owned by neither service.
1091 * const lb = yield* AWS.ELBv2.LoadBalancer("Alb", {
1092 * subnets: [subnetA.subnetId, subnetB.subnetId],
1093 * securityGroups: [sg.groupId],
1094 * });
1095 * const listener = yield* AWS.ELBv2.Listener("Http", {
1096 * loadBalancerArn: lb.loadBalancerArn,
1097 * port: 80,
1098 * defaultActions: [
1099 * { type: "fixedResponse", statusCode: "404", messageBody: "no route" },
1100 * ],
1101 * });
1102 *
1103 * // Each service composes only its own TargetGroup + ListenerRule on the
1104 * // shared listener. Destroying one service removes its rule + target
1105 * // group; the ALB, listener, and the other service are untouched.
1106 * const api = yield* Service("Api", {
1107 * cluster,
1108 * image: "my-org/api:latest",
1109 * port: 3000,
1110 * loadBalancer: { listener, rules: [{ path: "/api/*" }] },
1111 * });
1112 * const web = yield* Service("Web", {
1113 * cluster,
1114 * image: "my-org/web:latest",
1115 * port: 8080,
1116 * loadBalancer: { listener, rules: [{ path: "/*" }] },
1117 * });
1118 * ```
1119 *
1120 * **Example:** Catch-All on a Shared Listener
1121 * ```typescript
1122 * // A bare listener reference adds a single `path: "/*"` rule.
1123 * const svc = yield* Service("Svc", {
1124 * cluster,
1125 * image: "my-org/web:latest",
1126 * port: 8080,
1127 * loadBalancer: listener,
1128 * });
1129 * ```
1130 *
1131 * **Example:** Owned ALB with Routing Rules and an HTTP → HTTPS Redirect
1132 * ```typescript
1133 * // `"80/http"`-style `listen` strings mean the service OWNS the ALB and
1134 * // these listeners (mixing them with shared listener references is a
1135 * // typed error).
1136 * const svc = yield* Service("Svc", {
1137 * cluster,
1138 * image: "my-org/web:latest",
1139 * port: 8080,
1140 * certificateArn,
1141 * loadBalancer: {
1142 * rules: [
1143 * { listen: "80/http", redirect: "443/https" },
1144 * { listen: "443/https", forward: "8080/http" },
1145 * ],
1146 * },
1147 * });
1148 * ```
1149 *
1150 * ### Custom Domains
1151 * **Example:** Domain with a Composed Certificate
1152 * ```typescript
1153 * // Looks up the matching Route 53 hosted zone, composes a DNS-validated
1154 * // ACM certificate in the service's region, wires it to the HTTPS
1155 * // listener, and creates alias A/AAAA records. `url` becomes
1156 * // https://api.example.com.
1157 * const svc = yield* Service("Api", {
1158 * cluster,
1159 * image: "my-org/api:latest",
1160 * port: 3000,
1161 * loadBalancer: { domain: "api.example.com" },
1162 * });
1163 * ```
1164 *
1165 * **Example:** Domain with an Existing Certificate
1166 * ```typescript
1167 * const svc = yield* Service("Api", {
1168 * cluster,
1169 * image: "my-org/api:latest",
1170 * port: 3000,
1171 * loadBalancer: {
1172 * domain: { name: "api.example.com", aliases: ["www.api.example.com"], cert: certificateArn },
1173 * },
1174 * });
1175 * ```
1176 *
1177 * ### Network Load Balancers
1178 * **Example:** TCP Service Behind an NLB
1179 * ```typescript
1180 * // tcp/udp/tls/tcp_udp listen protocols compose a Network Load Balancer;
1181 * // each rule's action becomes its listener's default forward (NLB
1182 * // listeners route by port alone).
1183 * const svc = yield* Service("Tcp", {
1184 * cluster,
1185 * image: "my-org/tcp-echo:latest",
1186 * port: 9000,
1187 * loadBalancer: { rules: [{ listen: "80/tcp" }] },
1188 * });
1189 * ```
1190 *
1191 * ### Health Checks
1192 * **Example:** Per-Target-Group Health Overrides
1193 * ```typescript
1194 * const svc = yield* Service("Api", {
1195 * cluster,
1196 * image: "my-org/api:latest",
1197 * port: 3000,
1198 * loadBalancer: {
1199 * rules: [{ listen: "80/http" }],
1200 * health: {
1201 * "3000/http": {
1202 * path: "/healthz",
1203 * interval: "15 seconds",
1204 * healthyThreshold: 3,
1205 * successCodes: "200-299",
1206 * },
1207 * },
1208 * },
1209 * });
1210 * ```
1211 *
1212 * **Example:** Container Health Check
1213 * ```typescript
1214 * const svc = yield* Service("Api", {
1215 * cluster,
1216 * image: "my-org/api:latest",
1217 * port: 3000,
1218 * healthCheck: {
1219 * command: ["CMD-SHELL", "curl -f http://localhost:3000/ || exit 1"],
1220 * interval: "30 seconds",
1221 * retries: 3,
1222 * },
1223 * });
1224 * ```
1225 *
1226 * ### Autoscaling
1227 * **Example:** Target-Tracking Autoscaling
1228 * ```typescript
1229 * // Composes a ScalableTarget (min/max) plus one target-tracking policy
1230 * // per metric. Redeploys stop pinning desiredCount while scaling is set.
1231 * const svc = yield* Service("Api", {
1232 * cluster,
1233 * image: "my-org/api:latest",
1234 * port: 3000,
1235 * loadBalancer: true,
1236 * scaling: {
1237 * min: 1,
1238 * max: 4,
1239 * cpuUtilization: 70,
1240 * requestCount: 200,
1241 * scaleInCooldown: "5 minutes",
1242 * },
1243 * });
1244 * ```
1245 *
1246 * ### Secrets & Logging
1247 * **Example:** Inject SSM / Secrets Manager Secrets
1248 * ```typescript
1249 * // Values are ARNs; the container gets them as env vars via `valueFrom`
1250 * // and the execution role is granted read on exactly these ARNs.
1251 * const svc = yield* Service("Api", {
1252 * cluster,
1253 * image: "my-org/api:latest",
1254 * port: 3000,
1255 * secrets: {
1256 * DB_PASSWORD: dbPasswordSecret.secretArn,
1257 * API_KEY: apiKeyParameter.parameterArn,
1258 * },
1259 * logging: { retention: "2 weeks" },
1260 * });
1261 * ```
1262 *
1263 * ### Service Discovery
1264 * **Example:** Register in a Cloud Map Namespace
1265 * ```typescript
1266 * const namespace = yield* AWS.CloudMap.PrivateDnsNamespace("AppNs", {
1267 * name: "internal.example.com",
1268 * vpc: vpc.vpcId,
1269 * });
1270 * const svc = yield* Service("Api", {
1271 * cluster,
1272 * image: "my-org/api:latest",
1273 * port: 3000,
1274 * serviceRegistry: { namespace },
1275 * });
1276 * ```
1277 *
1278 * ### Volumes
1279 * **Example:** Mount an EFS File System
1280 * ```typescript
1281 * const svc = yield* Service("Api", {
1282 * cluster,
1283 * image: "my-org/api:latest",
1284 * port: 3000,
1285 * volumes: [{ efs: fileSystem, path: "/mnt/data" }],
1286 * });
1287 * ```
1288 *
1289 * ### Capacity
1290 * **Example:** Fargate Spot
1291 * ```typescript
1292 * // The cluster must have the Fargate capacity providers associated:
1293 * // Cluster("C", { capacityProviders: ["FARGATE", "FARGATE_SPOT"] }).
1294 * const svc = yield* Service("Worker", {
1295 * cluster,
1296 * image: "my-org/worker:latest",
1297 * capacity: { fargate: { weight: 1, base: 1 }, spot: { weight: 4 } },
1298 * });
1299 * ```
1300 *
1301 * ### Load Balancing
1302 * **Example:** Manual (User-Supplied) Target Group
1303 * ```typescript
1304 * const service = yield* Service("ApiService", {
1305 * cluster,
1306 * task: apiTask,
1307 * vpcId: vpc.vpcId,
1308 * subnets: [subnet1.subnetId, subnet2.subnetId],
1309 * loadBalancers: [
1310 * {
1311 * targetGroupArn,
1312 * containerName: apiTask.containerName,
1313 * containerPort: apiTask.port,
1314 * },
1315 * ],
1316 * });
1317 * ```
1318 *
1319 * ### Capacity & Placement
1320 * **Example:** FARGATE_SPOT Capacity Provider Strategy
1321 * ```typescript
1322 * const service = yield* Service("WorkerService", {
1323 * cluster,
1324 * task: workerTask,
1325 * vpcId: vpc.vpcId,
1326 * subnets: [subnet.subnetId],
1327 * capacityProviderStrategy: [
1328 * { capacityProvider: "FARGATE_SPOT", weight: 4 },
1329 * { capacityProvider: "FARGATE", weight: 1, base: 1 },
1330 * ],
1331 * placementStrategy: [{ type: "spread", field: "attribute:ecs.availability-zone" }],
1332 * });
1333 * ```
1334 *
1335 * ### Deployment
1336 * **Example:** Rolling Update with Circuit Breaker
1337 * ```typescript
1338 * const service = yield* Service("ApiService", {
1339 * cluster,
1340 * task: apiTask,
1341 * vpcId: vpc.vpcId,
1342 * subnets: [subnet1.subnetId, subnet2.subnetId],
1343 * desiredCount: 3,
1344 * enableExecuteCommand: true,
1345 * deploymentConfiguration: {
1346 * minimumHealthyPercent: 100,
1347 * maximumPercent: 200,
1348 * deploymentCircuitBreaker: { enable: true, rollback: true },
1349 * },
1350 * healthCheckGracePeriod: "30 seconds",
1351 * });
1352 * ```
1353 *
1354 * @resource
1355 */
1356export const Service: Platform<
1357 Service,
1358 ServiceServices,
1359 ServiceShape,
1360 ServiceRuntimeContext
1361> = Platform("AWS.ECS.Service", {
1362 createRuntimeContext: createContainerRuntimeContext("AWS.ECS.Service") as (
1363 id: string,
1364 ) => ServiceRuntimeContext,
1365 // Compose the managed load balancer (owned ALB/listeners or shared-listener
1366 // rules + target groups) as REAL namespaced child resources before the core
1367 // service resource is declared.
1368 transformProps: (id, props) => transformServiceProps(id, props),
1369 // Autoscaling references the service's own Output attributes (cluster/name/
1370 // target-group ARNs), so it composes AFTER the resource exists.
1371 onCreate: (resource, props) =>
1372 composeServiceScaling(
1373 resource as Service,
1374 props as ServiceProps,
1375 // Typed failures (e.g. RequestCountScalingRequiresLoadBalancer)
1376 // surface through the deploy like any resource-construction error.
1377 ) as Effect.Effect<void, never, any>,
1378});
1379
1380/** The BYO task reference, when the props use the `task:` form. */
1381const taskRefOf = (props: ServiceProps | undefined) =>
1382 props !== undefined && "task" in props ? props.task : undefined;
1383
1384// ───────────────────────────────────────────────────────────────────────────
1385// Managed load balancer — composition (the StaticSite pattern)
1386// ───────────────────────────────────────────────────────────────────────────
1387
1388const isELBv2Listener = (value: unknown): value is Listener =>
1389 typeof value === "object" &&
1390 value !== null &&
1391 "Type" in value &&
1392 (value as { Type?: unknown }).Type === "AWS.ELBv2.Listener";
1393
1394interface ParsedListen {
1395 port: number;
1396 protocol: ServiceListenerProtocol;
1397}
1398
1399const LISTENER_PROTOCOLS: ReadonlySet<string> = new Set([
1400 "http",
1401 "https",
1402 "tcp",
1403 "udp",
1404 "tcp_udp",
1405 "tls",
1406]);
1407
1408const parseListenSpec = (serviceId: string, spec: string, kind: string) =>
1409 Effect.gen(function* () {
1410 const [portString, protocol] = spec.split("/");
1411 const port = Number(portString);
1412 if (
1413 !Number.isInteger(port) ||
1414 port <= 0 ||
1415 port > 65535 ||
1416 protocol === undefined ||
1417 !LISTENER_PROTOCOLS.has(protocol)
1418 ) {
1419 return yield* Effect.fail(
1420 new UnsupportedListenerProtocol({
1421 serviceId,
1422 spec,
1423 message: `AWS.ECS.Service "${serviceId}": unsupported ${kind} spec '${spec}' — expected "<port>/<protocol>" with protocol one of http, https (ALB) or tcp, udp, tcp_udp, tls (NLB)`,
1424 }),
1425 );
1426 }
1427 return { port, protocol } as ParsedListen;
1428 });
1429
1430/**
1431 * Resolve the account's default VPC and its per-AZ default subnets. Shared by
1432 * the composition step (target group / security group VPC, owned ALB subnets)
1433 * and the provider's network resolution.
1434 */
1435const lookupDefaultNetwork = Effect.gen(function* () {
1436 const vpc = yield* ec2.describeVpcs
1437 .items({
1438 Filters: [{ Name: "isDefault", Values: ["true"] }],
1439 })
1440 .pipe(
1441 Stream.filter((v) => v.IsDefault === true),
1442 Stream.runHead,
1443 Effect.map(Option.getOrUndefined),
1444 );
1445 if (!vpc?.VpcId) {
1446 return yield* Effect.die(
1447 new Error(
1448 "AWS.ECS.Service: no default VPC in this account/region — pass `vpcId` and `subnets` explicitly",
1449 ),
1450 );
1451 }
1452 const subnetIds = yield* ec2.describeSubnets
1453 .items({
1454 Filters: [
1455 { Name: "vpc-id", Values: [vpc.VpcId] },
1456 { Name: "default-for-az", Values: ["true"] },
1457 ],
1458 })
1459 .pipe(
1460 Stream.map((s) => s.SubnetId),
1461 Stream.filter((s): s is string => s !== undefined),
1462 Stream.runCollect,
1463 Effect.map((chunk) => Array.from(chunk)),
1464 );
1465 if (subnetIds.length === 0) {
1466 return yield* Effect.die(
1467 new Error(
1468 "AWS.ECS.Service: the default VPC has no default subnets — pass `subnets` explicitly",
1469 ),
1470 );
1471 }
1472 return { vpcId: vpc.VpcId, subnets: subnetIds };
1473});
1474
1475const toValuesArray = (value: string | string[] | undefined) =>
1476 value === undefined ? undefined : Array.isArray(value) ? value : [value];
1477
1478/** Flat rule conditions → the `ELBv2.ListenerRule` condition shape. */
1479const ruleConditionsOf = (
1480 rule: ServiceLoadBalancerRule,
1481): ListenerRuleCondition[] => {
1482 const conditions: ListenerRuleCondition[] = [];
1483 const path = toValuesArray(rule.path);
1484 if (path) conditions.push({ pathPattern: { values: path } });
1485 const host = toValuesArray(rule.host);
1486 if (host) conditions.push({ hostHeader: { values: host } });
1487 if (rule.header) {
1488 conditions.push({
1489 httpHeader: { name: rule.header.name, values: rule.header.values },
1490 });
1491 }
1492 if (rule.query) conditions.push({ queryString: { values: rule.query } });
1493 return conditions;
1494};
1495
1496/** IP protocols a listener protocol admits through the managed security group. */
1497const sgProtocolsOf = (protocol: string): string[] =>
1498 protocol === "udp"
1499 ? ["udp"]
1500 : protocol === "tcp_udp"
1501 ? ["tcp", "udp"]
1502 : ["tcp"];
1503
1504interface ManagedSgPort {
1505 port: number;
1506 ipProtocol: string;
1507}
1508
1509/**
1510 * Ingress rules for the managed security group. `dynamicPort` is the main
1511 * container's port when it is only known as an Output (the `task:` form) —
1512 * the rule list is then computed at resolve time so ports can be deduped.
1513 */
1514const managedSgIngress = (
1515 staticPorts: ManagedSgPort[],
1516 dynamicPort: number | unknown | undefined,
1517): SecurityGroupRuleData[] => {
1518 const rulesOf = (entries: ManagedSgPort[]): SecurityGroupRuleData[] => {
1519 const seen = new Set<string>();
1520 return entries.flatMap(({ port, ipProtocol }) => {
1521 const dedupeKey = `${ipProtocol}:${port}`;
1522 if (seen.has(dedupeKey)) {
1523 return [];
1524 }
1525 seen.add(dedupeKey);
1526 return [
1527 {
1528 ipProtocol,
1529 fromPort: port,
1530 toPort: port,
1531 cidrIpv4: "0.0.0.0/0",
1532 description: "Alchemy-managed ECS service ingress",
1533 },
1534 ];
1535 });
1536 };
1537 if (dynamicPort === undefined) {
1538 return rulesOf(staticPorts);
1539 }
1540 if (typeof dynamicPort === "number") {
1541 return rulesOf([...staticPorts, { port: dynamicPort, ipProtocol: "tcp" }]);
1542 }
1543 return Output.map(dynamicPort as Output.Output<number>, (port) =>
1544 rulesOf([...staticPorts, { port, ipProtocol: "tcp" }]),
1545 ) as unknown as SecurityGroupRuleData[];
1546};
1547
1548/** Normalize the {@link ServiceCapacityConfig} sugar into a capacity provider strategy. */
1549const capacityProviderStrategyOf = (
1550 capacity: ServiceCapacityConfig,
1551): ecs.CapacityProviderStrategyItem[] =>
1552 capacity === "spot"
1553 ? [{ capacityProvider: "FARGATE_SPOT", weight: 1 }]
1554 : [
1555 ...(capacity.fargate
1556 ? [
1557 {
1558 capacityProvider: "FARGATE",
1559 weight: capacity.fargate.weight,
1560 base: capacity.fargate.base,
1561 },
1562 ]
1563 : []),
1564 ...(capacity.spot
1565 ? [
1566 {
1567 capacityProvider: "FARGATE_SPOT",
1568 weight: capacity.spot.weight,
1569 base: capacity.spot.base,
1570 },
1571 ]
1572 : []),
1573 ];
1574
1575/**
1576 * Compose the Cloud Map service for {@link ServicePropsBase.serviceRegistry}
1577 * and rewrite it into the raw `serviceRegistries` list.
1578 */
1579const composeServiceRegistry = (id: string, props: ServiceProps) =>
1580 Effect.gen(function* () {
1581 const registry = props.serviceRegistry!;
1582 const namespace = registry.namespace;
1583 const isHttpNamespace =
1584 typeof namespace === "object" &&
1585 namespace !== null &&
1586 "Type" in namespace &&
1587 (namespace as { Type?: unknown }).Type === "AWS.CloudMap.HttpNamespace";
1588 const cloudMapService = yield* CloudMapService("ServiceRegistry", {
1589 namespaceId: namespace.namespaceId,
1590 dnsRecords: isHttpNamespace
1591 ? undefined
1592 : [
1593 registry.port !== undefined
1594 ? { type: "SRV", ttl: "10 seconds" }
1595 : { type: "A", ttl: "10 seconds" },
1596 ],
1597 tags: props.tags,
1598 });
1599 return {
1600 ...props,
1601 serviceRegistry: undefined,
1602 serviceRegistries: [
1603 ...(props.serviceRegistries ?? []),
1604 {
1605 registryArn: cloudMapService.serviceArn as unknown as string,
1606 port: registry.port,
1607 },
1608 ],
1609 } as ServiceProps;
1610 });
1611
1612/**
1613 * The `transformProps` hook: normalize the `capacity` sugar, then — when
1614 * service discovery or managed load balancing is requested — compose the
1615 * Cloud Map / ELBv2 (and security group) child resources under the service's
1616 * namespace and rewrite the props to reference their outputs (the internal
1617 * `ingress` prop and `serviceRegistries`). A no-op at runtime.
1618 */
1619const transformServiceProps = (
1620 id: string,
1621 props: ServiceProps,
1622): Effect.Effect<ServiceProps, unknown, any> =>
1623 Effect.gen(function* () {
1624 // Composition is a plan/deploy concern — never runs inside bundles.
1625 if (globalThis.__ALCHEMY_RUNTIME__) {
1626 return props;
1627 }
1628 let next: ServiceProps = props;
1629 if (next.capacity !== undefined) {
1630 next = {
1631 ...next,
1632 capacityProviderStrategy:
1633 next.capacityProviderStrategy ??
1634 capacityProviderStrategyOf(next.capacity),
1635 capacity: undefined,
1636 };
1637 }
1638 const lbProp =
1639 next.loadBalancer !== undefined
1640 ? next.loadBalancer
1641 : ((next.public ?? false) as boolean);
1642 const wantsIngress = lbProp !== false && lbProp !== undefined;
1643 if (!wantsIngress && next.serviceRegistry === undefined) {
1644 return next;
1645 }
1646 return yield* Effect.gen(function* () {
1647 if (next.serviceRegistry !== undefined) {
1648 next = yield* composeServiceRegistry(id, next);
1649 }
1650 if (wantsIngress) {
1651 next = yield* composeManagedIngress(
1652 id,
1653 next,
1654 lbProp as true | Listener | ServiceLoadBalancerConfig,
1655 );
1656 }
1657 return next;
1658 }).pipe(Namespace.push(id));
1659 });
1660
1661const composeManagedIngress = (
1662 id: string,
1663 props: ServiceProps,
1664 lbProp: true | Listener | ServiceLoadBalancerConfig,
1665) =>
1666 Effect.gen(function* () {
1667 const config: ServiceLoadBalancerConfig =
1668 lbProp === true
1669 ? {}
1670 : isELBv2Listener(lbProp)
1671 ? { listener: lbProp }
1672 : lbProp;
1673 const rules = config.rules ?? (config.listener !== undefined ? [{}] : []);
1674
1675 // ── classify ownership ───────────────────────────────────────────────
1676 const hasSharedRefs =
1677 config.listener !== undefined ||
1678 rules.some((rule) => isELBv2Listener(rule.listen));
1679 const hasOwnedStrings = rules.some(
1680 (rule) => typeof rule.listen === "string",
1681 );
1682 if (hasSharedRefs && hasOwnedStrings) {
1683 return yield* Effect.fail(
1684 new MixedListenerOwnership({
1685 serviceId: id,
1686 message: `AWS.ECS.Service "${id}": rules mix owned "<port>/<protocol>" listen strings with shared ELBv2.Listener references — a service either owns its ALB (strings only) or attaches rules to shared listeners (references only)`,
1687 }),
1688 );
1689 }
1690 const owned = !hasSharedRefs;
1691 if (!owned && config.public !== undefined) {
1692 return yield* Effect.fail(
1693 new OwnedOnlyLoadBalancerOption({
1694 serviceId: id,
1695 option: "public",
1696 message: `AWS.ECS.Service "${id}": \`public\` only applies to an owned load balancer — the shared listener's ALB controls its own scheme`,
1697 }),
1698 );
1699 }
1700 // `true`, a bare `{}`, or an owned config with zero rules: single default
1701 // listener forwarding to the container port.
1702 const trueLike = owned && rules.length === 0;
1703
1704 // ── protocol family: ALB (http/https) vs NLB (tcp/udp/tls/tcp_udp) ──
1705 const families = new Set<"application" | "network">();
1706 for (const rule of rules) {
1707 const specs: [string | Listener | undefined, string][] = [
1708 [rule.listen, "listen"],
1709 [rule.forward, "forward"],
1710 [rule.redirect, "redirect"],
1711 ];
1712 for (const [spec, kind] of specs) {
1713 if (typeof spec === "string") {
1714 const parsed = yield* parseListenSpec(id, spec, kind);
1715 families.add(
1716 NETWORK_PROTOCOLS.has(parsed.protocol) ? "network" : "application",
1717 );
1718 }
1719 }
1720 }
1721 if (families.size > 1) {
1722 return yield* Effect.fail(
1723 new MixedLoadBalancerProtocols({
1724 serviceId: id,
1725 message: `AWS.ECS.Service "${id}": rules mix application (http/https) and network (tcp/udp/tls/tcp_udp) protocols — one service composes exactly one load balancer type`,
1726 }),
1727 );
1728 }
1729 const lbType: "application" | "network" = families.has("network")
1730 ? "network"
1731 : "application";
1732 if (lbType === "network") {
1733 // NLB listeners route by port alone: no rule conditions, no redirect
1734 // actions, no attaching rules to shared listeners, and exactly one
1735 // rule per listener port (it becomes the listener's default action).
1736 const failNetworkRule = (ruleIndex: number, reason: string) =>
1737 Effect.fail(
1738 new NetworkListenerRuleUnsupported({
1739 serviceId: id,
1740 ruleIndex,
1741 message: `AWS.ECS.Service "${id}": rule ${ruleIndex} ${reason}`,
1742 }),
1743 );
1744 if (!owned) {
1745 return yield* failNetworkRule(
1746 0,
1747 "uses network (tcp/udp/tls/tcp_udp) protocols with a shared listener — NLB listeners have no rules, so network ingress is always owned",
1748 );
1749 }
1750 const seenPorts = new Set<number>();
1751 for (const [index, rule] of rules.entries()) {
1752 if (typeof rule.listen !== "string") {
1753 return yield* failNetworkRule(
1754 index,
1755 "must declare an explicit `listen` — network listeners have no default",
1756 );
1757 }
1758 if (rule.redirect !== undefined) {
1759 return yield* failNetworkRule(
1760 index,
1761 "sets `redirect` — NLB listeners cannot redirect",
1762 );
1763 }
1764 if (
1765 rule.path !== undefined ||
1766 rule.host !== undefined ||
1767 rule.header !== undefined ||
1768 rule.query !== undefined
1769 ) {
1770 return yield* failNetworkRule(
1771 index,
1772 "sets routing conditions — NLB listeners route by port alone",
1773 );
1774 }
1775 const { port } = yield* parseListenSpec(id, rule.listen, "listen");
1776 if (seenPorts.has(port)) {
1777 return yield* failNetworkRule(
1778 index,
1779 `re-declares listener port ${port} — a network listener forwards to exactly one target group`,
1780 );
1781 }
1782 seenPorts.add(port);
1783 }
1784 }
1785
1786 // ── custom domain (owned only): hosted zones + certificate ──────────
1787 const domain =
1788 config.domain === undefined
1789 ? undefined
1790 : typeof config.domain === "string"
1791 ? { name: config.domain }
1792 : config.domain;
1793 if (domain !== undefined && !owned) {
1794 return yield* Effect.fail(
1795 new OwnedOnlyLoadBalancerOption({
1796 serviceId: id,
1797 option: "domain",
1798 message: `AWS.ECS.Service "${id}": \`domain\` only applies to an owned load balancer — the shared listener's ALB owns its DNS and certificates`,
1799 }),
1800 );
1801 }
1802 let certificateArn: string | undefined = props.certificateArn;
1803 const domainNames =
1804 domain !== undefined ? [domain.name, ...(domain.aliases ?? [])] : [];
1805 const domainZones = new Map<string, string>();
1806 if (domain !== undefined) {
1807 for (const name of domainNames) {
1808 const zoneId = yield* findPublicHostedZoneId(name);
1809 if (zoneId === undefined) {
1810 return yield* Effect.fail(
1811 new ServiceHostedZoneNotFound({
1812 serviceId: id,
1813 domainName: name,
1814 message: `AWS.ECS.Service "${id}": no public Route 53 hosted zone contains '${name}' — create the hosted zone first (alias records land in it)`,
1815 }),
1816 );
1817 }
1818 domainZones.set(name, zoneId);
1819 }
1820 if (domain.cert !== undefined) {
1821 certificateArn = domain.cert;
1822 } else {
1823 // DNS-validated certificate in the service's own region (an ALB/NLB
1824 // listener requires an in-region certificate).
1825 const { region } = yield* AWSEnvironment.current;
1826 const certificate = yield* Certificate("Certificate", {
1827 domainName: domain.name,
1828 subjectAlternativeNames: domain.aliases,
1829 hostedZoneId: domainZones.get(domain.name)!,
1830 region,
1831 tags: props.tags,
1832 });
1833 certificateArn = certificate.certificateArn as unknown as string;
1834 }
1835 }
1836
1837 // ── normalize rules + target-group specs ────────────────────────────
1838 const byoTask = taskRefOf(props);
1839 const defaultContainerPort: unknown =
1840 byoTask !== undefined
1841 ? byoTask.port
1842 : ((props as { port?: number }).port ?? 3000);
1843
1844 interface TgSpec {
1845 key: string;
1846 logicalId: string;
1847 port: unknown;
1848 protocol: "HTTP" | "HTTPS" | "TCP" | "UDP" | "TCP_UDP" | "TLS";
1849 container: string | undefined;
1850 forwardPort: number | undefined;
1851 forwardProtocol: ServiceListenerProtocol | undefined;
1852 }
1853 const tgSpecs = new Map<string, TgSpec>();
1854 const ensureTgSpec = (
1855 forward: ParsedListen | undefined,
1856 container: string | undefined,
1857 ): string => {
1858 const key = `${forward ? `${forward.port}/${forward.protocol}` : "default"}|${container ?? ""}`;
1859 if (!tgSpecs.has(key)) {
1860 tgSpecs.set(key, {
1861 key,
1862 logicalId: [
1863 "TargetGroup",
1864 forward ? `${forward.port}-${forward.protocol}` : undefined,
1865 container,
1866 ]
1867 .filter((part): part is string => part !== undefined)
1868 .join("-"),
1869 port: forward?.port ?? defaultContainerPort,
1870 protocol: forward
1871 ? (forward.protocol.toUpperCase() as TgSpec["protocol"])
1872 : lbType === "network"
1873 ? "TCP"
1874 : "HTTP",
1875 container,
1876 forwardPort: forward?.port,
1877 forwardProtocol: forward?.protocol,
1878 });
1879 }
1880 return key;
1881 };
1882
1883 type NormalizedAction =
1884 | { type: "forward"; tgKey: string }
1885 | { type: "redirect"; port: number; protocol: ServiceListenerProtocol };
1886 interface NormalizedRule {
1887 index: number;
1888 rule: ServiceLoadBalancerRule;
1889 /** Parsed owned listen spec, shared listener ref, or undefined (default). */
1890 listen: ParsedListen | Listener | undefined;
1891 action: NormalizedAction;
1892 conditions: ListenerRuleCondition[];
1893 }
1894 const normalized: NormalizedRule[] = [];
1895 for (const [index, rule] of rules.entries()) {
1896 if (rule.forward !== undefined && rule.redirect !== undefined) {
1897 return yield* Effect.fail(
1898 new ServiceRuleActionConflict({
1899 serviceId: id,
1900 ruleIndex: index,
1901 message: `AWS.ECS.Service "${id}": rule ${index} sets both \`forward\` and \`redirect\` — they are mutually exclusive`,
1902 }),
1903 );
1904 }
1905 const listen =
1906 typeof rule.listen === "string"
1907 ? yield* parseListenSpec(id, rule.listen, "listen")
1908 : rule.listen;
1909 if (!owned && listen === undefined && config.listener === undefined) {
1910 return yield* Effect.fail(
1911 new MissingRuleListener({
1912 serviceId: id,
1913 ruleIndex: index,
1914 message: `AWS.ECS.Service "${id}": rule ${index} has no \`listen\` and the config has no default \`listener\``,
1915 }),
1916 );
1917 }
1918 const action: NormalizedAction =
1919 rule.redirect !== undefined
1920 ? {
1921 type: "redirect",
1922 ...(yield* parseListenSpec(id, rule.redirect, "redirect")),
1923 }
1924 : {
1925 type: "forward",
1926 tgKey: ensureTgSpec(
1927 rule.forward !== undefined
1928 ? yield* parseListenSpec(id, rule.forward, "forward")
1929 : undefined,
1930 rule.container,
1931 ),
1932 };
1933 normalized.push({
1934 index,
1935 rule,
1936 listen,
1937 action,
1938 conditions: ruleConditionsOf(rule),
1939 });
1940 }
1941 if (trueLike) {
1942 ensureTgSpec(undefined, undefined);
1943 }
1944
1945 // ── owned listener specs ────────────────────────────────────────────
1946 // Network rules always declare an explicit `listen` (validated above),
1947 // so the implicit default listener is application-only.
1948 const defaultOwnedListen: ParsedListen = {
1949 port: props.listenerPort ?? (certificateArn !== undefined ? 443 : 80),
1950 protocol: certificateArn !== undefined ? "https" : "http",
1951 };
1952 const ownedListenSpecs = new Map<number, ParsedListen>();
1953 if (owned) {
1954 if (trueLike || normalized.some((r) => r.listen === undefined)) {
1955 ownedListenSpecs.set(defaultOwnedListen.port, defaultOwnedListen);
1956 }
1957 for (const r of normalized) {
1958 if (r.listen !== undefined && !isELBv2Listener(r.listen)) {
1959 const spec = r.listen as ParsedListen;
1960 const existing = ownedListenSpecs.get(spec.port);
1961 if (existing !== undefined && existing.protocol !== spec.protocol) {
1962 return yield* Effect.fail(
1963 new UnsupportedListenerProtocol({
1964 serviceId: id,
1965 spec: `${spec.port}/${spec.protocol}`,
1966 message: `AWS.ECS.Service "${id}": port ${spec.port} is declared with both "${existing.protocol}" and "${spec.protocol}" — one protocol per listener port`,
1967 }),
1968 );
1969 }
1970 ownedListenSpecs.set(spec.port, spec);
1971 }
1972 }
1973 for (const spec of ownedListenSpecs.values()) {
1974 if (
1975 (spec.protocol === "https" || spec.protocol === "tls") &&
1976 certificateArn === undefined
1977 ) {
1978 return yield* Effect.fail(
1979 new MissingListenerCertificate({
1980 serviceId: id,
1981 spec: `${spec.port}/${spec.protocol}`,
1982 message: `AWS.ECS.Service "${id}": listener "${spec.port}/${spec.protocol}" requires a certificate — pass \`certificateArn\` or a \`domain\``,
1983 }),
1984 );
1985 }
1986 }
1987 }
1988
1989 /** The owned listener port a rule attaches to. */
1990 const ownedPortOf = (r: NormalizedRule): number =>
1991 r.listen !== undefined && !isELBv2Listener(r.listen)
1992 ? (r.listen as ParsedListen).port
1993 : defaultOwnedListen.port;
1994
1995 // ── network (VPC for TGs + SG, subnets for an owned ALB) ────────────
1996 const network =
1997 props.vpcId !== undefined && props.subnets !== undefined
1998 ? { vpcId: props.vpcId, subnets: props.subnets }
1999 : yield* lookupDefaultNetwork;
2000
2001 // ── managed security group ──────────────────────────────────────────
2002 const staticPorts: ManagedSgPort[] = [
2003 ...(owned
2004 ? [...ownedListenSpecs.values()].flatMap((spec) =>
2005 sgProtocolsOf(spec.protocol).map((ipProtocol) => ({
2006 port: spec.port,
2007 ipProtocol,
2008 })),
2009 )
2010 : []),
2011 ...[...tgSpecs.values()].flatMap((spec) =>
2012 spec.forwardPort !== undefined
2013 ? sgProtocolsOf(spec.forwardProtocol ?? "tcp").map((ipProtocol) => ({
2014 port: spec.forwardPort!,
2015 ipProtocol,
2016 }))
2017 : [],
2018 ),
2019 ];
2020 const usesDefaultPort = [...tgSpecs.values()].some(
2021 (spec) => spec.forwardPort === undefined,
2022 );
2023 const managedSg = props.securityGroups
2024 ? undefined
2025 : yield* SecurityGroup("SecurityGroup", {
2026 vpcId: network.vpcId as VpcId,
2027 description: `Alchemy-managed ingress for ECS service ${id}`,
2028 ingress: managedSgIngress(
2029 staticPorts,
2030 usesDefaultPort ? defaultContainerPort : undefined,
2031 ),
2032 tags: props.tags,
2033 });
2034
2035 // ── target groups (one per distinct forward + container pair) ───────
2036 // Per-TG health overrides are keyed by the target's "{port}/{protocol}"
2037 // spec: the rule's `forward` spec, or the container port + the LB
2038 // type's default protocol for the default target group.
2039 const healthOverrides = config.health ?? {};
2040 const healthKeyOf = (spec: TgSpec): string | undefined => {
2041 const protocol =
2042 spec.forwardProtocol ?? (lbType === "network" ? "tcp" : "http");
2043 const port =
2044 spec.forwardPort ??
2045 (typeof defaultContainerPort === "number"
2046 ? defaultContainerPort
2047 : undefined);
2048 return port === undefined ? undefined : `${port}/${protocol}`;
2049 };
2050 const matchedHealthKeys = new Set<string>();
2051 const targetGroups = new Map<string, TargetGroup>();
2052 for (const spec of tgSpecs.values()) {
2053 const healthKey = healthKeyOf(spec);
2054 const health =
2055 healthKey !== undefined ? healthOverrides[healthKey] : undefined;
2056 if (healthKey !== undefined && health !== undefined) {
2057 matchedHealthKeys.add(healthKey);
2058 }
2059 const isNetworkTg =
2060 spec.protocol === "TCP" ||
2061 spec.protocol === "UDP" ||
2062 spec.protocol === "TCP_UDP" ||
2063 spec.protocol === "TLS";
2064 // A path/successCodes override on a network target group opts its
2065 // health check into HTTP; otherwise NLB targets get TCP checks.
2066 const wantsHttpCheck =
2067 health?.path !== undefined || health?.successCodes !== undefined;
2068 targetGroups.set(
2069 spec.key,
2070 yield* TargetGroup(spec.logicalId, {
2071 vpcId: network.vpcId as string,
2072 port: spec.port as number,
2073 protocol: spec.protocol,
2074 targetType: "ip",
2075 healthCheckPath: isNetworkTg
2076 ? wantsHttpCheck
2077 ? (health?.path ?? "/")
2078 : undefined
2079 : (health?.path ?? props.healthCheckPath ?? "/"),
2080 healthCheckProtocol:
2081 isNetworkTg && wantsHttpCheck ? "HTTP" : undefined,
2082 // Fast-converge defaults: targets go healthy in ~20s instead of
2083 // the AWS default ~150s, and drain in 30s instead of 300s.
2084 healthCheckInterval: health?.interval ?? "10 seconds",
2085 healthCheckTimeout: health?.timeout,
2086 healthyThresholdCount: health?.healthyThreshold ?? 2,
2087 unhealthyThresholdCount: health?.unhealthyThreshold ?? 2,
2088 matcher:
2089 health?.successCodes !== undefined
2090 ? { HttpCode: health.successCodes }
2091 : undefined,
2092 attributes: { "deregistration_delay.timeout_seconds": "30" },
2093 tags: props.tags,
2094 }),
2095 );
2096 }
2097 for (const key of Object.keys(healthOverrides)) {
2098 if (!matchedHealthKeys.has(key)) {
2099 const validKeys = [...tgSpecs.values()]
2100 .map(healthKeyOf)
2101 .filter((candidate): candidate is string => candidate !== undefined);
2102 return yield* Effect.fail(
2103 new ServiceHealthTargetNotFound({
2104 serviceId: id,
2105 key,
2106 message: `AWS.ECS.Service "${id}": \`health\` key '${key}' matches no target group — valid keys: ${validKeys.length > 0 ? validKeys.join(", ") : "(none statically derivable)"}`,
2107 }),
2108 );
2109 }
2110 }
2111
2112 const actionToListenerAction = (
2113 action: NormalizedAction,
2114 ): ListenerAction =>
2115 action.type === "redirect"
2116 ? {
2117 type: "redirect",
2118 statusCode: "HTTP_301",
2119 protocol: action.protocol.toUpperCase(),
2120 port: String(action.port),
2121 }
2122 : {
2123 type: "forward",
2124 targetGroups: [
2125 {
2126 targetGroupArn: targetGroups.get(action.tgKey)!
2127 .targetGroupArn as unknown as TargetGroupArn,
2128 },
2129 ],
2130 };
2131
2132 // ── owned ALB + listeners ───────────────────────────────────────────
2133 let alb: LoadBalancer | undefined;
2134 const ownedListeners = new Map<number, Listener>();
2135 /** Rules absorbed into a listener's default action (no ListenerRule). */
2136 const defaultsTaken = new Set<NormalizedRule>();
2137 if (owned) {
2138 alb = yield* LoadBalancer("LoadBalancer", {
2139 type: lbType,
2140 scheme: config.public === false ? "internal" : "internet-facing",
2141 subnets: (props.subnets ?? network.subnets) as unknown as SubnetId[],
2142 securityGroups: (props.securityGroups ?? [
2143 managedSg!.groupId,
2144 ]) as unknown as SecurityGroupId[],
2145 tags: props.tags,
2146 });
2147 for (const spec of ownedListenSpecs.values()) {
2148 // Default action: the first conditionless rule on this listener
2149 // wins; the `true`-like form forwards to the sole target group;
2150 // otherwise unmatched requests get a 404.
2151 const conditionless = normalized.find(
2152 (r) => r.conditions.length === 0 && ownedPortOf(r) === spec.port,
2153 );
2154 if (conditionless) {
2155 defaultsTaken.add(conditionless);
2156 }
2157 const defaultActions: ListenerAction[] = conditionless
2158 ? [actionToListenerAction(conditionless.action)]
2159 : trueLike
2160 ? [
2161 {
2162 type: "forward",
2163 targetGroups: [
2164 {
2165 targetGroupArn: targetGroups.get("default|")!
2166 .targetGroupArn as unknown as TargetGroupArn,
2167 },
2168 ],
2169 },
2170 ]
2171 : [
2172 {
2173 type: "fixedResponse",
2174 statusCode: "404",
2175 contentType: "text/plain",
2176 messageBody: "Not Found",
2177 },
2178 ];
2179 ownedListeners.set(
2180 spec.port,
2181 yield* Listener(`Listener-${spec.port}`, {
2182 loadBalancerArn: alb.loadBalancerArn as any,
2183 port: spec.port,
2184 protocol: spec.protocol.toUpperCase() as
2185 | "HTTP"
2186 | "HTTPS"
2187 | "TCP"
2188 | "UDP"
2189 | "TCP_UDP"
2190 | "TLS",
2191 certificateArn:
2192 spec.protocol === "https" || spec.protocol === "tls"
2193 ? certificateArn
2194 : undefined,
2195 defaultActions,
2196 }),
2197 );
2198 }
2199 }
2200
2201 // ── domain: alias records pointing at the owned load balancer ───────
2202 if (domain !== undefined && alb !== undefined) {
2203 for (const name of domainNames) {
2204 const zoneId = domainZones.get(name)!;
2205 const sanitizedName = name.replaceAll(/[^a-zA-Z0-9-]/g, "-");
2206 for (const recordType of ["A", "AAAA"] as const) {
2207 yield* Route53Record(`Domain-${sanitizedName}-${recordType}`, {
2208 hostedZoneId: zoneId,
2209 name,
2210 type: recordType,
2211 aliasTarget: {
2212 hostedZoneId: alb.canonicalHostedZoneId,
2213 dnsName: alb.dnsName,
2214 evaluateTargetHealth: false,
2215 },
2216 });
2217 }
2218 }
2219 }
2220
2221 // ── listener rules ──────────────────────────────────────────────────
2222 const chain = yield* Namespace.CurrentChain;
2223 const namespacePrefix = [...chain].reverse().join("/");
2224 for (const r of normalized) {
2225 if (defaultsTaken.has(r)) {
2226 continue;
2227 }
2228 const ruleId = `Rule-${r.index}`;
2229 // A conditionless rule still needs at least one ELBv2 condition — use
2230 // the catch-all path (this is also the bare-listener default rule).
2231 const conditions =
2232 r.conditions.length > 0
2233 ? r.conditions
2234 : [{ pathPattern: { values: ["/*"] } }];
2235 const ruleListener = owned
2236 ? ownedListeners.get(ownedPortOf(r))!
2237 : isELBv2Listener(r.listen)
2238 ? r.listen
2239 : config.listener!;
2240 const priority =
2241 r.rule.priority ?? deriveRulePriority(`${namespacePrefix}/${ruleId}`);
2242 yield* ListenerRule(ruleId, {
2243 listenerArn: ruleListener.listenerArn as any,
2244 priority,
2245 conditions,
2246 actions: [actionToListenerAction(r.action)],
2247 tags: props.tags,
2248 });
2249 }
2250
2251 // ── primary listener (attrs + URL derivation) ───────────────────────
2252 const primary = owned
2253 ? (() => {
2254 const spec =
2255 ownedListenSpecs.get(defaultOwnedListen.port) ??
2256 [...ownedListenSpecs.values()][0];
2257 const listener = ownedListeners.get(spec.port)!;
2258 return {
2259 listenerArn: listener.listenerArn,
2260 loadBalancerArn: alb!.loadBalancerArn,
2261 port: spec.port as unknown,
2262 protocol: spec.protocol.toUpperCase() as unknown,
2263 };
2264 })()
2265 : (() => {
2266 const ref =
2267 config.listener ??
2268 (normalized.map((r) => r.listen).find(isELBv2Listener) as Listener);
2269 return {
2270 listenerArn: ref.listenerArn,
2271 loadBalancerArn: ref.loadBalancerArn,
2272 port: ref.port as unknown,
2273 protocol: ref.protocol as unknown,
2274 };
2275 })();
2276
2277 const ingress: ServiceManagedIngress = {
2278 kind: owned ? "owned" : "shared",
2279 loadBalancerArn: primary.loadBalancerArn as unknown as string,
2280 listenerArn: primary.listenerArn as unknown as string,
2281 listenerPort: primary.port as number,
2282 listenerProtocol: primary.protocol as string,
2283 securityGroupId: managedSg?.groupId as unknown as string | undefined,
2284 domain: domain?.name,
2285 targets: [...tgSpecs.values()].map((spec) => ({
2286 targetGroupArn: targetGroups.get(spec.key)!
2287 .targetGroupArn as unknown as string,
2288 containerPort: spec.forwardPort,
2289 container: spec.container,
2290 })),
2291 };
2292
2293 return {
2294 ...props,
2295 loadBalancer: undefined,
2296 public: undefined,
2297 ingress,
2298 } as ServiceProps;
2299 });
2300
2301// ───────────────────────────────────────────────────────────────────────────
2302// Autoscaling — composition (the `onCreate` hook)
2303// ───────────────────────────────────────────────────────────────────────────
2304
2305/**
2306 * Compose the Application Auto Scaling resources for the `scaling` prop:
2307 * a `ScalableTarget` bounding the service's desired count plus one
2308 * target-tracking `ScalingPolicy` per declared metric. Runs as the
2309 * platform's `onCreate` hook (AFTER the service resource is declared)
2310 * because the scalable target's `resourceId` and the request-count metric's
2311 * `ResourceLabel` are derived from the service's own Output attributes.
2312 */
2313const composeServiceScaling = (
2314 service: Service,
2315 props: ServiceProps | undefined,
2316): Effect.Effect<void, unknown, any> =>
2317 Effect.gen(function* () {
2318 // Composition is a plan/deploy concern — never runs inside bundles.
2319 if (globalThis.__ALCHEMY_RUNTIME__) {
2320 return;
2321 }
2322 const scaling = props?.scaling;
2323 if (scaling === undefined) {
2324 return;
2325 }
2326 const id = service.LogicalId;
2327 if (
2328 scaling.requestCount !== undefined &&
2329 props?.loadBalancer === undefined &&
2330 props?.public === undefined &&
2331 props?.ingress === undefined
2332 ) {
2333 return yield* Effect.fail(
2334 new RequestCountScalingRequiresLoadBalancer({
2335 serviceId: id,
2336 message: `AWS.ECS.Service "${id}": \`scaling.requestCount\` tracks ALBRequestCountPerTarget, which needs a managed target group — set \`loadBalancer\``,
2337 }),
2338 );
2339 }
2340 yield* Effect.gen(function* () {
2341 const clusterName = Output.map(
2342 service.clusterArn as unknown as Output.Output<string>,
2343 (arn) => arn.split("/").pop()!,
2344 );
2345 const min = scaling.min ?? 1;
2346 const max = scaling.max ?? min;
2347 const target = yield* ScalableTarget("ScalableTarget", {
2348 serviceNamespace: "ecs",
2349 resourceId:
2350 Output.interpolate`service/${clusterName}/${service.serviceName}` as unknown as string,
2351 scalableDimension: "ecs:service:DesiredCount",
2352 minCapacity: min,
2353 maxCapacity: max,
2354 tags: props?.tags,
2355 });
2356 // Reference the target's outputs so policies deploy after (and are
2357 // destroyed before) the scalable target.
2358 const policyBase = {
2359 serviceNamespace: "ecs" as const,
2360 resourceId: target.resourceId as unknown as string,
2361 scalableDimension: "ecs:service:DesiredCount" as const,
2362 };
2363 const cooldowns = {
2364 ScaleInCooldown: toWireSeconds(scaling.scaleInCooldown),
2365 ScaleOutCooldown: toWireSeconds(scaling.scaleOutCooldown),
2366 };
2367 if (scaling.cpuUtilization !== undefined) {
2368 yield* ScalingPolicy("CpuScaling", {
2369 ...policyBase,
2370 targetTracking: {
2371 TargetValue: scaling.cpuUtilization,
2372 PredefinedMetricSpecification: {
2373 PredefinedMetricType: "ECSServiceAverageCPUUtilization",
2374 },
2375 ...cooldowns,
2376 },
2377 });
2378 }
2379 if (scaling.memoryUtilization !== undefined) {
2380 yield* ScalingPolicy("MemoryScaling", {
2381 ...policyBase,
2382 targetTracking: {
2383 TargetValue: scaling.memoryUtilization,
2384 PredefinedMetricSpecification: {
2385 PredefinedMetricType: "ECSServiceAverageMemoryUtilization",
2386 },
2387 ...cooldowns,
2388 },
2389 });
2390 }
2391 if (scaling.requestCount !== undefined) {
2392 // ALBRequestCountPerTarget's ResourceLabel is
2393 // `app/{lb-name}/{lb-id}/targetgroup/{tg-name}/{tg-id}` — both
2394 // halves parsed from the managed-ingress ARNs on the service.
2395 const loadBalancerPart = Output.map(
2396 service.loadBalancerArn as unknown as Output.Output<string>,
2397 (arn) =>
2398 arn.slice(arn.indexOf("loadbalancer/") + "loadbalancer/".length),
2399 );
2400 const targetGroupPart = Output.map(
2401 service.targetGroupArn as unknown as Output.Output<string>,
2402 (arn) => arn.slice(arn.indexOf("targetgroup/")),
2403 );
2404 yield* ScalingPolicy("RequestCountScaling", {
2405 ...policyBase,
2406 targetTracking: {
2407 TargetValue: scaling.requestCount,
2408 PredefinedMetricSpecification: {
2409 PredefinedMetricType: "ALBRequestCountPerTarget",
2410 ResourceLabel:
2411 Output.interpolate`${loadBalancerPart}/${targetGroupPart}` as unknown as string,
2412 },
2413 ...cooldowns,
2414 },
2415 });
2416 }
2417 }).pipe(Namespace.push(id));
2418 });
2419
2420// ───────────────────────────────────────────────────────────────────────────
2421// Task-definition sugar (secrets / logging / volumes) — helpers
2422// ───────────────────────────────────────────────────────────────────────────
2423
2424const SECRETS_POLICY_NAME = "alchemy-secrets";
2425
2426/**
2427 * Sync the execution role's inline secrets-read policy to exactly the
2428 * SSM parameter / Secrets Manager ARNs referenced by the `secrets` prop
2429 * (deleted when no secrets remain).
2430 */
2431const syncTaskSecretsPolicy = Effect.fn(function* ({
2432 roleName,
2433 secretArns,
2434}: {
2435 roleName: string;
2436 secretArns: string[];
2437}) {
2438 if (secretArns.length === 0) {
2439 yield* iam
2440 .deleteRolePolicy({
2441 RoleName: roleName,
2442 PolicyName: SECRETS_POLICY_NAME,
2443 })
2444 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
2445 return;
2446 }
2447 const serviceOf = (arn: string) => arn.split(":")[2];
2448 const ssmArns = secretArns.filter((arn) => serviceOf(arn) === "ssm");
2449 const secretsManagerArns = secretArns.filter(
2450 (arn) => serviceOf(arn) === "secretsmanager",
2451 );
2452 yield* iam.putRolePolicy({
2453 RoleName: roleName,
2454 PolicyName: SECRETS_POLICY_NAME,
2455 PolicyDocument: JSON.stringify({
2456 Version: "2012-10-17",
2457 Statement: [
2458 ...(ssmArns.length > 0
2459 ? [
2460 {
2461 Effect: "Allow",
2462 Action: ["ssm:GetParameters"],
2463 Resource: ssmArns,
2464 },
2465 ]
2466 : []),
2467 ...(secretsManagerArns.length > 0
2468 ? [
2469 {
2470 Effect: "Allow",
2471 Action: ["secretsmanager:GetSecretValue"],
2472 Resource: secretsManagerArns,
2473 },
2474 ]
2475 : []),
2476 ],
2477 }),
2478 });
2479});
2480
2481/** CloudWatch Logs' allowed retention values, in days, ascending. */
2482const LOG_RETENTION_DAYS = [
2483 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827,
2484 2192, 2557, 2922, 3288, 3653,
2485];
2486
2487/**
2488 * Apply the `logging.retention` prop to the auto-created log group: round
2489 * the duration UP to the nearest CloudWatch-supported retention, or clear
2490 * the policy for `"forever"`. Leaves the group untouched when unset.
2491 */
2492const syncLogGroupRetention = Effect.fn(function* ({
2493 logGroupName,
2494 retention,
2495}: {
2496 logGroupName: string;
2497 retention: Duration.Input | "forever" | undefined;
2498}) {
2499 if (retention === undefined) {
2500 return;
2501 }
2502 if (retention === "forever") {
2503 yield* logs
2504 .deleteRetentionPolicy({ logGroupName })
2505 .pipe(Effect.catchTag("ResourceNotFoundException", () => Effect.void));
2506 return;
2507 }
2508 const days = Math.max(1, Math.ceil((toSeconds(retention) ?? 0) / 86_400));
2509 yield* logs.putRetentionPolicy({
2510 logGroupName,
2511 retentionInDays:
2512 LOG_RETENTION_DAYS.find((allowed) => allowed >= days) ??
2513 LOG_RETENTION_DAYS[LOG_RETENTION_DAYS.length - 1]!,
2514 });
2515});
2516
2517/**
2518 * Desugar the `volumes` prop: raw {@link ecs.Volume} entries pass through;
2519 * `{ efs, path }` entries become an EFS task volume (transit encryption
2520 * enabled, access-point auth when given) plus a primary-container mount
2521 * point at `path`.
2522 */
2523const resolveServiceVolumes = (
2524 volumes: (ecs.Volume | ServiceEfsVolume)[] | undefined,
2525): { volumes: ecs.Volume[]; mountPoints: ecs.MountPoint[] } => {
2526 const resolved: ecs.Volume[] = [];
2527 const mountPoints: ecs.MountPoint[] = [];
2528 for (const [index, volume] of (volumes ?? []).entries()) {
2529 if ("efs" in volume) {
2530 const efs = volume.efs;
2531 const fileSystemId =
2532 "fileSystemId" in efs ? efs.fileSystemId : efs.fileSystem.fileSystemId;
2533 const accessPointId =
2534 "fileSystemId" in efs ? undefined : efs.accessPoint?.accessPointId;
2535 const name = `efs-${index}`;
2536 resolved.push({
2537 name,
2538 efsVolumeConfiguration: {
2539 fileSystemId,
2540 transitEncryption: "ENABLED",
2541 authorizationConfig:
2542 accessPointId !== undefined ? { accessPointId } : undefined,
2543 },
2544 });
2545 mountPoints.push({ sourceVolume: name, containerPath: volume.path });
2546 } else {
2547 resolved.push(volume);
2548 }
2549 }
2550 return { volumes: resolved, mountPoints };
2551};
2552
2553type ServiceConvergenceDependencies = Credentials | HttpClient | Region;
2554
2555interface ServiceConvergenceSnapshot {
2556 readonly service: ecs.Service | undefined;
2557 readonly targetGroups: readonly {
2558 readonly missing: boolean;
2559 readonly states: readonly (string | undefined)[];
2560 }[];
2561 readonly converged: boolean;
2562}
2563
2564type ServiceConvergenceMode = "stable" | "drained" | "deleted";
2565
2566/**
2567 * Observe the complete service deployment boundary: ECS rollout state,
2568 * desired/running/pending counts, superseded deployments, and every target
2569 * group's registered target state.
2570 */
2571const observeServiceConvergence = (input: {
2572 clusterArn: string;
2573 serviceName: string;
2574 expectedTaskDefinitionArn?: string;
2575 expectedDeploymentId?: string;
2576 mode: ServiceConvergenceMode;
2577}): Effect.Effect<
2578 ServiceConvergenceSnapshot,
2579 ecs.DescribeServicesError | elbv2.DescribeTargetHealthError,
2580 ServiceConvergenceDependencies
2581> =>
2582 Effect.gen(function* () {
2583 const described = yield* ecs
2584 .describeServices({
2585 cluster: input.clusterArn,
2586 services: [input.serviceName],
2587 })
2588 .pipe(
2589 Effect.catchTag("ClusterNotFoundException", () =>
2590 Effect.succeed(undefined),
2591 ),
2592 );
2593 const service = described?.services?.find(
2594 (candidate) =>
2595 candidate.serviceName === input.serviceName &&
2596 candidate.status !== "INACTIVE",
2597 );
2598
2599 if (service === undefined) {
2600 return {
2601 service,
2602 targetGroups: [],
2603 converged: input.mode === "drained" || input.mode === "deleted",
2604 };
2605 }
2606
2607 const targetGroupArns = [
2608 ...new Set(
2609 (service.loadBalancers ?? []).flatMap((loadBalancer) =>
2610 loadBalancer.targetGroupArn ? [loadBalancer.targetGroupArn] : [],
2611 ),
2612 ),
2613 ];
2614 const targetGroups = yield* Effect.forEach(
2615 targetGroupArns,
2616 (targetGroupArn) =>
2617 elbv2.describeTargetHealth({ TargetGroupArn: targetGroupArn }).pipe(
2618 Effect.map((health) => ({
2619 missing: false,
2620 states: (health.TargetHealthDescriptions ?? []).map(
2621 (target) => target.TargetHealth?.State,
2622 ),
2623 })),
2624 Effect.catchTag("TargetGroupNotFoundException", () =>
2625 Effect.succeed({
2626 missing: true,
2627 states: [] as (string | undefined)[],
2628 }),
2629 ),
2630 ),
2631 { concurrency: 4 },
2632 );
2633
2634 if (input.mode === "deleted") {
2635 return { service, targetGroups, converged: false };
2636 }
2637
2638 const runningCount = service.runningCount ?? 0;
2639 const pendingCount = service.pendingCount ?? 0;
2640 const desiredCount = service.desiredCount ?? 0;
2641 const expectedTargetCount =
2642 service.schedulingStrategy === "DAEMON" ? runningCount : desiredCount;
2643
2644 if (input.mode === "drained") {
2645 return {
2646 service,
2647 targetGroups,
2648 converged:
2649 runningCount === 0 &&
2650 pendingCount === 0 &&
2651 targetGroups.every(
2652 (targetGroup) =>
2653 targetGroup.missing || targetGroup.states.length === 0,
2654 ),
2655 };
2656 }
2657
2658 const deployments = service.deployments ?? [];
2659 const primary = deployments.find(
2660 (deployment) => deployment.status === "PRIMARY",
2661 );
2662 const deploymentController = service.deploymentController?.type ?? "ECS";
2663 const countsConverged =
2664 service.schedulingStrategy === "DAEMON"
2665 ? pendingCount === 0
2666 : runningCount === desiredCount && pendingCount === 0;
2667 const deploymentConverged =
2668 deployments.length === 1 &&
2669 primary !== undefined &&
2670 (input.expectedDeploymentId === undefined ||
2671 primary.id === input.expectedDeploymentId) &&
2672 primary.taskDefinition === input.expectedTaskDefinitionArn &&
2673 (deploymentController === "ECS"
2674 ? primary.rolloutState === "COMPLETED"
2675 : primary.rolloutState === undefined ||
2676 primary.rolloutState === "COMPLETED");
2677 const targetsConverged = targetGroups.every(
2678 (targetGroup) =>
2679 !targetGroup.missing &&
2680 targetGroup.states.length === expectedTargetCount &&
2681 targetGroup.states.every((state) => state === "healthy"),
2682 );
2683
2684 return {
2685 service,
2686 targetGroups,
2687 converged:
2688 service.status === "ACTIVE" &&
2689 service.taskDefinition === input.expectedTaskDefinitionArn &&
2690 countsConverged &&
2691 deploymentConverged &&
2692 targetsConverged,
2693 };
2694 });
2695
2696const waitForServiceConvergence = (input: {
2697 clusterArn: string;
2698 serviceName: string;
2699 expectedTaskDefinitionArn?: string;
2700 expectedDeploymentId?: string;
2701 mode: ServiceConvergenceMode;
2702 timeout?: Duration.Input;
2703}): Effect.Effect<
2704 ServiceConvergenceSnapshot,
2705 | ecs.DescribeServicesError
2706 | elbv2.DescribeTargetHealthError
2707 | ServiceDidNotStabilize,
2708 ServiceConvergenceDependencies
2709> => {
2710 const pollIntervalMillis = 5_000;
2711 const timeoutMillis = Math.min(
2712 toMillis(input.timeout ?? "10 minutes")!,
2713 30 * 60 * 1_000,
2714 );
2715 const attempts = Math.max(1, Math.ceil(timeoutMillis / pollIntervalMillis));
2716 const expectedDeploymentFailed = (snapshot: ServiceConvergenceSnapshot) =>
2717 input.mode === "stable" &&
2718 snapshot.service?.deployments?.some(
2719 (deployment) =>
2720 (input.expectedDeploymentId !== undefined
2721 ? deployment.id === input.expectedDeploymentId
2722 : deployment.status === "PRIMARY" &&
2723 deployment.taskDefinition === input.expectedTaskDefinitionArn) &&
2724 deployment.rolloutState === "FAILED",
2725 ) === true;
2726
2727 return observeServiceConvergence(input).pipe(
2728 Effect.repeat({
2729 schedule: Schedule.max([
2730 Schedule.spaced(pollIntervalMillis),
2731 Schedule.recurs(attempts),
2732 ]),
2733 until: (snapshot) =>
2734 snapshot.converged || expectedDeploymentFailed(snapshot),
2735 }),
2736 Effect.flatMap((snapshot) => {
2737 if (snapshot.converged) {
2738 return Effect.succeed(snapshot);
2739 }
2740 const service = snapshot.service;
2741 const expectedTargetCount =
2742 service?.schedulingStrategy === "DAEMON"
2743 ? (service.runningCount ?? 0)
2744 : (service?.desiredCount ?? 0);
2745 const unhealthyTargetCount = snapshot.targetGroups.reduce(
2746 (total, targetGroup) =>
2747 total +
2748 (targetGroup.missing
2749 ? Math.max(1, expectedTargetCount)
2750 : targetGroup.states.filter((state) => state !== "healthy").length +
2751 Math.abs(targetGroup.states.length - expectedTargetCount)),
2752 0,
2753 );
2754 return Effect.fail(
2755 new ServiceDidNotStabilize({
2756 clusterArn: input.clusterArn,
2757 serviceName: input.serviceName,
2758 expectedTaskDefinitionArn: input.expectedTaskDefinitionArn,
2759 status: service?.status,
2760 desiredCount: service?.desiredCount,
2761 runningCount: service?.runningCount,
2762 pendingCount: service?.pendingCount,
2763 deploymentCount: service?.deployments?.length ?? 0,
2764 unhealthyTargetCount,
2765 message: expectedDeploymentFailed(snapshot)
2766 ? `ECS service ${input.serviceName} reported a failed deployment`
2767 : `ECS service ${input.serviceName} did not become ${input.mode} before timeout`,
2768 }),
2769 );
2770 }),
2771 );
2772};
2773
2774export const ServiceProvider = () =>
2775 Provider.effect(
2776 Service,
2777 Effect.gen(function* () {
2778 const stack = yield* Stack;
2779 const imageSource = yield* makeImageSource;
2780
2781 const alchemyEnv = {
2782 ALCHEMY_STACK_NAME: stack.name,
2783 ALCHEMY_STAGE: stack.stage,
2784 ALCHEMY_PHASE: "runtime",
2785 };
2786
2787 // Derive the cluster ARN from either form of the `cluster` prop. May
2788 // legitimately receive `undefined`: a `creating` state row persisted
2789 // before upstream Outputs resolved can't round-trip an Output-valued
2790 // `cluster` (it deserializes as `undefined`), and recovery paths hand
2791 // those props back as `olds`.
2792 const clusterArnOf = (
2793 cluster: ServiceProps["cluster"] | ClusterArn | undefined,
2794 ): ClusterArn | undefined =>
2795 typeof cluster === "string"
2796 ? (cluster as ClusterArn)
2797 : typeof (cluster as { clusterArn?: unknown } | undefined)
2798 ?.clusterArn === "string"
2799 ? ((cluster as { clusterArn: string }).clusterArn as ClusterArn)
2800 : undefined;
2801 const toEcsTags = (tags: Record<string, string>): ecs.Tag[] =>
2802 Object.entries(tags).map(([key, value]) => ({ key, value }));
2803
2804 const toServiceName = (
2805 id: string,
2806 props: { serviceName?: string } = {},
2807 ) =>
2808 props.serviceName
2809 ? Effect.succeed(props.serviceName)
2810 : createPhysicalName({
2811 id,
2812 maxLength: 255,
2813 lowercase: true,
2814 });
2815
2816 // ── networking ────────────────────────────────────────────────────
2817
2818 /**
2819 * Resolve the VPC + subnets the service runs in. Explicit props win;
2820 * otherwise fall back to the account's default VPC and its per-AZ
2821 * default subnets (public — so `assignPublicIp` then defaults to true
2822 * to allow image pulls without a NAT).
2823 */
2824 const resolveNetwork = Effect.fn(function* (news: {
2825 vpcId?: string;
2826 subnets?: string[];
2827 assignPublicIp?: boolean;
2828 }) {
2829 if (news.vpcId !== undefined && news.subnets !== undefined) {
2830 return {
2831 vpcId: news.vpcId,
2832 subnets: news.subnets,
2833 assignPublicIp: news.assignPublicIp ?? false,
2834 };
2835 }
2836 const network = yield* lookupDefaultNetwork;
2837 return {
2838 vpcId: network.vpcId,
2839 subnets: news.subnets ?? network.subnets,
2840 assignPublicIp: news.assignPublicIp ?? true,
2841 };
2842 });
2843
2844 // ── managed ingress (composed ELBv2 resources) ────────────────────
2845
2846 /**
2847 * Best-effort public URL for managed ingress: resolve the (owned or
2848 * shared) load balancer's DNS name and combine it with the primary
2849 * listener's protocol/port. Returns undefined when not derivable.
2850 */
2851 const deriveIngressUrl = Effect.fn(function* (
2852 ingress: ServiceManagedIngress | undefined,
2853 ) {
2854 // A custom domain wins: it aliases the load balancer and carries
2855 // the listener's protocol/port.
2856 if (ingress?.domain !== undefined) {
2857 const domainProtocol = (
2858 ingress.listenerProtocol ?? "HTTPS"
2859 ).toLowerCase();
2860 const domainPort = ingress.listenerPort;
2861 const isWellKnownPort =
2862 domainPort === undefined ||
2863 (domainProtocol === "http" && domainPort === 80) ||
2864 (domainProtocol === "https" && domainPort === 443);
2865 return `${domainProtocol}://${ingress.domain}${isWellKnownPort ? "" : `:${domainPort}`}`;
2866 }
2867 if (!ingress?.loadBalancerArn) {
2868 return undefined;
2869 }
2870 const described = yield* elbv2
2871 .describeLoadBalancers({
2872 LoadBalancerArns: [ingress.loadBalancerArn],
2873 })
2874 .pipe(
2875 Effect.catchTag("LoadBalancerNotFoundException", () =>
2876 Effect.succeed(undefined),
2877 ),
2878 );
2879 const dnsName = described?.LoadBalancers?.[0]?.DNSName;
2880 if (!dnsName) {
2881 return undefined;
2882 }
2883 // Works unchanged against the local emulator: floci's ALB DNS
2884 // (`*.elb.localhost.floci.io`) resolves to 127.0.0.1 and its data
2885 // plane serves each listener on the listener's own port, which
2886 // `ensureFloci` publishes on the managed container (80/443).
2887 const protocol = (ingress.listenerProtocol ?? "HTTP").toLowerCase();
2888 const port = ingress.listenerPort;
2889 const isDefaultPort =
2890 port === undefined ||
2891 (protocol === "http" && port === 80) ||
2892 (protocol === "https" && port === 443);
2893 return `${protocol}://${dnsName}${isDefaultPort ? "" : `:${port}`}`;
2894 });
2895
2896 /**
2897 * Reap the ALB/TG/listener/security group that the pre-composition
2898 * provider created INLINE and recorded in the service's own attributes.
2899 * Those legacy state rows carry the ingress ARNs with no `ingressKind`
2900 * marker; the composed shape stamps the marker and owns its ingress as
2901 * real child resources (deleted by the engine, never here). Reaping on
2902 * the first reconcile under the new shape keeps the breaking redeploy
2903 * from stranding the old inline infrastructure.
2904 */
2905 const reapLegacyIngress = Effect.fn(function* ({
2906 output,
2907 session,
2908 }: {
2909 output: Service["Attributes"] | undefined;
2910 session: { note: (message: string) => Effect.Effect<void> };
2911 }) {
2912 if (!output || output.ingressKind !== undefined) {
2913 return;
2914 }
2915 if (
2916 !output.listenerArn &&
2917 !output.targetGroupArn &&
2918 !output.loadBalancerArn &&
2919 !output.securityGroupId
2920 ) {
2921 return;
2922 }
2923 yield* session.note(
2924 "Migrating to composed load balancer resources — deleting the legacy inline ALB/TG/listener",
2925 );
2926 if (output.listenerArn) {
2927 yield* elbv2
2928 .deleteListener({ ListenerArn: output.listenerArn })
2929 .pipe(
2930 Effect.catchTag("ListenerNotFoundException", () => Effect.void),
2931 );
2932 }
2933 if (output.targetGroupArn) {
2934 yield* elbv2
2935 .deleteTargetGroup({ TargetGroupArn: output.targetGroupArn })
2936 .pipe(
2937 Effect.retry({
2938 while: (e) => e._tag === "ResourceInUseException",
2939 schedule: Schedule.max([
2940 Schedule.spaced("3 seconds"),
2941 Schedule.recurs(8),
2942 ]),
2943 }),
2944 Effect.catch(() => Effect.void),
2945 );
2946 }
2947 if (output.loadBalancerArn) {
2948 yield* elbv2
2949 .deleteLoadBalancer({ LoadBalancerArn: output.loadBalancerArn })
2950 .pipe(
2951 Effect.catchTag(
2952 "LoadBalancerNotFoundException",
2953 () => Effect.void,
2954 ),
2955 );
2956 }
2957 if (output.securityGroupId) {
2958 // The old ALB/service ENIs release the group asynchronously; retry
2959 // bounded, then give up with a note rather than fail the migration
2960 // deploy.
2961 yield* ec2
2962 .deleteSecurityGroup({ GroupId: output.securityGroupId })
2963 .pipe(
2964 Effect.catchTag("InvalidGroup.NotFound", () => Effect.void),
2965 Effect.retry({
2966 while: (e) => e._tag === "DependencyViolation",
2967 schedule: Schedule.max([
2968 Schedule.spaced("5 seconds"),
2969 Schedule.recurs(24),
2970 ]),
2971 }),
2972 Effect.catchTag("DependencyViolation", () =>
2973 session.note(
2974 "Legacy ingress security group still has attached ENIs; leaving it to release asynchronously",
2975 ),
2976 ),
2977 );
2978 }
2979 });
2980
2981 // ── task definition synthesis (image-owning forms) ────────────────
2982
2983 /**
2984 * Synthesize (or roll) the service-owned task definition from the
2985 * image source + `TaskDefinitionConfig` surface. Mirrors the
2986 * `AWS.ECS.Task` reconcile flow.
2987 */
2988 const synthesizeTaskDefinition = Effect.fn(function* ({
2989 id,
2990 news,
2991 output,
2992 bindings,
2993 tags,
2994 session,
2995 }: {
2996 id: string;
2997 news: BundledServiceProps | DockerfileServiceProps | ImageServiceProps;
2998 output: Service["Attributes"] | undefined;
2999 bindings: Parameters<typeof attachTaskBindings>[0]["bindings"];
3000 tags: Record<string, string>;
3001 session: { note: (message: string) => Effect.Effect<void> };
3002 }) {
3003 const family =
3004 output?.taskFamily ??
3005 (yield* createPhysicalName({
3006 id: `${id}-task`,
3007 maxLength: 255,
3008 lowercase: true,
3009 }));
3010 const taskRoleName =
3011 output?.taskRoleName ??
3012 (yield* createPhysicalName({
3013 id: `${id}-task-role`,
3014 maxLength: 64,
3015 }));
3016 const executionRoleName =
3017 output?.executionRoleName ??
3018 (yield* createPhysicalName({
3019 id: `${id}-execution-role`,
3020 maxLength: 64,
3021 }));
3022 const taskPolicyName = yield* createPhysicalName({
3023 id: `${id}-task-policy`,
3024 maxLength: 128,
3025 });
3026 const repositoryName =
3027 output?.repositoryName ??
3028 (yield* createPhysicalName({
3029 id: `${id}-repo`,
3030 maxLength: 256,
3031 lowercase: true,
3032 }));
3033 const logGroupName =
3034 output?.logGroupName ??
3035 (yield* createPhysicalName({
3036 id: `${id}-logs`,
3037 maxLength: 512,
3038 lowercase: true,
3039 }));
3040
3041 const taskRoleArn =
3042 output?.taskRoleArn ??
3043 (yield* createTaskRoleIfNotExists({ id, roleName: taskRoleName }));
3044
3045 // `taskRoleManagedPolicyArns` is part of the inherited
3046 // `TaskDefinitionConfig` surface — attach it like the standalone
3047 // Task provider does (its sibling `executionRoleManagedPolicyArns`
3048 // is already honored below). `attachRolePolicy` is idempotent for
3049 // already-attached ARNs; a `LimitExceededException` means the
3050 // policy was NOT attached, so it propagates and fails the deploy
3051 // rather than silently shipping a role with missing permissions.
3052 yield* Effect.all(
3053 (news.taskRoleManagedPolicyArns ?? []).map((policyArn) =>
3054 iam.attachRolePolicy({
3055 RoleName: taskRoleName,
3056 PolicyArn: policyArn,
3057 }),
3058 ),
3059 { concurrency: "unbounded" },
3060 );
3061
3062 const executionRoleArn =
3063 output?.executionRoleArn ??
3064 (yield* ensureTaskExecutionRole({
3065 id,
3066 roleName: executionRoleName,
3067 managedPolicyArns: news.executionRoleManagedPolicyArns,
3068 }));
3069
3070 const {
3071 env: bindingEnv,
3072 volumes: bindingVolumes,
3073 mountPoints: bindingMountPoints,
3074 } = yield* attachTaskBindings({
3075 roleName: taskRoleName,
3076 policyName: taskPolicyName,
3077 bindings,
3078 });
3079
3080 // Secrets: container `secrets` (valueFrom) + the execution role's
3081 // read permissions on exactly the referenced ARNs.
3082 const secretEntries = Object.entries(news.secrets ?? {});
3083 yield* syncTaskSecretsPolicy({
3084 roleName: executionRoleName,
3085 secretArns: secretEntries.map(([, arn]) => arn),
3086 });
3087
3088 // Environment files: the execution role reads the referenced S3
3089 // objects at task start.
3090 yield* syncEnvironmentFilesPolicy({
3091 roleName: executionRoleName,
3092 environmentFiles: news.environmentFiles,
3093 });
3094
3095 const logGroupArn =
3096 output?.logGroupArn ??
3097 (yield* ensureTaskLogGroup({ id, logGroupName }));
3098 yield* syncLogGroupRetention({
3099 logGroupName,
3100 retention: news.logging?.retention,
3101 });
3102
3103 const source = news as ImageSourceLike;
3104 const resolved = yield* imageSource.resolve({
3105 id,
3106 source,
3107 repositoryName,
3108 repositoryUri:
3109 output?.repositoryUri && output.repositoryName === repositoryName
3110 ? output.repositoryUri
3111 : undefined,
3112 tags,
3113 platform: taskImagePlatform(news.runtimePlatform),
3114 port: news.port,
3115 isExternal: news.isExternal,
3116 bootstrap: makeBunBootstrap(source.handler ?? "default"),
3117 session,
3118 });
3119
3120 // Desugar `{ efs, path }` volumes into task volumes + primary
3121 // container mount points, then fold the secrets / container health
3122 // check sugar into the primary-container overrides.
3123 const { volumes: resolvedVolumes, mountPoints: efsMountPoints } =
3124 resolveServiceVolumes(news.volumes);
3125 const containerOverrides: Partial<ecs.ContainerDefinition> = {
3126 ...news.container,
3127 ...(secretEntries.length > 0
3128 ? {
3129 secrets: [
3130 ...(news.container?.secrets ?? []),
3131 ...secretEntries.map(([name, valueFrom]) => ({
3132 name,
3133 valueFrom,
3134 })),
3135 ],
3136 }
3137 : {}),
3138 ...(news.healthCheck !== undefined
3139 ? {
3140 healthCheck: {
3141 command: news.healthCheck.command,
3142 interval: toWireSeconds(news.healthCheck.interval),
3143 timeout: toWireSeconds(news.healthCheck.timeout),
3144 retries: news.healthCheck.retries,
3145 startPeriod: toWireSeconds(news.healthCheck.startPeriod),
3146 },
3147 }
3148 : {}),
3149 ...(efsMountPoints.length > 0
3150 ? {
3151 mountPoints: [
3152 ...(news.container?.mountPoints ?? []),
3153 ...efsMountPoints,
3154 ],
3155 }
3156 : {}),
3157 };
3158
3159 const taskDefinition = yield* registerTaskDefinitionRevision({
3160 props: {
3161 ...news,
3162 placementConstraints: news.taskPlacementConstraints,
3163 volumes: resolvedVolumes.length > 0 ? resolvedVolumes : undefined,
3164 container: containerOverrides,
3165 env: {
3166 ...bindingEnv,
3167 ...alchemyEnv,
3168 ...news.env,
3169 },
3170 },
3171 family,
3172 imageUri: resolved.imageUri,
3173 taskRoleArn,
3174 executionRoleArn,
3175 logGroupName,
3176 tags,
3177 bindingVolumes,
3178 bindingMountPoints,
3179 });
3180
3181 yield* syncTaskDefinitionTags({
3182 revisionArn: taskDefinition.taskDefinitionArn!,
3183 tags,
3184 });
3185
3186 const containerName =
3187 taskDefinition.containerDefinitions?.[0]?.name ?? family;
3188 return {
3189 taskDefinitionArn: taskDefinition.taskDefinitionArn!,
3190 taskFamily: family,
3191 containerName,
3192 port: news.port ?? 3000,
3193 imageUri: resolved.imageUri,
3194 repositoryName: resolved.repositoryName,
3195 repositoryUri: resolved.repositoryUri,
3196 taskRoleArn,
3197 taskRoleName,
3198 executionRoleArn,
3199 executionRoleName,
3200 logGroupName,
3201 logGroupArn,
3202 code: { hash: resolved.codeHash },
3203 };
3204 });
3205
3206 const networkConfigurationOf = (
3207 network: {
3208 subnets: string[];
3209 assignPublicIp: boolean;
3210 },
3211 securityGroups: string[] | undefined,
3212 ) => ({
3213 awsvpcConfiguration: {
3214 subnets: network.subnets,
3215 securityGroups,
3216 assignPublicIp: (network.assignPublicIp ? "ENABLED" : "DISABLED") as
3217 | "ENABLED"
3218 | "DISABLED",
3219 },
3220 });
3221
3222 // load balancers passed to create/update: explicit user-supplied list
3223 // plus the composed managed-ingress target groups.
3224 const loadBalancersOf = (
3225 news: ServiceProps,
3226 task: { containerName: string; port: number },
3227 ): ecs.LoadBalancer[] | undefined => {
3228 const managed: ecs.LoadBalancer[] = (news.ingress?.targets ?? []).map(
3229 (target) => ({
3230 targetGroupArn: target.targetGroupArn,
3231 containerName: target.container ?? task.containerName,
3232 containerPort: target.containerPort ?? task.port,
3233 }),
3234 );
3235 const all = [...(news.loadBalancers ?? []), ...managed];
3236 return all.length > 0 ? all : undefined;
3237 };
3238
3239 // In-place mutable fields shared by createService and updateService.
3240 const mutableInput = (
3241 news: ServiceProps,
3242 task: { taskDefinitionArn: string },
3243 network: { subnets: string[]; assignPublicIp: boolean },
3244 securityGroups: string[] | undefined,
3245 ) => ({
3246 taskDefinition: task.taskDefinitionArn,
3247 platformVersion: news.platformVersion,
3248 deploymentConfiguration: news.deploymentConfiguration,
3249 healthCheckGracePeriodSeconds: toWireSeconds(
3250 news.healthCheckGracePeriod,
3251 ),
3252 networkConfiguration: networkConfigurationOf(network, securityGroups),
3253 capacityProviderStrategy: news.capacityProviderStrategy,
3254 placementConstraints: news.placementConstraints,
3255 placementStrategy: news.placementStrategy,
3256 enableExecuteCommand: news.enableExecuteCommand,
3257 propagateTags: news.propagateTags,
3258 availabilityZoneRebalancing: news.availabilityZoneRebalancing,
3259 serviceConnectConfiguration: news.serviceConnectConfiguration,
3260 volumeConfigurations: news.volumeConfigurations,
3261 // launchType and capacityProviderStrategy are mutually exclusive;
3262 // only send launchType when no strategy is provided.
3263 launchType: news.capacityProviderStrategy
3264 ? undefined
3265 : (news.launchType ?? "FARGATE"),
3266 });
3267
3268 return {
3269 stables: ["serviceArn", "serviceName", "clusterArn"],
3270 diff: Effect.fn(function* ({ id, olds, news, output }) {
3271 if (!isResolved(news)) return;
3272 // serviceName change → delete-first replace (name is the identity).
3273 if (
3274 (yield* toServiceName(id, olds ?? {})) !==
3275 (yield* toServiceName(id, news ?? {}))
3276 ) {
3277 return { action: "replace", deleteFirst: true } as const;
3278 }
3279 // cluster change → replace (a service can't move clusters). Only
3280 // when both sides are known — a half-created state row may have
3281 // lost an Output-valued `cluster` (see `clusterArnOf`), and an
3282 // unknown old cluster must fall through to the create/update
3283 // recovery path rather than force a replacement.
3284 const oldClusterArn = clusterArnOf(olds?.cluster);
3285 const newClusterArn = clusterArnOf(news.cluster);
3286 if (
3287 oldClusterArn !== undefined &&
3288 newClusterArn !== undefined &&
3289 oldClusterArn !== newClusterArn
3290 ) {
3291 return { action: "replace", deleteFirst: true } as const;
3292 }
3293 // Truly-immutable post-create fields. Everything else (desiredCount,
3294 // taskDefinition, network, deployment config, placement, loadBalancers,
3295 // exec, tags, …) is applied in place by `updateService`.
3296 if (
3297 olds !== undefined &&
3298 !deepEqual(
3299 {
3300 // launchType ↔ capacityProviderStrategy switch is immutable.
3301 usesStrategy: !!olds.capacityProviderStrategy,
3302 schedulingStrategy: olds.schedulingStrategy ?? "REPLICA",
3303 deploymentControllerType:
3304 olds.deploymentController?.type ?? "ECS",
3305 enableECSManagedTags: olds.enableECSManagedTags ?? true,
3306 role: olds.role,
3307 },
3308 {
3309 usesStrategy: !!news.capacityProviderStrategy,
3310 schedulingStrategy: news.schedulingStrategy ?? "REPLICA",
3311 deploymentControllerType:
3312 news.deploymentController?.type ?? "ECS",
3313 enableECSManagedTags: news.enableECSManagedTags ?? true,
3314 role: news.role,
3315 },
3316 )
3317 ) {
3318 return { action: "replace", deleteFirst: true } as const;
3319 }
3320 // Content drift for image-owning sources: props don't change when
3321 // the files under `context`/`main` do, so hash the source at plan
3322 // time (`main` runs the bundler, covering the bootstrap entry) and
3323 // surface drift as an update; without this a bootstrap or code-only
3324 // change would silently no-op until `--force`.
3325 if (output?.code && taskRefOf(news) === undefined) {
3326 const imageNews = news as ImageOwningServicePropsBase;
3327 const source = news as ImageSourceLike;
3328 const hash = yield* imageSource.hash({
3329 source,
3330 platform: taskImagePlatform(imageNews.runtimePlatform),
3331 port: imageNews.port,
3332 isExternal: imageNews.isExternal,
3333 bootstrap: makeBunBootstrap(source.handler ?? "default"),
3334 });
3335 if (hash !== undefined && hash !== output.code.hash) {
3336 return { action: "update" } as const;
3337 }
3338 }
3339 }),
3340 read: Effect.fn(function* ({ id, olds, output }) {
3341 const clusterArn = output?.clusterArn ?? clusterArnOf(olds?.cluster);
3342 if (clusterArn === undefined) {
3343 // No attributes and no recoverable cluster from the persisted
3344 // props (an Output-valued `cluster` doesn't survive a
3345 // `creating`-state round-trip). We can't locate the service, so
3346 // report "not found" — the engine re-drives the create and
3347 // reconcile converges on any half-created service by name.
3348 return undefined;
3349 }
3350 const serviceName =
3351 output?.serviceName ?? (yield* toServiceName(id, olds ?? {}));
3352 const described = yield* ecs
3353 .describeServices({
3354 cluster: clusterArn,
3355 services: [serviceName],
3356 include: ["TAGS"],
3357 })
3358 .pipe(
3359 Effect.catchTag("ClusterNotFoundException", () =>
3360 Effect.succeed(undefined),
3361 ),
3362 );
3363 const service = described?.services?.[0];
3364 if (!service?.serviceArn) {
3365 return undefined;
3366 }
3367 return {
3368 ...output!,
3369 serviceArn: service.serviceArn as ServiceArn,
3370 serviceName: service.serviceName!,
3371 clusterArn: service.clusterArn as ClusterArn,
3372 taskDefinitionArn: service.taskDefinition!,
3373 status: service.status ?? "ACTIVE",
3374 };
3375 }),
3376 list: () =>
3377 Effect.gen(function* () {
3378 // ECS services are scoped to a cluster, so enumerate every cluster
3379 // first, then list services per cluster, then hydrate via
3380 // describeServices (which accepts up to 10 services per call).
3381 const clusterArns = yield* ecs.listClusters.pages({}).pipe(
3382 Stream.runCollect,
3383 Effect.map((chunk) =>
3384 Array.from(chunk).flatMap((page) => page.clusterArns ?? []),
3385 ),
3386 );
3387
3388 const perCluster = yield* Effect.forEach(
3389 clusterArns,
3390 (clusterArn) =>
3391 Effect.gen(function* () {
3392 const serviceArns = yield* ecs.listServices
3393 .pages({ cluster: clusterArn })
3394 .pipe(
3395 Stream.runCollect,
3396 Effect.map((chunk) =>
3397 Array.from(chunk).flatMap(
3398 (page) => page.serviceArns ?? [],
3399 ),
3400 ),
3401 Effect.catchTag("ClusterNotFoundException", () =>
3402 Effect.succeed([] as string[]),
3403 ),
3404 );
3405 if (serviceArns.length === 0) {
3406 return [] as Service["Attributes"][];
3407 }
3408
3409 const batches: string[][] = [];
3410 for (let i = 0; i < serviceArns.length; i += 10) {
3411 batches.push(serviceArns.slice(i, i + 10));
3412 }
3413
3414 const described = yield* Effect.forEach(
3415 batches,
3416 (services) =>
3417 ecs
3418 .describeServices({ cluster: clusterArn, services })
3419 .pipe(
3420 Effect.map((res) => res.services ?? []),
3421 Effect.catchTag("ClusterNotFoundException", () =>
3422 Effect.succeed([] as ecs.Service[]),
3423 ),
3424 ),
3425 { concurrency: 4 },
3426 );
3427
3428 return described.flat().flatMap((service) =>
3429 service.serviceArn && service.status !== "INACTIVE"
3430 ? [
3431 {
3432 serviceArn: service.serviceArn as ServiceArn,
3433 serviceName: service.serviceName!,
3434 clusterArn: service.clusterArn as ClusterArn,
3435 taskDefinitionArn: service.taskDefinition!,
3436 status: service.status ?? "ACTIVE",
3437 } satisfies Service["Attributes"],
3438 ]
3439 : [],
3440 );
3441 }),
3442 { concurrency: 5 },
3443 );
3444
3445 return perCluster.flat();
3446 }),
3447 reconcile: Effect.fn(function* ({
3448 id,
3449 news,
3450 olds,
3451 output,
3452 bindings,
3453 session,
3454 }) {
3455 const serviceName = yield* toServiceName(id, news);
3456 const clusterArn = clusterArnOf(news.cluster) as ClusterArn;
3457 const desiredTags = {
3458 ...(yield* createInternalTags(id)),
3459 ...news.tags,
3460 };
3461
3462 // Resolve the task definition: BYO reference, or synthesize the
3463 // service-owned definition from the image source.
3464 const byoTask = taskRefOf(news);
3465 const owned =
3466 byoTask === undefined
3467 ? yield* synthesizeTaskDefinition({
3468 id,
3469 news: news as
3470 | BundledServiceProps
3471 | DockerfileServiceProps
3472 | ImageServiceProps,
3473 output,
3474 bindings,
3475 tags: desiredTags,
3476 session,
3477 })
3478 : undefined;
3479 const task = byoTask ?? {
3480 taskDefinitionArn: owned!.taskDefinitionArn,
3481 containerName: owned!.containerName,
3482 port: owned!.port,
3483 };
3484
3485 // Resolve networking (explicit props or the default VPC).
3486 const network = yield* resolveNetwork(news);
3487
3488 // Managed ingress: the composed child-resource outputs arrive via
3489 // the internal `ingress` prop (written by the factory's
3490 // composition step). The managed security group — when composed —
3491 // is attached to the service ENIs as well.
3492 const ingress = news.ingress;
3493 const securityGroups =
3494 news.securityGroups ??
3495 (ingress?.securityGroupId ? [ingress.securityGroupId] : undefined);
3496
3497 // Observe — describe service in target cluster. The cluster may
3498 // not yet exist on first reconcile, so we tolerate
3499 // `ClusterNotFoundException`.
3500 const described = yield* ecs
3501 .describeServices({
3502 cluster: clusterArn,
3503 services: [serviceName],
3504 include: ["TAGS"],
3505 })
3506 .pipe(
3507 Effect.catchTag("ClusterNotFoundException", () =>
3508 Effect.succeed(undefined),
3509 ),
3510 );
3511 const observed = described?.services?.find(
3512 (s) =>
3513 s.serviceName === serviceName &&
3514 s.status !== "INACTIVE" &&
3515 s.status !== "DRAINING",
3516 );
3517
3518 // Managed-ingress attributes: derived from the composed (or
3519 // shared) child-resource outputs; the URL comes from the load
3520 // balancer's DNS name (best-effort).
3521 const ingressAttrs = {
3522 url: yield* deriveIngressUrl(ingress),
3523 loadBalancerArn: ingress?.loadBalancerArn,
3524 targetGroupArn: ingress?.targets[0]?.targetGroupArn,
3525 listenerArn: ingress?.listenerArn,
3526 securityGroupId: ingress?.securityGroupId,
3527 ingressKind: ingress?.kind,
3528 };
3529
3530 const ownedAttributes = {
3531 taskFamily: owned?.taskFamily,
3532 containerName: owned?.containerName,
3533 port: owned?.port,
3534 imageUri: owned?.imageUri,
3535 repositoryName: owned?.repositoryName,
3536 repositoryUri: owned?.repositoryUri,
3537 taskRoleArn: owned?.taskRoleArn,
3538 taskRoleName: owned?.taskRoleName,
3539 executionRoleArn: owned?.executionRoleArn,
3540 executionRoleName: owned?.executionRoleName,
3541 logGroupName: owned?.logGroupName,
3542 logGroupArn: owned?.logGroupArn,
3543 code: owned?.code,
3544 };
3545
3546 if (!observed?.serviceArn) {
3547 const created = yield* ecs.createService({
3548 ...mutableInput(news, task, network, securityGroups),
3549 // With autoscaling, `desiredCount` is only the initial size
3550 // (defaulting to `scaling.min`) — the scalable target owns it
3551 // from then on.
3552 desiredCount: news.desiredCount ?? news.scaling?.min ?? 1,
3553 serviceName,
3554 cluster: clusterArn,
3555 loadBalancers: loadBalancersOf(news, task),
3556 serviceRegistries: news.serviceRegistries,
3557 deploymentController: news.deploymentController,
3558 schedulingStrategy: news.schedulingStrategy,
3559 role: news.role,
3560 tags: toEcsTags(desiredTags),
3561 enableECSManagedTags: news.enableECSManagedTags ?? true,
3562 });
3563 const service = created.service;
3564 if (!service?.serviceArn) {
3565 return yield* Effect.die(
3566 new Error("createService returned no service"),
3567 );
3568 }
3569 yield* session.note(
3570 `Waiting for ECS service ${serviceName} to stabilize`,
3571 );
3572 const stable = yield* waitForServiceConvergence({
3573 clusterArn,
3574 serviceName,
3575 expectedTaskDefinitionArn: task.taskDefinitionArn,
3576 expectedDeploymentId: service.deployments?.find(
3577 (deployment) => deployment.status === "PRIMARY",
3578 )?.id,
3579 mode: "stable",
3580 timeout: news.deploymentStabilizationTimeout,
3581 });
3582 const stableService = stable.service!;
3583 // Legacy inline ingress recorded in prior attrs (service itself
3584 // was missing — e.g. recreated) is stale now: reap it.
3585 yield* reapLegacyIngress({ output, session });
3586 // Synthesizing registered a NEW revision; reap the superseded
3587 // one so revisions don't accumulate ACTIVE forever (same-family
3588 // guarded — a prior BYO `task:` reference is left untouched).
3589 if (owned !== undefined) {
3590 yield* reapSupersededTaskDefinitionRevision({
3591 previousArn: output?.taskDefinitionArn,
3592 nextArn: owned.taskDefinitionArn,
3593 });
3594 }
3595 yield* session.note(service.serviceArn);
3596 return {
3597 serviceArn: stableService.serviceArn as ServiceArn,
3598 serviceName: stableService.serviceName!,
3599 clusterArn: stableService.clusterArn as ClusterArn,
3600 taskDefinitionArn: stableService.taskDefinition!,
3601 status: stableService.status ?? "ACTIVE",
3602 ...ingressAttrs,
3603 ...ownedAttributes,
3604 };
3605 }
3606
3607 // Sync — apply in-place mutable fields via updateService. Force a new
3608 // deployment so a changed task definition (same revision-less ARN) or
3609 // load-balancer wiring rolls out.
3610 const updated = yield* ecs
3611 .updateService({
3612 ...mutableInput(news, task, network, securityGroups),
3613 // While autoscaling manages the desired count, leave it
3614 // unchanged on updates (undefined on the wire) so redeploys
3615 // don't fight the autoscaler's decisions.
3616 desiredCount:
3617 news.scaling !== undefined
3618 ? undefined
3619 : (news.desiredCount ?? 1),
3620 service: serviceName,
3621 cluster: clusterArn,
3622 // `undefined` means "leave unchanged" on the wire, so pass an
3623 // explicit empty list to DETACH when a previously-managed
3624 // target group is no longer desired.
3625 loadBalancers:
3626 loadBalancersOf(news, task) ??
3627 (output?.targetGroupArn !== undefined ? [] : undefined),
3628 // Same wire semantics for service registries: detach with an
3629 // explicit empty list when a previously-declared registry is
3630 // no longer desired.
3631 serviceRegistries:
3632 news.serviceRegistries ??
3633 ((olds?.serviceRegistries?.length ?? 0) > 0 ? [] : undefined),
3634 enableExecuteCommand: news.enableExecuteCommand,
3635 forceNewDeployment: true,
3636 })
3637 .pipe(
3638 // The service may still be transitioning (e.g. a prior
3639 // deployment settling). updateService rejects with
3640 // ServiceNotActiveException until it returns to ACTIVE — retry
3641 // bounded.
3642 Effect.retry({
3643 while: (e) => e._tag === "ServiceNotActiveException",
3644 schedule: Schedule.max([
3645 Schedule.spaced("5 seconds"),
3646 Schedule.recurs(8),
3647 ]),
3648 }),
3649 );
3650 yield* session.note(
3651 `Waiting for ECS service ${serviceName} to stabilize`,
3652 );
3653 const stable = yield* waitForServiceConvergence({
3654 clusterArn,
3655 serviceName,
3656 expectedTaskDefinitionArn: task.taskDefinitionArn,
3657 expectedDeploymentId: updated.service?.deployments?.find(
3658 (deployment) => deployment.status === "PRIMARY",
3659 )?.id,
3660 mode: "stable",
3661 timeout: news.deploymentStabilizationTimeout,
3662 });
3663 const stableService = stable.service!;
3664
3665 // Sync tags — diff observed service tags against desired.
3666 const observedTags = Object.fromEntries(
3667 (observed.tags ?? [])
3668 .filter(
3669 (t): t is { key: string; value: string } =>
3670 typeof t.key === "string" && typeof t.value === "string",
3671 )
3672 .map((t) => [t.key, t.value]),
3673 );
3674 const { removed: removedTags, upsert: upsertTags } = diffTags(
3675 observedTags,
3676 desiredTags,
3677 );
3678 if (upsertTags.length > 0) {
3679 yield* ecs.tagResource({
3680 resourceArn: observed.serviceArn,
3681 tags: upsertTags.map((t) => ({ key: t.Key, value: t.Value })),
3682 });
3683 }
3684 if (removedTags.length > 0) {
3685 yield* ecs.untagResource({
3686 resourceArn: observed.serviceArn,
3687 tagKeys: removedTags,
3688 });
3689 }
3690
3691 // First reconcile under the composed shape: the service now points
3692 // at the composed target groups, so the legacy inline ingress can
3693 // be reaped without dropping traffic wiring.
3694 yield* reapLegacyIngress({ output, session });
3695
3696 // Synthesizing registered a NEW revision and `updateService` above
3697 // rolled the service onto it — reap the superseded revision so
3698 // revisions don't accumulate ACTIVE forever (same-family guarded —
3699 // a prior BYO `task:` reference is left untouched).
3700 if (owned !== undefined) {
3701 yield* reapSupersededTaskDefinitionRevision({
3702 previousArn: output?.taskDefinitionArn,
3703 nextArn: owned.taskDefinitionArn,
3704 });
3705 }
3706
3707 yield* session.note(observed.serviceArn);
3708 return {
3709 serviceArn: stableService.serviceArn as ServiceArn,
3710 serviceName: stableService.serviceName!,
3711 clusterArn: stableService.clusterArn as ClusterArn,
3712 taskDefinitionArn: stableService.taskDefinition!,
3713 status: stableService.status ?? "ACTIVE",
3714 ...ingressAttrs,
3715 ...ownedAttributes,
3716 };
3717 }),
3718 delete: Effect.fn(function* ({ output, session }) {
3719 // Scale to zero and prove every task/target has drained before
3720 // deleting the service or its dependent task-definition resources.
3721 yield* ecs
3722 .updateService({
3723 cluster: output.clusterArn,
3724 service: output.serviceName,
3725 desiredCount: 0,
3726 })
3727 .pipe(
3728 // `ServiceNotActiveException` means the service is DRAINING or
3729 // INACTIVE — an earlier, interrupted delete already issued
3730 // `deleteService`. Neither status ever returns to ACTIVE, so
3731 // there is nothing to scale: fall through to the drain/delete
3732 // waits below, which treat both as progress toward "gone".
3733 Effect.catchTag("ServiceNotActiveException", () => Effect.void),
3734 Effect.catchTag("ServiceNotFoundException", () => Effect.void),
3735 Effect.catchTag("ClusterNotFoundException", () => Effect.void),
3736 );
3737
3738 yield* session.note(
3739 `Waiting for ECS service ${output.serviceName} to drain`,
3740 );
3741 yield* waitForServiceConvergence({
3742 clusterArn: output.clusterArn,
3743 serviceName: output.serviceName,
3744 mode: "drained",
3745 });
3746
3747 yield* ecs
3748 .deleteService({
3749 cluster: output.clusterArn,
3750 service: output.serviceName,
3751 force: true,
3752 })
3753 .pipe(
3754 Effect.catchTag("ServiceNotFoundException", () => Effect.void),
3755 Effect.catchTag("ClusterNotFoundException", () => Effect.void),
3756 );
3757
3758 yield* waitForServiceConvergence({
3759 clusterArn: output.clusterArn,
3760 serviceName: output.serviceName,
3761 mode: "deleted",
3762 });
3763
3764 // Inline ingress teardown applies ONLY to legacy state rows (no
3765 // `ingressKind` marker) whose ALB/TG/listener/SG were created
3766 // inline by the pre-composition provider. Composed ingress is made
3767 // of real child resources the engine deletes itself — and in the
3768 // shared form the listener/ALB recorded here belong to someone
3769 // else entirely and must never be touched.
3770 if (output.ingressKind === undefined) {
3771 if (output.listenerArn) {
3772 yield* elbv2
3773 .deleteListener({
3774 ListenerArn: output.listenerArn,
3775 })
3776 .pipe(
3777 Effect.catchTag(
3778 "ListenerNotFoundException",
3779 () => Effect.void,
3780 ),
3781 );
3782 }
3783 if (output.targetGroupArn) {
3784 yield* elbv2
3785 .deleteTargetGroup({
3786 TargetGroupArn: output.targetGroupArn,
3787 })
3788 .pipe(Effect.catch(() => Effect.void));
3789 }
3790 if (output.loadBalancerArn) {
3791 yield* elbv2
3792 .deleteLoadBalancer({
3793 LoadBalancerArn: output.loadBalancerArn,
3794 })
3795 .pipe(
3796 Effect.catchTag(
3797 "LoadBalancerNotFoundException",
3798 () => Effect.void,
3799 ),
3800 );
3801 }
3802
3803 // Owned ingress security group: the ALB/service ENIs release it
3804 // asynchronously, so retry the dependency violation, bounded.
3805 if (output.securityGroupId) {
3806 yield* ec2
3807 .deleteSecurityGroup({
3808 GroupId: output.securityGroupId,
3809 })
3810 .pipe(
3811 Effect.catchTag("InvalidGroup.NotFound", () => Effect.void),
3812 Effect.retry({
3813 while: (e) => e._tag === "DependencyViolation",
3814 schedule: Schedule.max([
3815 Schedule.spaced("5 seconds"),
3816 Schedule.recurs(24),
3817 ]).pipe(
3818 Schedule.tap(() =>
3819 session.note(
3820 "Waiting for ENIs to release the ingress security group...",
3821 ),
3822 ),
3823 ),
3824 }),
3825 );
3826 }
3827 }
3828
3829 // Synthesized task definition infrastructure (image-owning form).
3830 if (
3831 output.taskFamily &&
3832 output.repositoryName &&
3833 output.logGroupName &&
3834 output.taskRoleName &&
3835 output.executionRoleName
3836 ) {
3837 yield* deleteTaskDefinitionInfrastructure({
3838 taskDefinitionArn: output.taskDefinitionArn,
3839 taskFamily: output.taskFamily,
3840 repositoryName: output.repositoryName,
3841 logGroupName: output.logGroupName,
3842 taskRoleName: output.taskRoleName,
3843 executionRoleName: output.executionRoleName,
3844 });
3845 }
3846 }),
3847 };
3848 }),
3849 );