1import * as ec2 from "@distilled.cloud/aws/ec2";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import {
10 createInternalTags,
11 createTagsList,
12 diffTags,
13 hasTags,
14} from "../../Tags.ts";
15import type { Providers } from "../Providers.ts";
16
17export type KeyPairId<ID extends string = string> = `key-${ID}`;
18
19/** Key algorithm for a generated EC2 key pair. */
20export type KeyPairType = "rsa" | "ed25519";
21
22/** Private-key file format for a generated EC2 key pair. */
23export type KeyPairFormat = "pem" | "ppk";
24
25export interface KeyPairProps {
26 /**
27 * Name of the key pair. If omitted, a unique name is generated from the
28 * stack, stage, and logical id. Changing it replaces the key pair.
29 */
30 keyName?: string;
31 /**
32 * Algorithm used when Alchemy generates the key pair. Ignored when
33 * {@link publicKeyMaterial} is supplied (an imported key keeps its own type).
34 * Changing it replaces the key pair.
35 * @default "rsa"
36 */
37 keyType?: KeyPairType;
38 /**
39 * Format of the returned private key material. Only meaningful when Alchemy
40 * generates the key pair. Changing it replaces the key pair.
41 * @default "pem"
42 */
43 keyFormat?: KeyPairFormat;
44 /**
45 * Public key material (PEM or OpenSSH) to import instead of generating a new
46 * key pair. When set, AWS stores only the public key — no `privateKey` is
47 * returned. Changing it replaces the key pair.
48 */
49 publicKeyMaterial?: string;
50 /**
51 * Tags to assign to the key pair. Merged with the alchemy auto-tags.
52 */
53 tags?: Record<string, string>;
54}
55
56export interface KeyPair extends Resource<
57 "AWS.EC2.KeyPair",
58 KeyPairProps,
59 {
60 /** The ID of the key pair (e.g. `key-0123456789abcdef0`). */
61 keyPairId: KeyPairId;
62 /** The name of the key pair. */
63 keyName: string;
64 /** SHA-1/MD5 fingerprint of the key pair. */
65 keyFingerprint: string;
66 /** The algorithm of the key pair. */
67 keyType: KeyPairType;
68 /**
69 * The unencrypted PEM/PPK private key material. Only present when Alchemy
70 * generated the key pair (not when {@link KeyPairProps.publicKeyMaterial}
71 * was imported). AWS returns this exactly once, at create time; it is then
72 * persisted as a secret in alchemy state.
73 */
74 privateKey?: Redacted.Redacted<string>;
75 },
76 never,
77 Providers
78> {}
79
80/**
81 * An EC2 key pair used to grant SSH access to instances launched with its
82 * `keyName`.
83 *
84 * By default Alchemy asks EC2 to generate the key pair and captures the
85 * private key (returned only once, at create time) as a secret in state. Pass
86 * {@link KeyPairProps.publicKeyMaterial} to import your own public key instead,
87 * in which case no private key is stored.
88 *
89 * ### Creating a Key Pair
90 * **Example:** Generated key pair
91 * ```typescript
92 * const keyPair = yield* AWS.EC2.KeyPair("DeployKey", {
93 * keyType: "ed25519",
94 * });
95 * // keyPair.keyName -> pass to AWS.EC2.Instance({ keyName })
96 * // keyPair.privateKey -> Redacted<string> (the PEM private key)
97 * ```
98 *
99 * **Example:** Imported public key
100 * ```typescript
101 * const keyPair = yield* AWS.EC2.KeyPair("ImportedKey", {
102 * publicKeyMaterial: "ssh-ed25519 AAAAC3Nz... user@host",
103 * });
104 * ```
105 *
106 * @resource
107 */
108export const KeyPair = Resource<KeyPair>("AWS.EC2.KeyPair");
109
110const toKeyName = (id: string, props: { keyName?: string } = {}) =>
111 props.keyName
112 ? Effect.succeed(props.keyName)
113 : createPhysicalName({ id, maxLength: 255 });
114
115const asRedacted = (
116 material: string | Redacted.Redacted<string> | undefined,
117): Redacted.Redacted<string> | undefined =>
118 material === undefined
119 ? undefined
120 : Redacted.isRedacted(material)
121 ? material
122 : Redacted.make(material);
123
124export const KeyPairProvider = () =>
125 Provider.effect(
126 KeyPair,
127 Effect.gen(function* () {
128 const describeByName = (keyName: string) =>
129 ec2.describeKeyPairs({ KeyNames: [keyName] }).pipe(
130 Effect.catchTag("InvalidKeyPair.NotFound", () =>
131 Effect.succeed({ KeyPairs: [] }),
132 ),
133 Effect.map((r) => r.KeyPairs?.[0]),
134 );
135
136 return {
137 stables: ["keyPairId", "keyName", "keyType"],
138
139 // Generated key pairs are immutable except for tags. A changed name /
140 // type / format / imported material means a different key pair.
141 diff: Effect.fn(function* ({ id, olds, news }) {
142 if (!isResolved(news)) return;
143 const oldName = yield* toKeyName(id, olds ?? {});
144 const newName = yield* toKeyName(id, news);
145 if (
146 oldName !== newName ||
147 (olds?.keyType ?? "rsa") !== (news.keyType ?? "rsa") ||
148 (olds?.keyFormat ?? "pem") !== (news.keyFormat ?? "pem") ||
149 olds?.publicKeyMaterial !== news.publicKeyMaterial
150 ) {
151 return { action: "replace" } as const;
152 }
153 }),
154
155 read: Effect.fn(function* ({ id, olds, output }) {
156 const keyName = output?.keyName ?? (yield* toKeyName(id, olds ?? {}));
157 const info = yield* describeByName(keyName);
158 if (!info?.KeyPairId) return undefined;
159 const tags = yield* createInternalTags(id);
160 const observedTags: Record<string, string | undefined> =
161 Object.fromEntries(
162 (info.Tags ?? []).map((t) => [t.Key ?? "", t.Value]),
163 );
164 if (!hasTags(tags, observedTags)) {
165 // Exists but unbranded — let the engine gate adoption behind --adopt.
166 return Unowned({
167 keyPairId: info.KeyPairId as KeyPairId,
168 keyName: info.KeyName ?? keyName,
169 keyFingerprint: info.KeyFingerprint ?? "",
170 keyType: (info.KeyType ?? "rsa") as KeyPairType,
171 // Private key is only available at create time; keep any cached
172 // copy from prior state.
173 privateKey: output?.privateKey,
174 });
175 }
176 return {
177 keyPairId: info.KeyPairId as KeyPairId,
178 keyName: info.KeyName ?? keyName,
179 keyFingerprint: info.KeyFingerprint ?? output?.keyFingerprint ?? "",
180 keyType: (info.KeyType ?? output?.keyType ?? "rsa") as KeyPairType,
181 privateKey: output?.privateKey,
182 };
183 }),
184
185 list: () =>
186 Effect.gen(function* () {
187 const result = yield* ec2.describeKeyPairs({});
188 return (result.KeyPairs ?? [])
189 .filter(
190 (
191 kp,
192 ): kp is ec2.KeyPairInfo & {
193 KeyPairId: string;
194 KeyName: string;
195 } => kp.KeyPairId != null && kp.KeyName != null,
196 )
197 .filter((kp) =>
198 (kp.Tags ?? []).some((t) => t.Key === "alchemy::stack"),
199 )
200 .map((kp) => ({
201 keyPairId: kp.KeyPairId as KeyPairId,
202 keyName: kp.KeyName,
203 keyFingerprint: kp.KeyFingerprint ?? "",
204 keyType: (kp.KeyType ?? "rsa") as KeyPairType,
205 privateKey: undefined,
206 }));
207 }),
208
209 reconcile: Effect.fn(function* ({ id, news = {}, output, session }) {
210 const keyName = output?.keyName ?? (yield* toKeyName(id, news));
211 const internalTags = yield* createInternalTags(id);
212 const desiredTags = { ...internalTags, ...news.tags };
213
214 // Observe — is the key pair already present?
215 let info = yield* describeByName(keyName);
216 let privateKey = output?.privateKey;
217
218 // Ensure — import or generate when missing.
219 if (!info?.KeyPairId) {
220 if (news.publicKeyMaterial !== undefined) {
221 const imported = yield* ec2
222 .importKeyPair({
223 KeyName: keyName,
224 PublicKeyMaterial: new TextEncoder().encode(
225 news.publicKeyMaterial,
226 ),
227 TagSpecifications: [
228 {
229 ResourceType: "key-pair",
230 Tags: createTagsList(desiredTags),
231 },
232 ],
233 })
234 .pipe(
235 Effect.catchTag("InvalidKeyPair.Duplicate", () =>
236 Effect.succeed(undefined),
237 ),
238 );
239 if (imported?.KeyPairId) {
240 info = {
241 KeyPairId: imported.KeyPairId,
242 KeyName: imported.KeyName,
243 KeyFingerprint: imported.KeyFingerprint,
244 KeyType: "rsa",
245 };
246 } else {
247 info = yield* describeByName(keyName);
248 }
249 } else {
250 const created = yield* ec2
251 .createKeyPair({
252 KeyName: keyName,
253 KeyType: news.keyType ?? "rsa",
254 KeyFormat: news.keyFormat ?? "pem",
255 TagSpecifications: [
256 {
257 ResourceType: "key-pair",
258 Tags: createTagsList(desiredTags),
259 },
260 ],
261 })
262 .pipe(
263 Effect.catchTag("InvalidKeyPair.Duplicate", () =>
264 Effect.succeed(undefined),
265 ),
266 );
267 if (created?.KeyPairId) {
268 privateKey = asRedacted(created.KeyMaterial) ?? privateKey;
269 info = {
270 KeyPairId: created.KeyPairId,
271 KeyName: created.KeyName,
272 KeyFingerprint: created.KeyFingerprint,
273 KeyType: (news.keyType ?? "rsa") as KeyPairType,
274 };
275 } else {
276 info = yield* describeByName(keyName);
277 }
278 }
279 }
280
281 if (!info?.KeyPairId) {
282 return yield* Effect.die(
283 new Error(`Failed to resolve EC2 key pair '${keyName}'`),
284 );
285 }
286 const keyPairId = info.KeyPairId as KeyPairId;
287
288 // Sync tags — observed cloud tags vs desired.
289 const observedTags = Object.fromEntries(
290 (info.Tags ?? []).map((t) => [t.Key!, t.Value!]),
291 ) as Record<string, string>;
292 const { removed, upsert } = diffTags(observedTags, desiredTags);
293 if (removed.length > 0) {
294 yield* ec2.deleteTags({
295 Resources: [keyPairId],
296 Tags: removed.map((key) => ({ Key: key })),
297 });
298 }
299 if (upsert.length > 0) {
300 yield* ec2.createTags({ Resources: [keyPairId], Tags: upsert });
301 }
302
303 yield* session.note(`Key pair ${keyName} (${keyPairId})`);
304 return {
305 keyPairId,
306 keyName: info.KeyName ?? keyName,
307 keyFingerprint: info.KeyFingerprint ?? "",
308 keyType: (info.KeyType ?? news.keyType ?? "rsa") as KeyPairType,
309 privateKey,
310 };
311 }),
312
313 delete: Effect.fn(function* ({ output }) {
314 // `deleteKeyPair` is idempotent — deleting a missing key pair
315 // succeeds, so there is no NotFound error to catch.
316 yield* ec2.deleteKeyPair({ KeyPairId: output.keyPairId });
317 }),
318 };
319 }),
320 );