1import * as batch from "@distilled.cloud/aws/batch";
2import * as ec2 from "@distilled.cloud/aws/ec2";
3import * as iam from "@distilled.cloud/aws/iam";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import * as Schedule from "effect/Schedule";
7import * as Stream from "effect/Stream";
8import { isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { createInternalTags, diffTags } from "../../Tags.ts";
13import { AWSEnvironment, type AccountID } from "../Environment.ts";
14import type { Providers } from "../Providers.ts";
15import type { RegionID } from "../Region.ts";
16import { pollBatch, retryBatch } from "./internal.ts";
17
18export type ComputeEnvironmentName = string;
19export type ComputeEnvironmentArn =
20 `arn:aws:batch:${RegionID}:${AccountID}:compute-environment/${ComputeEnvironmentName}`;
21
22/**
23 * Raised when networking is left implicit but the account/region has no
24 * default VPC to fall back to.
25 */
26export class NoDefaultVpcError extends Data.TaggedError("NoDefaultVpcError")<{
27 readonly message: string;
28}> {}
29
30/**
31 * Raised when AWS Batch finishes reconciling a compute environment in an
32 * unusable state. The status reason is preserved so callers see the actual
33 * AWS configuration or dependency failure instead of receiving stale
34 * `status: "INVALID"` attributes from a successful deployment.
35 */
36export class ComputeEnvironmentInvalidError extends Data.TaggedError(
37 "ComputeEnvironmentInvalidError",
38)<{
39 readonly computeEnvironmentName: string;
40 readonly status: string;
41 readonly statusReason: string | undefined;
42 readonly message: string;
43}> {}
44
45export interface ComputeEnvironmentProps {
46 /**
47 * Name of the compute environment. If omitted, a unique name is generated.
48 * Up to 128 characters (letters, numbers, hyphens, underscores).
49 */
50 computeEnvironmentName?: string;
51 /**
52 * Whether AWS Batch manages the compute capacity. Unmanaged environments
53 * are useful when capacity is registered separately, and do not require
54 * subnets or security groups.
55 * Changing this replaces the compute environment.
56 * @default "MANAGED"
57 */
58 managementType?: "MANAGED" | "UNMANAGED";
59 /**
60 * Fargate capacity type for the managed compute environment.
61 * Changing this replaces the compute environment.
62 * @default "FARGATE"
63 */
64 type?: "FARGATE" | "FARGATE_SPOT";
65 /**
66 * Maximum number of Fargate vCPUs the environment can scale to.
67 * @default 4
68 */
69 maxvCpus?: number;
70 /**
71 * Number of externally-managed vCPUs available to an unmanaged compute
72 * environment.
73 * @default 4
74 */
75 unmanagedvCpus?: number;
76 /**
77 * VPC subnets the Fargate tasks run in. If omitted, the default VPC's
78 * subnets are used.
79 */
80 subnets?: string[];
81 /**
82 * Security groups for the Fargate tasks. If omitted, the default VPC's
83 * default security group is used.
84 */
85 securityGroupIds?: string[];
86 /**
87 * Whether the compute environment accepts jobs from associated queues.
88 * @default "ENABLED"
89 */
90 state?: "ENABLED" | "DISABLED";
91 /**
92 * Service role ARN. If omitted, AWS Batch uses (and auto-creates on first
93 * use) the `AWSServiceRoleForBatch` service-linked role.
94 */
95 serviceRole?: string;
96 /**
97 * User-defined tags to apply to the compute environment.
98 */
99 tags?: Record<string, string>;
100}
101
102export interface ComputeEnvironment extends Resource<
103 "AWS.Batch.ComputeEnvironment",
104 ComputeEnvironmentProps,
105 {
106 computeEnvironmentName: ComputeEnvironmentName;
107 computeEnvironmentArn: ComputeEnvironmentArn;
108 ecsClusterArn: string | undefined;
109 managementType: "MANAGED" | "UNMANAGED";
110 type: "FARGATE" | "FARGATE_SPOT";
111 state: "ENABLED" | "DISABLED";
112 status: string;
113 maxvCpus: number;
114 unmanagedvCpus: number;
115 subnets: string[];
116 securityGroupIds: string[];
117 tags: Record<string, string>;
118 },
119 never,
120 Providers
121> {}
122
123/**
124 * An AWS Batch managed compute environment backed by Fargate (or Fargate
125 * Spot) capacity. Fargate compute environments provision in seconds and
126 * require no instance management.
127 *
128 * ### Creating Compute Environments
129 * **Example:** Default Fargate Compute Environment
130 * ```typescript
131 * // Uses the default VPC's subnets and default security group.
132 * const ce = yield* Batch.ComputeEnvironment("JobsCE", {});
133 * ```
134 *
135 * **Example:** Unmanaged Compute Environment
136 * ```typescript
137 * const ce = yield* Batch.ComputeEnvironment("ExternalCapacity", {
138 * managementType: "UNMANAGED",
139 * unmanagedvCpus: 8,
140 * });
141 * ```
142 *
143 * **Example:** Fargate Spot with explicit networking
144 * ```typescript
145 * const ce = yield* Batch.ComputeEnvironment("SpotCE", {
146 * type: "FARGATE_SPOT",
147 * maxvCpus: 16,
148 * subnets: [subnetA.subnetId, subnetB.subnetId],
149 * securityGroupIds: [sg.groupId],
150 * });
151 * ```
152 *
153 * ### Composing the Batch chain
154 * **Example:** Compute Environment → Job Queue
155 * ```typescript
156 * const ce = yield* Batch.ComputeEnvironment("JobsCE", {});
157 * const queue = yield* Batch.JobQueue("JobsQueue", {
158 * computeEnvironments: [ce.computeEnvironmentArn],
159 * });
160 * ```
161 *
162 * @resource
163 */
164export const ComputeEnvironment = Resource<ComputeEnvironment>(
165 "AWS.Batch.ComputeEnvironment",
166);
167
168const toAttributes = (
169 ce: batch.ComputeEnvironmentDetail,
170 tags: Record<string, string>,
171) => ({
172 computeEnvironmentName: ce.computeEnvironmentName!,
173 computeEnvironmentArn: ce.computeEnvironmentArn as ComputeEnvironmentArn,
174 ecsClusterArn: ce.ecsClusterArn,
175 managementType: (ce.type ?? "MANAGED") as "MANAGED" | "UNMANAGED",
176 type: (ce.computeResources?.type ?? "FARGATE") as "FARGATE" | "FARGATE_SPOT",
177 state: (ce.state ?? "ENABLED") as "ENABLED" | "DISABLED",
178 status: ce.status ?? "VALID",
179 maxvCpus: ce.computeResources?.maxvCpus ?? 0,
180 unmanagedvCpus: ce.unmanagedvCpus ?? 0,
181 subnets: [...(ce.computeResources?.subnets ?? [])],
182 securityGroupIds: [...(ce.computeResources?.securityGroupIds ?? [])],
183 tags,
184});
185
186const observedTagsOf = (ce: { tags?: { [key: string]: string | undefined } }) =>
187 Object.fromEntries(
188 Object.entries(ce.tags ?? {}).filter(
189 (e): e is [string, string] => typeof e[1] === "string",
190 ),
191 );
192
193/**
194 * IAM eventual-consistency race: a CE validated against a service role (or
195 * its policy attachment) created moments earlier in the same deploy can
196 * settle INVALID with a CLIENT_ERROR authorization statusReason (e.g.
197 * "... is not authorized to perform: ecs:DescribeClusters ...
198 * AccessDeniedException") even though the role and policy are correct.
199 * Batch only re-validates an INVALID CE on its slow periodic health check
200 * (tens of minutes), so waiting on the same CE cannot recover in deploy
201 * time — the recovery is to delete the freshly-created CE and re-create.
202 */
203const isAuthPropagationInvalid = (
204 ce: batch.ComputeEnvironmentDetail | undefined,
205): boolean =>
206 ce?.status === "INVALID" &&
207 ce.statusReason !== undefined &&
208 ce.statusReason.includes("CLIENT_ERROR") &&
209 (ce.statusReason.includes("is not authorized to perform") ||
210 ce.statusReason.includes("AccessDeniedException") ||
211 ce.statusReason.includes("AccessDenied"));
212
213/**
214 * Resolve the default VPC's subnets and default security group — used when
215 * the user doesn't pin networking explicitly.
216 */
217const resolveDefaultNetwork = Effect.gen(function* () {
218 const vpcs = yield* ec2.describeVpcs({
219 Filters: [{ Name: "is-default", Values: ["true"] }],
220 });
221 const vpcId = vpcs.Vpcs?.find(
222 (vpc) => vpc.State === undefined || vpc.State === "available",
223 )?.VpcId;
224 if (!vpcId) {
225 return yield* Effect.fail(
226 new NoDefaultVpcError({
227 message:
228 "No default VPC found — pass `subnets` and `securityGroupIds` explicitly",
229 }),
230 );
231 }
232 const subnets = yield* ec2.describeSubnets
233 .items({
234 Filters: [{ Name: "vpc-id", Values: [vpcId] }],
235 })
236 .pipe(
237 Stream.runCollect,
238 Effect.map((chunk) => Array.from(chunk)),
239 );
240 const groups = yield* ec2.describeSecurityGroups({
241 Filters: [
242 { Name: "vpc-id", Values: [vpcId] },
243 { Name: "group-name", Values: ["default"] },
244 ],
245 });
246 const network = {
247 subnets: subnets.flatMap((s) =>
248 s.SubnetId && (s.State === undefined || s.State === "available")
249 ? [s.SubnetId]
250 : [],
251 ),
252 securityGroupIds: (groups.SecurityGroups ?? []).flatMap((g) =>
253 g.GroupId ? [g.GroupId] : [],
254 ),
255 };
256 if (network.subnets.length === 0 || network.securityGroupIds.length === 0) {
257 return yield* Effect.fail(
258 new NoDefaultVpcError({
259 message:
260 "Default VPC networking is not ready — pass `subnets` and `securityGroupIds` explicitly",
261 }),
262 );
263 }
264 return network;
265}).pipe(
266 Effect.retry({
267 while: (error) => error._tag === "NoDefaultVpcError",
268 schedule: Schedule.max([Schedule.spaced("3 seconds"), Schedule.recurs(10)]),
269 }),
270);
271
272export const ComputeEnvironmentProvider = () =>
273 Provider.effect(
274 ComputeEnvironment,
275 Effect.gen(function* () {
276 const toName = (id: string, props: ComputeEnvironmentProps = {}) =>
277 props.computeEnvironmentName
278 ? Effect.succeed(props.computeEnvironmentName)
279 : createPhysicalName({ id, maxLength: 128 });
280
281 // A deleted environment lingers as a DELETED record for a while and the
282 // name is immediately reusable, so a describe-by-name can briefly
283 // return both the tombstone and the live one — prefer the live one.
284 const describeOne = (name: string) =>
285 batch
286 .describeComputeEnvironments({ computeEnvironments: [name] })
287 .pipe(
288 Effect.map(
289 (res) =>
290 res.computeEnvironments?.find(
291 (ce) => ce.status !== "DELETED",
292 ) ?? res.computeEnvironments?.[0],
293 ),
294 );
295
296 /** Wait until any CREATING/UPDATING/DELETING transition settles. */
297 const awaitSettled = (name: string) =>
298 pollBatch(
299 describeOne(name),
300 (ce) =>
301 ce === undefined ||
302 (ce.status !== "CREATING" &&
303 ce.status !== "UPDATING" &&
304 ce.status !== "DELETING"),
305 );
306
307 const awaitDisabled = (name: string) =>
308 pollBatch(
309 describeOne(name),
310 (ce) =>
311 ce === undefined ||
312 ce.status === "DELETED" ||
313 ce.status === "DELETING" ||
314 (ce.state === "DISABLED" &&
315 ce.status !== "CREATING" &&
316 ce.status !== "UPDATING"),
317 );
318
319 const invalid = (
320 name: string,
321 ce: batch.ComputeEnvironmentDetail | undefined,
322 ) => {
323 const status = ce?.status ?? "MISSING";
324 const statusReason = ce?.statusReason;
325 return new ComputeEnvironmentInvalidError({
326 computeEnvironmentName: name,
327 status,
328 statusReason,
329 message: `Compute environment ${name} settled as ${status}${statusReason ? `: ${statusReason}` : ""}`,
330 });
331 };
332
333 /**
334 * Restore the CE's regular (non-service-linked) service role when it no
335 * longer exists in IAM, returning the role name to drop afterwards.
336 * Batch cannot tear a CE down without assuming this role — a dangling
337 * role is the canonical cause of undeletable, forever-INVALID CEs.
338 */
339 const restoreServiceRoleIfMissing = Effect.fn(function* (
340 serviceRoleArn: string | undefined,
341 ) {
342 if (!serviceRoleArn || serviceRoleArn.includes("/aws-service-role/")) {
343 return undefined;
344 }
345 const roleName = serviceRoleArn.split("/").pop()!;
346 const exists = yield* iam.getRole({ RoleName: roleName }).pipe(
347 Effect.map(() => true),
348 Effect.catchTag("NoSuchEntityException", () => Effect.succeed(false)),
349 );
350 if (exists) return undefined;
351 yield* iam
352 .createRole({
353 RoleName: roleName,
354 AssumeRolePolicyDocument: JSON.stringify({
355 Version: "2012-10-17",
356 Statement: [
357 {
358 Effect: "Allow",
359 Principal: { Service: "batch.amazonaws.com" },
360 Action: "sts:AssumeRole",
361 },
362 ],
363 }),
364 Description:
365 "Temporarily restored by alchemy so AWS Batch can tear down a compute environment whose service role was deleted",
366 })
367 .pipe(
368 // Concurrent delete of a replaced instance may restore it first.
369 Effect.catchTag("EntityAlreadyExistsException", () => Effect.void),
370 );
371 yield* iam.attachRolePolicy({
372 RoleName: roleName,
373 PolicyArn: "arn:aws:iam::aws:policy/service-role/AWSBatchServiceRole",
374 });
375 // IAM propagation before Batch tries to assume the role.
376 yield* Effect.sleep("10 seconds");
377 return roleName;
378 });
379
380 /** Drop a role restored by {@link restoreServiceRoleIfMissing}. */
381 const dropRestoredServiceRole = Effect.fn(function* (roleName: string) {
382 yield* iam
383 .detachRolePolicy({
384 RoleName: roleName,
385 PolicyArn:
386 "arn:aws:iam::aws:policy/service-role/AWSBatchServiceRole",
387 })
388 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
389 yield* iam
390 .deleteRole({ RoleName: roleName })
391 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
392 });
393
394 /**
395 * A force nuke cannot rely on stack dependency ordering: a listed
396 * compute environment may still be attached to a separately-listed job
397 * queue. Delete those blockers first. Ordinary stack deletion never
398 * touches out-of-band queues.
399 */
400 const deleteRelatedJobQueues = (computeEnvironment: string) =>
401 Effect.gen(function* () {
402 const pages = yield* batch.describeJobQueues
403 .pages({})
404 .pipe(Stream.runCollect);
405 const queues = Array.from(pages)
406 .flatMap((page) => page.jobQueues ?? [])
407 .filter(
408 (queue) =>
409 queue.jobQueueName &&
410 queue.status !== "DELETED" &&
411 queue.computeEnvironmentOrder?.some(
412 (entry) =>
413 entry.computeEnvironment === computeEnvironment ||
414 entry.computeEnvironment?.endsWith(
415 `:compute-environment/${computeEnvironment}`,
416 ),
417 ),
418 );
419
420 yield* Effect.forEach(
421 queues,
422 (queue) =>
423 Effect.gen(function* () {
424 const queueName = queue.jobQueueName!;
425 const describeQueue = batch
426 .describeJobQueues({ jobQueues: [queueName] })
427 .pipe(
428 Effect.map((response) =>
429 response.jobQueues?.find(
430 (candidate) => candidate.status !== "DELETED",
431 ),
432 ),
433 );
434 let observed = yield* describeQueue;
435 if (!observed) return;
436 if (
437 observed.status !== "DELETING" &&
438 (observed.state ?? "ENABLED") !== "DISABLED"
439 ) {
440 yield* retryBatch(
441 batch.updateJobQueue({
442 jobQueue: queueName,
443 state: "DISABLED",
444 }),
445 (error) => error._tag === "JobQueueBeingModified",
446 ).pipe(
447 Effect.catchTag("JobQueueNotFound", () => Effect.void),
448 );
449 }
450 observed = yield* pollBatch(
451 describeQueue,
452 (candidate) =>
453 candidate === undefined ||
454 candidate.status === "DELETED" ||
455 candidate.status === "DELETING" ||
456 (candidate.state === "DISABLED" &&
457 (candidate.status === "VALID" ||
458 candidate.status === "INVALID")),
459 );
460 if (!observed || observed.status === "DELETED") return;
461 if (observed.status !== "DELETING") {
462 yield* retryBatch(
463 batch.deleteJobQueue({ jobQueue: queueName }),
464 (error) => error._tag === "JobQueueBeingModified",
465 ).pipe(
466 Effect.catchTag("JobQueueNotFound", () => Effect.void),
467 );
468 }
469 yield* pollBatch(
470 describeQueue,
471 (candidate) =>
472 candidate === undefined || candidate.status === "DELETED",
473 );
474 }),
475 { concurrency: 4, discard: true },
476 );
477 });
478
479 return {
480 stables: ["computeEnvironmentName", "computeEnvironmentArn"],
481 diff: Effect.fn(function* ({ id, olds, news }) {
482 if (!isResolved(news)) return;
483 if (
484 (yield* toName(id, olds ?? {})) !== (yield* toName(id, news ?? {}))
485 ) {
486 return { action: "replace" } as const;
487 }
488 if ((olds?.type ?? "FARGATE") !== (news?.type ?? "FARGATE")) {
489 return { action: "replace" } as const;
490 }
491 if (
492 (olds?.managementType ?? "MANAGED") !==
493 (news?.managementType ?? "MANAGED")
494 ) {
495 return { action: "replace" } as const;
496 }
497 }),
498 read: Effect.fn(function* ({ id, olds, output }) {
499 const name =
500 output?.computeEnvironmentName ?? (yield* toName(id, olds ?? {}));
501 const ce = yield* describeOne(name);
502 if (!ce?.computeEnvironmentArn || ce.status === "DELETED") {
503 return undefined;
504 }
505 return toAttributes(ce, observedTagsOf(ce));
506 }),
507 list: () =>
508 Effect.gen(function* () {
509 const pages = yield* batch.describeComputeEnvironments
510 .pages({})
511 .pipe(Stream.runCollect);
512 return Array.from(pages)
513 .flatMap((page) => page.computeEnvironments ?? [])
514 .flatMap((ce) =>
515 ce.computeEnvironmentArn && ce.status !== "DELETED"
516 ? [toAttributes(ce, observedTagsOf(ce))]
517 : [],
518 );
519 }),
520 reconcile: Effect.fn(function* ({ id, news = {}, output, session }) {
521 const { accountId, region } = yield* AWSEnvironment.current;
522 const name =
523 output?.computeEnvironmentName ?? (yield* toName(id, news));
524 const arn =
525 `arn:aws:batch:${region}:${accountId}:compute-environment/${name}` as ComputeEnvironmentArn;
526 const internalTags = yield* createInternalTags(id);
527 const desiredTags = { ...internalTags, ...news.tags };
528 const desiredState = news.state ?? "ENABLED";
529 const desiredManagementType = news.managementType ?? "MANAGED";
530 const desiredMaxvCpus = news.maxvCpus ?? 4;
531 const desiredUnmanagedvCpus = news.unmanagedvCpus ?? 4;
532 // Implicit networking means the *current* default VPC network, not
533 // whichever subnet IDs happened to exist at create time. Account
534 // cleanup can recreate the default VPC between reconciliations, so
535 // resolve the desired network on every pass and repair stale IDs.
536 const fallbackNetwork =
537 desiredManagementType === "MANAGED" &&
538 (!news.subnets || !news.securityGroupIds)
539 ? yield* resolveDefaultNetwork
540 : undefined;
541 let desiredNetwork =
542 desiredManagementType === "MANAGED"
543 ? {
544 subnets: news.subnets ?? fallbackNetwork!.subnets,
545 securityGroupIds:
546 news.securityGroupIds ?? fallbackNetwork!.securityGroupIds,
547 }
548 : undefined;
549 const sameSet = (a: readonly string[], b: readonly string[]) =>
550 a.length === b.length &&
551 [...a].sort().join(",") === [...b].sort().join(",");
552 const matchesDesired = (candidate: batch.ComputeEnvironmentDetail) =>
553 (candidate.type ?? "MANAGED") === desiredManagementType &&
554 (candidate.state ?? "ENABLED") === desiredState &&
555 (desiredManagementType === "UNMANAGED"
556 ? (candidate.unmanagedvCpus ?? 0) === desiredUnmanagedvCpus
557 : (candidate.computeResources?.maxvCpus ?? 0) ===
558 desiredMaxvCpus &&
559 sameSet(
560 candidate.computeResources?.subnets ?? [],
561 desiredNetwork!.subnets,
562 ) &&
563 sameSet(
564 candidate.computeResources?.securityGroupIds ?? [],
565 desiredNetwork!.securityGroupIds,
566 ));
567
568 /**
569 * INVALID is terminal until the configuration is changed. It is
570 * commonly caused by default-VPC subnet/security-group churn during
571 * highly concurrent account tests, so waiting on the same observed
572 * configuration cannot recover. Re-resolve implicit networking and
573 * submit a bounded repair update instead.
574 */
575 const awaitValid = Effect.fn(function* () {
576 let lastRepairNetwork: string | undefined;
577 for (let attempt = 0; attempt < 8; attempt++) {
578 const settled = yield* awaitSettled(name);
579 if (settled?.status === "VALID" && matchesDesired(settled)) {
580 return settled;
581 }
582 if (!settled || settled.status === "DELETED" || attempt === 7) {
583 return yield* Effect.fail(invalid(name, settled));
584 }
585
586 if (
587 desiredManagementType === "MANAGED" &&
588 (!news.subnets || !news.securityGroupIds)
589 ) {
590 const refreshed = yield* resolveDefaultNetwork;
591 desiredNetwork = {
592 subnets: news.subnets ?? refreshed.subnets,
593 securityGroupIds:
594 news.securityGroupIds ?? refreshed.securityGroupIds,
595 };
596 }
597 const repairNetwork = [
598 desiredManagementType,
599 "|",
600 ...(desiredNetwork
601 ? [
602 ...[...desiredNetwork.subnets].sort(),
603 "|",
604 ...[...desiredNetwork.securityGroupIds].sort(),
605 ]
606 : [desiredUnmanagedvCpus]),
607 ].join(",");
608 if (lastRepairNetwork === repairNetwork) {
609 // EC2 may briefly keep returning a just-deleted default VPC
610 // network. Do not spend the repair budget re-submitting the
611 // same known-invalid IDs; wait for EC2's replacement view.
612 yield* Effect.sleep("5 seconds");
613 continue;
614 }
615 yield* retryBatch(
616 batch.updateComputeEnvironment({
617 computeEnvironment: name,
618 state: desiredState,
619 unmanagedvCpus:
620 desiredManagementType === "UNMANAGED"
621 ? desiredUnmanagedvCpus
622 : undefined,
623 computeResources:
624 desiredManagementType === "MANAGED"
625 ? {
626 maxvCpus: desiredMaxvCpus,
627 subnets: desiredNetwork!.subnets,
628 securityGroupIds: desiredNetwork!.securityGroupIds,
629 }
630 : undefined,
631 }),
632 (error) => error._tag === "ComputeEnvironmentBeingModified",
633 );
634 lastRepairNetwork = repairNetwork;
635 // Describe can briefly return the pre-update record before the
636 // transition becomes visible.
637 yield* Effect.sleep("3 seconds");
638 }
639 return yield* Effect.fail(invalid(name, undefined));
640 });
641
642 // Observe — cloud state is authoritative.
643 let ce = yield* describeOne(name);
644 if (ce?.status === "DELETED") ce = undefined;
645
646 // Ensure — create if missing, then wait until the environment
647 // settles to VALID (Fargate CEs settle in seconds).
648 const create = Effect.gen(function* () {
649 yield* batch.createComputeEnvironment({
650 computeEnvironmentName: name,
651 type: desiredManagementType,
652 state: desiredState,
653 unmanagedvCpus:
654 desiredManagementType === "UNMANAGED"
655 ? desiredUnmanagedvCpus
656 : undefined,
657 computeResources:
658 desiredManagementType === "MANAGED"
659 ? {
660 type: news.type ?? "FARGATE",
661 maxvCpus: desiredMaxvCpus,
662 subnets: desiredNetwork!.subnets,
663 securityGroupIds: desiredNetwork!.securityGroupIds,
664 }
665 : undefined,
666 serviceRole: news.serviceRole,
667 tags: desiredTags,
668 });
669 });
670
671 /**
672 * Tear down a CE we created in *this* reconcile that settled
673 * INVALID from an IAM propagation race (see
674 * {@link isAuthPropagationInvalid}). The CE carries no user state
675 * yet, so delete-and-recreate is safe. Mirrors the delete
676 * lifecycle's recovery: the async teardown itself can fail once on
677 * the same not-yet-propagated role, in which case the CE re-settles
678 * INVALID and the delete is re-issued.
679 */
680 const reapInvalidCreate = Effect.fn(function* () {
681 yield* retryBatch(
682 batch.updateComputeEnvironment({
683 computeEnvironment: name,
684 state: "DISABLED",
685 }),
686 (e) => e._tag === "ComputeEnvironmentBeingModified",
687 ).pipe(
688 Effect.catchTag("ComputeEnvironmentNotFound", () => Effect.void),
689 );
690 const disabled = yield* awaitDisabled(name);
691 if (!disabled || disabled.status === "DELETED") return;
692 const requestDelete = retryBatch(
693 batch.deleteComputeEnvironment({ computeEnvironment: name }),
694 (e) =>
695 e._tag === "ComputeEnvironmentInUse" ||
696 e._tag === "ComputeEnvironmentBeingModified",
697 ).pipe(
698 Effect.catchTag("ComputeEnvironmentNotFound", () => Effect.void),
699 );
700 if (disabled.status !== "DELETING") {
701 yield* requestDelete;
702 // Describe can briefly return the pre-delete record before the
703 // DELETING transition becomes visible.
704 yield* Effect.sleep("3 seconds");
705 }
706 const awaitGone = pollBatch(
707 describeOne(name),
708 (c) =>
709 c === undefined ||
710 c.status === "DELETED" ||
711 c.status === "INVALID",
712 );
713 let final = yield* awaitGone;
714 if (final && final.status === "INVALID") {
715 // The async deletion attempt failed (likely the same IAM
716 // propagation lag) — re-issue once and wait again.
717 yield* requestDelete;
718 yield* Effect.sleep("3 seconds");
719 final = yield* awaitGone;
720 }
721 if (final && final.status !== "DELETED") {
722 return yield* Effect.fail(invalid(name, final));
723 }
724 });
725
726 /**
727 * Create and wait for the CE to settle, recovering (bounded) from
728 * the freshly-created-INVALID IAM propagation race by deleting the
729 * INVALID CE and re-creating. Each delete cycle takes ~1-2 minutes,
730 * which is itself ample IAM propagation time. A CE that settles
731 * INVALID for any other reason — or is still auth-INVALID after 3
732 * total create attempts — is returned as-is so the caller's
733 * awaitValid path fails loudly with the typed
734 * ComputeEnvironmentInvalidError.
735 */
736 const createAndSettle = Effect.gen(function* () {
737 yield* create;
738 let settled = yield* awaitSettled(name);
739 for (
740 let attempt = 0;
741 attempt < 2 && isAuthPropagationInvalid(settled);
742 attempt++
743 ) {
744 yield* reapInvalidCreate();
745 // Small grace beyond the deletion window before re-validating.
746 yield* Effect.sleep("5 seconds");
747 yield* create;
748 settled = yield* awaitSettled(name);
749 }
750 return settled;
751 });
752
753 if (ce?.computeEnvironmentArn && ce.status === "DELETING") {
754 // An interrupted destroy left the environment mid-deletion — let
755 // it finish, then fall through to a fresh create below.
756 ce = yield* awaitSettled(name);
757 if (ce?.status === "DELETED") ce = undefined;
758 }
759 if (!ce?.computeEnvironmentArn) {
760 ce = yield* createAndSettle;
761 } else if (ce.status !== "VALID" && ce.status !== "INVALID") {
762 // A prior modification is still settling — wait before diffing.
763 ce = yield* awaitSettled(name);
764 }
765 if (ce?.status === "DELETED") {
766 // The settle above can still land on a tombstone (deletion that
767 // completed between observe and settle) — one more create pass.
768 ce = yield* createAndSettle;
769 }
770
771 if (!ce?.computeEnvironmentArn) {
772 return yield* Effect.die(
773 new Error(`ComputeEnvironment ${name} did not settle`),
774 );
775 }
776
777 // Sync — diff observed against desired, apply only the delta.
778 const update: batch.UpdateComputeEnvironmentRequest = {
779 computeEnvironment: name,
780 };
781 let dirty = false;
782 if ((ce.state ?? "ENABLED") !== desiredState) {
783 update.state = desiredState;
784 dirty = true;
785 }
786 if (desiredManagementType === "UNMANAGED") {
787 if ((ce.unmanagedvCpus ?? 0) !== desiredUnmanagedvCpus) {
788 update.unmanagedvCpus = desiredUnmanagedvCpus;
789 dirty = true;
790 }
791 } else {
792 const resources: batch.ComputeResourceUpdate = {};
793 if ((ce.computeResources?.maxvCpus ?? 0) !== desiredMaxvCpus) {
794 resources.maxvCpus = desiredMaxvCpus;
795 }
796 if (
797 !sameSet(
798 ce.computeResources?.subnets ?? [],
799 desiredNetwork!.subnets,
800 )
801 ) {
802 resources.subnets = desiredNetwork!.subnets;
803 }
804 if (
805 !sameSet(
806 ce.computeResources?.securityGroupIds ?? [],
807 desiredNetwork!.securityGroupIds,
808 )
809 ) {
810 resources.securityGroupIds = desiredNetwork!.securityGroupIds;
811 }
812 if (Object.keys(resources).length > 0) {
813 update.computeResources = resources;
814 dirty = true;
815 }
816 }
817 if (dirty) {
818 yield* retryBatch(
819 batch.updateComputeEnvironment(update),
820 (e) => e._tag === "ComputeEnvironmentBeingModified",
821 );
822 ce = yield* awaitValid();
823 } else if (ce.status !== "VALID") {
824 ce = yield* awaitValid();
825 }
826
827 // Sync tags — diff against OBSERVED cloud tags.
828 const { upsert, removed } = diffTags(observedTagsOf(ce), desiredTags);
829 if (upsert.length > 0) {
830 yield* batch.tagResource({
831 resourceArn: arn,
832 tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
833 });
834 }
835 if (removed.length > 0) {
836 yield* batch.untagResource({ resourceArn: arn, tagKeys: removed });
837 }
838
839 yield* session.note(arn);
840 return toAttributes(
841 { ...ce, computeEnvironmentName: name, computeEnvironmentArn: arn },
842 desiredTags,
843 );
844 }),
845 delete: Effect.fn(function* ({ output, force }) {
846 const name = output.computeEnvironmentName;
847 const existing = yield* describeOne(name);
848 if (!existing || existing.status === "DELETED") return;
849
850 if (force === true) {
851 yield* deleteRelatedJobQueues(name);
852 }
853
854 // Must be DISABLED before deletion.
855 if (
856 existing.status !== "DELETING" &&
857 (existing.state ?? "ENABLED") !== "DISABLED"
858 ) {
859 yield* retryBatch(
860 batch.updateComputeEnvironment({
861 computeEnvironment: name,
862 state: "DISABLED",
863 }),
864 (e) => e._tag === "ComputeEnvironmentBeingModified",
865 ).pipe(
866 Effect.catchTag("ComputeEnvironmentNotFound", () => Effect.void),
867 );
868 }
869 const disabled = yield* awaitDisabled(name);
870 if (!disabled || disabled.status === "DELETED") return;
871
872 // Batch tears the CE down (its ECS cluster, ENIs, …) by assuming the
873 // CE's service role. If that role was deleted first (crashed destroy,
874 // force-nuke ordering), the async deletion fails with an sts:AssumeRole
875 // CLIENT_ERROR and the CE is stuck INVALID forever. Restore a minimal
876 // same-name role (same ARN — role ARNs carry no unique id) so teardown
877 // can proceed, and drop it again once the CE is gone. Service-linked
878 // roles are never restored (they're account infrastructure).
879 const restoredRole = yield* restoreServiceRoleIfMissing(
880 disabled.serviceRole,
881 );
882
883 // deleteComputeEnvironment is idempotent (succeeds when missing) but
884 // rejects while a JobQueue association is still tearing down — retry
885 // through that window (queue deletion is asynchronous).
886 const requestDelete = retryBatch(
887 batch.deleteComputeEnvironment({ computeEnvironment: name }),
888 (e) =>
889 e._tag === "ComputeEnvironmentInUse" ||
890 e._tag === "ComputeEnvironmentBeingModified",
891 24,
892 ).pipe(
893 Effect.catchTag("ComputeEnvironmentNotFound", () => Effect.void),
894 );
895 if (disabled.status !== "DELETING") {
896 yield* requestDelete;
897 // Describe can briefly return the pre-delete record (e.g. a stale
898 // INVALID status) before the DELETING transition becomes visible.
899 yield* Effect.sleep("3 seconds");
900 }
901
902 // Wait until it's actually gone (CE deletion takes ~1-2 minutes).
903 // Settling on INVALID means the async deletion attempt FAILED (e.g.
904 // the service role was dangling until the restore above) — it never
905 // self-recovers, so re-issue the delete once and wait again.
906 const awaitGone = pollBatch(
907 describeOne(name),
908 (ce) =>
909 ce === undefined ||
910 ce.status === "DELETED" ||
911 ce.status === "INVALID",
912 );
913 let final = yield* awaitGone;
914 if (final && final.status === "INVALID") {
915 yield* requestDelete;
916 yield* Effect.sleep("3 seconds");
917 final = yield* awaitGone;
918 } else if (final && final.status !== "DELETED") {
919 // Still DELETING when the poll budget expired — a slow but healthy
920 // teardown gets one more poll round before failing loudly.
921 final = yield* awaitGone;
922 }
923
924 if (
925 restoredRole !== undefined &&
926 (final === undefined || final.status === "DELETED")
927 ) {
928 yield* dropRestoredServiceRole(restoredRole);
929 }
930
931 // A poll that merely expires MUST NOT report success — the engine
932 // would then delete the CE's dependencies (service role, subnets)
933 // while teardown is still consuming them, wedging the CE for good.
934 if (final && final.status !== "DELETED") {
935 return yield* Effect.fail(invalid(name, final));
936 }
937 }),
938 };
939 }),
940 );