1import * as codedeploy from "@distilled.cloud/aws/codedeploy";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { toWireSeconds } from "../../Util/Duration.ts";
11import { AWSEnvironment } from "../Environment.ts";
12import 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 */
20export 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). */
40const 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
53export 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
85export 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 */
131export const DeploymentConfig = Resource<DeploymentConfig>(
132 "AWS.CodeDeploy.DeploymentConfig",
133);
134
135/** Build the ARN for a CodeDeploy deployment configuration. */
136const deploymentConfigArn = (
137 region: string,
138 account: string,
139 name: string,
140): string => `arn:aws:codedeploy:${region}:${account}:deploymentconfig:${name}`;
141
142export 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 );