diff --git a/packages/alchemy/src/AWS/EC2/Image.ts b/packages/alchemy/src/AWS/EC2/Image.ts index 30473d5268..4d37782762 100644 --- a/packages/alchemy/src/AWS/EC2/Image.ts +++ b/packages/alchemy/src/AWS/EC2/Image.ts @@ -66,7 +66,10 @@ export const amazonLinux2023 = (options?: { }) => findLatestImage({ owners: ["amazon"], - name: ["al2023-ami-*-*"], + // `al2023-ami-2023.*` selects the standard image. The broader + // `al2023-ami-*` also matches `al2023-ami-minimal-*`, which ships without + // the SSM agent and a stripped toolset and frequently sorts newest. + name: ["al2023-ami-2023.*"], architecture: options?.architecture, description: "Amazon Linux 2023", }); diff --git a/packages/alchemy/src/AWS/EC2/Instance.ts b/packages/alchemy/src/AWS/EC2/Instance.ts index 0f49f4685f..a3bdfcf330 100644 --- a/packages/alchemy/src/AWS/EC2/Instance.ts +++ b/packages/alchemy/src/AWS/EC2/Instance.ts @@ -70,9 +70,10 @@ export interface InstanceProps extends PlatformProps { */ securityGroupIds?: Input[]; /** - * Optional EC2 key pair name for SSH access. + * Optional EC2 key pair name for SSH access. Accepts a reference such as + * `AWS.EC2.KeyPair(...).keyName`. */ - keyName?: string; + keyName?: Input; /** * Optional IAM instance profile name to attach at launch. */ @@ -527,8 +528,10 @@ export const InstanceProvider = () => ), Effect.retry({ while: (error) => error instanceof InstanceStillExists, - schedule: Schedule.exponential("250 millis").pipe( - Schedule.both(Schedule.recurs(8)), + // Termination (shutting-down -> terminated) can take a couple of + // minutes; the prior ~64s budget timed out intermittently. + schedule: Schedule.spaced("5 seconds").pipe( + Schedule.both(Schedule.recurs(48)), ), }), Effect.catchTag("InvalidInstanceID.NotFound", () => Effect.void), @@ -553,7 +556,7 @@ export const InstanceProvider = () => { imageId: news.imageId, instanceType: news.instanceType, - keyName: news.keyName, + keyName: news.keyName as string | undefined, subnetId: news.subnetId as string | undefined, securityGroupIds: news.securityGroupIds as string[] | undefined, associatePublicIpAddress: news.associatePublicIpAddress, diff --git a/packages/alchemy/src/AWS/EC2/KeyPair.ts b/packages/alchemy/src/AWS/EC2/KeyPair.ts new file mode 100644 index 0000000000..ff226ca807 --- /dev/null +++ b/packages/alchemy/src/AWS/EC2/KeyPair.ts @@ -0,0 +1,319 @@ +import * as ec2 from "@distilled.cloud/aws/ec2"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import { Unowned } from "../../AdoptPolicy.ts"; +import { isResolved } from "../../Diff.ts"; +import { createPhysicalName } from "../../PhysicalName.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import { + createInternalTags, + createTagsList, + diffTags, + hasTags, +} from "../../Tags.ts"; +import type { Providers } from "../Providers.ts"; + +export type KeyPairId = `key-${ID}`; + +/** Key algorithm for a generated EC2 key pair. */ +export type KeyPairType = "rsa" | "ed25519"; + +/** Private-key file format for a generated EC2 key pair. */ +export type KeyPairFormat = "pem" | "ppk"; + +export interface KeyPairProps { + /** + * Name of the key pair. If omitted, a unique name is generated from the + * stack, stage, and logical id. Changing it replaces the key pair. + */ + keyName?: string; + /** + * Algorithm used when Alchemy generates the key pair. Ignored when + * {@link publicKeyMaterial} is supplied (an imported key keeps its own type). + * Changing it replaces the key pair. + * @default "rsa" + */ + keyType?: KeyPairType; + /** + * Format of the returned private key material. Only meaningful when Alchemy + * generates the key pair. Changing it replaces the key pair. + * @default "pem" + */ + keyFormat?: KeyPairFormat; + /** + * Public key material (PEM or OpenSSH) to import instead of generating a new + * key pair. When set, AWS stores only the public key — no `privateKey` is + * returned. Changing it replaces the key pair. + */ + publicKeyMaterial?: string; + /** + * Tags to assign to the key pair. Merged with the alchemy auto-tags. + */ + tags?: Record; +} + +export interface KeyPair extends Resource< + "AWS.EC2.KeyPair", + KeyPairProps, + { + /** The ID of the key pair (e.g. `key-0123456789abcdef0`). */ + keyPairId: KeyPairId; + /** The name of the key pair. */ + keyName: string; + /** SHA-1/MD5 fingerprint of the key pair. */ + keyFingerprint: string; + /** The algorithm of the key pair. */ + keyType: KeyPairType; + /** + * The unencrypted PEM/PPK private key material. Only present when Alchemy + * generated the key pair (not when {@link KeyPairProps.publicKeyMaterial} + * was imported). AWS returns this exactly once, at create time; it is then + * persisted as a secret in alchemy state. + */ + privateKey?: Redacted.Redacted; + }, + never, + Providers +> {} + +/** + * An EC2 key pair used to grant SSH access to instances launched with its + * `keyName`. + * + * By default Alchemy asks EC2 to generate the key pair and captures the + * private key (returned only once, at create time) as a secret in state. Pass + * {@link KeyPairProps.publicKeyMaterial} to import your own public key instead, + * in which case no private key is stored. + * + * @resource + * @section Creating a Key Pair + * @example Generated key pair + * ```typescript + * const keyPair = yield* AWS.EC2.KeyPair("DeployKey", { + * keyType: "ed25519", + * }); + * // keyPair.keyName -> pass to AWS.EC2.Instance({ keyName }) + * // keyPair.privateKey -> Redacted (the PEM private key) + * ``` + * + * @example Imported public key + * ```typescript + * const keyPair = yield* AWS.EC2.KeyPair("ImportedKey", { + * publicKeyMaterial: "ssh-ed25519 AAAAC3Nz... user@host", + * }); + * ``` + */ +export const KeyPair = Resource("AWS.EC2.KeyPair"); + +const toKeyName = (id: string, props: { keyName?: string } = {}) => + props.keyName + ? Effect.succeed(props.keyName) + : createPhysicalName({ id, maxLength: 255 }); + +const asRedacted = ( + material: string | Redacted.Redacted | undefined, +): Redacted.Redacted | undefined => + material === undefined + ? undefined + : Redacted.isRedacted(material) + ? material + : Redacted.make(material); + +export const KeyPairProvider = () => + Provider.effect( + KeyPair, + Effect.gen(function* () { + const describeByName = (keyName: string) => + ec2.describeKeyPairs({ KeyNames: [keyName] }).pipe( + Effect.catchTag("InvalidKeyPair.NotFound", () => + Effect.succeed({ KeyPairs: [] }), + ), + Effect.map((r) => r.KeyPairs?.[0]), + ); + + return { + stables: ["keyPairId", "keyName", "keyType"], + + // Generated key pairs are immutable except for tags. A changed name / + // type / format / imported material means a different key pair. + diff: Effect.fn(function* ({ id, olds, news }) { + if (!isResolved(news)) return; + const oldName = yield* toKeyName(id, olds ?? {}); + const newName = yield* toKeyName(id, news); + if ( + oldName !== newName || + (olds?.keyType ?? "rsa") !== (news.keyType ?? "rsa") || + (olds?.keyFormat ?? "pem") !== (news.keyFormat ?? "pem") || + olds?.publicKeyMaterial !== news.publicKeyMaterial + ) { + return { action: "replace" } as const; + } + }), + + read: Effect.fn(function* ({ id, olds, output }) { + const keyName = output?.keyName ?? (yield* toKeyName(id, olds ?? {})); + const info = yield* describeByName(keyName); + if (!info?.KeyPairId) return undefined; + const tags = yield* createInternalTags(id); + const observedTags: Record = + Object.fromEntries( + (info.Tags ?? []).map((t) => [t.Key ?? "", t.Value]), + ); + if (!hasTags(tags, observedTags)) { + // Exists but unbranded — let the engine gate adoption behind --adopt. + return Unowned({ + keyPairId: info.KeyPairId as KeyPairId, + keyName: info.KeyName ?? keyName, + keyFingerprint: info.KeyFingerprint ?? "", + keyType: (info.KeyType ?? "rsa") as KeyPairType, + // Private key is only available at create time; keep any cached + // copy from prior state. + privateKey: output?.privateKey, + }); + } + return { + keyPairId: info.KeyPairId as KeyPairId, + keyName: info.KeyName ?? keyName, + keyFingerprint: info.KeyFingerprint ?? output?.keyFingerprint ?? "", + keyType: (info.KeyType ?? output?.keyType ?? "rsa") as KeyPairType, + privateKey: output?.privateKey, + }; + }), + + list: () => + Effect.gen(function* () { + const result = yield* ec2.describeKeyPairs({}); + return (result.KeyPairs ?? []) + .filter( + ( + kp, + ): kp is ec2.KeyPairInfo & { + KeyPairId: string; + KeyName: string; + } => kp.KeyPairId != null && kp.KeyName != null, + ) + .filter((kp) => + (kp.Tags ?? []).some((t) => t.Key === "alchemy::stack"), + ) + .map((kp) => ({ + keyPairId: kp.KeyPairId as KeyPairId, + keyName: kp.KeyName, + keyFingerprint: kp.KeyFingerprint ?? "", + keyType: (kp.KeyType ?? "rsa") as KeyPairType, + privateKey: undefined, + })); + }), + + reconcile: Effect.fn(function* ({ id, news = {}, output, session }) { + const keyName = output?.keyName ?? (yield* toKeyName(id, news)); + const internalTags = yield* createInternalTags(id); + const desiredTags = { ...internalTags, ...news.tags }; + + // Observe — is the key pair already present? + let info = yield* describeByName(keyName); + let privateKey = output?.privateKey; + + // Ensure — import or generate when missing. + if (!info?.KeyPairId) { + if (news.publicKeyMaterial !== undefined) { + const imported = yield* ec2 + .importKeyPair({ + KeyName: keyName, + PublicKeyMaterial: new TextEncoder().encode( + news.publicKeyMaterial, + ), + TagSpecifications: [ + { + ResourceType: "key-pair", + Tags: createTagsList(desiredTags), + }, + ], + }) + .pipe( + Effect.catchTag("InvalidKeyPair.Duplicate", () => + Effect.succeed(undefined), + ), + ); + if (imported?.KeyPairId) { + info = { + KeyPairId: imported.KeyPairId, + KeyName: imported.KeyName, + KeyFingerprint: imported.KeyFingerprint, + KeyType: "rsa", + }; + } else { + info = yield* describeByName(keyName); + } + } else { + const created = yield* ec2 + .createKeyPair({ + KeyName: keyName, + KeyType: news.keyType ?? "rsa", + KeyFormat: news.keyFormat ?? "pem", + TagSpecifications: [ + { + ResourceType: "key-pair", + Tags: createTagsList(desiredTags), + }, + ], + }) + .pipe( + Effect.catchTag("InvalidKeyPair.Duplicate", () => + Effect.succeed(undefined), + ), + ); + if (created?.KeyPairId) { + privateKey = asRedacted(created.KeyMaterial) ?? privateKey; + info = { + KeyPairId: created.KeyPairId, + KeyName: created.KeyName, + KeyFingerprint: created.KeyFingerprint, + KeyType: (news.keyType ?? "rsa") as KeyPairType, + }; + } else { + info = yield* describeByName(keyName); + } + } + } + + if (!info?.KeyPairId) { + return yield* Effect.die( + new Error(`Failed to resolve EC2 key pair '${keyName}'`), + ); + } + const keyPairId = info.KeyPairId as KeyPairId; + + // Sync tags — observed cloud tags vs desired. + const observedTags = Object.fromEntries( + (info.Tags ?? []).map((t) => [t.Key!, t.Value!]), + ) as Record; + const { removed, upsert } = diffTags(observedTags, desiredTags); + if (removed.length > 0) { + yield* ec2.deleteTags({ + Resources: [keyPairId], + Tags: removed.map((key) => ({ Key: key })), + }); + } + if (upsert.length > 0) { + yield* ec2.createTags({ Resources: [keyPairId], Tags: upsert }); + } + + yield* session.note(`Key pair ${keyName} (${keyPairId})`); + return { + keyPairId, + keyName: info.KeyName ?? keyName, + keyFingerprint: info.KeyFingerprint ?? "", + keyType: (info.KeyType ?? news.keyType ?? "rsa") as KeyPairType, + privateKey, + }; + }), + + delete: Effect.fn(function* ({ output }) { + // `deleteKeyPair` is idempotent — deleting a missing key pair + // succeeds, so there is no NotFound error to catch. + yield* ec2.deleteKeyPair({ KeyPairId: output.keyPairId }); + }), + }; + }), + ); diff --git a/packages/alchemy/src/AWS/EC2/hosted.ts b/packages/alchemy/src/AWS/EC2/hosted.ts index a9638b7490..93d8b2bd2e 100644 --- a/packages/alchemy/src/AWS/EC2/hosted.ts +++ b/packages/alchemy/src/AWS/EC2/hosted.ts @@ -6,6 +6,7 @@ import type * as rolldown from "rolldown"; import * as Bundle from "../../Bundle/Bundle.ts"; import { findCwdForBundle } from "../../Bundle/TempRoot.ts"; import type { ScopedPlanStatusSession } from "../../Cli/Cli.ts"; +import type { Input } from "../../Input.ts"; import { createPhysicalName } from "../../PhysicalName.ts"; import type { PlatformProps } from "../../Platform.ts"; import type { ResourceBinding } from "../../Resource.ts"; @@ -28,7 +29,7 @@ export interface Ec2HostedBinding { export interface Ec2HostedProps extends PlatformProps { imageId: string; instanceType: string; - keyName?: string; + keyName?: Input; instanceProfileName?: string; userData?: string; subnetId?: any; @@ -155,14 +156,26 @@ export const createEc2HostedSupport = ({ input: entry, cwd, platform: "node", + // The hosted process runs under `bun` (installed by the user-data); + // keep `bun`/`bun:*` external and resolve the `bun` export condition + // so `@effect/platform-bun` picks its Bun implementations. + external: [ + "bun", + "bun:*", + ...((props.build?.input?.external as string[] | undefined) ?? []), + ], + resolve: { + conditionNames: ["bun", "import", "module", "default"], + ...props.build?.input?.resolve, + }, plugins: [props.build?.input?.plugins, plugins], }, { ...props.build?.output, format: "esm", sourcemap: props.build?.output?.sourcemap ?? false, - minify: props.build?.output?.minify ?? true, - entryFileNames: "index.js", + minify: props.build?.output?.minify ?? false, + entryFileNames: "index.mjs", }, ); }); @@ -173,7 +186,8 @@ export const createEc2HostedSupport = ({ realMain, virtualEntryPlugin( (importPath) => ` -import { NodeServices } from "@effect/platform-node"; +import { BunServices } from "@effect/platform-bun"; +import { BunHttpServer } from "alchemy/Http"; import { Stack } from "alchemy/Stack"; import * as Config from "effect/Config"; import * as ConfigProvider from "effect/ConfigProvider"; @@ -187,11 +201,14 @@ import * as Region from "@distilled.cloud/aws/Region"; import { ${handler} as handler } from ${JSON.stringify(importPath)}; const platform = Layer.mergeAll( - NodeServices.layer, + BunServices.layer, FetchHttpClient.layer, Logger.layer([Logger.consolePretty()]), ); +// Resolve the bundled program (the runners registered via host.run / serve) +// and run it with a Bun HTTP server bound to PORT, so a returned { fetch } +// handler is actually served and host.run loops stay alive. const program = handler.pipe( Effect.flatMap((instance) => instance.RuntimeContext.exports), Effect.flatMap((exports) => exports.program), @@ -212,6 +229,7 @@ const program = handler.pipe( ).pipe( Layer.provideMerge(Credentials.fromEnv()), Layer.provideMerge(Region.fromEnv()), + Layer.provideMerge(BunHttpServer()), Layer.provideMerge(platform), Layer.provideMerge( Layer.succeed( @@ -224,18 +242,28 @@ const program = handler.pipe( Effect.scoped ); -await Effect.runPromise(program); +console.log("Instance bootstrap starting..."); +await Effect.runPromise(program).catch((err) => { + console.error("Instance bootstrap failed:", err); + process.exit(1); +}); `, ), ); - const mainFile = bundleOutput.files[0]; - const code = - typeof mainFile.content === "string" - ? new TextEncoder().encode(mainFile.content) - : mainFile.content; - - const archive = yield* zipCode(code); + // Zip every emitted file: the entry becomes `index.mjs` (what the systemd + // unit runs) and shared chunks keep their `*.js` names so the entry's + // relative imports resolve. Dropping a chunk crashes the process at start. + const toBytes = (content: string | Uint8Array) => + typeof content === "string" ? new TextEncoder().encode(content) : content; + const [entryFile, ...chunkFiles] = bundleOutput.files; + const archive = yield* zipCode( + toBytes(entryFile.content), + chunkFiles.map((file) => ({ + path: file.path, + content: toBytes(file.content), + })), + ); return { archive, hash: bundleOutput.hash }; }); @@ -263,39 +291,54 @@ await Effect.runPromise(program); region: string; }) { const appDir = `/opt/${unitName}`; + const bucket = yield* Assets.BucketName; + // User-data runs once via cloud-init's `scripts-user` (once-per-instance), + // and is skipped on any subsequent boot — so it must NOT carry the work + // that can fail transiently (bun install over the network, S3 sync). It + // only writes the setup script + unit and enables the service. The systemd + // service (Restart=always) runs the setup on every start, so a flaky bun + // install / S3 read self-heals and the service survives reboots. return `#!/bin/bash -set -euo pipefail - -PKG_INSTALL="" -if command -v dnf >/dev/null 2>&1; then - PKG_INSTALL="dnf install -y" -elif command -v yum >/dev/null 2>&1; then - PKG_INSTALL="yum install -y" -fi - -if [ -n "$PKG_INSTALL" ]; then - $PKG_INSTALL unzip curl awscli -fi +set -uo pipefail mkdir -p "${appDir}" +cat >/usr/local/bin/${unitName}-setup.sh <<'SETUP_EOF' +#!/bin/bash +set -uo pipefail export HOME=/root + +# unzip (needed below) — install if missing. +command -v unzip >/dev/null 2>&1 || { + (command -v dnf >/dev/null 2>&1 && dnf install -y unzip) \ + || (command -v yum >/dev/null 2>&1 && yum install -y unzip) || true +} + +# AWS CLI — preinstalled on Amazon Linux 2023; install v2 otherwise. +command -v aws >/dev/null 2>&1 || { + curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-$(uname -m).zip" -o /tmp/awscliv2.zip \ + && (cd /tmp && unzip -q -o awscliv2.zip && ./aws/install) || true +} + +# bun — retry the network install a few times. if [ ! -x /root/.bun/bin/bun ]; then - curl -fsSL https://bun.sh/install | bash + for attempt in 1 2 3 4 5; do + curl -fsSL https://bun.sh/install | bash && break + sleep 5 + done fi -cat >/usr/local/bin/${unitName}-sync.sh <<'EOF' -#!/bin/bash -set -euo pipefail +# Sync the bundle + env from S3 (must succeed for the service to start). +set -e mkdir -p "${appDir}" -aws s3 cp "s3://${yield* Assets.BucketName}/${bundleKey}" "${appDir}/bundle.zip" --region "${region}" -aws s3 cp "s3://${yield* Assets.BucketName}/${envKey}" "${appDir}/env" --region "${region}" +aws s3 cp "s3://${bucket}/${bundleKey}" "${appDir}/bundle.zip" --region "${region}" +aws s3 cp "s3://${bucket}/${envKey}" "${appDir}/env" --region "${region}" rm -f "${appDir}/index.mjs" unzip -o "${appDir}/bundle.zip" -d "${appDir}" -EOF -chmod +x /usr/local/bin/${unitName}-sync.sh +SETUP_EOF +chmod +x /usr/local/bin/${unitName}-setup.sh -cat >/etc/systemd/system/${unitName}.service <<'EOF' +cat >/etc/systemd/system/${unitName}.service <<'UNIT_EOF' [Unit] Description=Alchemy EC2 instance runtime ${unitName} After=network-online.target @@ -304,17 +347,16 @@ Wants=network-online.target [Service] Type=simple WorkingDirectory=${appDir} -ExecStartPre=/usr/local/bin/${unitName}-sync.sh -EnvironmentFile=${appDir}/env +ExecStartPre=/usr/local/bin/${unitName}-setup.sh +EnvironmentFile=-${appDir}/env ExecStart=/root/.bun/bin/bun ${appDir}/index.mjs Restart=always RestartSec=5 [Install] WantedBy=multi-user.target -EOF +UNIT_EOF -/usr/local/bin/${unitName}-sync.sh systemctl daemon-reload systemctl enable --now ${unitName}.service `; @@ -791,7 +833,7 @@ systemctl enable --now ${unitName}.service return { ImageId: news.imageId, InstanceType: news.instanceType, - KeyName: news.keyName, + KeyName: news.keyName as string | undefined, IamInstanceProfile: runtime.instanceProfileName ? { Name: runtime.instanceProfileName, diff --git a/packages/alchemy/src/AWS/EC2/index.ts b/packages/alchemy/src/AWS/EC2/index.ts index e5baa29aca..2f215677d5 100644 --- a/packages/alchemy/src/AWS/EC2/index.ts +++ b/packages/alchemy/src/AWS/EC2/index.ts @@ -3,6 +3,7 @@ export * from "./EIP.ts"; export * from "./Image.ts"; export * from "./Instance.ts"; export * from "./InternetGateway.ts"; +export * from "./KeyPair.ts"; export * from "./NatGateway.ts"; export * from "./Network.ts"; export * from "./NetworkAcl.ts"; diff --git a/packages/alchemy/src/AWS/Providers.ts b/packages/alchemy/src/AWS/Providers.ts index 83ff200ae1..79ce360d68 100644 --- a/packages/alchemy/src/AWS/Providers.ts +++ b/packages/alchemy/src/AWS/Providers.ts @@ -110,6 +110,7 @@ export const providers = () => EC2.EIP, EC2.Instance, EC2.InternetGateway, + EC2.KeyPair, EC2.NatGateway, EC2.NetworkAcl, EC2.NetworkAclAssociation, @@ -246,6 +247,7 @@ export const providers = () => EC2.EIPProvider(), EC2.InstanceProvider(), EC2.InternetGatewayProvider(), + EC2.KeyPairProvider(), EC2.NatGatewayProvider(), EC2.NetworkAclAssociationProvider(), EC2.NetworkAclEntryProvider(), diff --git a/packages/alchemy/test/AWS/EC2/Instance.smoke.test.ts b/packages/alchemy/test/AWS/EC2/Instance.smoke.test.ts new file mode 100644 index 0000000000..24cc36fa0e --- /dev/null +++ b/packages/alchemy/test/AWS/EC2/Instance.smoke.test.ts @@ -0,0 +1,80 @@ +import * as AWS from "@/AWS"; +import * as Test from "@/Test/Vitest"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import TestInstance, { keyPair } from "./fixtures/instance.ts"; + +const { test } = Test.make({ providers: AWS.providers() }); + +// Full end-to-end: bundle the hosted program, launch a real EC2 instance into a +// public subnet, and prove over HTTP (directly against the instance's public +// IP) that (a) the `{ fetch }` handler is served by the instance's Bun HTTP +// server and (b) the `ServerHost.run` background loop is executing on the +// instance (`/ticks` keeps climbing). +// +// Heavy (instance boot + bun install + S3 sync + systemd), so skipped under +// `FAST=1`. +test.provider.skipIf(!!process.env.FAST)( + "deploys a real EC2 instance that serves HTTP and runs a background loop", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const { publicIpAddress, privateKey } = yield* stack.deploy( + Effect.gen(function* () { + const instance = yield* TestInstance; + // Resolve the same key-pair resource the instance uses and return its + // private key from the stack (resolved to a `Redacted` value). + const key = yield* keyPair; + return { + publicIpAddress: instance.publicIpAddress, + privateKey: key.privateKey, + }; + }), + ); + + expect(publicIpAddress).toBeTruthy(); + // Unredact the returned private key so it can be printed / used for SSH. + const pem = privateKey ? Redacted.value(privateKey) : undefined; + expect(pem).toContain("PRIVATE KEY"); + yield* Effect.log(`instance ssh private key:\n${pem}`); + const base = `http://${publicIpAddress}:3000`; + + // Poll until the instance boots, installs bun, syncs the bundle from S3, + // and the systemd unit serves 200 on :3000. Connection errors before the + // server binds are normalised to "not ready" so the poll keeps going + // (a bare `Effect.retry` does not retry the transport-level failure). + const served = yield* HttpClient.get(`${base}/health`).pipe( + Effect.map((res) => res.status === 200), + Effect.catch(() => Effect.succeed(false)), + Effect.repeat({ + schedule: Schedule.spaced("8 seconds"), + until: (ok) => ok, + times: 75, + }), + ); + expect(served).toBe(true); + + const body = yield* HttpClient.get(`${base}/health`).pipe( + Effect.flatMap((res) => res.json), + ); + expect(body).toEqual({ ok: true }); + + // Prove the ServerHost.run background loop is executing on the instance: + // the tick counter climbs between two reads. + const readTicks = HttpClient.get(`${base}/ticks`).pipe( + Effect.flatMap((res) => res.json), + Effect.map((value) => (value as { ticks: number }).ticks), + ); + const first = yield* readTicks; + yield* Effect.sleep("3 seconds"); + const second = yield* readTicks; + expect(second).toBeGreaterThan(first); + + yield* stack.destroy(); + }), + { timeout: 1_200_000 }, +); diff --git a/packages/alchemy/test/AWS/EC2/KeyPair.test.ts b/packages/alchemy/test/AWS/EC2/KeyPair.test.ts new file mode 100644 index 0000000000..e2f8ff6378 --- /dev/null +++ b/packages/alchemy/test/AWS/EC2/KeyPair.test.ts @@ -0,0 +1,72 @@ +import * as AWS from "@/AWS"; +import { KeyPair } from "@/AWS/EC2/KeyPair.ts"; +import * as Provider from "@/Provider"; +import * as Test from "@/Test/Vitest"; +import * as ec2 from "@distilled.cloud/aws/ec2"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; + +const { test } = Test.make({ providers: AWS.providers() }); + +// Create a generated key pair, assert the private key is captured, verify it +// exists out-of-band, then destroy it and confirm it is gone. +test.provider( + "create generates a key pair and captures the private key", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const keyPair = yield* stack.deploy( + Effect.gen(function* () { + return yield* KeyPair("GeneratedKey", { keyType: "ed25519" }); + }), + ); + + expect(keyPair.keyPairId).toMatch(/^key-/); + expect(keyPair.keyName).toBeTruthy(); + expect(keyPair.keyType).toBe("ed25519"); + expect(keyPair.keyFingerprint).toBeTruthy(); + // AWS returns the private key exactly once, at create time. + expect(keyPair.privateKey).toBeDefined(); + expect(Redacted.value(keyPair.privateKey!)).toContain("PRIVATE KEY"); + + // Verify out-of-band. + const described = yield* ec2.describeKeyPairs({ + KeyPairIds: [keyPair.keyPairId], + }); + expect(described.KeyPairs?.[0]?.KeyName).toBe(keyPair.keyName); + expect(described.KeyPairs?.[0]?.KeyType).toBe("ed25519"); + + yield* stack.destroy(); + + // Confirm deletion. + const after = yield* ec2 + .describeKeyPairs({ KeyNames: [keyPair.keyName] }) + .pipe( + Effect.catchTag("InvalidKeyPair.NotFound", () => + Effect.succeed({ KeyPairs: [] }), + ), + ); + expect(after.KeyPairs ?? []).toHaveLength(0); + }), +); + +// `list()` enumerates branded key pairs in the account. +test.provider("list enumerates the deployed key pair", (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const keyPair = yield* stack.deploy( + Effect.gen(function* () { + return yield* KeyPair("ListKey", {}); + }), + ); + + const provider = yield* Provider.findProvider(KeyPair); + const all = yield* provider.list(); + expect(all.some((k) => k.keyPairId === keyPair.keyPairId)).toBe(true); + + yield* stack.destroy(); + }), +); diff --git a/packages/alchemy/test/AWS/EC2/fixtures/instance.ts b/packages/alchemy/test/AWS/EC2/fixtures/instance.ts new file mode 100644 index 0000000000..0c12d6ebae --- /dev/null +++ b/packages/alchemy/test/AWS/EC2/fixtures/instance.ts @@ -0,0 +1,133 @@ +import * as AWS from "@/AWS"; +import { ServerHost } from "@/Server/Process.ts"; +import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + +/** + * Alchemy-managed EC2 key pair granting SSH access to the instance. Exported so + * the test can resolve it in the same deploy and read its (redacted) private + * key — yielding the same logical id returns the same resource. + */ +export const keyPair = AWS.EC2.KeyPair("Ec2E2EKeyPair", { + keyType: "ed25519", +}); + +/** + * End-to-end fixture for a hosted `AWS.EC2.Instance`: a long-running server. + * + * The props Effect provisions the networking (a public-subnet VPC) and the + * instance's security group, then launches the instance into it. The program + * Effect registers a `ServerHost.run` background loop (the #706 pattern) and + * returns a `{ fetch }` handler that the instance's Bun HTTP server serves on + * `port`. `/ticks` reports the loop counter so the test can prove the + * background loop runs inside the deployed instance. + */ +export default class TestInstance extends AWS.EC2.Instance()( + "Ec2E2EInstance", + Effect.gen(function* () { + // Props (image AMI lookup, networking) are only needed at plan/deploy + // time. Inside the deployed instance the resource already exists and only + // `exports.program` is used, so short-circuit before the infra-resolving + // calls — `__ALCHEMY_RUNTIME__` is folded to `true` in the bundle, so this + // branch (and the AWS SDK it pulls in) is dead-code-eliminated from the + // image. + if (globalThis.__ALCHEMY_RUNTIME__) { + // Only the required props need a value here; the infra-derived ones + // (subnetId / securityGroupIds / …) are unused at runtime and are left + // unset so the stub still satisfies `InstanceProps`. + return { + main: import.meta.filename, + imageId: "", + instanceType: "t3.small", + port: 3000, + }; + } + + const imageId = yield* AWS.EC2.amazonLinux2023(); + if (!imageId) { + return yield* Effect.die( + new Error("could not resolve an Amazon Linux 2023 AMI"), + ); + } + const network = yield* AWS.EC2.Network("Ec2E2ENetwork", { + cidrBlock: "10.81.0.0/16", + availabilityZones: 1, + }); + const securityGroup = yield* AWS.EC2.SecurityGroup("Ec2E2ESg", { + vpcId: network.vpcId, + description: "alchemy ec2 instance e2e", + ingress: [ + { + ipProtocol: "tcp", + fromPort: 3000, + toPort: 3000, + cidrIpv4: "0.0.0.0/0", + description: "app", + }, + { + ipProtocol: "tcp", + fromPort: 22, + toPort: 22, + cidrIpv4: "0.0.0.0/0", + description: "ssh", + }, + ], + egress: [ + { + ipProtocol: "-1", + cidrIpv4: "0.0.0.0/0", + description: "all outbound", + }, + ], + }); + + // An Alchemy-managed EC2 key pair grants SSH access to the instance. + const key = yield* keyPair; + + return { + main: import.meta.filename, + imageId, + instanceType: "t3.small", + subnetId: network.publicSubnetIds[0], + securityGroupIds: [securityGroup.groupId], + associatePublicIpAddress: true, + port: 3000, + keyName: key.keyName, + // SSM access so the instance is manageable via Session Manager. + roleManagedPolicyArns: [ + "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore", + ], + }; + }), + Effect.gen(function* () { + const host = yield* ServerHost; + const ticks = yield* Ref.make(0); + + // Long-running background loop (the `host.run` pattern from #706). + yield* host.run( + Ref.update(ticks, (n) => n + 1).pipe( + Effect.repeat(Schedule.spaced("1 second")), + Effect.asVoid, + ), + ); + + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + const url = new URL(request.url, "http://instance"); + if (url.pathname === "/health") { + return yield* HttpServerResponse.json({ ok: true }); + } + if (url.pathname === "/ticks") { + return yield* HttpServerResponse.json({ + ticks: yield* Ref.get(ticks), + }); + } + return HttpServerResponse.text("hello from ec2 instance"); + }), + }; + }), +) {}