| 1 | import * as codedeploy from "@distilled.cloud/aws/codedeploy"; |
| 2 | import type * as Duration from "effect/Duration"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | import { isResolved } from "../../Diff.ts"; |
| 7 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 8 | import * as Provider from "../../Provider.ts"; |
| 9 | import { Resource } from "../../Resource.ts"; |
| 10 | import { toWireSeconds } from "../../Util/Duration.ts"; |
| 11 | import { AWSEnvironment } from "../Environment.ts"; |
| 12 | import type { Providers } from "../Providers.ts"; |
| 13 | |
| 14 | /** |
| 15 | * Zonal deployment behaviour (EC2/on-prem `Server` platform): deploy one |
| 16 | * Availability Zone at a time, monitoring each zone before moving on. |
| 17 | * Mirrors the wire `ZonalConfig` with `Duration.Input` monitor durations |
| 18 | * (CodeDeploy's wire unit for both is whole seconds). |
| 19 | */ |
| 20 | export interface ZonalConfigProps { |
| 21 | /** |
| 22 | * How long to monitor the FIRST zone after it finishes before starting |
| 23 | * the next zone. Wire unit: whole seconds. |
| 24 | * @default monitorDuration |
| 25 | */ |
| 26 | firstZoneMonitorDuration?: Duration.Input; |
| 27 | /** |
| 28 | * How long to monitor each subsequent zone after it finishes before |
| 29 | * starting the next one. Wire unit: whole seconds. |
| 30 | * @default 0 |
| 31 | */ |
| 32 | monitorDuration?: Duration.Input; |
| 33 | /** |
| 34 | * Minimum healthy hosts required per Availability Zone while deploying. |
| 35 | */ |
| 36 | minimumHealthyHostsPerZone?: codedeploy.MinimumHealthyHostsPerZone; |
| 37 | } |
| 38 | |
| 39 | /** Convert the zonal-config props to the CodeDeploy wire shape (seconds). */ |
| 40 | const toWireZonalConfig = ( |
| 41 | props: ZonalConfigProps | undefined, |
| 42 | ): codedeploy.ZonalConfig | undefined => |
| 43 | props === undefined |
| 44 | ? undefined |
| 45 | : { |
| 46 | firstZoneMonitorDurationInSeconds: toWireSeconds( |
| 47 | props.firstZoneMonitorDuration, |
| 48 | ), |
| 49 | monitorDurationInSeconds: toWireSeconds(props.monitorDuration), |
| 50 | minimumHealthyHostsPerZone: props.minimumHealthyHostsPerZone, |
| 51 | }; |
| 52 | |
| 53 | export interface DeploymentConfigProps { |
| 54 | /** |
| 55 | * Name of the deployment configuration (1-100 chars). If omitted a |
| 56 | * deterministic physical name is generated. Changing the name replaces |
| 57 | * the configuration. |
| 58 | */ |
| 59 | deploymentConfigName?: string; |
| 60 | /** |
| 61 | * The compute platform the configuration applies to. Immutable — changing |
| 62 | * it replaces the configuration. |
| 63 | * @default "Server" |
| 64 | */ |
| 65 | computePlatform?: "Server" | "Lambda" | "ECS"; |
| 66 | /** |
| 67 | * Minimum healthy hosts during the deployment (`HOST_COUNT` or |
| 68 | * `FLEET_PERCENT`). `Server` platform only. |
| 69 | */ |
| 70 | minimumHealthyHosts?: codedeploy.MinimumHealthyHosts; |
| 71 | /** |
| 72 | * How traffic shifts to the new version (`TimeBasedCanary`, |
| 73 | * `TimeBasedLinear`, or `AllAtOnce`). `Lambda`/`ECS` platforms. The |
| 74 | * `canaryInterval`/`linearInterval` fields are whole minutes (the wire |
| 75 | * unit is semantically part of the AWS field). |
| 76 | */ |
| 77 | trafficRoutingConfig?: codedeploy.TrafficRoutingConfig; |
| 78 | /** |
| 79 | * Deploy one Availability Zone at a time with per-zone monitor |
| 80 | * durations. `Server` platform only. |
| 81 | */ |
| 82 | zonalConfig?: ZonalConfigProps; |
| 83 | } |
| 84 | |
| 85 | export interface DeploymentConfig extends Resource< |
| 86 | "AWS.CodeDeploy.DeploymentConfig", |
| 87 | DeploymentConfigProps, |
| 88 | { |
| 89 | /** Physical name of the deployment configuration. */ |
| 90 | deploymentConfigName: string; |
| 91 | /** Unique CodeDeploy-assigned deployment-config ID. */ |
| 92 | deploymentConfigId: string; |
| 93 | /** ARN of the deployment configuration. */ |
| 94 | deploymentConfigArn: string; |
| 95 | /** The compute platform (`Lambda`, `Server`, or `ECS`). */ |
| 96 | computePlatform: string; |
| 97 | }, |
| 98 | never, |
| 99 | Providers |
| 100 | > {} |
| 101 | |
| 102 | /** |
| 103 | * A custom AWS CodeDeploy deployment configuration — the rules for how |
| 104 | * traffic shifts during a deployment (canary/linear traffic routing for |
| 105 | * `Lambda`/`ECS`, minimum-healthy-hosts and zonal rollout for `Server`). |
| 106 | * Deployment configurations are immutable: any change replaces the |
| 107 | * configuration. |
| 108 | * |
| 109 | * ### Creating a Deployment Config |
| 110 | * **Example:** Lambda Canary Config |
| 111 | * ```typescript |
| 112 | * const config = yield* CodeDeploy.DeploymentConfig("canary", { |
| 113 | * computePlatform: "Lambda", |
| 114 | * trafficRoutingConfig: { |
| 115 | * type: "TimeBasedCanary", |
| 116 | * timeBasedCanary: { canaryPercentage: 10, canaryInterval: 5 }, |
| 117 | * }, |
| 118 | * }); |
| 119 | * ``` |
| 120 | * |
| 121 | * **Example:** Server Config with Minimum Healthy Hosts |
| 122 | * ```typescript |
| 123 | * const config = yield* CodeDeploy.DeploymentConfig("half-fleet", { |
| 124 | * computePlatform: "Server", |
| 125 | * minimumHealthyHosts: { type: "FLEET_PERCENT", value: 50 }, |
| 126 | * }); |
| 127 | * ``` |
| 128 | * |
| 129 | * @resource |
| 130 | */ |
| 131 | export const DeploymentConfig = Resource<DeploymentConfig>( |
| 132 | "AWS.CodeDeploy.DeploymentConfig", |
| 133 | ); |
| 134 | |
| 135 | /** Build the ARN for a CodeDeploy deployment configuration. */ |
| 136 | const deploymentConfigArn = ( |
| 137 | region: string, |
| 138 | account: string, |
| 139 | name: string, |
| 140 | ): string => `arn:aws:codedeploy:${region}:${account}:deploymentconfig:${name}`; |
| 141 | |
| 142 | export const DeploymentConfigProvider = () => |
| 143 | Provider.effect( |
| 144 | DeploymentConfig, |
| 145 | Effect.gen(function* () { |
| 146 | const toName = (id: string, props: Partial<DeploymentConfigProps>) => |
| 147 | props.deploymentConfigName |
| 148 | ? Effect.succeed(props.deploymentConfigName) |
| 149 | : createPhysicalName({ id, maxLength: 100 }); |
| 150 | |
| 151 | const getConfig = Effect.fn(function* (name: string) { |
| 152 | const response = yield* codedeploy |
| 153 | .getDeploymentConfig({ deploymentConfigName: name }) |
| 154 | .pipe( |
| 155 | Effect.catchTag("DeploymentConfigDoesNotExistException", () => |
| 156 | Effect.succeed(undefined), |
| 157 | ), |
| 158 | ); |
| 159 | return response?.deploymentConfigInfo; |
| 160 | }); |
| 161 | |
| 162 | return { |
| 163 | stables: [ |
| 164 | "deploymentConfigName", |
| 165 | "deploymentConfigId", |
| 166 | "computePlatform", |
| 167 | ], |
| 168 | |
| 169 | diff: Effect.fn(function* ({ id, olds, news }) { |
| 170 | if (!isResolved(news)) return undefined; |
| 171 | if ( |
| 172 | (yield* toName(id, olds ?? {})) !== (yield* toName(id, news ?? {})) |
| 173 | ) { |
| 174 | return { action: "replace" } as const; |
| 175 | } |
| 176 | // Deployment configurations are immutable — any settings change |
| 177 | // replaces the configuration. |
| 178 | const key = (props: Partial<DeploymentConfigProps>) => |
| 179 | JSON.stringify([ |
| 180 | props.computePlatform ?? "Server", |
| 181 | props.minimumHealthyHosts, |
| 182 | props.trafficRoutingConfig, |
| 183 | toWireZonalConfig(props.zonalConfig), |
| 184 | ]); |
| 185 | if (key(olds ?? {}) !== key(news ?? {})) { |
| 186 | return { action: "replace" } as const; |
| 187 | } |
| 188 | }), |
| 189 | |
| 190 | read: Effect.fn(function* ({ id, olds, output }) { |
| 191 | const { accountId, region } = yield* AWSEnvironment.current; |
| 192 | const name = |
| 193 | output?.deploymentConfigName ?? (yield* toName(id, olds ?? {})); |
| 194 | const config = yield* getConfig(name); |
| 195 | if (config?.deploymentConfigId === undefined) return undefined; |
| 196 | // Deployment configurations cannot be tagged, so there is no |
| 197 | // ownership marker to check — a name match is treated as ours. |
| 198 | return { |
| 199 | deploymentConfigName: config.deploymentConfigName ?? name, |
| 200 | deploymentConfigId: config.deploymentConfigId, |
| 201 | deploymentConfigArn: deploymentConfigArn(region, accountId, name), |
| 202 | computePlatform: config.computePlatform ?? "Server", |
| 203 | }; |
| 204 | }), |
| 205 | |
| 206 | reconcile: Effect.fn(function* ({ id, news, output, session }) { |
| 207 | const { accountId, region } = yield* AWSEnvironment.current; |
| 208 | const name = |
| 209 | output?.deploymentConfigName ?? (yield* toName(id, news)); |
| 210 | const computePlatform = news.computePlatform ?? "Server"; |
| 211 | |
| 212 | // 1. Observe — cloud state is authoritative. |
| 213 | let config = yield* getConfig(name); |
| 214 | |
| 215 | // 2. Ensure — create if missing (configs are immutable, so there |
| 216 | // is nothing to sync). Tolerate an already-exists race. |
| 217 | if (config?.deploymentConfigId === undefined) { |
| 218 | yield* codedeploy |
| 219 | .createDeploymentConfig({ |
| 220 | deploymentConfigName: name, |
| 221 | computePlatform, |
| 222 | minimumHealthyHosts: news.minimumHealthyHosts, |
| 223 | trafficRoutingConfig: news.trafficRoutingConfig, |
| 224 | zonalConfig: toWireZonalConfig(news.zonalConfig), |
| 225 | }) |
| 226 | .pipe( |
| 227 | Effect.catchTag("DeploymentConfigAlreadyExistsException", () => |
| 228 | Effect.succeed(undefined), |
| 229 | ), |
| 230 | ); |
| 231 | config = yield* getConfig(name); |
| 232 | } |
| 233 | |
| 234 | // 3. Return fresh attributes. |
| 235 | yield* session.note(name); |
| 236 | return { |
| 237 | deploymentConfigName: config?.deploymentConfigName ?? name, |
| 238 | deploymentConfigId: config?.deploymentConfigId ?? "", |
| 239 | deploymentConfigArn: deploymentConfigArn(region, accountId, name), |
| 240 | computePlatform: config?.computePlatform ?? computePlatform, |
| 241 | }; |
| 242 | }), |
| 243 | |
| 244 | delete: Effect.fn(function* ({ output }) { |
| 245 | // A config still referenced by a deployment group fails with |
| 246 | // DeploymentConfigInUseException — the engine deletes dependents |
| 247 | // first, but the reference release is eventually consistent, so |
| 248 | // retry through it (bounded). Deleting a non-existent config |
| 249 | // succeeds (no not-found variant in the typed error union). |
| 250 | yield* codedeploy |
| 251 | .deleteDeploymentConfig({ |
| 252 | deploymentConfigName: output.deploymentConfigName, |
| 253 | }) |
| 254 | .pipe( |
| 255 | Effect.retry({ |
| 256 | while: (e): boolean => |
| 257 | e._tag === "DeploymentConfigInUseException", |
| 258 | schedule: Schedule.max([ |
| 259 | Schedule.fixed(3000), |
| 260 | Schedule.recurs(10), |
| 261 | ]), |
| 262 | }), |
| 263 | ); |
| 264 | }), |
| 265 | |
| 266 | list: () => |
| 267 | Effect.gen(function* () { |
| 268 | const { accountId, region } = yield* AWSEnvironment.current; |
| 269 | const names = yield* codedeploy.listDeploymentConfigs |
| 270 | .pages({}) |
| 271 | .pipe( |
| 272 | Stream.runCollect, |
| 273 | Effect.map((chunk) => |
| 274 | Array.from(chunk).flatMap( |
| 275 | (page) => page.deploymentConfigsList ?? [], |
| 276 | ), |
| 277 | ), |
| 278 | ); |
| 279 | return ( |
| 280 | names |
| 281 | // Predefined CodeDeployDefault.* configurations are |
| 282 | // AWS-managed and cannot be deleted — never ours. |
| 283 | .filter((name) => !name.startsWith("CodeDeployDefault.")) |
| 284 | .map((name) => ({ |
| 285 | deploymentConfigName: name, |
| 286 | deploymentConfigId: "", |
| 287 | deploymentConfigArn: deploymentConfigArn( |
| 288 | region, |
| 289 | accountId, |
| 290 | name, |
| 291 | ), |
| 292 | computePlatform: "", |
| 293 | })) |
| 294 | ); |
| 295 | }), |
| 296 | }; |
| 297 | }), |
| 298 | ); |