1import * as iam from "@distilled.cloud/aws/iam";
2import * as s3 from "@distilled.cloud/aws/s3";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import type * as rolldown from "rolldown";
6import * as Bundle from "../../Bundle/Bundle.ts";
7import { findCwdForBundle, resolveMainPath } from "../../Bundle/TempRoot.ts";
8import type { ScopedPlanStatusSession } from "../../Report.ts";
9import type { Input } from "../../Input.ts";
10import { createPhysicalName } from "../../PhysicalName.ts";
11import type { PlatformProps } from "../../Platform.ts";
12import type { ResourceBinding } from "../../Resource.ts";
13import {
14 createHostRuntimeContext,
15 type HostRuntimeContext,
16} from "../../Server/Process.ts";
17import { createInternalTags, createTagsList, hasTags } from "../../Tags.ts";
18import { sha256 } from "../../Util/sha256.ts";
19import { zipCode } from "../../Util/zip.ts";
20import { Assets } from "../Assets.ts";
21import { AWSEnvironment } from "../Environment.ts";
22import type { PolicyStatement } from "../IAM/Policy.ts";
23
24/**
25 * Binding contract accepted by EC2-hosted runtimes: environment variables and
26 * IAM policy statements attached by capability bindings.
27 */
28export interface Ec2HostedBinding {
29 /** Environment variables injected into the hosted runtime. */
30 env?: Record<string, any>;
31 /** IAM policy statements attached to the instance profile role. */
32 policyStatements?: PolicyStatement[];
33}
34
35/**
36 * Shared props for EC2-backed hosted runtimes (an Instance that bundles and
37 * runs an application entrypoint).
38 */
39export interface Ec2HostedProps extends PlatformProps {
40 /** AMI ID to launch the instance from. */
41 imageId: string;
42 /** EC2 instance type, e.g. `t3.micro`. */
43 instanceType: string;
44 /** Name of an EC2 key pair for SSH access. */
45 keyName?: Input<string>;
46 /** Existing instance profile to attach instead of a managed one. */
47 instanceProfileName?: string;
48 /** Additional user-data script prepended to the runtime bootstrap. */
49 userData?: string;
50 /** Subnet to launch the instance into. */
51 subnetId?: any;
52 /** Security groups attached to the instance. */
53 securityGroupIds?: readonly any[];
54 /** Whether to associate a public IP address. */
55 associatePublicIpAddress?: boolean;
56 /** Static private IP address within the subnet. */
57 privateIpAddress?: string;
58 /** Availability Zone to launch into. */
59 availabilityZone?: string;
60 /** Tags applied to the instance. */
61 tags?: Record<string, string>;
62 /** Path to the application entrypoint to bundle and run on the instance. */
63 main?: string;
64 /** Named export of the handler within `main`. */
65 handler?: string;
66 /** Port the hosted HTTP server listens on. */
67 port?: number;
68 /** Environment variables injected into the hosted runtime. */
69 env?: Record<string, any>;
70 /**
71 * Overrides for the rolldown bundling of `main`. Unused code is
72 * tree-shaken. `effect`, alchemy, and `@distilled.cloud` are marked
73 * pure so unused parts prune more aggressively. List extra packages
74 * with `pure.packages`, or disable with `pure: false`.
75 */
76 build?: Bundle.BundleConfig;
77 /** Managed policy ARNs attached to the instance role. */
78 roleManagedPolicyArns?: string[];
79}
80
81export interface Ec2HostedRuntimeState {
82 userData?: string;
83 roleName?: string;
84 roleArn?: string;
85 policyName?: string;
86 instanceProfileName?: string;
87 instanceProfileArn?: string;
88 managedIam?: boolean;
89 runtimeUnitName?: string;
90 assetPrefix?: string;
91 code?: {
92 hash: string;
93 };
94}
95
96export interface Ec2HostedCleanupState {
97 roleName?: string;
98 policyName?: string;
99 instanceProfileName?: string;
100 managedIam?: boolean;
101 assetPrefix?: string;
102}
103
104/**
105 * Deploy-time / plan-time host context for EC2-backed platforms that bundle a
106 * long-lived program (`exports.program`) and collect background work via `run`
107 * / HTTP handlers via `serve`. Alias of the shared {@link HostRuntimeContext}.
108 */
109export type Ec2HostRuntimeContext = HostRuntimeContext;
110
111export const createEc2HostRuntimeContext = createHostRuntimeContext;
112
113export const createEc2HostedSupport = ({
114 stackName,
115 stage,
116 virtualEntryPlugin,
117 resourceType,
118}: {
119 stackName: string;
120 stage: string;
121 fs: FileSystem.FileSystem;
122 virtualEntryPlugin: (
123 content: (importPath: string) => string,
124 ) => rolldown.Plugin;
125 resourceType: string;
126}) => {
127 const alchemyEnv = {
128 ALCHEMY_STACK_NAME: stackName,
129 ALCHEMY_STAGE: stage,
130 ALCHEMY_PHASE: "runtime",
131 };
132
133 const createRoleName = (id: string) =>
134 createPhysicalName({
135 id: `${id}-role`,
136 maxLength: 64,
137 });
138
139 const createPolicyName = (id: string) =>
140 createPhysicalName({
141 id: `${id}-policy`,
142 maxLength: 128,
143 });
144
145 const createManagedProfileName = (id: string) =>
146 createPhysicalName({
147 id: `${id}-profile`,
148 maxLength: 128,
149 });
150
151 const createRuntimeUnitName = (id: string) =>
152 createPhysicalName({
153 id: `${id}-instance`,
154 maxLength: 64,
155 lowercase: true,
156 }).pipe(Effect.map((name) => name.replaceAll(/[^a-z0-9-]/g, "-")));
157
158 const normalizeSecurityGroups = (groups?: readonly string[]) =>
159 [...(groups ?? [])].sort((a, b) => a.localeCompare(b));
160
161 const bundleProgram = Effect.fn(function* (
162 id: string,
163 props: Ec2HostedProps,
164 ) {
165 if (!props.main) {
166 return yield* Effect.fail(
167 new Error(
168 `${resourceType} '${id}' requires 'main' when bundling a hosted process`,
169 ),
170 );
171 }
172
173 const handler = props.handler ?? "default";
174 const realMain = yield* resolveMainPath(props.main);
175 const cwd = yield* findCwdForBundle(realMain);
176
177 const buildBundle = Effect.fn(function* (
178 entry: string,
179 plugins?: rolldown.RolldownPluginOption,
180 ) {
181 return yield* Bundle.build(
182 {
183 ...props.build?.input,
184 input: entry,
185 cwd,
186 platform: "node",
187 // The hosted process runs under `bun` (installed by the user-data);
188 // keep `bun`/`bun:*` external and resolve the `bun` export condition
189 // so `@effect/platform-bun` picks its Bun implementations.
190 external: [
191 "bun",
192 "bun:*",
193 ...((props.build?.input?.external as string[] | undefined) ?? []),
194 ],
195 resolve: {
196 conditionNames: [...Bundle.BUN_CONDITION_NAMES],
197 ...props.build?.input?.resolve,
198 },
199 plugins: [props.build?.input?.plugins, plugins],
200 },
201 {
202 ...props.build?.output,
203 format: "esm",
204 sourcemap: props.build?.output?.sourcemap ?? false,
205 minify: props.build?.output?.minify ?? false,
206 entryFileNames: "index.mjs",
207 },
208 props.build,
209 );
210 });
211
212 const bundleOutput = props.isExternal
213 ? yield* buildBundle(realMain)
214 : yield* buildBundle(
215 realMain,
216 virtualEntryPlugin(
217 (importPath) => `
218import { bootstrap } from "alchemy/Runtime/Bootstrap/Ec2";
219import { ${handler} as entrypoint } from ${JSON.stringify(importPath)};
220
221await bootstrap(entrypoint);
222`,
223 ),
224 );
225
226 // Zip every emitted file: the entry becomes `index.mjs` (what the systemd
227 // unit runs) and shared chunks keep their `*.js` names so the entry's
228 // relative imports resolve. Dropping a chunk crashes the process at start.
229 const toBytes = (content: string | Uint8Array<ArrayBufferLike>) =>
230 typeof content === "string" ? new TextEncoder().encode(content) : content;
231 const [entryFile, ...chunkFiles] = bundleOutput.files;
232 const archive = yield* zipCode(
233 toBytes(entryFile.content),
234 chunkFiles.map((file) => ({
235 path: file.path,
236 content: toBytes(file.content),
237 })),
238 );
239 return { archive, hash: bundleOutput.hash };
240 });
241
242 const quoteEnvValue = (value: any) => {
243 const text =
244 typeof value === "string" ? value : JSON.stringify(value ?? null);
245 return `'${text.replaceAll(/'/g, `'""'`).replaceAll(/\n/g, "\\n")}'`;
246 };
247
248 const renderEnvFile = (env: Record<string, any>) =>
249 Object.entries(env)
250 .sort(([a], [b]) => a.localeCompare(b))
251 .map(([key, value]) => `${key}=${quoteEnvValue(value)}`)
252 .join("\n");
253
254 const renderHostedUserData = Effect.fn(function* ({
255 unitName,
256 bundleKey,
257 envKey,
258 region,
259 }: {
260 unitName: string;
261 bundleKey: string;
262 envKey: string;
263 region: string;
264 }) {
265 const appDir = `/opt/${unitName}`;
266 const bucket = yield* Assets.BucketName;
267 // User-data runs once via cloud-init's `scripts-user` (once-per-instance),
268 // and is skipped on any subsequent boot — so it must NOT carry the work
269 // that can fail transiently (bun install over the network, S3 sync). It
270 // only writes the setup script + unit and enables the service. The systemd
271 // service (Restart=always) runs the setup on every start, so a flaky bun
272 // install / S3 read self-heals and the service survives reboots.
273 return `#!/bin/bash
274set -uo pipefail
275
276mkdir -p "${appDir}"
277
278cat >/usr/local/bin/${unitName}-setup.sh <<'SETUP_EOF'
279#!/bin/bash
280set -uo pipefail
281export HOME=/root
282
283# unzip (needed below) — install if missing.
284command -v unzip >/dev/null 2>&1 || {
285 (command -v dnf >/dev/null 2>&1 && dnf install -y unzip) \
286 || (command -v yum >/dev/null 2>&1 && yum install -y unzip) \
287 || (command -v apt-get >/dev/null 2>&1 && apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y unzip) || true
288}
289
290# AWS CLI — preinstalled on Amazon Linux 2023; install v2 otherwise.
291command -v aws >/dev/null 2>&1 || {
292 curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-$(uname -m).zip" -o /tmp/awscliv2.zip \
293 && (cd /tmp && unzip -q -o awscliv2.zip && ./aws/install) || true
294}
295
296# bun — retry the network install a few times.
297if [ ! -x /root/.bun/bin/bun ]; then
298 for attempt in 1 2 3 4 5; do
299 curl -fsSL https://bun.sh/install | bash && break
300 sleep 5
301 done
302fi
303
304# Sync the bundle + env from S3 (must succeed for the service to start).
305set -e
306mkdir -p "${appDir}"
307aws s3 cp "s3://${bucket}/${bundleKey}" "${appDir}/bundle.zip" --region "${region}"
308aws s3 cp "s3://${bucket}/${envKey}" "${appDir}/env" --region "${region}"
309rm -f "${appDir}/index.mjs"
310unzip -o "${appDir}/bundle.zip" -d "${appDir}"
311SETUP_EOF
312chmod +x /usr/local/bin/${unitName}-setup.sh
313
314cat >/etc/systemd/system/${unitName}.service <<'UNIT_EOF'
315[Unit]
316Description=Alchemy EC2 instance runtime ${unitName}
317After=network-online.target
318Wants=network-online.target
319
320[Service]
321Type=simple
322WorkingDirectory=${appDir}
323ExecStartPre=/usr/local/bin/${unitName}-setup.sh
324EnvironmentFile=-${appDir}/env
325# --no-install: the uploaded bundle is self-contained; bun must never fall
326# into its auto-install path (which hangs startup on network package
327# resolution) — fail fast if the bundle is incomplete instead.
328ExecStart=/root/.bun/bin/bun --no-install ${appDir}/index.mjs
329Restart=always
330RestartSec=5
331
332[Install]
333WantedBy=multi-user.target
334UNIT_EOF
335
336systemctl daemon-reload
337systemctl enable --now ${unitName}.service
338`;
339 });
340
341 const mergeUserData = (hosted: string, userData?: string) => {
342 if (!userData) {
343 return hosted;
344 }
345 return `${hosted}\n\n# User supplied bootstrap\n${userData.replace(
346 /^#!\/bin\/bash\s*/,
347 "",
348 )}`;
349 };
350
351 const listAttachedPolicyArns = (roleName: string) =>
352 iam
353 .listAttachedRolePolicies({
354 RoleName: roleName,
355 })
356 .pipe(
357 Effect.map((result) =>
358 (result.AttachedPolicies ?? [])
359 .map((policy) => policy.PolicyArn)
360 .filter((policyArn): policyArn is string => Boolean(policyArn)),
361 ),
362 );
363
364 const attachManagedPolicies = Effect.fn(function* ({
365 roleName,
366 managedPolicyArns,
367 }: {
368 roleName: string;
369 managedPolicyArns: string[];
370 }) {
371 const attached = new Set(yield* listAttachedPolicyArns(roleName));
372 for (const policyArn of managedPolicyArns) {
373 if (!attached.has(policyArn)) {
374 yield* iam.attachRolePolicy({
375 RoleName: roleName,
376 PolicyArn: policyArn,
377 });
378 }
379 }
380 });
381
382 const ensureManagedRole = Effect.fn(function* ({
383 id,
384 roleName,
385 managedPolicyArns,
386 }: {
387 id: string;
388 roleName: string;
389 managedPolicyArns: string[];
390 }) {
391 const { accountId } = yield* AWSEnvironment.current;
392 const tags = yield* createInternalTags(id);
393 const role = yield* iam
394 .createRole({
395 RoleName: roleName,
396 AssumeRolePolicyDocument: JSON.stringify({
397 Version: "2012-10-17",
398 Statement: [
399 {
400 Effect: "Allow",
401 Principal: {
402 Service: "ec2.amazonaws.com",
403 },
404 Action: "sts:AssumeRole",
405 },
406 ],
407 }),
408 Tags: createTagsList(tags),
409 })
410 .pipe(
411 Effect.catchTag("EntityAlreadyExistsException", () =>
412 iam.getRole({ RoleName: roleName }).pipe(
413 Effect.filterOrFail(
414 (existing) => hasTags(tags, existing.Role?.Tags),
415 () =>
416 new Error(
417 `Role '${roleName}' already exists and is not managed by alchemy`,
418 ),
419 ),
420 ),
421 ),
422 );
423
424 yield* attachManagedPolicies({
425 roleName,
426 managedPolicyArns,
427 });
428
429 return role.Role?.Arn ?? `arn:aws:iam::${accountId}:role/${roleName}`;
430 });
431
432 const ensureManagedInstanceProfile = Effect.fn(function* ({
433 id,
434 profileName,
435 roleName,
436 }: {
437 id: string;
438 profileName: string;
439 roleName: string;
440 }) {
441 const tags = yield* createInternalTags(id);
442 yield* iam
443 .createInstanceProfile({
444 InstanceProfileName: profileName,
445 Tags: createTagsList(tags),
446 })
447 .pipe(Effect.catchTag("EntityAlreadyExistsException", () => Effect.void));
448
449 const profile = yield* iam.getInstanceProfile({
450 InstanceProfileName: profileName,
451 });
452 const currentRoleName = profile.InstanceProfile.Roles?.[0]?.RoleName;
453
454 if (currentRoleName && currentRoleName !== roleName) {
455 yield* iam
456 .removeRoleFromInstanceProfile({
457 InstanceProfileName: profileName,
458 RoleName: currentRoleName,
459 })
460 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
461 }
462
463 if (currentRoleName !== roleName) {
464 yield* iam.addRoleToInstanceProfile({
465 InstanceProfileName: profileName,
466 RoleName: roleName,
467 });
468 }
469
470 const refreshed = yield* iam.getInstanceProfile({
471 InstanceProfileName: profileName,
472 });
473 return {
474 instanceProfileName: refreshed.InstanceProfile.InstanceProfileName,
475 instanceProfileArn: refreshed.InstanceProfile.Arn,
476 };
477 });
478
479 const attachHostedBindings = Effect.fn(function* ({
480 roleName,
481 policyName,
482 assetPrefix,
483 bindings,
484 }: {
485 roleName: string;
486 policyName: string;
487 assetPrefix: string;
488 bindings: ResourceBinding<Ec2HostedBinding>[];
489 }) {
490 const activeBindings = bindings.filter(
491 (binding: ResourceBinding<Ec2HostedBinding> & { action?: string }) =>
492 binding.action !== "delete",
493 );
494
495 const env = activeBindings
496 .map((binding) => binding?.data?.env)
497 .reduce((acc, value) => ({ ...acc, ...value }), {});
498
499 const policyStatements = activeBindings.flatMap(
500 (binding) =>
501 binding?.data?.policyStatements?.map((statement) => ({
502 ...statement,
503 Sid: statement.Sid?.replace(/[^A-Za-z0-9]+/gi, ""),
504 })) ?? [],
505 );
506
507 policyStatements.push({
508 Sid: undefined,
509 Effect: "Allow",
510 Action: ["s3:GetObject"],
511 Resource: [`arn:aws:s3:::${yield* Assets.BucketName}/${assetPrefix}/*`],
512 });
513
514 yield* iam.putRolePolicy({
515 RoleName: roleName,
516 PolicyName: policyName,
517 PolicyDocument: JSON.stringify({
518 Version: "2012-10-17",
519 Statement: policyStatements,
520 }),
521 });
522
523 return env;
524 });
525
526 const uploadHostedArtifacts = Effect.fn(function* ({
527 bundleKey,
528 envKey,
529 archive,
530 env,
531 }: {
532 bundleKey: string;
533 envKey: string;
534 archive: Uint8Array<ArrayBufferLike>;
535 env: Record<string, any>;
536 }) {
537 const assets = yield* Assets;
538 const contentHash = yield* sha256(archive);
539 const uploadedAssetKey = yield* assets.uploadAsset(contentHash, archive);
540 yield* s3.copyObject({
541 Bucket: yield* assets.bucketName,
542 Key: bundleKey,
543 CopySource: `${yield* assets.bucketName}/${uploadedAssetKey}`,
544 });
545 yield* s3.putObject({
546 Bucket: yield* assets.bucketName,
547 Key: envKey,
548 Body: renderEnvFile(env),
549 ContentType: "text/plain; charset=utf-8",
550 });
551 });
552
553 const resolveHostedRuntime = Effect.fn(function* ({
554 id,
555 news,
556 bindings,
557 output,
558 }: {
559 id: string;
560 news: Ec2HostedProps;
561 bindings: ResourceBinding<Ec2HostedBinding>[];
562 output?: Ec2HostedRuntimeState;
563 }) {
564 if (!news.main) {
565 return {
566 userData: news.userData,
567 roleName: output?.roleName,
568 roleArn: output?.roleArn,
569 policyName: output?.policyName,
570 instanceProfileName:
571 news.instanceProfileName ?? output?.instanceProfileName,
572 instanceProfileArn: output?.instanceProfileArn,
573 managedIam: output?.managedIam ?? false,
574 runtimeUnitName: output?.runtimeUnitName,
575 assetPrefix: output?.assetPrefix,
576 code: output?.code,
577 } satisfies Ec2HostedRuntimeState;
578 }
579
580 if (
581 news.instanceProfileName &&
582 (news.roleManagedPolicyArns?.length ?? 0) > 0
583 ) {
584 return yield* Effect.fail(
585 new Error(
586 `${resourceType} does not support roleManagedPolicyArns with a custom instanceProfileName in host mode`,
587 ),
588 );
589 }
590
591 const { region } = yield* AWSEnvironment.current;
592 const runtimeUnitName =
593 output?.runtimeUnitName ?? (yield* createRuntimeUnitName(id));
594 const assetPrefix = output?.assetPrefix ?? `ec2/${runtimeUnitName}`;
595 const bundleKey = `${assetPrefix}/bundle.zip`;
596 const envKey = `${assetPrefix}/env`;
597 const policyName = output?.policyName ?? (yield* createPolicyName(id));
598
599 const managedIam = !news.instanceProfileName;
600 let roleName: string;
601 let roleArn: string | undefined;
602 let instanceProfileName: string | undefined;
603 let instanceProfileArn: string | undefined;
604
605 if (managedIam) {
606 roleName = output?.roleName ?? (yield* createRoleName(id));
607 roleArn =
608 output?.roleArn ??
609 (yield* ensureManagedRole({
610 id,
611 roleName,
612 managedPolicyArns: news.roleManagedPolicyArns ?? [],
613 }));
614 const profileName =
615 output?.instanceProfileName ?? (yield* createManagedProfileName(id));
616 const profile = yield* ensureManagedInstanceProfile({
617 id,
618 profileName,
619 roleName,
620 });
621 instanceProfileName = profile.instanceProfileName;
622 instanceProfileArn = profile.instanceProfileArn;
623 } else {
624 const profile = yield* iam.getInstanceProfile({
625 InstanceProfileName: news.instanceProfileName!,
626 });
627 const role = profile.InstanceProfile.Roles?.[0];
628 if (!role?.RoleName) {
629 return yield* Effect.fail(
630 new Error(
631 `Instance profile '${news.instanceProfileName}' must have a role attached for host mode`,
632 ),
633 );
634 }
635 roleName = role.RoleName;
636 roleArn = role.Arn;
637 instanceProfileName = profile.InstanceProfile.InstanceProfileName;
638 instanceProfileArn = profile.InstanceProfile.Arn;
639 }
640
641 const bindingEnv = yield* attachHostedBindings({
642 roleName,
643 policyName,
644 assetPrefix,
645 bindings,
646 });
647 const env = {
648 ...bindingEnv,
649 ...alchemyEnv,
650 // Lambda injects AWS_REGION natively; an EC2 systemd service does not
651 // get one, and the runtime's `Region.fromEnv()` (and any composition
652 // code that reads the region, e.g. EC2.Network's runtime AZ branch)
653 // dies without it.
654 AWS_REGION: region,
655 ...(news.port !== undefined ? { PORT: news.port } : {}),
656 ...news.env,
657 };
658
659 const { archive, hash } = yield* bundleProgram(id, news);
660 yield* uploadHostedArtifacts({
661 bundleKey,
662 envKey,
663 archive,
664 env,
665 });
666
667 const hostedUserData = yield* renderHostedUserData({
668 unitName: runtimeUnitName,
669 bundleKey,
670 envKey,
671 region,
672 });
673
674 return {
675 userData: mergeUserData(hostedUserData, news.userData),
676 roleName,
677 roleArn,
678 policyName,
679 instanceProfileName,
680 instanceProfileArn,
681 managedIam,
682 runtimeUnitName,
683 assetPrefix,
684 code: {
685 hash,
686 },
687 } satisfies Ec2HostedRuntimeState;
688 });
689
690 const cleanupHostedRuntime = Effect.fn(function* ({
691 output,
692 session,
693 }: {
694 output: Ec2HostedCleanupState;
695 session?: Pick<ScopedPlanStatusSession, "note">;
696 }) {
697 if (output.roleName && output.policyName) {
698 yield* iam
699 .deleteRolePolicy({
700 RoleName: output.roleName,
701 PolicyName: output.policyName,
702 })
703 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
704 }
705
706 if (output.managedIam && output.instanceProfileName && output.roleName) {
707 const attachedPolicyArns = yield* listAttachedPolicyArns(
708 output.roleName,
709 ).pipe(Effect.catch(() => Effect.succeed([])));
710 yield* iam
711 .removeRoleFromInstanceProfile({
712 InstanceProfileName: output.instanceProfileName,
713 RoleName: output.roleName,
714 })
715 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
716 yield* iam
717 .deleteInstanceProfile({
718 InstanceProfileName: output.instanceProfileName,
719 })
720 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
721 for (const policyArn of attachedPolicyArns) {
722 yield* iam
723 .detachRolePolicy({
724 RoleName: output.roleName,
725 PolicyArn: policyArn,
726 })
727 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
728 }
729 yield* iam
730 .deleteRole({
731 RoleName: output.roleName,
732 })
733 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
734 }
735
736 if (output.assetPrefix) {
737 for (const key of [
738 `${output.assetPrefix}/bundle.zip`,
739 `${output.assetPrefix}/env`,
740 ]) {
741 yield* s3
742 .deleteObject({
743 Bucket: yield* Assets.BucketName,
744 Key: key,
745 })
746 .pipe(Effect.catchTag("NoSuchKey", () => Effect.void));
747 }
748 }
749
750 if (session) {
751 yield* session.note(`Cleaned hosted assets for ${resourceType}`);
752 }
753 });
754
755 const buildEc2NetworkData = ({
756 subnetId,
757 securityGroupIds,
758 associatePublicIpAddress,
759 privateIpAddress,
760 }: Pick<
761 Ec2HostedProps,
762 | "subnetId"
763 | "securityGroupIds"
764 | "associatePublicIpAddress"
765 | "privateIpAddress"
766 >) => {
767 const groups = normalizeSecurityGroups(securityGroupIds);
768 const usePrimaryNetworkInterface =
769 subnetId !== undefined ||
770 associatePublicIpAddress !== undefined ||
771 privateIpAddress !== undefined;
772
773 return {
774 usePrimaryNetworkInterface,
775 groups,
776 networkInterfaces: usePrimaryNetworkInterface
777 ? [
778 {
779 DeviceIndex: 0,
780 SubnetId: subnetId,
781 Groups: groups.length > 0 ? groups : undefined,
782 AssociatePublicIpAddress: associatePublicIpAddress,
783 PrivateIpAddress: privateIpAddress,
784 DeleteOnTermination: true,
785 },
786 ]
787 : undefined,
788 };
789 };
790
791 const buildLaunchTemplateData = (
792 news: Pick<
793 Ec2HostedProps,
794 | "imageId"
795 | "instanceType"
796 | "keyName"
797 | "subnetId"
798 | "securityGroupIds"
799 | "associatePublicIpAddress"
800 | "privateIpAddress"
801 | "availabilityZone"
802 | "tags"
803 >,
804 runtime: Pick<Ec2HostedRuntimeState, "userData" | "instanceProfileName">,
805 ) => {
806 const encodedUserData = runtime.userData
807 ? Buffer.from(runtime.userData).toString("base64")
808 : undefined;
809 const network = buildEc2NetworkData(news);
810 const instanceTags = {
811 ...news.tags,
812 };
813
814 return {
815 ImageId: news.imageId,
816 InstanceType: news.instanceType,
817 KeyName: news.keyName as string | undefined,
818 IamInstanceProfile: runtime.instanceProfileName
819 ? {
820 Name: runtime.instanceProfileName,
821 }
822 : undefined,
823 UserData: encodedUserData,
824 Placement: news.availabilityZone
825 ? {
826 AvailabilityZone: news.availabilityZone,
827 }
828 : undefined,
829 NetworkInterfaces: network.networkInterfaces,
830 SubnetId: network.usePrimaryNetworkInterface ? undefined : news.subnetId,
831 SecurityGroupIds: network.usePrimaryNetworkInterface
832 ? undefined
833 : network.groups.length > 0
834 ? network.groups
835 : undefined,
836 PrivateIpAddress: network.usePrimaryNetworkInterface
837 ? undefined
838 : news.privateIpAddress,
839 TagSpecifications:
840 Object.keys(instanceTags).length > 0
841 ? [
842 {
843 ResourceType: "instance",
844 Tags: createTagsList(instanceTags),
845 },
846 ]
847 : undefined,
848 };
849 };
850
851 return {
852 normalizeSecurityGroups,
853 buildLaunchTemplateData,
854 bundleProgram,
855 resolveHostedRuntime,
856 cleanupHostedRuntime,
857 };
858};