1import type { Credentials } from "@distilled.cloud/aws/Credentials";
2import * as ec2 from "@distilled.cloud/aws/ec2";
3import { Region } from "@distilled.cloud/aws/Region";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Schedule from "effect/Schedule";
8import * as Stream from "effect/Stream";
9import * as Bundle from "../../Bundle/Bundle.ts";
10import type { ScopedPlanStatusSession } from "../../Report.ts";
11import { deepEqual, isResolved } from "../../Diff.ts";
12import type { Input } from "../../Input.ts";
13import { Platform, type Main, type PlatformProps } from "../../Platform.ts";
14import * as Provider from "../../Provider.ts";
15import { Resource } from "../../Resource.ts";
16import type { ServerHost } from "../../Server/Process.ts";
17import { Stack } from "../../Stack.ts";
18import { Stage } from "../../Stage.ts";
19import {
20 createAlchemyTagFilters,
21 createInternalTags,
22 diffTags,
23} from "../../Tags.ts";
24import type { AccountID } from "../Environment.ts";
25import { AWSEnvironment } from "../Environment.ts";
26import type { PolicyStatement } from "../IAM/Policy.ts";
27import type { Providers } from "../Providers.ts";
28import type { RegionID } from "../Region.ts";
29import {
30 type Ec2HostedProps,
31 createEc2HostRuntimeContext,
32 createEc2HostedSupport,
33 type Ec2HostRuntimeContext,
34} from "./hosted.ts";
35import type { SecurityGroupId } from "./SecurityGroup.ts";
36import type { SubnetId } from "./Subnet.ts";
37import type { VpcId } from "./Vpc.ts";
38
39export type InstanceId<ID extends string = string> = `i-${ID}`;
40export const InstanceId = <ID extends string>(id: ID): ID & InstanceId<ID> =>
41 `i-${id}` as ID & InstanceId<ID>;
42
43export type InstanceArn<ID extends InstanceId = InstanceId> =
44 `arn:aws:ec2:${RegionID}:${AccountID}:instance/${ID}`;
45
46export const isInstance = (value: any): value is Instance => {
47 return (
48 typeof value === "object" &&
49 value !== null &&
50 "Type" in value &&
51 value.Type === "AWS.EC2.Instance"
52 );
53};
54
55export interface InstanceProps extends PlatformProps {
56 /**
57 * AMI ID to launch.
58 */
59 imageId: string;
60 /**
61 * EC2 instance type, such as `t3.micro`.
62 */
63 instanceType: string;
64 /**
65 * Optional subnet to launch into.
66 */
67 subnetId?: Input<SubnetId>;
68 /**
69 * Security groups to attach to the primary network interface.
70 */
71 securityGroupIds?: Input<SecurityGroupId>[];
72 /**
73 * Optional EC2 key pair name for SSH access. Accepts a reference such as
74 * `AWS.EC2.KeyPair(...).keyName`.
75 */
76 keyName?: Input<string>;
77 /**
78 * Optional IAM instance profile name to attach at launch.
79 */
80 instanceProfileName?: string;
81 /**
82 * User data script to provide at launch time.
83 */
84 userData?: string;
85 /**
86 * Whether to associate a public IPv4 address on launch.
87 */
88 associatePublicIpAddress?: boolean;
89 /**
90 * Optional private IPv4 address for the primary interface.
91 */
92 privateIpAddress?: string;
93 /**
94 * Optional availability zone override.
95 */
96 availabilityZone?: string;
97 /**
98 * Whether source/destination checking is enabled.
99 * @default true
100 */
101 sourceDestCheck?: boolean;
102 /**
103 * User-defined tags to apply to the instance.
104 */
105 tags?: Record<string, string>;
106 /**
107 * Module entrypoint for the bundled instance program.
108 * When omitted, the instance behaves as a low-level EC2 resource.
109 */
110 main?: string;
111 /**
112 * Named export to load from `main`.
113 * @default "default"
114 */
115 handler?: string;
116 /**
117 * Port exposed by the process, if any.
118 * @default 3000
119 */
120 port?: number;
121 /**
122 * Additional environment variables for the hosted process.
123 */
124 env?: Record<string, any>;
125 /**
126 * Bundler configuration for the hosted process entrypoint. Unused
127 * code is tree-shaken. `effect`, alchemy, and `@distilled.cloud` are
128 * marked pure so unused parts prune more aggressively. List extra
129 * packages with `pure.packages`, or disable with `pure: false`.
130 */
131 build?: Bundle.BundleConfig;
132 /**
133 * Additional managed policy ARNs for the managed instance role.
134 * This can only be used when Alchemy manages the instance profile.
135 */
136 roleManagedPolicyArns?: string[];
137}
138
139export interface Instance extends Resource<
140 "AWS.EC2.Instance",
141 InstanceProps,
142 {
143 /**
144 * The ID of the instance.
145 */
146 instanceId: InstanceId;
147 /**
148 * The Amazon Resource Name (ARN) of the instance.
149 */
150 instanceArn: InstanceArn;
151 /**
152 * The AMI ID the instance launched from.
153 */
154 imageId: string;
155 /**
156 * The instance type.
157 */
158 instanceType: string;
159 /**
160 * The current instance state.
161 */
162 state: string;
163 /**
164 * The VPC the instance belongs to, if any.
165 */
166 vpcId?: VpcId;
167 /**
168 * The subnet the instance belongs to, if any.
169 */
170 subnetId?: SubnetId;
171 /**
172 * The availability zone of the instance.
173 */
174 availabilityZone?: string;
175 /**
176 * The attached security group IDs.
177 */
178 securityGroupIds: string[];
179 /**
180 * The primary private IPv4 address.
181 */
182 privateIpAddress?: string;
183 /**
184 * The public IPv4 address, if assigned.
185 */
186 publicIpAddress?: string;
187 /**
188 * The private DNS name.
189 */
190 privateDnsName?: string;
191 /**
192 * The public DNS name, if assigned.
193 */
194 publicDnsName?: string;
195 /**
196 * The key pair name used for SSH access.
197 */
198 keyName?: string;
199 /**
200 * The IAM instance profile ARN attached to the instance, if any.
201 */
202 instanceProfileArn?: string;
203 /**
204 * The IAM instance profile ID attached to the instance, if any.
205 */
206 instanceProfileId?: string;
207 /**
208 * The IAM instance profile name attached to the instance, if known.
209 */
210 instanceProfileName?: string;
211 /**
212 * Whether source/destination checking is enabled.
213 */
214 sourceDestCheck?: boolean;
215 /**
216 * When the instance was launched.
217 */
218 launchTime?: string;
219 /**
220 * Current tags on the instance.
221 */
222 tags: Record<string, string>;
223 /**
224 * Role attached by the hosted runtime, if any.
225 */
226 roleArn?: string;
227 /**
228 * Role name attached by the hosted runtime, if any.
229 */
230 roleName?: string;
231 /**
232 * Inline policy name used for bindings/runtime sync, if any.
233 */
234 policyName?: string;
235 /**
236 * Whether the role/profile were created and owned by Alchemy.
237 */
238 managedIam?: boolean;
239 /**
240 * Deterministic runtime unit name for hosted instances.
241 */
242 runtimeUnitName?: string;
243 /**
244 * Asset prefix for hosted bundles and env files.
245 */
246 assetPrefix?: string;
247 /**
248 * Bundle hash for hosted instances.
249 */
250 code?: {
251 hash: string;
252 };
253 },
254 {
255 /** Environment variables injected into the hosted instance runtime. */
256 env?: Record<string, any>;
257 /** IAM policy statements attached to the instance profile role. */
258 policyStatements?: PolicyStatement[];
259 },
260 Providers
261> {}
262
263export type InstanceServices =
264 | ServerHost
265 | Credentials
266 | Region
267 | AWSEnvironment;
268
269export type InstanceShape = Main<InstanceServices>;
270
271export type InstanceRuntimeContext = Ec2HostRuntimeContext;
272
273/**
274 * An EC2 instance that can either act as a low-level compute primitive or run
275 * a bundled long-lived Effect program directly on the machine.
276 * ### Launching Instances
277 * **Example:** Basic Instance
278 * ```typescript
279 * const instance = yield* AWS.EC2.Instance("AppInstance", {
280 * imageId: AWS.EC2.amazonLinux2023(),
281 * instanceType: "t3.micro",
282 * subnetId: subnet.subnetId,
283 * });
284 * ```
285 *
286 * ### Hosting Processes
287 * **Example:** HTTP Server on an Instance
288 * ```typescript
289 * const api = yield* Effect.gen(function* () {
290 * yield* Http.serve(
291 * HttpServerResponse.json({ ok: true }),
292 * );
293 *
294 * return {
295 * main: import.meta.url,
296 * imageId: AWS.EC2.amazonLinux2023(),
297 * instanceType: "t3.small",
298 * subnetId: subnet.subnetId,
299 * securityGroupIds: [securityGroup.groupId],
300 * associatePublicIpAddress: true,
301 * port: 3000,
302 * };
303 * }).pipe(
304 * Effect.provide(AWS.EC2.HttpServer),
305 * AWS.EC2.Instance("ApiInstance"),
306 * );
307 * ```
308 *
309 * ### Bundling & Tree-shaking
310 * `main` is bundled with rolldown at deploy time. Unused code is
311 * tree-shaken. `effect`, alchemy, and `@distilled.cloud` are marked
312 * pure so unused parts prune more aggressively. Your app is not
313 * marked pure.
314 *
315 * **Example:** Mark additional packages as pure
316 * Only list packages with no top-level side effects.
317 * ```typescript
318 * {
319 * main: import.meta.url,
320 * build: {
321 * pure: { packages: ["my-lib", "@my-scope/*"] },
322 * },
323 * }
324 * ```
325 *
326 * **Example:** Turn it off
327 * ```typescript
328 * {
329 * main: import.meta.url,
330 * build: { pure: false },
331 * }
332 * ```
333 *
334 * @resource
335 */
336export const Instance: Platform<
337 Instance,
338 InstanceServices,
339 InstanceShape,
340 InstanceRuntimeContext
341> = Platform("AWS.EC2.Instance", {
342 createRuntimeContext: createEc2HostRuntimeContext("AWS.EC2.Instance"),
343});
344
345export const InstanceProvider = () =>
346 Provider.effect(
347 Instance,
348 Effect.gen(function* () {
349 const stack = yield* Stack;
350 const stage = yield* Stage;
351 const fs = yield* FileSystem.FileSystem;
352 const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin;
353
354 const toInstanceArn = (instanceId: InstanceId) =>
355 AWSEnvironment.current.pipe(
356 Effect.map(
357 (env) =>
358 `arn:aws:ec2:${env.region}:${env.accountId}:instance/${instanceId}` as InstanceArn,
359 ),
360 );
361
362 const hosted = createEc2HostedSupport({
363 stackName: stack.name,
364 stage,
365 fs,
366 virtualEntryPlugin,
367 resourceType: "EC2.Instance",
368 });
369
370 const isPendingInstanceProfileError = (error: unknown) => {
371 const tag = (error as { _tag?: string })?._tag;
372 if (
373 tag === "InvalidIAMInstanceProfile.NotFound" ||
374 tag === "InvalidParameterValue"
375 ) {
376 return true;
377 }
378 if (tag !== "UnknownAwsError") {
379 return false;
380 }
381 const unknown = error as {
382 errorTag?: string;
383 message?: string;
384 errorData?: {
385 message?: string;
386 Message?: string;
387 };
388 };
389 const message =
390 unknown.message ??
391 unknown.errorData?.message ??
392 unknown.errorData?.Message ??
393 "";
394 return (
395 unknown.errorTag === "InvalidParameterValue" &&
396 message.includes("iamInstanceProfile.name") &&
397 message.includes("Invalid IAM Instance Profile name")
398 );
399 };
400
401 const isPendingInstanceLookupError = (error: unknown) => {
402 const tag = (error as { _tag?: string })?._tag;
403 return (
404 error instanceof InstanceNotFound ||
405 tag === "InvalidInstanceID.NotFound"
406 );
407 };
408
409 const toTagRecord = (tags?: Array<{ Key?: string; Value?: string }>) =>
410 Object.fromEntries(
411 (tags ?? [])
412 .filter((tag): tag is { Key: string; Value: string } =>
413 Boolean(tag.Key && tag.Value !== undefined),
414 )
415 .map((tag) => [tag.Key, tag.Value]),
416 );
417
418 const toAttributes = Effect.fn(function* (instance: ec2.Instance) {
419 return {
420 instanceId: instance.InstanceId as InstanceId,
421 instanceArn: yield* toInstanceArn(instance.InstanceId as InstanceId),
422 imageId: instance.ImageId!,
423 instanceType: String(instance.InstanceType ?? ""),
424 state: instance.State?.Name ?? "unknown",
425 vpcId: instance.VpcId as VpcId | undefined,
426 subnetId: instance.SubnetId as SubnetId | undefined,
427 availabilityZone: instance.Placement?.AvailabilityZone,
428 securityGroupIds: (instance.SecurityGroups ?? [])
429 .map((group) => group.GroupId)
430 .filter((value): value is string => Boolean(value)),
431 privateIpAddress: instance.PrivateIpAddress,
432 publicIpAddress: instance.PublicIpAddress,
433 privateDnsName: instance.PrivateDnsName,
434 publicDnsName: instance.PublicDnsName,
435 keyName: instance.KeyName,
436 instanceProfileArn: instance.IamInstanceProfile?.Arn,
437 instanceProfileId: instance.IamInstanceProfile?.Id,
438 instanceProfileName: undefined,
439 sourceDestCheck: instance.SourceDestCheck,
440 launchTime:
441 instance.LaunchTime instanceof Date
442 ? instance.LaunchTime.toISOString()
443 : (instance.LaunchTime as string | undefined),
444 tags: toTagRecord(instance.Tags),
445 } satisfies Instance["Attributes"];
446 });
447
448 const describeInstance = (instanceId: string) =>
449 ec2
450 .describeInstances({
451 InstanceIds: [instanceId],
452 })
453 .pipe(
454 Effect.map(
455 (result) =>
456 (result.Reservations ?? []).flatMap(
457 (reservation) => reservation.Instances ?? [],
458 )[0],
459 ),
460 Effect.flatMap((instance) =>
461 instance
462 ? Effect.succeed(instance)
463 : Effect.fail(new InstanceNotFound({ instanceId })),
464 ),
465 );
466
467 // Generation-scoped recovery lookup: instances are branded with the
468 // engine's per-generation instance id (`alchemy::instance`) on top of
469 // the stack/stage/id ownership tags. An interrupted create resumes with
470 // the same generation id, so it still recovers its own orphan — while a
471 // replacement's create phase runs under a freshly minted generation id
472 // and can never re-adopt the old generation's live instance that the
473 // cleanup phase is about to terminate.
474 const findInstanceByTags = Effect.fn(function* (
475 id: string,
476 generation: string,
477 ) {
478 const filters = [
479 ...(yield* createAlchemyTagFilters(id)),
480 { Name: "tag:alchemy::instance", Values: [generation] },
481 ];
482 return yield* ec2.describeInstances
483 .items({
484 Filters: filters,
485 })
486 .pipe(
487 Stream.flatMap((reservation) =>
488 Stream.fromArray(reservation.Instances ?? []),
489 ),
490 Stream.filter((instance) => {
491 const state = instance.State?.Name;
492 return (
493 state === "pending" ||
494 state === "running" ||
495 state === "stopping" ||
496 state === "stopped"
497 );
498 }),
499 Stream.runCollect,
500 Effect.map(
501 (instances) =>
502 [...instances].sort((a, b) => {
503 const aTime =
504 a.LaunchTime instanceof Date
505 ? a.LaunchTime.getTime()
506 : Date.parse(String(a.LaunchTime ?? 0));
507 const bTime =
508 b.LaunchTime instanceof Date
509 ? b.LaunchTime.getTime()
510 : Date.parse(String(b.LaunchTime ?? 0));
511 return bTime - aTime;
512 })[0],
513 ),
514 );
515 });
516
517 const waitForState = Effect.fn(function* ({
518 instanceId,
519 states,
520 session,
521 }: {
522 instanceId: string;
523 states: string[];
524 session: Pick<ScopedPlanStatusSession, "note">;
525 }) {
526 return yield* describeInstance(instanceId).pipe(
527 Effect.tap((instance) =>
528 session.note(
529 `Waiting for instance ${instanceId}: ${instance.State?.Name ?? "unknown"}`,
530 ),
531 ),
532 Effect.filterOrFail(
533 (instance) => states.includes(instance.State?.Name ?? ""),
534 (instance) =>
535 new InstanceStateMismatch({
536 instanceId,
537 actual: instance.State?.Name ?? "unknown",
538 expected: states,
539 }),
540 ),
541 Effect.retry({
542 while: (error) =>
543 error instanceof InstanceStateMismatch ||
544 isPendingInstanceLookupError(error),
545 schedule: Schedule.max([
546 Schedule.exponential("250 millis"),
547 Schedule.recurs(8),
548 ]),
549 }),
550 );
551 });
552
553 const waitForDeleted = Effect.fn(function* ({
554 instanceId,
555 session,
556 }: {
557 instanceId: string;
558 session: Pick<ScopedPlanStatusSession, "note">;
559 }) {
560 yield* describeInstance(instanceId).pipe(
561 Effect.tap((instance) =>
562 session.note(
563 `Waiting for instance deletion ${instanceId}: ${instance.State?.Name ?? "unknown"}`,
564 ),
565 ),
566 Effect.flatMap((instance) =>
567 instance.State?.Name === "terminated"
568 ? Effect.succeed(undefined)
569 : Effect.fail(new InstanceStillExists({ instanceId })),
570 ),
571 Effect.retry({
572 while: (error) => error instanceof InstanceStillExists,
573 // Termination (shutting-down -> terminated) can take a couple of
574 // minutes; the prior ~64s budget timed out intermittently.
575 schedule: Schedule.max([
576 Schedule.spaced("5 seconds"),
577 Schedule.recurs(48),
578 ]),
579 }),
580 Effect.catchTag("InvalidInstanceID.NotFound", () => Effect.void),
581 Effect.catchTag("InstanceNotFound", () => Effect.void),
582 );
583 });
584
585 const resolvedSecurityGroups = (
586 groups?: InstanceProps["securityGroupIds"],
587 ) => hosted.normalizeSecurityGroups(groups as string[] | undefined);
588
589 const buildRunInstancesRequest = (
590 news: InstanceProps,
591 runtime: {
592 userData?: string;
593 instanceProfileName?: string;
594 },
595 tags: Record<string, string>,
596 ): ec2.RunInstancesRequest => {
597 return {
598 ...hosted.buildLaunchTemplateData(
599 {
600 imageId: news.imageId,
601 instanceType: news.instanceType,
602 keyName: news.keyName as string | undefined,
603 subnetId: news.subnetId as string | undefined,
604 securityGroupIds: news.securityGroupIds as string[] | undefined,
605 associatePublicIpAddress: news.associatePublicIpAddress,
606 privateIpAddress: news.privateIpAddress,
607 availabilityZone: news.availabilityZone,
608 tags,
609 },
610 runtime,
611 ),
612 MinCount: 1,
613 MaxCount: 1,
614 };
615 };
616
617 return {
618 stables: ["instanceId", "instanceArn", "vpcId", "subnetId"],
619 list: () =>
620 Effect.gen(function* () {
621 // describeInstances paginates; each page nests instances under
622 // Reservations[].Instances[]. Enumerate every non-terminated
623 // instance in the ambient account/region.
624 const instances = yield* ec2.describeInstances.pages({}).pipe(
625 Stream.runCollect,
626 Effect.map((chunk) =>
627 Array.from(chunk).flatMap((page) =>
628 (page.Reservations ?? []).flatMap(
629 (reservation) => reservation.Instances ?? [],
630 ),
631 ),
632 ),
633 );
634 return yield* Effect.forEach(
635 instances.filter(
636 (instance) => instance.State?.Name !== "terminated",
637 ),
638 (instance) => toAttributes(instance),
639 );
640 }),
641 diff: Effect.fn(function* ({ id, news, olds, output }) {
642 // The hosted bundle hash must participate in planning even while
643 // OTHER props are unresolved Outputs (an `imageId` AMI lookup, a
644 // subnet reference): a content-only edit changes no prop at all,
645 // so bailing on full resolution silently no-ops the update. The
646 // content inputs are plain — gate on THEM, not on the whole bag
647 // (the same isResolved-defeats-content-diff bug the MicroVM image
648 // diff had).
649 const raw = news as unknown as Record<string, unknown>;
650 const contentInputs = {
651 main: raw.main,
652 handler: raw.handler,
653 build: raw.build,
654 port: raw.port,
655 // `isExternal` chooses the bundler entry (raw file vs virtual
656 // `export default` wrapper). Omitting it here re-bundles an
657 // external program as an Effect entrypoint and fails the plan
658 // with MISSING_EXPORT when the source has no default export.
659 isExternal: raw.isExternal,
660 };
661 if (
662 isResolved(contentInputs) &&
663 contentInputs.main !== undefined &&
664 output?.code?.hash
665 ) {
666 const { hash } = yield* hosted.bundleProgram(
667 id,
668 contentInputs as unknown as Ec2HostedProps,
669 );
670 if (hash !== output.code.hash) {
671 return {
672 action: "update",
673 stables: ["instanceId", "instanceArn", "vpcId", "subnetId"],
674 } as const;
675 }
676 }
677 if (!isResolved(news)) return;
678 const reusesFixedPrivateIp =
679 news.privateIpAddress !== undefined &&
680 (output?.privateIpAddress ?? olds.privateIpAddress) ===
681 news.privateIpAddress &&
682 (news.subnetId === undefined ||
683 (output?.subnetId ?? olds.subnetId) === news.subnetId);
684
685 const hostModeChanged = Boolean(olds.main) !== Boolean(news.main);
686 if (
687 hostModeChanged ||
688 olds.imageId !== news.imageId ||
689 olds.subnetId !== news.subnetId ||
690 olds.keyName !== news.keyName ||
691 olds.instanceProfileName !== news.instanceProfileName ||
692 olds.userData !== news.userData ||
693 olds.associatePublicIpAddress !== news.associatePublicIpAddress ||
694 olds.privateIpAddress !== news.privateIpAddress ||
695 olds.availabilityZone !== news.availabilityZone
696 ) {
697 // A primary private IP cannot belong to two instances in the
698 // same subnet. An omitted subnet may still resolve to that subnet.
699 return reusesFixedPrivateIp
700 ? ({ action: "replace", deleteFirst: true } as const)
701 : ({ action: "replace" } as const);
702 }
703
704 if (
705 olds.instanceType !== news.instanceType ||
706 olds.sourceDestCheck !== news.sourceDestCheck ||
707 olds.main !== news.main ||
708 olds.handler !== news.handler ||
709 olds.port !== news.port ||
710 !deepEqual(olds.env ?? {}, news.env ?? {}) ||
711 !deepEqual(olds.build ?? {}, news.build ?? {}) ||
712 !deepEqual(
713 olds.roleManagedPolicyArns ?? [],
714 news.roleManagedPolicyArns ?? [],
715 ) ||
716 !deepEqual(
717 resolvedSecurityGroups(olds.securityGroupIds),
718 resolvedSecurityGroups(news.securityGroupIds),
719 ) ||
720 !deepEqual(olds.tags ?? {}, news.tags ?? {})
721 ) {
722 return {
723 action: "update",
724 stables: ["instanceId", "instanceArn", "vpcId", "subnetId"],
725 } as const;
726 }
727
728 // Content-only changes were already handled by the resolved-subset
729 // bundle-hash check above.
730 }),
731 read: Effect.fn(function* ({ id, instanceId, output }) {
732 const instance = output?.instanceId
733 ? yield* describeInstance(output.instanceId).pipe(
734 Effect.catchTag("InvalidInstanceID.NotFound", () =>
735 Effect.succeed(undefined),
736 ),
737 Effect.catchTag("InstanceNotFound", () =>
738 Effect.succeed(undefined),
739 ),
740 )
741 : yield* findInstanceByTags(id, instanceId);
742 return instance
743 ? {
744 ...(yield* toAttributes(instance)),
745 instanceProfileName: output?.instanceProfileName,
746 roleArn: output?.roleArn,
747 roleName: output?.roleName,
748 policyName: output?.policyName,
749 managedIam: output?.managedIam,
750 runtimeUnitName: output?.runtimeUnitName,
751 assetPrefix: output?.assetPrefix,
752 code: output?.code,
753 }
754 : undefined;
755 }),
756 reconcile: Effect.fn(function* ({
757 id,
758 instanceId: generation,
759 news,
760 output,
761 bindings,
762 session,
763 }) {
764 const desiredTags = {
765 ...(yield* createInternalTags(id)),
766 // Generation brand consumed by the recovery lookup above.
767 "alchemy::instance": generation,
768 ...news.tags,
769 };
770 const runtime = yield* hosted.resolveHostedRuntime({
771 id,
772 news,
773 bindings,
774 output,
775 });
776
777 // Observe — try the cached instanceId first, then fall back to the
778 // alchemy-tag search for recovery from interrupted creates. EC2
779 // assigns instance IDs server-side so we always read the live
780 // record before deciding whether to launch a new one.
781 let instance: ec2.Instance | undefined = output?.instanceId
782 ? yield* describeInstance(output.instanceId).pipe(
783 Effect.catchTag("InvalidInstanceID.NotFound", () =>
784 Effect.succeed(undefined),
785 ),
786 Effect.catchTag("InstanceNotFound", () =>
787 Effect.succeed(undefined),
788 ),
789 )
790 : yield* findInstanceByTags(id, generation);
791
792 // A terminated instance is a tombstone; treat as missing so we
793 // launch a fresh one.
794 if (instance && instance.State?.Name === "terminated") {
795 instance = undefined;
796 }
797
798 // Ensure — RunInstances when no live instance was observed. The
799 // initial sourceDestCheck is set here because RunInstances itself
800 // doesn't accept it.
801 if (instance === undefined) {
802 const created = yield* ec2
803 .runInstances(
804 buildRunInstancesRequest(news, runtime, desiredTags),
805 )
806 .pipe(
807 Effect.retry({
808 while: isPendingInstanceProfileError,
809 schedule: Schedule.max([
810 Schedule.exponential("500 millis"),
811 Schedule.recurs(8),
812 ]),
813 }),
814 );
815 const newInstanceId = created.Instances?.[0]?.InstanceId as
816 | InstanceId
817 | undefined;
818 if (!newInstanceId) {
819 return yield* Effect.fail(
820 new Error(`RunInstances returned no instance ID for '${id}'`),
821 );
822 }
823 yield* session.note(newInstanceId);
824 instance = yield* waitForState({
825 instanceId: newInstanceId,
826 states: ["running"],
827 session,
828 });
829 if (news.sourceDestCheck !== undefined) {
830 yield* ec2.modifyInstanceAttribute({
831 InstanceId: newInstanceId,
832 SourceDestCheck: { Value: news.sourceDestCheck },
833 });
834 }
835 }
836
837 const instanceId = instance.InstanceId as InstanceId;
838 let restarted = false;
839
840 // Sync security groups — observed primary-ENI groups vs desired.
841 const observedSecurityGroups = (instance.SecurityGroups ?? [])
842 .map((g) => g.GroupId)
843 .filter((g): g is string => Boolean(g));
844 const desiredSecurityGroups = resolvedSecurityGroups(
845 news.securityGroupIds,
846 );
847 if (
848 desiredSecurityGroups &&
849 desiredSecurityGroups.length > 0 &&
850 JSON.stringify([...observedSecurityGroups].sort()) !==
851 JSON.stringify([...desiredSecurityGroups].sort())
852 ) {
853 yield* ec2.modifyInstanceAttribute({
854 InstanceId: instanceId,
855 Groups: desiredSecurityGroups,
856 });
857 }
858
859 // Sync source/dest check — observed flag vs desired (default true).
860 const desiredSourceDestCheck = news.sourceDestCheck ?? true;
861 if ((instance.SourceDestCheck ?? true) !== desiredSourceDestCheck) {
862 yield* ec2.modifyInstanceAttribute({
863 InstanceId: instanceId,
864 SourceDestCheck: { Value: desiredSourceDestCheck },
865 });
866 }
867
868 // Sync instance type — observed type vs desired. Type changes need
869 // the instance stopped, then we restart it if it was running.
870 if (
871 news.instanceType &&
872 String(instance.InstanceType ?? "") !== news.instanceType
873 ) {
874 const wasRunning = instance.State?.Name === "running";
875 if (wasRunning) {
876 yield* ec2.stopInstances({ InstanceIds: [instanceId] });
877 yield* waitForState({
878 instanceId,
879 states: ["stopped"],
880 session,
881 });
882 }
883 yield* ec2.modifyInstanceAttribute({
884 InstanceId: instanceId,
885 InstanceType: {
886 Value: news.instanceType as ec2.InstanceType,
887 },
888 });
889 if (wasRunning) {
890 yield* ec2.startInstances({ InstanceIds: [instanceId] });
891 yield* waitForState({
892 instanceId,
893 states: ["running"],
894 session,
895 });
896 restarted = true;
897 }
898 }
899
900 // Sync tags — observed cloud tags vs desired.
901 const currentTags = Object.fromEntries(
902 (instance.Tags ?? [])
903 .filter((t): t is { Key: string; Value: string } =>
904 Boolean(t.Key && t.Value !== undefined),
905 )
906 .map((t) => [t.Key, t.Value]),
907 ) as Record<string, string>;
908 const { removed, upsert } = diffTags(currentTags, desiredTags);
909 if (removed.length > 0) {
910 yield* ec2.deleteTags({
911 Resources: [instanceId],
912 Tags: removed.map((key) => ({ Key: key })),
913 });
914 }
915 if (upsert.length > 0) {
916 yield* ec2.createTags({
917 Resources: [instanceId],
918 Tags: upsert,
919 });
920 }
921
922 // Hosted-runtime reboot — picks up new bundle/env without an
923 // explicit instance-type cycle. Skip if the type-change path
924 // already restarted us.
925 if (news.main && !restarted) {
926 yield* ec2.rebootInstances({ InstanceIds: [instanceId] });
927 yield* waitForState({
928 instanceId,
929 states: ["running"],
930 session,
931 });
932 }
933
934 // Re-read final state so attributes reflect the post-sync cloud.
935 const final = yield* describeInstance(instanceId);
936 return {
937 ...(yield* toAttributes(final)),
938 instanceProfileName:
939 runtime.instanceProfileName ?? output?.instanceProfileName,
940 roleArn: runtime.roleArn ?? output?.roleArn,
941 roleName: runtime.roleName ?? output?.roleName,
942 policyName: runtime.policyName ?? output?.policyName,
943 managedIam: runtime.managedIam ?? output?.managedIam,
944 runtimeUnitName: runtime.runtimeUnitName ?? output?.runtimeUnitName,
945 assetPrefix: runtime.assetPrefix ?? output?.assetPrefix,
946 code: runtime.code ?? output?.code,
947 };
948 }),
949 delete: Effect.fn(function* ({ output, session }) {
950 yield* ec2
951 .terminateInstances({
952 InstanceIds: [output.instanceId],
953 })
954 .pipe(
955 Effect.catchTag("InvalidInstanceID.NotFound", () => Effect.void),
956 );
957 yield* waitForDeleted({
958 instanceId: output.instanceId,
959 session,
960 });
961
962 yield* hosted.cleanupHostedRuntime({ output });
963 }),
964 };
965 }),
966 );
967
968class InstanceNotFound extends Data.TaggedError("InstanceNotFound")<{
969 instanceId: string;
970}> {}
971
972class InstanceStillExists extends Data.TaggedError("InstanceStillExists")<{
973 instanceId: string;
974}> {}
975
976class InstanceStateMismatch extends Data.TaggedError("InstanceStateMismatch")<{
977 instanceId: string;
978 actual: string;
979 expected: string[];
980}> {}