| 1 | import type { Credentials } from "@distilled.cloud/aws/Credentials"; |
| 2 | import * as ec2 from "@distilled.cloud/aws/ec2"; |
| 3 | import { Region } from "@distilled.cloud/aws/Region"; |
| 4 | import * as Data from "effect/Data"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as FileSystem from "effect/FileSystem"; |
| 7 | import * as Schedule from "effect/Schedule"; |
| 8 | import * as Stream from "effect/Stream"; |
| 9 | import * as Bundle from "../../Bundle/Bundle.ts"; |
| 10 | import type { ScopedPlanStatusSession } from "../../Report.ts"; |
| 11 | import { deepEqual, isResolved } from "../../Diff.ts"; |
| 12 | import type { Input } from "../../Input.ts"; |
| 13 | import { Platform, type Main, type PlatformProps } from "../../Platform.ts"; |
| 14 | import * as Provider from "../../Provider.ts"; |
| 15 | import { Resource } from "../../Resource.ts"; |
| 16 | import type { ServerHost } from "../../Server/Process.ts"; |
| 17 | import { Stack } from "../../Stack.ts"; |
| 18 | import { Stage } from "../../Stage.ts"; |
| 19 | import { |
| 20 | createAlchemyTagFilters, |
| 21 | createInternalTags, |
| 22 | diffTags, |
| 23 | } from "../../Tags.ts"; |
| 24 | import type { AccountID } from "../Environment.ts"; |
| 25 | import { AWSEnvironment } from "../Environment.ts"; |
| 26 | import type { PolicyStatement } from "../IAM/Policy.ts"; |
| 27 | import type { Providers } from "../Providers.ts"; |
| 28 | import type { RegionID } from "../Region.ts"; |
| 29 | import { |
| 30 | type Ec2HostedProps, |
| 31 | createEc2HostRuntimeContext, |
| 32 | createEc2HostedSupport, |
| 33 | type Ec2HostRuntimeContext, |
| 34 | } from "./hosted.ts"; |
| 35 | import type { SecurityGroupId } from "./SecurityGroup.ts"; |
| 36 | import type { SubnetId } from "./Subnet.ts"; |
| 37 | import type { VpcId } from "./Vpc.ts"; |
| 38 | |
| 39 | export type InstanceId<ID extends string = string> = `i-${ID}`; |
| 40 | export const InstanceId = <ID extends string>(id: ID): ID & InstanceId<ID> => |
| 41 | `i-${id}` as ID & InstanceId<ID>; |
| 42 | |
| 43 | export type InstanceArn<ID extends InstanceId = InstanceId> = |
| 44 | `arn:aws:ec2:${RegionID}:${AccountID}:instance/${ID}`; |
| 45 | |
| 46 | export 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 | |
| 55 | export 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 | |
| 139 | export 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 | |
| 263 | export type InstanceServices = |
| 264 | | ServerHost |
| 265 | | Credentials |
| 266 | | Region |
| 267 | | AWSEnvironment; |
| 268 | |
| 269 | export type InstanceShape = Main<InstanceServices>; |
| 270 | |
| 271 | export 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 | */ |
| 336 | export const Instance: Platform< |
| 337 | Instance, |
| 338 | InstanceServices, |
| 339 | InstanceShape, |
| 340 | InstanceRuntimeContext |
| 341 | > = Platform("AWS.EC2.Instance", { |
| 342 | createRuntimeContext: createEc2HostRuntimeContext("AWS.EC2.Instance"), |
| 343 | }); |
| 344 | |
| 345 | export 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 | |
| 968 | class InstanceNotFound extends Data.TaggedError("InstanceNotFound")<{ |
| 969 | instanceId: string; |
| 970 | }> {} |
| 971 | |
| 972 | class InstanceStillExists extends Data.TaggedError("InstanceStillExists")<{ |
| 973 | instanceId: string; |
| 974 | }> {} |
| 975 | |
| 976 | class InstanceStateMismatch extends Data.TaggedError("InstanceStateMismatch")<{ |
| 977 | instanceId: string; |
| 978 | actual: string; |
| 979 | expected: string[]; |
| 980 | }> {} |