1import * as glue from "@distilled.cloud/aws/glue";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
11import { AWSEnvironment } from "../Environment.ts";
12import type { Providers } from "../Providers.ts";
13import { connectionArn, fetchObservedTags, syncTags } from "./internal.ts";
14
15export type GlueConnectionType =
16 | "JDBC"
17 | "SFTP"
18 | "MONGODB"
19 | "KAFKA"
20 | "NETWORK"
21 | "MARKETPLACE"
22 | "CUSTOM"
23 | (string & {});
24
25export interface ConnectionProps {
26 /**
27 * Name of the connection. If omitted, a unique name is generated. Changing
28 * the name replaces the connection.
29 * @default a generated physical name
30 */
31 connectionName?: string;
32 /**
33 * The type of connection. `JDBC` is the common relational case.
34 */
35 connectionType: GlueConnectionType;
36 /**
37 * A description of the connection.
38 */
39 description?: string;
40 /**
41 * Connection-type-specific properties, e.g. for `JDBC`:
42 * `{ JDBC_CONNECTION_URL, USERNAME, PASSWORD }`. Secret values (e.g.
43 * `PASSWORD`) should be wrapped with `Redacted.make(...)` so they are
44 * kept out of logs and state output.
45 */
46 connectionProperties: Record<string, string | Redacted.Redacted<string>>;
47 /**
48 * Criteria used to match connections (for MATCH_CRITERIA lookups).
49 */
50 matchCriteria?: string[];
51 /**
52 * VPC networking requirements — required for connections that reach into a
53 * VPC (e.g. JDBC to an RDS instance).
54 */
55 physicalConnectionRequirements?: {
56 /** The subnet the connection uses. */
57 subnetId?: string;
58 /** Security group IDs applied to the connection's ENI. */
59 securityGroupIdList?: string[];
60 /** The availability zone (must match the subnet). */
61 availabilityZone?: string;
62 };
63 /**
64 * The AWS account ID of the Data Catalog. Changing it replaces the
65 * connection.
66 * @default the caller's account (the default Data Catalog)
67 */
68 catalogId?: string;
69 /**
70 * Tags to apply to the connection. Merged with internal Alchemy tags.
71 */
72 tags?: Record<string, string>;
73}
74
75export interface Connection extends Resource<
76 "AWS.Glue.Connection",
77 ConnectionProps,
78 {
79 /** The name of the connection. */
80 connectionName: string;
81 /** The ARN of the connection. */
82 connectionArn: string;
83 /** The connection type, e.g. `JDBC`. */
84 connectionType: string;
85 /** The AWS account ID of the Data Catalog the connection lives in. */
86 catalogId: string;
87 },
88 {},
89 Providers
90> {}
91
92/**
93 * An AWS Glue connection — stores the connection details (JDBC URL, VPC
94 * networking, credentials) that crawlers and jobs use to reach a data store.
95 * ### Creating Connections
96 * **Example:** JDBC Connection
97 * ```typescript
98 * import * as AWS from "alchemy/AWS";
99 * import * as Redacted from "effect/Redacted";
100 *
101 * const connection = yield* AWS.Glue.Connection("Warehouse", {
102 * connectionType: "JDBC",
103 * connectionProperties: {
104 * JDBC_CONNECTION_URL: "jdbc:postgresql://db.example.com:5432/warehouse",
105 * USERNAME: "glue",
106 * PASSWORD: Redacted.make("secret"),
107 * },
108 * physicalConnectionRequirements: {
109 * subnetId: subnet.subnetId,
110 * securityGroupIdList: [securityGroup.groupId],
111 * availabilityZone: "us-west-2a",
112 * },
113 * });
114 * ```
115 *
116 * @resource
117 */
118export const Connection = Resource<Connection>("AWS.Glue.Connection");
119
120export const ConnectionProvider = () =>
121 Provider.effect(
122 Connection,
123 Effect.gen(function* () {
124 const createName = Effect.fn(function* (
125 id: string,
126 props: { connectionName?: string | undefined },
127 ) {
128 return (
129 props.connectionName ??
130 (yield* createPhysicalName({ id, maxLength: 255 }))
131 );
132 });
133
134 const observe = Effect.fn(function* (
135 name: string,
136 catalogId: string | undefined,
137 ) {
138 return yield* glue
139 .getConnection({
140 Name: name,
141 CatalogId: catalogId,
142 HidePassword: true,
143 })
144 .pipe(
145 Effect.map((r) => r.Connection),
146 Effect.catchTag("EntityNotFoundException", () =>
147 Effect.succeed(undefined),
148 ),
149 );
150 });
151
152 const buildInput = (name: string, props: ConnectionProps) => ({
153 Name: name,
154 ConnectionType: props.connectionType,
155 Description: props.description,
156 ConnectionProperties: Object.fromEntries(
157 Object.entries(props.connectionProperties).map(([key, value]) => [
158 key,
159 Redacted.isRedacted(value) ? Redacted.value(value) : value,
160 ]),
161 ),
162 MatchCriteria: props.matchCriteria,
163 PhysicalConnectionRequirements: props.physicalConnectionRequirements
164 ? {
165 SubnetId: props.physicalConnectionRequirements.subnetId,
166 SecurityGroupIdList:
167 props.physicalConnectionRequirements.securityGroupIdList,
168 AvailabilityZone:
169 props.physicalConnectionRequirements.availabilityZone,
170 }
171 : undefined,
172 });
173
174 return Connection.Provider.of({
175 stables: ["connectionName", "connectionArn", "catalogId"],
176
177 list: () =>
178 Effect.gen(function* () {
179 const { accountId, region } = yield* AWSEnvironment.current;
180 const pages = yield* glue.getConnections
181 .pages({})
182 .pipe(Stream.runCollect);
183 return Array.from(pages)
184 .flatMap((page) => page.ConnectionList ?? [])
185 .filter((c) => c.Name !== undefined)
186 .map((c) => ({
187 connectionName: c.Name!,
188 connectionArn: connectionArn(region, accountId, c.Name!),
189 connectionType: c.ConnectionType ?? "",
190 catalogId: accountId,
191 }));
192 }),
193
194 read: Effect.fn(function* ({ id, olds, output }) {
195 const { accountId, region } = yield* AWSEnvironment.current;
196 const catalogId = output?.catalogId ?? olds?.catalogId ?? accountId;
197 const name =
198 output?.connectionName ?? (yield* createName(id, olds ?? {}));
199 const connection = yield* observe(name, catalogId);
200 if (connection?.Name === undefined) return undefined;
201 const arn = connectionArn(region, accountId, connection.Name);
202 const attrs = {
203 connectionName: connection.Name,
204 connectionArn: arn,
205 connectionType: connection.ConnectionType ?? "",
206 catalogId,
207 };
208 const tags = yield* fetchObservedTags(arn);
209 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
210 }),
211
212 diff: Effect.fn(function* ({ id, news, olds }) {
213 if (!isResolved(news)) return undefined;
214 const oldName = yield* createName(id, olds);
215 const newName = yield* createName(id, news);
216 if (oldName !== newName) return { action: "replace" } as const;
217 if ((olds.catalogId ?? undefined) !== (news.catalogId ?? undefined)) {
218 return { action: "replace" } as const;
219 }
220 // type / properties / networking / description → update
221 }),
222
223 reconcile: Effect.fn(function* ({ id, news, output, session }) {
224 const { accountId, region } = yield* AWSEnvironment.current;
225 const catalogId = news.catalogId ?? output?.catalogId ?? accountId;
226 const name = output?.connectionName ?? (yield* createName(id, news));
227 const internalTags = yield* createInternalTags(id);
228 const desiredTags = { ...news.tags, ...internalTags };
229 const arn = connectionArn(region, accountId, name);
230 const input = buildInput(name, news);
231
232 // 1. OBSERVE
233 const existing = yield* observe(name, catalogId);
234
235 // 2. ENSURE / 3. SYNC
236 if (existing === undefined) {
237 yield* glue
238 .createConnection({
239 CatalogId: catalogId,
240 ConnectionInput: input,
241 Tags: desiredTags,
242 })
243 .pipe(
244 Effect.catchTag("AlreadyExistsException", () => Effect.void),
245 );
246 } else {
247 yield* glue.updateConnection({
248 CatalogId: catalogId,
249 Name: name,
250 ConnectionInput: input,
251 });
252 }
253
254 // 3b. SYNC TAGS
255 const observedTags = yield* fetchObservedTags(arn);
256 yield* syncTags(arn, observedTags, desiredTags);
257
258 yield* session.note(name);
259 return {
260 connectionName: name,
261 connectionArn: arn,
262 connectionType: news.connectionType,
263 catalogId,
264 };
265 }),
266
267 delete: Effect.fn(function* ({ output }) {
268 yield* glue
269 .deleteConnection({
270 ConnectionName: output.connectionName,
271 CatalogId: output.catalogId,
272 })
273 .pipe(
274 Effect.catchTag("EntityNotFoundException", () => Effect.void),
275 );
276 }),
277 });
278 }),
279 );