1import * as redshiftserverless from "@distilled.cloud/aws/redshift-serverless";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import type * as Redacted from "effect/Redacted";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
13import type { Providers } from "../Providers.ts";
14import { readTags, syncTags, toWireTags } from "./internal.ts";
15
16export interface NamespaceProps {
17 /**
18 * Name of the namespace. Must be 3-64 characters, lowercase letters,
19 * numbers, and hyphens. If omitted, a deterministic physical name is
20 * generated. Changing the name replaces the namespace.
21 */
22 namespaceName?: string;
23 /**
24 * Name of the first database created in the namespace. Changing the
25 * database name replaces the namespace.
26 * @default "dev"
27 */
28 dbName?: string;
29 /**
30 * Administrator username for the namespace's initial database. Provide it
31 * together with `adminUserPassword`, or set `manageAdminPassword` to have
32 * Redshift store a generated password in Secrets Manager.
33 */
34 adminUsername?: string;
35 /**
36 * Administrator password. Ignored when `manageAdminPassword` is true.
37 */
38 adminUserPassword?: Redacted.Redacted<string>;
39 /**
40 * Let Redshift generate and manage the admin password in Secrets Manager.
41 * The secret ARN is returned as `adminPasswordSecretArn`.
42 * @default false
43 */
44 manageAdminPassword?: boolean;
45 /**
46 * Customer-managed KMS key ID used to encrypt the Secrets Manager secret
47 * that holds the managed admin password.
48 */
49 adminPasswordSecretKmsKeyId?: string;
50 /**
51 * Customer-managed KMS key ID used to encrypt the namespace's data.
52 * Changing the key replaces the namespace.
53 * @default AWS-owned key
54 */
55 kmsKeyId?: string;
56 /**
57 * ARN of the IAM role set as the namespace's default role. Must also be a
58 * member of `iamRoles`.
59 */
60 defaultIamRoleArn?: string;
61 /**
62 * ARNs of IAM roles associated with the namespace (used by COPY/UNLOAD and
63 * other role-based features).
64 */
65 iamRoles?: string[];
66 /**
67 * Log types exported to CloudWatch Logs: `userlog`, `connectionlog`,
68 * and/or `useractivitylog`.
69 */
70 logExports?: ("userlog" | "connectionlog" | "useractivitylog")[];
71 /**
72 * User-defined tags for the namespace.
73 */
74 tags?: Record<string, string>;
75}
76
77export interface Namespace extends Resource<
78 "AWS.RedshiftServerless.Namespace",
79 NamespaceProps,
80 {
81 /**
82 * Name of the namespace.
83 */
84 namespaceName: string;
85 /**
86 * ARN of the namespace.
87 */
88 namespaceArn: string;
89 /**
90 * Unique ID of the namespace.
91 */
92 namespaceId: string;
93 /**
94 * Name of the first database created in the namespace.
95 */
96 dbName: string | undefined;
97 /**
98 * ARN of the Secrets Manager secret holding the admin password when
99 * `manageAdminPassword` is enabled.
100 */
101 adminPasswordSecretArn: string | undefined;
102 /**
103 * Current namespace status (e.g. `"AVAILABLE"`).
104 */
105 status: string;
106 },
107 never,
108 Providers
109> {}
110
111/**
112 * An Amazon Redshift Serverless namespace — the storage-and-database half of
113 * a serverless data warehouse.
114 *
115 * A namespace holds databases, schemas, tables, admin credentials, and IAM
116 * roles. Compute is provided separately by a {@link Workgroup} that points at
117 * the namespace. Creating a namespace is quick (~1 minute); the provider
118 * waits (bounded) for it to become `AVAILABLE`.
119 *
120 * ### Creating a Namespace
121 * **Example:** Inline Admin Credentials
122 * ```typescript
123 * const namespace = yield* RedshiftServerless.Namespace("Analytics", {
124 * dbName: "analytics",
125 * adminUsername: "admin",
126 * adminUserPassword: Redacted.make("SuperSecret123!"),
127 * });
128 * ```
129 *
130 * **Example:** Secrets-Manager-Managed Admin Password
131 * ```typescript
132 * const namespace = yield* RedshiftServerless.Namespace("Analytics", {
133 * dbName: "analytics",
134 * adminUsername: "admin",
135 * manageAdminPassword: true,
136 * });
137 * // namespace.adminPasswordSecretArn -> the generated secret's ARN
138 * ```
139 *
140 * ### IAM Roles and Encryption
141 * **Example:** Default Role and Customer KMS Key
142 * ```typescript
143 * const namespace = yield* RedshiftServerless.Namespace("Analytics", {
144 * dbName: "analytics",
145 * defaultIamRoleArn: role.roleArn,
146 * iamRoles: [role.roleArn],
147 * kmsKeyId: key.keyId,
148 * logExports: ["userlog", "connectionlog"],
149 * });
150 * ```
151 *
152 * @resource
153 */
154export const Namespace = Resource<Namespace>(
155 "AWS.RedshiftServerless.Namespace",
156);
157
158class NamespaceNotSettled extends Data.TaggedError("NamespaceNotSettled")<{
159 readonly namespaceName: string;
160 readonly status: string;
161}> {}
162
163const statusIs = (status: string | undefined, expected: string): boolean =>
164 status?.toUpperCase() === expected;
165
166const sameStringSet = (a: string[], b: string[]): boolean => {
167 const as = [...a].sort();
168 const bs = [...b].sort();
169 return as.length === bs.length && as.every((v, i) => v === bs[i]);
170};
171
172export const NamespaceProvider = () =>
173 Provider.effect(
174 Namespace,
175 Effect.gen(function* () {
176 const toName = (id: string, props: Partial<NamespaceProps>) =>
177 props.namespaceName
178 ? Effect.succeed(props.namespaceName)
179 : createPhysicalName({ id, maxLength: 64 });
180
181 const readNamespace = Effect.fn(function* (name: string) {
182 const response = yield* redshiftserverless
183 .getNamespace({ namespaceName: name })
184 .pipe(
185 Effect.catchTag("ResourceNotFoundException", () =>
186 Effect.succeed(undefined),
187 ),
188 );
189 return response?.namespace;
190 });
191
192 // Namespace create/update converge through a MODIFYING status. It
193 // typically settles within a minute; budget ~3 min (36 * 5s).
194 const waitForAvailable = Effect.fn(function* (name: string) {
195 return yield* readNamespace(name).pipe(
196 Effect.flatMap((ns) =>
197 ns !== undefined && !statusIs(ns.status, "AVAILABLE")
198 ? Effect.fail(
199 new NamespaceNotSettled({
200 namespaceName: name,
201 status: ns.status ?? "UNKNOWN",
202 }),
203 )
204 : Effect.succeed(ns),
205 ),
206 Effect.retry({
207 while: (e) => e instanceof NamespaceNotSettled,
208 schedule: Schedule.max([
209 Schedule.fixed("5 seconds"),
210 Schedule.recurs(36),
211 ]),
212 }),
213 );
214 });
215
216 const waitUntilGone = Effect.fn(function* (name: string) {
217 yield* readNamespace(name).pipe(
218 Effect.flatMap((ns) =>
219 ns === undefined
220 ? Effect.void
221 : Effect.fail(
222 new NamespaceNotSettled({
223 namespaceName: name,
224 status: ns.status ?? "DELETING",
225 }),
226 ),
227 ),
228 Effect.retry({
229 while: (e) => e instanceof NamespaceNotSettled,
230 schedule: Schedule.max([
231 Schedule.fixed("5 seconds"),
232 Schedule.recurs(60),
233 ]),
234 }),
235 );
236 });
237
238 const toAttrs = (ns: redshiftserverless.Namespace) => ({
239 namespaceName: ns.namespaceName!,
240 namespaceArn: ns.namespaceArn!,
241 namespaceId: ns.namespaceId!,
242 dbName: ns.dbName,
243 adminPasswordSecretArn: ns.adminPasswordSecretArn,
244 status: ns.status ?? "UNKNOWN",
245 });
246
247 return {
248 stables: ["namespaceName", "namespaceArn", "namespaceId"],
249
250 diff: Effect.fn(function* ({ id, olds, news }) {
251 if (!isResolved(news)) return undefined;
252 if (
253 (yield* toName(id, olds ?? {})) !== (yield* toName(id, news ?? {}))
254 ) {
255 return { action: "replace" } as const;
256 }
257 // dbName and the KMS key are create-only.
258 if ((news?.dbName ?? undefined) !== (olds?.dbName ?? undefined)) {
259 return { action: "replace" } as const;
260 }
261 if ((news?.kmsKeyId ?? undefined) !== (olds?.kmsKeyId ?? undefined)) {
262 return { action: "replace" } as const;
263 }
264 }),
265
266 read: Effect.fn(function* ({ id, olds, output }) {
267 const name = output?.namespaceName ?? (yield* toName(id, olds ?? {}));
268 const ns = yield* readNamespace(name);
269 if (ns === undefined) return undefined;
270 const attrs = toAttrs(ns);
271 const tags = yield* readTags(attrs.namespaceArn);
272 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
273 }),
274
275 reconcile: Effect.fn(function* ({ id, news, output, session }) {
276 const name = output?.namespaceName ?? (yield* toName(id, news));
277 const internalTags = yield* createInternalTags(id);
278 const desiredTags = { ...internalTags, ...news.tags };
279
280 // 1. Observe — cloud state is authoritative.
281 let observed = yield* readNamespace(name);
282
283 // 2. Ensure — create if missing; tolerate a concurrent create.
284 if (observed === undefined) {
285 const created = yield* redshiftserverless
286 .createNamespace({
287 namespaceName: name,
288 dbName: news.dbName,
289 adminUsername: news.adminUsername,
290 // Redshift generates the password when it manages it; passing
291 // one alongside manageAdminPassword is rejected.
292 adminUserPassword: news.manageAdminPassword
293 ? undefined
294 : news.adminUserPassword,
295 manageAdminPassword: news.manageAdminPassword,
296 adminPasswordSecretKmsKeyId: news.adminPasswordSecretKmsKeyId,
297 kmsKeyId: news.kmsKeyId,
298 defaultIamRoleArn: news.defaultIamRoleArn,
299 iamRoles: news.iamRoles,
300 logExports: news.logExports,
301 tags: toWireTags(desiredTags),
302 })
303 .pipe(
304 Effect.map((r) => r.namespace),
305 Effect.catchTag("ConflictException", () =>
306 redshiftserverless
307 .getNamespace({ namespaceName: name })
308 .pipe(Effect.map((r) => r.namespace)),
309 ),
310 );
311 observed = created;
312 }
313
314 const settled = yield* waitForAvailable(name);
315 if (settled !== undefined) observed = settled;
316 if (observed === undefined) {
317 return yield* Effect.fail(
318 new Error(
319 `Redshift namespace '${name}' disappeared while reconciling`,
320 ),
321 );
322 }
323
324 // 3. Sync — apply mutable aspects the user specified when they drift
325 // from OBSERVED state.
326 const update: Omit<
327 redshiftserverless.UpdateNamespaceRequest,
328 "namespaceName"
329 > = {};
330 if (
331 news.defaultIamRoleArn !== undefined &&
332 observed.defaultIamRoleArn !== news.defaultIamRoleArn
333 ) {
334 update.defaultIamRoleArn = news.defaultIamRoleArn;
335 }
336 if (
337 news.iamRoles !== undefined &&
338 !sameStringSet(observed.iamRoles ?? [], news.iamRoles)
339 ) {
340 update.iamRoles = news.iamRoles;
341 }
342 if (
343 news.logExports !== undefined &&
344 !sameStringSet(observed.logExports ?? [], news.logExports)
345 ) {
346 update.logExports = news.logExports;
347 }
348 if (Object.keys(update).length > 0) {
349 yield* redshiftserverless.updateNamespace({
350 namespaceName: name,
351 ...update,
352 });
353 const updated = yield* waitForAvailable(name);
354 if (updated !== undefined) observed = updated;
355 }
356
357 // 3b. Sync tags — diff against OBSERVED cloud tags.
358 yield* syncTags(observed.namespaceArn!, desiredTags);
359
360 // 4. Return fresh attributes.
361 yield* session.note(name);
362 return toAttrs(observed);
363 }),
364
365 delete: Effect.fn(function* ({ output }) {
366 yield* redshiftserverless
367 .deleteNamespace({ namespaceName: output.namespaceName })
368 .pipe(
369 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
370 // A workgroup may still be detaching — retry briefly.
371 Effect.retry({
372 while: (e) => e._tag === "ConflictException",
373 schedule: Schedule.max([
374 Schedule.fixed("5 seconds"),
375 Schedule.recurs(24),
376 ]),
377 }),
378 Effect.catchTag("ConflictException", () => Effect.void),
379 );
380 yield* waitUntilGone(output.namespaceName);
381 }),
382
383 list: () =>
384 redshiftserverless.listNamespaces.pages({}).pipe(
385 Stream.runCollect,
386 Effect.map((chunk) =>
387 Array.from(chunk)
388 .flatMap((page) => page.namespaces ?? [])
389 .flatMap((ns) =>
390 ns.namespaceName !== undefined &&
391 ns.namespaceArn !== undefined &&
392 ns.namespaceId !== undefined
393 ? [
394 {
395 namespaceName: ns.namespaceName,
396 namespaceArn: ns.namespaceArn,
397 namespaceId: ns.namespaceId,
398 dbName: ns.dbName,
399 adminPasswordSecretArn: ns.adminPasswordSecretArn,
400 status: ns.status ?? "UNKNOWN",
401 },
402 ]
403 : [],
404 ),
405 ),
406 ),
407 };
408 }),
409 );