| 1 | import type { Credentials } from "@distilled.cloud/aws/Credentials"; |
| 2 | import { Region } from "@distilled.cloud/aws/Region"; |
| 3 | import * as ec2 from "@distilled.cloud/aws/ec2"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Stream from "effect/Stream"; |
| 7 | import type * as rolldown from "rolldown"; |
| 8 | import * as Bundle from "../../Bundle/Bundle.ts"; |
| 9 | import { deepEqual, isResolved } from "../../Diff.ts"; |
| 10 | import type { Input } from "../../Input.ts"; |
| 11 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 12 | import { Platform, type Main, type PlatformProps } from "../../Platform.ts"; |
| 13 | import * as Provider from "../../Provider.ts"; |
| 14 | import { Resource } from "../../Resource.ts"; |
| 15 | import { Stack } from "../../Stack.ts"; |
| 16 | import { Stage } from "../../Stage.ts"; |
| 17 | import { createInternalTags, diffTags, hasTags } from "../../Tags.ts"; |
| 18 | import type { SecurityGroupId } from "../EC2/SecurityGroup.ts"; |
| 19 | import { |
| 20 | createEc2HostRuntimeContext, |
| 21 | createEc2HostedSupport, |
| 22 | type Ec2HostRuntimeContext, |
| 23 | } from "../EC2/hosted.ts"; |
| 24 | import type { AccountID } from "../Environment.ts"; |
| 25 | import type { PolicyStatement } from "../IAM/Policy.ts"; |
| 26 | import type { Providers } from "../Providers.ts"; |
| 27 | import type { RegionID } from "../Region.ts"; |
| 28 | import { AWSEnvironment } from "../index.ts"; |
| 29 | |
| 30 | export type LaunchTemplateId = `lt-${string}`; |
| 31 | export type LaunchTemplateName = string; |
| 32 | export type LaunchTemplateArn = |
| 33 | `arn:aws:ec2:${RegionID}:${AccountID}:launch-template/${LaunchTemplateId}`; |
| 34 | |
| 35 | export interface LaunchTemplateProps extends PlatformProps { |
| 36 | /** |
| 37 | * Launch template name. If omitted, a deterministic name is generated. |
| 38 | */ |
| 39 | launchTemplateName?: string; |
| 40 | /** |
| 41 | * AMI ID to launch. |
| 42 | */ |
| 43 | imageId: string; |
| 44 | /** |
| 45 | * EC2 instance type, such as `t3.micro`. |
| 46 | */ |
| 47 | instanceType: string; |
| 48 | /** |
| 49 | * Security groups to attach to the primary network interface. |
| 50 | */ |
| 51 | securityGroupIds?: Input<SecurityGroupId>[]; |
| 52 | /** |
| 53 | * Optional EC2 key pair name for SSH access. |
| 54 | */ |
| 55 | keyName?: string; |
| 56 | /** |
| 57 | * Optional IAM instance profile name to attach at launch. |
| 58 | */ |
| 59 | instanceProfileName?: string; |
| 60 | /** |
| 61 | * User data script to provide at launch time. |
| 62 | */ |
| 63 | userData?: string; |
| 64 | /** |
| 65 | * Whether to associate a public IPv4 address on launch. |
| 66 | */ |
| 67 | associatePublicIpAddress?: boolean; |
| 68 | /** |
| 69 | * User-defined tags to apply to the launch template and launched instances. |
| 70 | */ |
| 71 | tags?: Record<string, string>; |
| 72 | /** |
| 73 | * Module entrypoint for the bundled instance program. |
| 74 | * When omitted, the launch template behaves as a low-level EC2 primitive. |
| 75 | */ |
| 76 | main?: string; |
| 77 | /** |
| 78 | * Named export to load from `main`. |
| 79 | * @default "default" |
| 80 | */ |
| 81 | handler?: string; |
| 82 | /** |
| 83 | * Port exposed by the process, if any. |
| 84 | * @default 3000 |
| 85 | */ |
| 86 | port?: number; |
| 87 | /** |
| 88 | * Additional environment variables for the hosted process. |
| 89 | */ |
| 90 | env?: Record<string, any>; |
| 91 | /** |
| 92 | * Bundler configuration for the hosted process entrypoint. |
| 93 | */ |
| 94 | build?: { |
| 95 | input?: Partial<rolldown.InputOptions>; |
| 96 | output?: Partial<rolldown.OutputOptions>; |
| 97 | }; |
| 98 | /** |
| 99 | * Additional managed policy ARNs for the managed instance role. |
| 100 | * This can only be used when Alchemy manages the instance profile. |
| 101 | */ |
| 102 | roleManagedPolicyArns?: string[]; |
| 103 | } |
| 104 | |
| 105 | export interface LaunchTemplate extends Resource< |
| 106 | "AWS.AutoScaling.LaunchTemplate", |
| 107 | LaunchTemplateProps, |
| 108 | { |
| 109 | /** |
| 110 | * ID of the launch template (`lt-...`). |
| 111 | */ |
| 112 | launchTemplateId: LaunchTemplateId; |
| 113 | /** |
| 114 | * ARN of the launch template. |
| 115 | */ |
| 116 | launchTemplateArn: LaunchTemplateArn; |
| 117 | /** |
| 118 | * Name of the launch template. |
| 119 | */ |
| 120 | launchTemplateName: LaunchTemplateName; |
| 121 | /** |
| 122 | * Version number launched by default. |
| 123 | */ |
| 124 | defaultVersionNumber: number; |
| 125 | /** |
| 126 | * Most recently created version number. |
| 127 | */ |
| 128 | latestVersionNumber: number; |
| 129 | /** |
| 130 | * Tags on the launch template. |
| 131 | */ |
| 132 | tags: Record<string, string>; |
| 133 | /** |
| 134 | * ARN of the managed instance role, when Alchemy manages IAM. |
| 135 | */ |
| 136 | roleArn?: string; |
| 137 | /** |
| 138 | * Name of the managed instance role, when Alchemy manages IAM. |
| 139 | */ |
| 140 | roleName?: string; |
| 141 | /** |
| 142 | * Name of the inline policy on the managed instance role. |
| 143 | */ |
| 144 | policyName?: string; |
| 145 | /** |
| 146 | * Whether Alchemy manages the instance profile/role for the hosted |
| 147 | * program. |
| 148 | */ |
| 149 | managedIam?: boolean; |
| 150 | /** |
| 151 | * systemd unit name running the hosted program on instances. |
| 152 | */ |
| 153 | runtimeUnitName?: string; |
| 154 | /** |
| 155 | * S3 key prefix where the bundled program assets are staged. |
| 156 | */ |
| 157 | assetPrefix?: string; |
| 158 | /** |
| 159 | * Bundled program code metadata. |
| 160 | */ |
| 161 | code?: { |
| 162 | /** |
| 163 | * Content hash of the bundled entrypoint. |
| 164 | */ |
| 165 | hash: string; |
| 166 | }; |
| 167 | }, |
| 168 | { |
| 169 | /** |
| 170 | * Environment variables contributed by bindings to the hosted process. |
| 171 | */ |
| 172 | env?: Record<string, any>; |
| 173 | /** |
| 174 | * IAM policy statements contributed by bindings to the instance role. |
| 175 | */ |
| 176 | policyStatements?: PolicyStatement[]; |
| 177 | }, |
| 178 | Providers |
| 179 | > {} |
| 180 | |
| 181 | export type LaunchTemplateServices = Credentials | Region; |
| 182 | |
| 183 | export type LaunchTemplateShape = Main<LaunchTemplateServices>; |
| 184 | |
| 185 | export type LaunchTemplateRuntimeContext = Ec2HostRuntimeContext; |
| 186 | |
| 187 | /** |
| 188 | * A launch template that preserves the `Host` authoring model used by |
| 189 | * `AWS.EC2.Instance`, but packages that host configuration for use with an |
| 190 | * Auto Scaling Group. |
| 191 | * ### Creating a Launch Template |
| 192 | * **Example:** Basic Launch Template |
| 193 | * ```typescript |
| 194 | * import { LaunchTemplate } from "alchemy/AWS/AutoScaling"; |
| 195 | * |
| 196 | * const template = yield* LaunchTemplate("Template", { |
| 197 | * imageId: "ami-0abcdef1234567890", |
| 198 | * instanceType: "t3.micro", |
| 199 | * }); |
| 200 | * ``` |
| 201 | * |
| 202 | * **Example:** Launch a fleet from the template |
| 203 | * ```typescript |
| 204 | * import { AutoScalingGroup } from "alchemy/AWS/AutoScaling"; |
| 205 | * |
| 206 | * const group = yield* AutoScalingGroup("Fleet", { |
| 207 | * launchTemplate: template, |
| 208 | * subnetIds: [subnet.subnetId], |
| 209 | * minSize: 1, |
| 210 | * maxSize: 3, |
| 211 | * }); |
| 212 | * ``` |
| 213 | * |
| 214 | * ### Hosting Processes |
| 215 | * **Example:** Hosted HTTP Launch Template |
| 216 | * ```typescript |
| 217 | * const template = yield* Effect.gen(function* () { |
| 218 | * yield* Http.serve(HttpServerResponse.json({ ok: true })); |
| 219 | * |
| 220 | * return { |
| 221 | * main: import.meta.url, |
| 222 | * imageId, |
| 223 | * instanceType: "t3.small", |
| 224 | * securityGroupIds: [securityGroup.groupId], |
| 225 | * port: 3000, |
| 226 | * }; |
| 227 | * }).pipe( |
| 228 | * Effect.provide(AWS.EC2.HttpServer), |
| 229 | * AWS.AutoScaling.LaunchTemplate("ApiTemplate"), |
| 230 | * ); |
| 231 | * ``` |
| 232 | * |
| 233 | * @resource |
| 234 | */ |
| 235 | export const LaunchTemplate: Platform< |
| 236 | LaunchTemplate, |
| 237 | LaunchTemplateServices, |
| 238 | LaunchTemplateShape, |
| 239 | LaunchTemplateRuntimeContext |
| 240 | > = Platform("AWS.AutoScaling.LaunchTemplate", { |
| 241 | createRuntimeContext: createEc2HostRuntimeContext( |
| 242 | "AWS.AutoScaling.LaunchTemplate", |
| 243 | ), |
| 244 | }); |
| 245 | |
| 246 | export const LaunchTemplateProvider = () => |
| 247 | Provider.effect( |
| 248 | LaunchTemplate, |
| 249 | Effect.gen(function* () { |
| 250 | const stack = yield* Stack; |
| 251 | const stage = yield* Stage; |
| 252 | const fs = yield* FileSystem.FileSystem; |
| 253 | const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin; |
| 254 | |
| 255 | const hosted = createEc2HostedSupport({ |
| 256 | stackName: stack.name, |
| 257 | stage, |
| 258 | fs, |
| 259 | virtualEntryPlugin, |
| 260 | resourceType: "AWS.AutoScaling.LaunchTemplate", |
| 261 | }); |
| 262 | |
| 263 | const toName = ( |
| 264 | id: string, |
| 265 | props: { launchTemplateName?: string } = {}, |
| 266 | ) => |
| 267 | props.launchTemplateName |
| 268 | ? Effect.succeed(props.launchTemplateName) |
| 269 | : createPhysicalName({ id, maxLength: 128, lowercase: true }); |
| 270 | |
| 271 | const toArn = (launchTemplateId: LaunchTemplateId) => |
| 272 | AWSEnvironment.current.pipe( |
| 273 | Effect.map( |
| 274 | (env) => |
| 275 | `arn:aws:ec2:${env.region}:${env.accountId}:launch-template/${launchTemplateId}` as LaunchTemplateArn, |
| 276 | ), |
| 277 | ); |
| 278 | |
| 279 | const describeById = (launchTemplateId: string) => |
| 280 | ec2 |
| 281 | .describeLaunchTemplates({ |
| 282 | LaunchTemplateIds: [launchTemplateId], |
| 283 | } as any) |
| 284 | .pipe( |
| 285 | Effect.map((result) => result.LaunchTemplates?.[0]), |
| 286 | Effect.catch((error) => |
| 287 | isLaunchTemplateNotFound(error) |
| 288 | ? Effect.succeed(undefined) |
| 289 | : Effect.fail(error), |
| 290 | ), |
| 291 | ); |
| 292 | |
| 293 | const describeByName = (launchTemplateName: string) => |
| 294 | ec2 |
| 295 | .describeLaunchTemplates({ |
| 296 | LaunchTemplateNames: [launchTemplateName], |
| 297 | } as any) |
| 298 | .pipe( |
| 299 | Effect.map((result) => result.LaunchTemplates?.[0]), |
| 300 | Effect.catch((error) => |
| 301 | isLaunchTemplateNotFound(error) |
| 302 | ? Effect.succeed(undefined) |
| 303 | : Effect.fail(error), |
| 304 | ), |
| 305 | ); |
| 306 | |
| 307 | const syncTemplateTags = Effect.fn(function* ({ |
| 308 | launchTemplateId, |
| 309 | oldTags, |
| 310 | newTags, |
| 311 | }: { |
| 312 | launchTemplateId: LaunchTemplateId; |
| 313 | oldTags: Record<string, string>; |
| 314 | newTags: Record<string, string>; |
| 315 | }) { |
| 316 | const { removed, upsert } = diffTags(oldTags, newTags); |
| 317 | if (removed.length > 0) { |
| 318 | yield* ec2.deleteTags({ |
| 319 | Resources: [launchTemplateId], |
| 320 | Tags: removed.map((key) => ({ Key: key })), |
| 321 | }); |
| 322 | } |
| 323 | if (upsert.length > 0) { |
| 324 | yield* ec2.createTags({ |
| 325 | Resources: [launchTemplateId], |
| 326 | Tags: upsert, |
| 327 | }); |
| 328 | } |
| 329 | }); |
| 330 | |
| 331 | const createVersion = Effect.fn(function* ({ |
| 332 | launchTemplateId, |
| 333 | news, |
| 334 | runtime, |
| 335 | }: { |
| 336 | launchTemplateId: LaunchTemplateId; |
| 337 | news: LaunchTemplateProps; |
| 338 | runtime: { |
| 339 | userData?: string; |
| 340 | instanceProfileName?: string; |
| 341 | code?: { |
| 342 | hash: string; |
| 343 | }; |
| 344 | }; |
| 345 | }) { |
| 346 | const created = yield* ec2.createLaunchTemplateVersion({ |
| 347 | LaunchTemplateId: launchTemplateId, |
| 348 | VersionDescription: runtime.code?.hash ?? "alchemy-update", |
| 349 | LaunchTemplateData: hosted.buildLaunchTemplateData( |
| 350 | { |
| 351 | imageId: news.imageId, |
| 352 | instanceType: news.instanceType, |
| 353 | keyName: news.keyName, |
| 354 | securityGroupIds: news.securityGroupIds as string[] | undefined, |
| 355 | associatePublicIpAddress: news.associatePublicIpAddress, |
| 356 | tags: news.tags, |
| 357 | }, |
| 358 | runtime, |
| 359 | ), |
| 360 | } as any); |
| 361 | |
| 362 | const versionNumber = created.LaunchTemplateVersion?.VersionNumber; |
| 363 | if (versionNumber === undefined) { |
| 364 | return yield* Effect.fail( |
| 365 | new Error( |
| 366 | `createLaunchTemplateVersion returned no version for '${launchTemplateId}'`, |
| 367 | ), |
| 368 | ); |
| 369 | } |
| 370 | |
| 371 | yield* ec2.modifyLaunchTemplate({ |
| 372 | LaunchTemplateId: launchTemplateId, |
| 373 | DefaultVersion: String(versionNumber), |
| 374 | } as any); |
| 375 | |
| 376 | return Number(versionNumber); |
| 377 | }); |
| 378 | |
| 379 | const toAttributes = Effect.fn(function* ( |
| 380 | template: ec2.LaunchTemplate, |
| 381 | runtime: Partial<LaunchTemplate["Attributes"]> = {}, |
| 382 | ) { |
| 383 | return { |
| 384 | launchTemplateId: template.LaunchTemplateId as LaunchTemplateId, |
| 385 | launchTemplateArn: yield* toArn( |
| 386 | template.LaunchTemplateId as LaunchTemplateId, |
| 387 | ), |
| 388 | launchTemplateName: template.LaunchTemplateName!, |
| 389 | defaultVersionNumber: Number(template.DefaultVersionNumber ?? 1), |
| 390 | latestVersionNumber: Number( |
| 391 | template.LatestVersionNumber ?? template.DefaultVersionNumber ?? 1, |
| 392 | ), |
| 393 | tags: toTagRecord(template.Tags), |
| 394 | roleArn: runtime.roleArn, |
| 395 | roleName: runtime.roleName, |
| 396 | policyName: runtime.policyName, |
| 397 | managedIam: runtime.managedIam, |
| 398 | runtimeUnitName: runtime.runtimeUnitName, |
| 399 | assetPrefix: runtime.assetPrefix, |
| 400 | code: runtime.code, |
| 401 | } satisfies LaunchTemplate["Attributes"]; |
| 402 | }); |
| 403 | |
| 404 | return { |
| 405 | stables: [ |
| 406 | "launchTemplateId", |
| 407 | "launchTemplateArn", |
| 408 | "launchTemplateName", |
| 409 | ], |
| 410 | diff: Effect.fn(function* ({ id, olds, news: _news, output }) { |
| 411 | if (!isResolved(_news)) return undefined; |
| 412 | const news = _news as typeof olds; |
| 413 | const oldName = yield* toName(id, olds ?? {}); |
| 414 | const newName = yield* toName(id, news ?? {}); |
| 415 | if (oldName !== newName) { |
| 416 | return { action: "replace" } as const; |
| 417 | } |
| 418 | |
| 419 | if (!deepEqual(olds, news)) { |
| 420 | return { |
| 421 | action: "update", |
| 422 | stables: [ |
| 423 | "launchTemplateId", |
| 424 | "launchTemplateArn", |
| 425 | "launchTemplateName", |
| 426 | ], |
| 427 | } as const; |
| 428 | } |
| 429 | |
| 430 | // The hosted bundle hash participates in planning: a change confined |
| 431 | // to the runtime program (or its imports) leaves every prop equal, |
| 432 | // so re-bundle and compare against the deployed hash. A mismatch |
| 433 | // plans an in-place update, whose reconcile publishes a new template |
| 434 | // version carrying the new bundle. |
| 435 | if (news.main && output?.code?.hash) { |
| 436 | const { hash } = yield* hosted.bundleProgram(id, news); |
| 437 | if (hash !== output.code.hash) { |
| 438 | return { |
| 439 | action: "update", |
| 440 | stables: [ |
| 441 | "launchTemplateId", |
| 442 | "launchTemplateArn", |
| 443 | "launchTemplateName", |
| 444 | ], |
| 445 | } as const; |
| 446 | } |
| 447 | } |
| 448 | }), |
| 449 | read: Effect.fn(function* ({ id, olds, output }) { |
| 450 | const template = |
| 451 | (output?.launchTemplateId && |
| 452 | (yield* describeById(output.launchTemplateId))) ?? |
| 453 | (yield* describeByName(yield* toName(id, olds ?? {}))); |
| 454 | |
| 455 | return template |
| 456 | ? yield* toAttributes(template, { |
| 457 | roleArn: output?.roleArn, |
| 458 | roleName: output?.roleName, |
| 459 | policyName: output?.policyName, |
| 460 | managedIam: output?.managedIam, |
| 461 | runtimeUnitName: output?.runtimeUnitName, |
| 462 | assetPrefix: output?.assetPrefix, |
| 463 | code: output?.code, |
| 464 | }) |
| 465 | : undefined; |
| 466 | }), |
| 467 | list: () => |
| 468 | ec2.describeLaunchTemplates.pages({}).pipe( |
| 469 | Stream.runCollect, |
| 470 | Effect.flatMap((chunk) => |
| 471 | Effect.forEach( |
| 472 | Array.from(chunk).flatMap((page) => page.LaunchTemplates ?? []), |
| 473 | (template) => toAttributes(template), |
| 474 | ), |
| 475 | ), |
| 476 | ), |
| 477 | reconcile: Effect.fn(function* ({ |
| 478 | id, |
| 479 | news, |
| 480 | output, |
| 481 | bindings, |
| 482 | session, |
| 483 | }) { |
| 484 | const launchTemplateName = |
| 485 | output?.launchTemplateName ?? (yield* toName(id, news)); |
| 486 | const desiredTags = { |
| 487 | ...(yield* createInternalTags(id)), |
| 488 | ...news.tags, |
| 489 | }; |
| 490 | const runtime = yield* hosted.resolveHostedRuntime({ |
| 491 | id, |
| 492 | news, |
| 493 | bindings, |
| 494 | output, |
| 495 | }); |
| 496 | |
| 497 | // Observe — fetch live cloud state. We try both lookup paths |
| 498 | // (id from output, name from desired) so the reconciler |
| 499 | // converges whether `output` is fresh, stale, or missing. |
| 500 | let existing = |
| 501 | (output?.launchTemplateId && |
| 502 | (yield* describeById(output.launchTemplateId))) || |
| 503 | (yield* describeByName(launchTemplateName)); |
| 504 | |
| 505 | // Ensure — create the launch template if missing. We must |
| 506 | // verify alchemy ownership via tags here (since this resource |
| 507 | // does not implement `read` adoption gating). |
| 508 | if (!existing) { |
| 509 | const created = yield* ec2.createLaunchTemplate({ |
| 510 | LaunchTemplateName: launchTemplateName, |
| 511 | VersionDescription: runtime.code?.hash ?? "alchemy-create", |
| 512 | TagSpecifications: [ |
| 513 | { |
| 514 | ResourceType: "launch-template", |
| 515 | Tags: Object.entries(desiredTags).map(([Key, Value]) => ({ |
| 516 | Key, |
| 517 | Value, |
| 518 | })), |
| 519 | }, |
| 520 | ], |
| 521 | LaunchTemplateData: hosted.buildLaunchTemplateData( |
| 522 | { |
| 523 | imageId: news.imageId, |
| 524 | instanceType: news.instanceType, |
| 525 | keyName: news.keyName, |
| 526 | securityGroupIds: news.securityGroupIds as |
| 527 | | string[] |
| 528 | | undefined, |
| 529 | associatePublicIpAddress: news.associatePublicIpAddress, |
| 530 | tags: desiredTags, |
| 531 | }, |
| 532 | runtime, |
| 533 | ), |
| 534 | } as any); |
| 535 | const template = created.LaunchTemplate; |
| 536 | if (!template?.LaunchTemplateId || !template.LaunchTemplateName) { |
| 537 | return yield* Effect.fail( |
| 538 | new Error( |
| 539 | `createLaunchTemplate returned no launch template for '${id}'`, |
| 540 | ), |
| 541 | ); |
| 542 | } |
| 543 | yield* session.note(template.LaunchTemplateId); |
| 544 | return yield* toAttributes(template as ec2.LaunchTemplate, runtime); |
| 545 | } |
| 546 | |
| 547 | if (!hasTags(desiredTags, toTagRecord(existing.Tags))) { |
| 548 | return yield* Effect.fail( |
| 549 | new Error( |
| 550 | `Launch template '${launchTemplateName}' already exists and is not managed by alchemy`, |
| 551 | ), |
| 552 | ); |
| 553 | } |
| 554 | |
| 555 | // Sync version — each reconcile creates a new version pinned as |
| 556 | // the default. ASGs that reference `$Default` automatically |
| 557 | // pick up the new version. |
| 558 | yield* createVersion({ |
| 559 | launchTemplateId: existing.LaunchTemplateId as LaunchTemplateId, |
| 560 | news, |
| 561 | runtime, |
| 562 | }); |
| 563 | |
| 564 | // Sync tags — diff observed cloud tags against desired. |
| 565 | yield* syncTemplateTags({ |
| 566 | launchTemplateId: existing.LaunchTemplateId as LaunchTemplateId, |
| 567 | oldTags: toTagRecord(existing.Tags), |
| 568 | newTags: desiredTags, |
| 569 | }); |
| 570 | |
| 571 | const refreshed = yield* describeById(existing.LaunchTemplateId!); |
| 572 | if (!refreshed) { |
| 573 | return yield* Effect.fail( |
| 574 | new Error( |
| 575 | `Launch template '${launchTemplateName}' was not readable after reconcile`, |
| 576 | ), |
| 577 | ); |
| 578 | } |
| 579 | yield* session.note(refreshed.LaunchTemplateId!); |
| 580 | return yield* toAttributes(refreshed, runtime); |
| 581 | }), |
| 582 | delete: Effect.fn(function* ({ output, session }) { |
| 583 | yield* ec2 |
| 584 | .deleteLaunchTemplate({ |
| 585 | LaunchTemplateId: output.launchTemplateId, |
| 586 | } as any) |
| 587 | .pipe( |
| 588 | Effect.catch((error) => |
| 589 | isLaunchTemplateNotFound(error) |
| 590 | ? Effect.void |
| 591 | : Effect.fail(error), |
| 592 | ), |
| 593 | ); |
| 594 | |
| 595 | yield* hosted.cleanupHostedRuntime({ output, session }); |
| 596 | }), |
| 597 | }; |
| 598 | }), |
| 599 | ); |
| 600 | |
| 601 | const isLaunchTemplateNotFound = (error: unknown) => { |
| 602 | const tag = (error as { _tag?: string })?._tag; |
| 603 | return ( |
| 604 | tag === "InvalidLaunchTemplateNameNotFoundException" || |
| 605 | tag === "InvalidLaunchTemplateIdNotFoundException" || |
| 606 | tag === "InvalidLaunchTemplateId.Malformed" || |
| 607 | tag === "InvalidLaunchTemplateId.NotFound" || |
| 608 | // Live `describeLaunchTemplates` surfaces the dot-form codes, which the |
| 609 | // ec2 SDK leaves untyped — a missing template on the read-before-create |
| 610 | // probe otherwise fails the whole plan. |
| 611 | tag === "InvalidLaunchTemplateName.NotFoundException" || |
| 612 | tag === "InvalidLaunchTemplateId.NotFoundException" |
| 613 | ); |
| 614 | }; |
| 615 | |
| 616 | const toTagRecord = (tags?: Array<{ Key?: string; Value?: string }>) => |
| 617 | Object.fromEntries( |
| 618 | (tags ?? []) |
| 619 | .filter((tag): tag is { Key: string; Value: string } => |
| 620 | Boolean(tag.Key && tag.Value !== undefined), |
| 621 | ) |
| 622 | .map((tag) => [tag.Key, tag.Value]), |
| 623 | ); |