1import type { Credentials } from "@distilled.cloud/aws/Credentials";
2import { Region } from "@distilled.cloud/aws/Region";
3import * as ec2 from "@distilled.cloud/aws/ec2";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Stream from "effect/Stream";
7import type * as rolldown from "rolldown";
8import * as Bundle from "../../Bundle/Bundle.ts";
9import { deepEqual, isResolved } from "../../Diff.ts";
10import type { Input } from "../../Input.ts";
11import { createPhysicalName } from "../../PhysicalName.ts";
12import { Platform, type Main, type PlatformProps } from "../../Platform.ts";
13import * as Provider from "../../Provider.ts";
14import { Resource } from "../../Resource.ts";
15import { Stack } from "../../Stack.ts";
16import { Stage } from "../../Stage.ts";
17import { createInternalTags, diffTags, hasTags } from "../../Tags.ts";
18import type { SecurityGroupId } from "../EC2/SecurityGroup.ts";
19import {
20 createEc2HostRuntimeContext,
21 createEc2HostedSupport,
22 type Ec2HostRuntimeContext,
23} from "../EC2/hosted.ts";
24import type { AccountID } from "../Environment.ts";
25import type { PolicyStatement } from "../IAM/Policy.ts";
26import type { Providers } from "../Providers.ts";
27import type { RegionID } from "../Region.ts";
28import { AWSEnvironment } from "../index.ts";
29
30export type LaunchTemplateId = `lt-${string}`;
31export type LaunchTemplateName = string;
32export type LaunchTemplateArn =
33 `arn:aws:ec2:${RegionID}:${AccountID}:launch-template/${LaunchTemplateId}`;
34
35export 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
105export 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
181export type LaunchTemplateServices = Credentials | Region;
182
183export type LaunchTemplateShape = Main<LaunchTemplateServices>;
184
185export 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 */
235export 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
246export 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
601const 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
616const 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 );