| 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 | |
| 35 | import * as Effect from "effect/Effect"; |
| 36 | import * as Stream from "effect/Stream"; |
| 37 | import { deepEqual } from "../../Diff.ts"; |
| 38 | import type { ImageSourceLike } from "../ECR/ImageSource.ts"; |
| 39 | import { |
| 40 | flociProvidersUrl, |
| 41 | makeDevWatchProvider, |
| 42 | } from "../Local/DevWatchProvider.ts"; |
| 43 | import { imageSourceTrigger, restartFamilyTasks } from "./EcsDevWatch.ts"; |
| 44 | import { Service, ServiceProvider, type ServiceProps } from "./Service.ts"; |
| 45 | |
| 46 | /** Cluster ARN from either form of the `cluster` prop (see ServiceProvider). */ |
| 47 | const 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). */ |
| 58 | const 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. */ |
| 67 | const taskRefOf = (props: ServiceProps) => |
| 68 | "task" in props ? props.task : undefined; |
| 69 | |
| 70 | export 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 | ); |