1/** @effect-diagnostics anyUnknownInErrorContext:off */
2
3/**
4 * The `alchemy dev` provider for `AWS.ECS.Service`: deploys the service
5 * (and, for the image-owning forms, its synthesized task definition) into
6 * the floci emulator and hot-swaps the service's running tasks on file
7 * change.
8 *
9 * Built on the shared dev-watch skeleton
10 * ([DevWatchProvider](../Local/DevWatchProvider.ts)); this file supplies
11 * only the Service-specific parts:
12 *
13 * - **Watch triggers** ({@link imageSourceTrigger}) apply to the
14 * image-owning forms (`main` / `context` / `image`). A BYO `task:`
15 * reference owns its image on the `AWS.ECS.Task` resource, so the
16 * service watcher has nothing to build — hot reload of the referenced
17 * task flows through [FlociTaskProvider](./FlociTaskProvider.ts) plus a
18 * redeploy to roll the service.
19 * - **Swap mechanism** — a trigger re-runs the LIVE reconcile: the
20 * content-addressed image pipeline pushes a new `<repositoryUri>:<hash>`
21 * tag, registers a new task definition revision, and `updateService`
22 * points the service at it. Floci swaps the service's task definition in
23 * place WITHOUT restarting running containers (probed), so the
24 * old-revision tasks are stopped and the floci service scheduler
25 * relaunches them on the new revision (~6s observed end-to-end for a
26 * container relaunch).
27 * - **Task roll** lives in the skeleton's `onReconciled` hook — NOT in the
28 * watch loop — so it also fires for engine-driven reconciles. Prop-only
29 * changes (an inline `dockerfile` edit, a new env var) never produce a
30 * file event: before the hook, they registered a new revision and
31 * `updateService`d onto it, but the running containers kept serving the
32 * old one until the next source edit.
33 */
34
35import * as Effect from "effect/Effect";
36import * as Stream from "effect/Stream";
37import { deepEqual } from "../../Diff.ts";
38import type { ImageSourceLike } from "../ECR/ImageSource.ts";
39import {
40 flociProvidersUrl,
41 makeDevWatchProvider,
42} from "../Local/DevWatchProvider.ts";
43import { imageSourceTrigger, restartFamilyTasks } from "./EcsDevWatch.ts";
44import { Service, ServiceProvider, type ServiceProps } from "./Service.ts";
45
46/** Cluster ARN from either form of the `cluster` prop (see ServiceProvider). */
47const clusterArnOfProps = (
48 cluster: ServiceProps["cluster"] | undefined,
49): string | undefined =>
50 typeof cluster === "string"
51 ? cluster
52 : typeof (cluster as { clusterArn?: unknown } | undefined)?.clusterArn ===
53 "string"
54 ? (cluster as { clusterArn: string }).clusterArn
55 : undefined;
56
57/** The truly-immutable post-create fields (mirrors the live diff). */
58const immutableFieldsOf = (props: ServiceProps) => ({
59 usesStrategy: !!props.capacityProviderStrategy,
60 schedulingStrategy: props.schedulingStrategy ?? "REPLICA",
61 deploymentControllerType: props.deploymentController?.type ?? "ECS",
62 enableECSManagedTags: props.enableECSManagedTags ?? true,
63 role: props.role,
64});
65
66/** The BYO task reference, when the props use the `task:` form. */
67const taskRefOf = (props: ServiceProps) =>
68 "task" in props ? props.task : undefined;
69
70export const FlociServiceProvider = () =>
71 makeDevWatchProvider<Service, ServiceProps, Service["Attributes"]>(
72 Service,
73 flociProvidersUrl(),
74 {
75 liveProvider: () => ServiceProvider(),
76 watchConfigOf: (news, attrs) => {
77 const source = news as ImageSourceLike;
78 return {
79 serviceName: attrs.serviceName,
80 clusterArn: attrs.clusterArn,
81 taskFamily: attrs.taskFamily,
82 byoTaskDefinitionArn: taskRefOf(news)?.taskDefinitionArn,
83 main: source.main,
84 handler: source.handler,
85 build: source.build,
86 isExternal: news.isExternal,
87 context: source.context,
88 dockerfile: source.dockerfile,
89 image: source.image,
90 port: (news as { port?: number }).port,
91 runtimePlatform: (news as { runtimePlatform?: unknown })
92 .runtimePlatform,
93 };
94 },
95 // Mirrors the live diff's cheap replacement rules (never building):
96 // serviceName is the identity, a service can't move clusters, and the
97 // immutable post-create flags replace delete-first.
98 replaceOn: ({ olds, news }) =>
99 Effect.sync(() => {
100 if ((olds.serviceName ?? null) !== (news.serviceName ?? null)) {
101 return { action: "replace" as const, deleteFirst: true };
102 }
103 const oldCluster = clusterArnOfProps(olds.cluster);
104 const newCluster = clusterArnOfProps(news.cluster);
105 if (
106 oldCluster !== undefined &&
107 newCluster !== undefined &&
108 oldCluster !== newCluster
109 ) {
110 return { action: "replace" as const, deleteFirst: true };
111 }
112 if (!deepEqual(immutableFieldsOf(olds), immutableFieldsOf(news))) {
113 return { action: "replace" as const, deleteFirst: true };
114 }
115 return undefined;
116 }),
117 // Fires on every reconcile — watcher-triggered AND engine-driven
118 // (prop changes never produce a file event, so rolling only from the
119 // watch loop left the service's containers on the old revision).
120 onReconciled: ({ id, previous, attrs }) =>
121 Effect.gen(function* () {
122 if (
123 attrs.taskFamily === undefined ||
124 attrs.taskDefinitionArn === undefined ||
125 previous?.taskDefinitionArn === attrs.taskDefinitionArn
126 ) {
127 return;
128 }
129 const startedAt = Date.now();
130 // `updateService` (inside the reconcile) pointed the service at
131 // the new revision, but floci's in-place swap keeps the old
132 // containers running — stop them so the scheduler relaunches on
133 // the new revision.
134 const restarted = yield* restartFamilyTasks({
135 family: attrs.taskFamily,
136 nextTaskDefinitionArn: attrs.taskDefinitionArn,
137 serviceManaged: true,
138 });
139 yield* Effect.logInfo(
140 `[alchemy dev] ${attrs.serviceName}: task definition swapped (${restarted} service task(s) rolling) in ${Date.now() - startedAt}ms`,
141 );
142 }).pipe(
143 Effect.catchCause((cause) =>
144 Effect.logWarning(
145 `[alchemy dev] ${id}: service roll failed`,
146 cause,
147 ),
148 ),
149 ),
150 startWatch: (ctx) =>
151 Effect.gen(function* () {
152 if (taskRefOf(ctx.news) !== undefined) {
153 // BYO task reference: the image (and its hot reload) belongs to
154 // the referenced `AWS.ECS.Task` resource.
155 return;
156 }
157 const trigger = yield* imageSourceTrigger({
158 id: ctx.id,
159 source: ctx.news as ImageSourceLike,
160 isExternal: ctx.news.isExternal,
161 });
162 yield* trigger.pipe(
163 // The reconcile registers the new revision and `updateService`s
164 // onto it; the `onReconciled` hook (shared with engine-driven
165 // updates) rolls the running tasks.
166 Stream.runForEach(() =>
167 ctx.rerunReconcile.pipe(
168 Effect.catchCause((cause) =>
169 Effect.logWarning(
170 `[alchemy dev] ${ctx.id}: image swap failed`,
171 cause,
172 ),
173 ),
174 ),
175 ),
176 );
177 }),
178 },
179 );