1import * as lambdacore from "@distilled.cloud/aws/lambda-core";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6
7import type { ScopedPlanStatusSession } from "../../Report.ts";
8import { deepEqual, isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { createInternalTags } from "../../Tags.ts";
13import type { Providers } from "../Providers.ts";
14
15export interface NetworkConnectorProps {
16 /**
17 * A unique name for the network connector within your account and Region.
18 * Must be 1-64 characters of letters, numbers, hyphens, or underscores.
19 * If omitted, a unique name is generated. Changing the name replaces the
20 * connector.
21 */
22 name?: string;
23
24 /**
25 * The IDs of the VPC subnets in which the connector provisions elastic
26 * network interfaces (ENIs). These determine which VPC the connector routes
27 * egress traffic into.
28 */
29 subnetIds: string[];
30
31 /**
32 * The IDs of the security groups applied to the connector's elastic network
33 * interfaces.
34 */
35 securityGroupIds?: string[];
36
37 /**
38 * The IP addressing mode for the connector's egress path.
39 * @default "IPv4"
40 */
41 networkProtocol?: lambdacore.NetworkProtocol;
42
43 /**
44 * The Lambda compute resource types that may attach to this connector.
45 * @default ["MicroVm"]
46 */
47 associatedComputeResourceTypes?: lambdacore.ComputeResourceType[];
48
49 /**
50 * The ARN of the IAM role that Lambda assumes to manage elastic network
51 * interfaces in your VPC. The role needs `ec2:CreateNetworkInterface` and
52 * the related describe/delete permissions.
53 */
54 operatorRole?: string;
55
56 /**
57 * Tags to apply to the network connector. Tags are set at creation time and
58 * cannot be changed afterwards (the API exposes no tagging operations).
59 */
60 tags?: Record<string, string>;
61}
62
63export interface NetworkConnector extends Resource<
64 "AWS.Lambda.NetworkConnector",
65 NetworkConnectorProps,
66 {
67 /**
68 * The Amazon Resource Name (ARN) of the network connector.
69 */
70 networkConnectorArn: string;
71 /**
72 * The unique ID of the network connector.
73 */
74 networkConnectorId: string;
75 /**
76 * The name of the network connector.
77 */
78 name: string;
79 /**
80 * The current state of the network connector (e.g. `ACTIVE`, `PENDING`,
81 * `FAILED`).
82 */
83 state: lambdacore.NetworkConnectorState;
84 /**
85 * The ARN of the IAM operator role, if one was configured.
86 */
87 operatorRole?: string;
88 /**
89 * The subnet IDs the connector provisions ENIs in.
90 */
91 subnetIds?: string[];
92 /**
93 * The security group IDs applied to the connector's ENIs.
94 */
95 securityGroupIds?: string[];
96 /**
97 * The IP addressing mode of the connector's egress path.
98 */
99 networkProtocol?: lambdacore.NetworkProtocol;
100 /**
101 * The compute resource types associated with the connector.
102 */
103 associatedComputeResourceTypes?: lambdacore.ComputeResourceType[];
104 /**
105 * The monotonic version of the connector, incremented on each update.
106 */
107 version?: number;
108 /**
109 * The timestamp of the connector's most recent modification (ISO 8601).
110 */
111 lastModified?: string;
112 },
113 never,
114 Providers
115> {}
116
117/**
118 * A Lambda network connector that gives Lambda compute resources — notably
119 * {@link MicrovmImage} MicroVMs — a managed egress path into your VPC. The
120 * connector provisions elastic network interfaces (ENIs) in the subnets you
121 * specify so workloads can reach private resources such as databases, caches,
122 * and internal APIs.
123 *
124 * Creation is asynchronous: the connector starts in `PENDING` while ENIs are
125 * provisioned (this can take several minutes) and the provider waits until it
126 * reaches `ACTIVE`. The connector name is immutable, so renaming it replaces the
127 * connector; the VPC configuration and operator role can be updated in place.
128 *
129 * ### Creating a Network Connector
130 * **Example:** VPC Egress Connector
131 * ```typescript
132 * const connector = yield* AWS.Lambda.NetworkConnector("Egress", {
133 * subnetIds: [subnetA.subnetId, subnetB.subnetId],
134 * securityGroupIds: [securityGroup.groupId],
135 * operatorRole: role.roleArn,
136 * });
137 * ```
138 *
139 * ### Dual-Stack Networking
140 * **Example:** IPv4 + IPv6 Egress
141 * ```typescript
142 * const connector = yield* AWS.Lambda.NetworkConnector("DualStack", {
143 * subnetIds: [subnet.subnetId],
144 * securityGroupIds: [securityGroup.groupId],
145 * networkProtocol: "DualStack",
146 * });
147 * ```
148 *
149 * ### Using a Connector with MicroVMs
150 * A connector is the producer; a {@link MicrovmImage} (or a per-run
151 * `RunMicrovm` call) is the consumer. Reference it by ARN in
152 * `egressNetworkConnectors`.
153 * **Example:** Image-level Egress
154 * ```typescript
155 * const image = yield* AWS.Lambda.MicrovmImage("Sandbox", {
156 * main: import.meta.filename,
157 * buildRole,
158 * egressNetworkConnectors: [connector.networkConnectorArn],
159 * });
160 * ```
161 *
162 * @resource
163 */
164export const NetworkConnector = Resource<NetworkConnector>(
165 "AWS.Lambda.NetworkConnector",
166);
167
168export const NetworkConnectorProvider = () =>
169 Provider.succeed(NetworkConnector, {
170 stables: ["networkConnectorArn", "networkConnectorId", "name"],
171
172 diff: Effect.fn(function* ({ id, olds, news }) {
173 if (!isResolved(news)) return;
174 const oldName = yield* resolveName(id, olds.name);
175 const newName = yield* resolveName(id, news.name);
176 if (oldName !== newName) {
177 return { action: "replace" } as const;
178 }
179 }),
180
181 read: Effect.fn(function* ({ id, olds, output }) {
182 const identifier =
183 output?.networkConnectorId ?? output?.networkConnectorArn;
184 const connector = identifier
185 ? yield* getConnector(identifier)
186 : yield* getConnector(yield* resolveName(id, olds?.name));
187 return connector ? toAttrs(connector) : undefined;
188 }),
189
190 list: () =>
191 Effect.gen(function* () {
192 const summaries = yield* lambdacore.listNetworkConnectors
193 .items({})
194 .pipe(
195 Stream.runCollect,
196 Effect.map((chunk) => Array.from(chunk)),
197 );
198 const connectors = yield* Effect.forEach(
199 summaries.filter((c) => c.State !== "DELETING"),
200 (summary) => getConnector(summary.Id ?? summary.Arn),
201 { concurrency: 10 },
202 );
203 return connectors.flatMap((c) => (c ? [toAttrs(c)] : []));
204 }),
205
206 reconcile: Effect.fn(function* ({ id, news, output, session }) {
207 const name = yield* resolveName(id, news.name);
208
209 // Observe — prefer the cached identifier, falling back to a name lookup
210 // so an interrupted create can recover before re-creating. A connector
211 // stuck in DELETE_FAILED is treated as missing so we recreate.
212 const found = output?.networkConnectorId
213 ? yield* getConnector(output.networkConnectorId)
214 : yield* getConnector(name);
215 const observed =
216 found && found.State !== "DELETE_FAILED" ? found : undefined;
217
218 // Ensure + sync — each branch returns the active connector, so we never
219 // reassign across branches (which `tsc` narrows poorly).
220 const connector = observed
221 ? yield* syncConnector(observed, name, news, session)
222 : yield* createConnector(name, news, id, session);
223
224 yield* session.note(`Network connector ${name} is ${connector.State}`);
225 return toAttrs(connector);
226 }),
227
228 delete: Effect.fn(function* ({ output, session }) {
229 yield* session.note(`Deleting network connector ${output.name}...`);
230 yield* lambdacore
231 .deleteNetworkConnector({ Identifier: output.networkConnectorId })
232 .pipe(Effect.catchTag("ResourceNotFoundException", () => Effect.void));
233 yield* waitForDeleted(output.networkConnectorId, session);
234 }),
235 });
236
237// === Helpers ===============================================================
238
239const resolveName = (id: string, name?: string) =>
240 name
241 ? Effect.succeed(name)
242 : createPhysicalName({ id, maxLength: 64, delimiter: "-" });
243
244// A freshly-created IAM operator role (and its inline policies) takes a few
245// seconds to propagate before the Lambda service can assume/use it. The API
246// surfaces that as an InvalidParameterValueException, so retry it briefly
247// (same pattern as Function.ts's role-propagation retry). A genuinely
248// misconfigured role still fails once the bounded retry window elapses.
249const isOperatorRolePropagationError = (e: {
250 _tag: string;
251 message?: string;
252}) =>
253 e._tag === "InvalidParameterValueException" &&
254 ((e.message?.includes(
255 "unable to assume the provided NetworkConnectorOperatorRole",
256 ) ??
257 false) ||
258 (e.message?.includes("invalid ConnectorOperatorRole permissions") ??
259 false));
260
261const retryRolePropagation =
262 (session: ScopedPlanStatusSession) =>
263 <A, E extends { _tag: string; message?: string }, R>(
264 self: Effect.Effect<A, E, R>,
265 ): Effect.Effect<A, E, R> =>
266 self.pipe(
267 Effect.tapError((e) =>
268 isOperatorRolePropagationError(e)
269 ? session.note(
270 "Waiting for the operator role to become assumable by Lambda...",
271 )
272 : Effect.void,
273 ),
274 Effect.retry({
275 while: (e) => isOperatorRolePropagationError(e),
276 schedule: Schedule.max([Schedule.fixed(1_000), Schedule.recurs(50)]),
277 }),
278 );
279
280const getConnector = (identifier: string) =>
281 lambdacore
282 .getNetworkConnector({ Identifier: identifier })
283 .pipe(
284 Effect.catchTag("ResourceNotFoundException", () =>
285 Effect.succeed(undefined),
286 ),
287 );
288
289const normalizeList = (values: readonly string[] | undefined): string[] =>
290 [...(values ?? [])].sort();
291
292const desiredEgress = (
293 props: NetworkConnectorProps,
294): lambdacore.NetworkConnectorVpcEgressConfiguration => ({
295 SubnetIds: props.subnetIds,
296 SecurityGroupIds: props.securityGroupIds,
297 NetworkProtocol: props.networkProtocol,
298 // The API requires AssociatedComputeResourceTypes for VPC_EGRESS connectors,
299 // so apply the documented default when the prop is omitted.
300 AssociatedComputeResourceTypes: props.associatedComputeResourceTypes ?? [
301 "MicroVm",
302 ],
303});
304
305const egressEqual = (
306 a: lambdacore.NetworkConnectorVpcEgressConfiguration | undefined,
307 b: lambdacore.NetworkConnectorVpcEgressConfiguration | undefined,
308): boolean =>
309 deepEqual(
310 {
311 SubnetIds: normalizeList(a?.SubnetIds),
312 SecurityGroupIds: normalizeList(a?.SecurityGroupIds),
313 NetworkProtocol: a?.NetworkProtocol,
314 AssociatedComputeResourceTypes: normalizeList(
315 a?.AssociatedComputeResourceTypes,
316 ),
317 },
318 {
319 SubnetIds: normalizeList(b?.SubnetIds),
320 SecurityGroupIds: normalizeList(b?.SecurityGroupIds),
321 NetworkProtocol: b?.NetworkProtocol,
322 AssociatedComputeResourceTypes: normalizeList(
323 b?.AssociatedComputeResourceTypes,
324 ),
325 },
326 );
327
328const toIso = (value: Date | string | undefined): string | undefined =>
329 value instanceof Date ? value.toISOString() : value;
330
331const toAttrs = (
332 connector: lambdacore.GetNetworkConnectorResponse,
333): NetworkConnector["Attributes"] => {
334 const egress = connector.Configuration?.VpcEgressConfiguration;
335 return {
336 networkConnectorArn: connector.Arn,
337 networkConnectorId: connector.Id,
338 name: connector.Name,
339 state: connector.State ?? "PENDING",
340 operatorRole: connector.OperatorRole,
341 subnetIds: egress?.SubnetIds,
342 securityGroupIds: egress?.SecurityGroupIds,
343 networkProtocol: egress?.NetworkProtocol,
344 associatedComputeResourceTypes: egress?.AssociatedComputeResourceTypes,
345 version: connector.Version,
346 lastModified: toIso(connector.LastModified),
347 };
348};
349
350// Create the connector with ownership tags and wait for it to become ACTIVE.
351const createConnector = Effect.fn(function* (
352 name: string,
353 news: NetworkConnectorProps,
354 id: string,
355 session: ScopedPlanStatusSession,
356) {
357 const internalTags = yield* createInternalTags(id);
358 yield* session.note(`Creating network connector ${name}...`);
359 const created = yield* lambdacore
360 .createNetworkConnector({
361 Name: name,
362 Configuration: { VpcEgressConfiguration: desiredEgress(news) },
363 OperatorRole: news.operatorRole,
364 Tags: { ...internalTags, ...news.tags },
365 })
366 .pipe(
367 retryRolePropagation(session),
368 // A concurrent create with the same name is a race; fall back to reading
369 // the existing connector.
370 Effect.catchTag("ResourceConflictException", () =>
371 getConnector(name).pipe(
372 Effect.flatMap((existing) =>
373 existing
374 ? Effect.succeed(existing)
375 : Effect.die(
376 `Network connector ${name} conflicted but was not found.`,
377 ),
378 ),
379 ),
380 ),
381 );
382 return yield* waitForActive(created.Id ?? created.Arn, session);
383});
384
385// Apply config / operator-role changes against observed state, waiting for the
386// update to settle; otherwise return the observed connector untouched.
387const syncConnector = Effect.fn(function* (
388 connector: lambdacore.GetNetworkConnectorResponse,
389 name: string,
390 news: NetworkConnectorProps,
391 session: ScopedPlanStatusSession,
392) {
393 const observedEgress = connector.Configuration?.VpcEgressConfiguration;
394 if (
395 egressEqual(observedEgress, desiredEgress(news)) &&
396 (connector.OperatorRole ?? undefined) === news.operatorRole
397 ) {
398 return connector;
399 }
400 yield* session.note(`Updating network connector ${name}...`);
401 yield* lambdacore
402 .updateNetworkConnector({
403 Identifier: connector.Id,
404 Configuration: { VpcEgressConfiguration: desiredEgress(news) },
405 OperatorRole: news.operatorRole,
406 })
407 .pipe(retryRolePropagation(session));
408 return yield* waitForUpdate(connector.Id, session);
409});
410
411class ConnectorPending extends Data.TaggedError("ConnectorPending")<{
412 identifier: string;
413 state: string;
414}> {}
415
416class ConnectorFailed extends Data.TaggedError("ConnectorFailed")<{
417 identifier: string;
418 state: string;
419 reason?: string;
420}> {}
421
422const waitForActive = (identifier: string, session: ScopedPlanStatusSession) =>
423 Effect.gen(function* () {
424 const connector = yield* lambdacore.getNetworkConnector({
425 Identifier: identifier,
426 });
427 switch (connector.State) {
428 case "ACTIVE":
429 return connector;
430 case "FAILED":
431 case "DELETE_FAILED":
432 return yield* new ConnectorFailed({
433 identifier,
434 state: connector.State,
435 reason: connector.StateReason ?? connector.StateReasonCode,
436 });
437 default:
438 return yield* new ConnectorPending({
439 identifier,
440 state: connector.State ?? "PENDING",
441 });
442 }
443 }).pipe(
444 Effect.retry({
445 while: (e) => e._tag === "ConnectorPending",
446 schedule: Schedule.max([
447 Schedule.fixed(10_000),
448 Schedule.recurs(72),
449 ]).pipe(
450 Schedule.tap(({ attempt }) =>
451 session.note(
452 `Waiting for network connector to become ACTIVE... (${attempt * 10}s)`,
453 ),
454 ),
455 ),
456 }),
457 );
458
459const waitForUpdate = (identifier: string, session: ScopedPlanStatusSession) =>
460 Effect.gen(function* () {
461 const connector = yield* lambdacore.getNetworkConnector({
462 Identifier: identifier,
463 });
464 switch (connector.LastUpdateStatus) {
465 case "Successful":
466 case undefined:
467 return connector;
468 case "Failed":
469 return yield* new ConnectorFailed({
470 identifier,
471 state: connector.LastUpdateStatus,
472 reason:
473 connector.LastUpdateStatusReason ??
474 connector.LastUpdateStatusReasonCode,
475 });
476 default:
477 return yield* new ConnectorPending({
478 identifier,
479 state: connector.LastUpdateStatus ?? "InProgress",
480 });
481 }
482 }).pipe(
483 Effect.retry({
484 while: (e) => e._tag === "ConnectorPending",
485 schedule: Schedule.max([
486 Schedule.fixed(10_000),
487 Schedule.recurs(72),
488 ]).pipe(
489 Schedule.tap(({ attempt }) =>
490 session.note(
491 `Waiting for network connector update... (${attempt * 10}s)`,
492 ),
493 ),
494 ),
495 }),
496 );
497
498const waitForDeleted = (identifier: string, session: ScopedPlanStatusSession) =>
499 Effect.gen(function* () {
500 const connector = yield* lambdacore
501 .getNetworkConnector({ Identifier: identifier })
502 .pipe(
503 Effect.catchTag("ResourceNotFoundException", () =>
504 Effect.succeed(undefined),
505 ),
506 );
507 // A missing connector is the success signal — it has been deleted.
508 if (!connector) return;
509 if (connector.State === "DELETE_FAILED") {
510 return yield* new ConnectorFailed({
511 identifier,
512 state: connector.State,
513 reason: connector.StateReason ?? connector.StateReasonCode,
514 });
515 }
516 return yield* new ConnectorPending({
517 identifier,
518 state: connector.State ?? "DELETING",
519 });
520 }).pipe(
521 Effect.retry({
522 while: (e) => e._tag === "ConnectorPending",
523 schedule: Schedule.max([
524 Schedule.fixed(10_000),
525 Schedule.recurs(72),
526 ]).pipe(
527 Schedule.tap(({ attempt }) =>
528 session.note(
529 `Waiting for network connector deletion... (${attempt * 10}s)`,
530 ),
531 ),
532 ),
533 }),
534 );