| 1 | import * as ag from "@distilled.cloud/aws/api-gateway"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Schedule from "effect/Schedule"; |
| 4 | import * as Stream from "effect/Stream"; |
| 5 | import { deepEqual, isResolved } from "../../Diff.ts"; |
| 6 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 7 | import * as Provider from "../../Provider.ts"; |
| 8 | import { Resource } from "../../Resource.ts"; |
| 9 | import { createInternalTags, tagRecord } from "../../Tags.ts"; |
| 10 | import type { Providers } from "../Providers.ts"; |
| 11 | |
| 12 | import { AWSEnvironment } from "../Environment.ts"; |
| 13 | import { syncTags, vpcLinkArn } from "./common.ts"; |
| 14 | |
| 15 | export interface VpcLinkProps { |
| 16 | /** |
| 17 | * Name of the VPC link. |
| 18 | * |
| 19 | * If omitted, Alchemy generates a deterministic physical name. |
| 20 | */ |
| 21 | name?: string; |
| 22 | /** |
| 23 | * Target ARNs for the integration (e.g. load balancer ARNs). |
| 24 | */ |
| 25 | targetArns: string[]; |
| 26 | /** Description of the VPC link. */ |
| 27 | description?: string; |
| 28 | /** User-defined tags for the VPC link. */ |
| 29 | tags?: Record<string, string>; |
| 30 | } |
| 31 | |
| 32 | /** @resource */ |
| 33 | export interface VpcLink extends Resource< |
| 34 | "AWS.ApiGateway.VpcLink", |
| 35 | VpcLinkProps, |
| 36 | { |
| 37 | vpcLinkId: string; |
| 38 | name: string | undefined; |
| 39 | description: string | undefined; |
| 40 | targetArns: string[] | undefined; |
| 41 | status: ag.VpcLinkStatus | undefined; |
| 42 | statusMessage: string | undefined; |
| 43 | tags: Record<string, string>; |
| 44 | }, |
| 45 | never, |
| 46 | Providers |
| 47 | > {} |
| 48 | |
| 49 | /** |
| 50 | * VPC link for private integrations (`connectionType: "VPC_LINK"` on a method integration). |
| 51 | * |
| 52 | * ### Private integrations |
| 53 | * **Example:** Create a VPC link |
| 54 | * ```typescript |
| 55 | * const link = yield* ApiGateway.VpcLink("NlbLink", { |
| 56 | * description: "Link to internal NLB", |
| 57 | * targetArns: [nlb.loadBalancerArn], |
| 58 | * }); |
| 59 | * |
| 60 | * yield* ApiGateway.Method("PrivateGet", { |
| 61 | * restApiId: api.restApiId, |
| 62 | * resourceId: resource.resourceId, |
| 63 | * httpMethod: "GET", |
| 64 | * integration: { |
| 65 | * type: "HTTP_PROXY", |
| 66 | * integrationHttpMethod: "GET", |
| 67 | * uri: "https://api.internal.example.com/hello", |
| 68 | * connectionType: "VPC_LINK", |
| 69 | * connectionId: link.vpcLinkId, |
| 70 | * }, |
| 71 | * }); |
| 72 | * ``` |
| 73 | */ |
| 74 | const VpcLinkResource = Resource<VpcLink>("AWS.ApiGateway.VpcLink"); |
| 75 | |
| 76 | export { VpcLinkResource as VpcLink }; |
| 77 | |
| 78 | const generatedName = (id: string, props: VpcLinkProps) => |
| 79 | props.name |
| 80 | ? Effect.succeed(props.name) |
| 81 | : createPhysicalName({ |
| 82 | id, |
| 83 | maxLength: 128, |
| 84 | }); |
| 85 | |
| 86 | const snapshotFromVpcLink = ( |
| 87 | v: ag.VpcLink, |
| 88 | tags: Record<string, string>, |
| 89 | ): VpcLink["Attributes"] => ({ |
| 90 | vpcLinkId: v.id!, |
| 91 | name: v.name, |
| 92 | description: v.description, |
| 93 | targetArns: v.targetArns, |
| 94 | status: v.status, |
| 95 | statusMessage: v.statusMessage, |
| 96 | tags, |
| 97 | }); |
| 98 | |
| 99 | export const VpcLinkProvider = () => |
| 100 | Provider.effect( |
| 101 | VpcLinkResource, |
| 102 | Effect.gen(function* () { |
| 103 | return { |
| 104 | stables: ["vpcLinkId"] as const, |
| 105 | diff: Effect.fn(function* ({ news: newsIn, olds }) { |
| 106 | if (!isResolved(newsIn)) return; |
| 107 | const news = newsIn as VpcLinkProps; |
| 108 | if (!deepEqual(news.targetArns, olds.targetArns)) { |
| 109 | return { action: "replace" } as const; |
| 110 | } |
| 111 | }), |
| 112 | read: Effect.fn(function* ({ output }) { |
| 113 | if (!output?.vpcLinkId) return undefined; |
| 114 | const v = yield* ag |
| 115 | .getVpcLink({ vpcLinkId: output.vpcLinkId }) |
| 116 | .pipe( |
| 117 | Effect.catchTag("NotFoundException", () => |
| 118 | Effect.succeed(undefined), |
| 119 | ), |
| 120 | ); |
| 121 | if (!v?.id) return undefined; |
| 122 | return snapshotFromVpcLink(v, tagRecord(v.tags)); |
| 123 | }), |
| 124 | list: () => |
| 125 | ag.getVpcLinks.pages({ limit: 500 }).pipe( |
| 126 | Stream.runCollect, |
| 127 | Effect.map((chunk) => |
| 128 | Array.from(chunk).flatMap((page) => |
| 129 | (page.items ?? []) |
| 130 | .filter((v): v is ag.VpcLink & { id: string } => !!v.id) |
| 131 | .map((v) => snapshotFromVpcLink(v, tagRecord(v.tags))), |
| 132 | ), |
| 133 | ), |
| 134 | ), |
| 135 | reconcile: Effect.fn(function* ({ id, news: newsIn, output, session }) { |
| 136 | if (!isResolved(newsIn)) { |
| 137 | return yield* Effect.die("VpcLink props were not resolved"); |
| 138 | } |
| 139 | const news = newsIn as VpcLinkProps; |
| 140 | const name = yield* generatedName(id, news); |
| 141 | const internalTags = yield* createInternalTags(id); |
| 142 | const desiredTags = { ...news.tags, ...internalTags }; |
| 143 | const { region } = yield* AWSEnvironment.current; |
| 144 | // Observe — fetch the live VPC link if we have a cached id. |
| 145 | // We never trust `output.description`/etc. for diffing; the |
| 146 | // observed cloud state drives every sync below. |
| 147 | let observed = output?.vpcLinkId |
| 148 | ? yield* ag |
| 149 | .getVpcLink({ vpcLinkId: output.vpcLinkId }) |
| 150 | .pipe( |
| 151 | Effect.catchTag("NotFoundException", () => |
| 152 | Effect.succeed(undefined), |
| 153 | ), |
| 154 | ) |
| 155 | : undefined; |
| 156 | |
| 157 | // Ensure — create the VPC link if missing. |
| 158 | if (!observed?.id) { |
| 159 | const created = yield* ag.createVpcLink({ |
| 160 | name, |
| 161 | description: news.description, |
| 162 | targetArns: news.targetArns, |
| 163 | tags: desiredTags, |
| 164 | }); |
| 165 | if (!created.id) { |
| 166 | return yield* Effect.die("createVpcLink missing id"); |
| 167 | } |
| 168 | yield* session.note(`Created VPC link ${created.id}`); |
| 169 | observed = yield* ag.getVpcLink({ vpcLinkId: created.id }); |
| 170 | } |
| 171 | |
| 172 | const vpcLinkId = observed.id!; |
| 173 | |
| 174 | // Sync mutable scalar fields — observed ↔ desired patch list. |
| 175 | const patches: ag.PatchOperation[] = []; |
| 176 | if (news.description !== observed.description) { |
| 177 | patches.push({ |
| 178 | op: "replace", |
| 179 | path: "/description", |
| 180 | value: news.description ?? "", |
| 181 | }); |
| 182 | } |
| 183 | if (news.name !== undefined && news.name !== observed.name) { |
| 184 | patches.push({ |
| 185 | op: "replace", |
| 186 | path: "/name", |
| 187 | value: news.name, |
| 188 | }); |
| 189 | } |
| 190 | if (patches.length > 0) { |
| 191 | yield* ag.updateVpcLink({ |
| 192 | vpcLinkId, |
| 193 | patchOperations: patches, |
| 194 | }); |
| 195 | } |
| 196 | |
| 197 | // Sync tags — observed ↔ desired so adoption converges without |
| 198 | // fighting the existing tag set. |
| 199 | const observedTags = tagRecord(observed.tags); |
| 200 | if (!deepEqual(observedTags, desiredTags)) { |
| 201 | yield* syncTags({ |
| 202 | resourceArn: vpcLinkArn(region, vpcLinkId), |
| 203 | oldTags: observedTags, |
| 204 | newTags: desiredTags, |
| 205 | }); |
| 206 | } |
| 207 | |
| 208 | yield* session.note(`Reconciled VPC link ${vpcLinkId}`); |
| 209 | const final = yield* ag.getVpcLink({ vpcLinkId }); |
| 210 | return snapshotFromVpcLink(final, tagRecord(final.tags)); |
| 211 | }), |
| 212 | delete: Effect.fn(function* ({ output, session }) { |
| 213 | yield* ag.deleteVpcLink({ vpcLinkId: output.vpcLinkId }).pipe( |
| 214 | Effect.retry({ |
| 215 | while: (e) => e._tag === "ConflictException", |
| 216 | schedule: Schedule.spaced("1 second"), |
| 217 | times: 8, |
| 218 | }), |
| 219 | Effect.catchTag("NotFoundException", () => Effect.void), |
| 220 | ); |
| 221 | yield* session.note(`Deleted VPC link ${output.vpcLinkId}`); |
| 222 | }), |
| 223 | }; |
| 224 | }), |
| 225 | ); |