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 { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
11import { toWireMinutes } from "../../Util/Duration.ts";
12import { AWSEnvironment } from "../Environment.ts";
13import type { Providers } from "../Providers.ts";
14
15/**
16 * Blue/green deployment behaviour for a deployment group. Mirrors the wire
17 * `BlueGreenDeploymentConfiguration` with `Duration.Input` wait times
18 * (CodeDeploy's wire unit for both is whole minutes).
19 */
20export interface BlueGreenDeploymentConfigurationProps {
21 /**
22 * What happens to the original (blue) instances after a successful
23 * blue/green deployment.
24 */
25 terminateBlueInstancesOnDeploymentSuccess?: {
26 /**
27 * `TERMINATE` the blue instances (after `terminationWaitTime`) or
28 * `KEEP_ALIVE` them deregistered from the load balancer.
29 */
30 action?: codedeploy.InstanceAction;
31 /**
32 * How long to wait after a successful deployment before terminating
33 * the blue instances. Wire unit: whole minutes.
34 */
35 terminationWaitTime?: Duration.Input;
36 };
37 /**
38 * How traffic is rerouted to the green fleet once it is provisioned.
39 */
40 deploymentReadyOption?: {
41 /**
42 * `CONTINUE_DEPLOYMENT` reroutes automatically; `STOP_DEPLOYMENT`
43 * waits for {@link ContinueDeployment} (up to `waitTime`).
44 */
45 actionOnTimeout?: codedeploy.DeploymentReadyAction;
46 /**
47 * How long to wait for a manual continue before the deployment stops
48 * (only with `actionOnTimeout: "STOP_DEPLOYMENT"`). Wire unit: whole
49 * minutes.
50 */
51 waitTime?: Duration.Input;
52 };
53 /**
54 * How the green fleet's instances are provisioned (discover existing or
55 * copy the Auto Scaling group).
56 */
57 greenFleetProvisioningOption?: codedeploy.GreenFleetProvisioningOption;
58}
59
60/** Convert the blue/green props to the CodeDeploy wire shape (minutes). */
61const toWireBlueGreen = (
62 props: BlueGreenDeploymentConfigurationProps | undefined,
63): codedeploy.BlueGreenDeploymentConfiguration | undefined =>
64 props === undefined
65 ? undefined
66 : {
67 terminateBlueInstancesOnDeploymentSuccess:
68 props.terminateBlueInstancesOnDeploymentSuccess === undefined
69 ? undefined
70 : {
71 action: props.terminateBlueInstancesOnDeploymentSuccess.action,
72 terminationWaitTimeInMinutes: toWireMinutes(
73 props.terminateBlueInstancesOnDeploymentSuccess
74 .terminationWaitTime,
75 ),
76 },
77 deploymentReadyOption:
78 props.deploymentReadyOption === undefined
79 ? undefined
80 : {
81 actionOnTimeout: props.deploymentReadyOption.actionOnTimeout,
82 waitTimeInMinutes: toWireMinutes(
83 props.deploymentReadyOption.waitTime,
84 ),
85 },
86 greenFleetProvisioningOption: props.greenFleetProvisioningOption,
87 };
88
89export interface DeploymentGroupProps {
90 /**
91 * Name of the CodeDeploy application that owns the group. Pass an
92 * `Application`'s `applicationName` attribute. Immutable — changing it
93 * replaces the deployment group.
94 */
95 applicationName: string;
96 /**
97 * Name of the deployment group (1-100 chars). If omitted a deterministic
98 * physical name is generated. Changing the name replaces the group.
99 */
100 deploymentGroupName?: string;
101 /**
102 * ARN of the IAM service role that grants CodeDeploy access to the target
103 * compute resources. Required.
104 */
105 serviceRoleArn: string;
106 /**
107 * Name of the deployment configuration (predefined like
108 * `CodeDeployDefault.LambdaAllAtOnce`, or a custom `DeploymentConfig`).
109 */
110 deploymentConfigName?: string;
111 /**
112 * How the deployment routes traffic (in-place vs. blue/green).
113 */
114 deploymentStyle?: codedeploy.DeploymentStyle;
115 /**
116 * Automatic rollback behaviour on deployment failure or alarm.
117 */
118 autoRollbackConfiguration?: codedeploy.AutoRollbackConfiguration;
119 /**
120 * CloudWatch alarms that can stop a deployment.
121 */
122 alarmConfiguration?: codedeploy.AlarmConfiguration;
123 /**
124 * Load-balancer configuration used for traffic shifting.
125 */
126 loadBalancerInfo?: codedeploy.LoadBalancerInfo;
127 /**
128 * Blue/green deployment configuration (green-fleet provisioning, traffic
129 * ready-options, blue-instance termination). Wait times accept
130 * `Duration.Input` (e.g. `"5 minutes"`).
131 */
132 blueGreenDeploymentConfiguration?: BlueGreenDeploymentConfigurationProps;
133 /**
134 * EC2 tag filters selecting the instances to deploy to (EC2/on-prem
135 * `Server` platform).
136 */
137 ec2TagFilters?: codedeploy.EC2TagFilter[];
138 /**
139 * Auto Scaling group names to deploy to (EC2/on-prem `Server` platform).
140 */
141 autoScalingGroups?: string[];
142 /**
143 * ECS services (cluster + service) targeted by blue/green ECS deployments.
144 */
145 ecsServices?: codedeploy.ECSService[];
146 /**
147 * SNS/notification triggers fired on deployment lifecycle events.
148 */
149 triggerConfigurations?: codedeploy.TriggerConfig[];
150 /**
151 * User-defined tags.
152 */
153 tags?: Record<string, string>;
154}
155
156export interface DeploymentGroup extends Resource<
157 "AWS.CodeDeploy.DeploymentGroup",
158 DeploymentGroupProps,
159 {
160 /** Physical name of the deployment group. */
161 deploymentGroupName: string;
162 /** Unique CodeDeploy-assigned deployment-group ID. */
163 deploymentGroupId: string;
164 /** ARN of the deployment group. */
165 deploymentGroupArn: string;
166 /** Name of the application this group belongs to. */
167 applicationName: string;
168 /** ARN of the service role CodeDeploy assumes for deployments. */
169 serviceRoleArn: string;
170 },
171 never,
172 Providers
173> {}
174
175/**
176 * An AWS CodeDeploy deployment group — the set of target instances/functions
177 * plus the deployment configuration for one {@link Application}. For the
178 * `Lambda` compute platform a group ties a service role and a deployment
179 * config (e.g. `CodeDeployDefault.LambdaAllAtOnce`) to an application.
180 *
181 * ### Creating a Deployment Group
182 * **Example:** Lambda Deployment Group
183 * ```typescript
184 * const app = yield* CodeDeploy.Application("api", { computePlatform: "Lambda" });
185 * const group = yield* CodeDeploy.DeploymentGroup("prod", {
186 * applicationName: app.applicationName,
187 * serviceRoleArn: role.roleArn,
188 * deploymentConfigName: "CodeDeployDefault.LambdaAllAtOnce",
189 * deploymentStyle: {
190 * deploymentType: "BLUE_GREEN",
191 * deploymentOption: "WITH_TRAFFIC_CONTROL",
192 * },
193 * autoRollbackConfiguration: {
194 * enabled: true,
195 * events: ["DEPLOYMENT_FAILURE"],
196 * },
197 * });
198 * ```
199 *
200 * @resource
201 */
202export const DeploymentGroup = Resource<DeploymentGroup>(
203 "AWS.CodeDeploy.DeploymentGroup",
204);
205
206/** Build the ARN for a CodeDeploy deployment group. */
207const deploymentGroupArn = (
208 region: string,
209 account: string,
210 appName: string,
211 groupName: string,
212): string =>
213 `arn:aws:codedeploy:${region}:${account}:deploymentgroup:${appName}/${groupName}`;
214
215/**
216 * Retry an effect while CodeDeploy reports that it cannot yet assume the
217 * service role. A freshly-created IAM role's trust relationship is eventually
218 * consistent, so `InvalidRoleException` immediately after role creation is a
219 * transient propagation error, not a permanent failure. Wrapped in an
220 * explicitly-typed helper so the retry's conditional type never widens the
221 * provider layer in declaration emit (see PATTERNS §7).
222 */
223const retryThroughRolePropagation = <A, E extends { _tag: string }, R>(
224 effect: Effect.Effect<A, E, R>,
225): Effect.Effect<A, E, R> =>
226 effect.pipe(
227 Effect.retry({
228 while: (e: E) => e._tag === "InvalidRoleException",
229 schedule: Schedule.max([Schedule.fixed(2000), Schedule.recurs(15)]),
230 }),
231 );
232
233/** Convert a CodeDeploy wire tag list into a plain record. */
234const toTagRecord = (
235 tags: ReadonlyArray<{ Key?: string; Value?: string }> | undefined,
236): Record<string, string> =>
237 Object.fromEntries(
238 (tags ?? [])
239 .filter(
240 (tag): tag is { Key: string; Value: string } =>
241 typeof tag.Key === "string" && typeof tag.Value === "string",
242 )
243 .map((tag) => [tag.Key, tag.Value]),
244 );
245
246export const DeploymentGroupProvider = () =>
247 Provider.effect(
248 DeploymentGroup,
249 Effect.gen(function* () {
250 const toName = (id: string, props: Partial<DeploymentGroupProps>) =>
251 props.deploymentGroupName
252 ? Effect.succeed(props.deploymentGroupName)
253 : createPhysicalName({ id, maxLength: 100 });
254
255 const getGroup = Effect.fn(function* (
256 appName: string,
257 groupName: string,
258 ) {
259 const response = yield* codedeploy
260 .getDeploymentGroup({
261 applicationName: appName,
262 deploymentGroupName: groupName,
263 })
264 .pipe(
265 Effect.catchTag(
266 [
267 "DeploymentGroupDoesNotExistException",
268 "ApplicationDoesNotExistException",
269 ],
270 () => Effect.succeed(undefined),
271 ),
272 );
273 return response?.deploymentGroupInfo;
274 });
275
276 const syncTags = Effect.fn(function* (
277 arn: string,
278 desiredTags: Record<string, string>,
279 ) {
280 const observed = yield* codedeploy
281 .listTagsForResource({ ResourceArn: arn })
282 .pipe(Effect.catch(() => Effect.succeed(undefined)));
283 const { removed, upsert } = diffTags(
284 toTagRecord(observed?.Tags),
285 desiredTags,
286 );
287 if (upsert.length > 0) {
288 yield* codedeploy.tagResource({ ResourceArn: arn, Tags: upsert });
289 }
290 if (removed.length > 0) {
291 yield* codedeploy.untagResource({
292 ResourceArn: arn,
293 TagKeys: removed,
294 });
295 }
296 });
297
298 return {
299 stables: [
300 "deploymentGroupName",
301 "deploymentGroupId",
302 "applicationName",
303 ],
304
305 diff: Effect.fn(function* ({ id, olds, news }) {
306 if (!isResolved(news)) return undefined;
307 if (
308 (yield* toName(id, olds ?? {})) !== (yield* toName(id, news ?? {}))
309 ) {
310 return { action: "replace" } as const;
311 }
312 // The parent application is immutable — replace on change.
313 if ((news?.applicationName ?? "") !== (olds?.applicationName ?? "")) {
314 return { action: "replace" } as const;
315 }
316 }),
317
318 read: Effect.fn(function* ({ id, olds, output }) {
319 const { accountId, region } = yield* AWSEnvironment.current;
320 const appName = output?.applicationName ?? olds?.applicationName;
321 if (appName === undefined) return undefined;
322 const name =
323 output?.deploymentGroupName ?? (yield* toName(id, olds ?? {}));
324 const group = yield* getGroup(appName, name);
325 if (group?.deploymentGroupId === undefined) return undefined;
326 const arn = deploymentGroupArn(region, accountId, appName, name);
327 const attrs = {
328 deploymentGroupName: group.deploymentGroupName ?? name,
329 deploymentGroupId: group.deploymentGroupId,
330 deploymentGroupArn: arn,
331 applicationName: group.applicationName ?? appName,
332 serviceRoleArn: group.serviceRoleArn ?? "",
333 };
334 const tags = yield* codedeploy
335 .listTagsForResource({ ResourceArn: arn })
336 .pipe(
337 Effect.map((res) => toTagRecord(res.Tags)),
338 Effect.catch(() => Effect.succeed({})),
339 );
340 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
341 }),
342
343 reconcile: Effect.fn(function* ({ id, news, output, session }) {
344 const { accountId, region } = yield* AWSEnvironment.current;
345 const appName = news.applicationName;
346 const name = output?.deploymentGroupName ?? (yield* toName(id, news));
347 const arn = deploymentGroupArn(region, accountId, appName, name);
348 const internalTags = yield* createInternalTags(id);
349 const desiredTags = { ...internalTags, ...news.tags };
350
351 // 1. Observe — cloud state is authoritative.
352 let group = yield* getGroup(appName, name);
353
354 // 2. Ensure — create if missing; otherwise converge via update.
355 if (group?.deploymentGroupId === undefined) {
356 yield* retryThroughRolePropagation(
357 codedeploy.createDeploymentGroup({
358 applicationName: appName,
359 deploymentGroupName: name,
360 serviceRoleArn: news.serviceRoleArn,
361 deploymentConfigName: news.deploymentConfigName,
362 deploymentStyle: news.deploymentStyle,
363 autoRollbackConfiguration: news.autoRollbackConfiguration,
364 alarmConfiguration: news.alarmConfiguration,
365 loadBalancerInfo: news.loadBalancerInfo,
366 blueGreenDeploymentConfiguration: toWireBlueGreen(
367 news.blueGreenDeploymentConfiguration,
368 ),
369 ec2TagFilters: news.ec2TagFilters,
370 autoScalingGroups: news.autoScalingGroups,
371 ecsServices: news.ecsServices,
372 triggerConfigurations: news.triggerConfigurations,
373 tags: Object.entries(desiredTags).map(([Key, Value]) => ({
374 Key,
375 Value,
376 })),
377 }),
378 ).pipe(
379 Effect.catchTag("DeploymentGroupAlreadyExistsException", () =>
380 Effect.succeed(undefined),
381 ),
382 );
383 group = yield* getGroup(appName, name);
384 } else {
385 // 3. Sync — converge mutable configuration in place.
386 yield* codedeploy.updateDeploymentGroup({
387 applicationName: appName,
388 currentDeploymentGroupName: name,
389 serviceRoleArn: news.serviceRoleArn,
390 deploymentConfigName: news.deploymentConfigName,
391 deploymentStyle: news.deploymentStyle,
392 autoRollbackConfiguration: news.autoRollbackConfiguration,
393 alarmConfiguration: news.alarmConfiguration,
394 loadBalancerInfo: news.loadBalancerInfo,
395 blueGreenDeploymentConfiguration: toWireBlueGreen(
396 news.blueGreenDeploymentConfiguration,
397 ),
398 ec2TagFilters: news.ec2TagFilters,
399 autoScalingGroups: news.autoScalingGroups,
400 ecsServices: news.ecsServices,
401 triggerConfigurations: news.triggerConfigurations,
402 });
403 group = yield* getGroup(appName, name);
404 }
405
406 // 3b. Sync tags — diff against OBSERVED cloud tags.
407 yield* syncTags(arn, desiredTags);
408
409 // 4. Return fresh attributes.
410 yield* session.note(name);
411 return {
412 deploymentGroupName: group?.deploymentGroupName ?? name,
413 deploymentGroupId: group?.deploymentGroupId ?? "",
414 deploymentGroupArn: arn,
415 applicationName: group?.applicationName ?? appName,
416 serviceRoleArn: group?.serviceRoleArn ?? news.serviceRoleArn,
417 };
418 }),
419
420 delete: Effect.fn(function* ({ output, olds }) {
421 const appName = output.applicationName || olds?.applicationName;
422 if (appName === undefined) return;
423 // DeleteDeploymentGroup is idempotent — deleting a non-existent
424 // group (or one under a deleted application) succeeds; its typed
425 // error union has no not-found variants.
426 yield* codedeploy.deleteDeploymentGroup({
427 applicationName: appName,
428 deploymentGroupName: output.deploymentGroupName,
429 });
430 }),
431
432 // Deployment groups are scoped to an application; there is no global
433 // enumeration without knowing the application name.
434 list: () => Effect.succeed([]),
435 };
436 }),
437 );