1import * as finspace from "@distilled.cloud/aws/finspace";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as EffectStream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import {
12 createInternalTags,
13 diffTags,
14 hasAlchemyTags,
15 type Tags,
16} from "../../Tags.ts";
17import type { Providers } from "../Providers.ts";
18
19export type KxEnvironmentStatus = finspace.EnvironmentStatus;
20export type TransitGatewayConfiguration = finspace.TransitGatewayConfiguration;
21export type CustomDNSServer = finspace.CustomDNSServer;
22
23export interface KxEnvironmentProps {
24 /**
25 * Name of the kdb environment.
26 * @default ${app}-${id}-${stage}-${suffix}
27 */
28 name?: string;
29 /**
30 * A description of the kdb environment.
31 */
32 description?: string;
33 /**
34 * The KMS key id used to encrypt data in the environment. Required;
35 * changing it replaces the environment.
36 */
37 kmsKeyId: string;
38 /**
39 * Transit gateway to attach so on-prem kdb clients can reach the
40 * environment. Attached once via `UpdateKxEnvironmentNetwork` after the
41 * environment is created; FinSpace does not support changing an attached
42 * network, so changing an existing configuration replaces the environment.
43 */
44 transitGatewayConfiguration?: TransitGatewayConfiguration;
45 /**
46 * Custom DNS servers to resolve on-prem hostnames from inside the
47 * environment. Attached together with `transitGatewayConfiguration`;
48 * changing an existing configuration replaces the environment.
49 */
50 customDNSConfiguration?: CustomDNSServer[];
51 /**
52 * Tags to associate with the environment.
53 */
54 tags?: Record<string, string>;
55}
56
57export interface KxEnvironment extends Resource<
58 "AWS.FinSpace.KxEnvironment",
59 KxEnvironmentProps,
60 {
61 /**
62 * Service-assigned unique identifier of the kdb environment.
63 */
64 environmentId: string;
65 /**
66 * ARN of the kdb environment.
67 */
68 environmentArn: string;
69 /**
70 * The environment's name.
71 */
72 name: string;
73 /**
74 * Current lifecycle status of the environment.
75 */
76 status: KxEnvironmentStatus | undefined;
77 /**
78 * ID of the KMS key encrypting the environment.
79 */
80 kmsKeyId: string | undefined;
81 /**
82 * The environment's description.
83 */
84 description: string | undefined;
85 /**
86 * The transit gateway attached to the environment, if any.
87 */
88 transitGatewayConfiguration: TransitGatewayConfiguration | undefined;
89 /**
90 * The custom DNS servers configured on the environment, if any.
91 */
92 customDNSConfiguration: CustomDNSServer[] | undefined;
93 /**
94 * Current tags reported for the environment.
95 */
96 tags: Record<string, string>;
97 },
98 never,
99 Providers
100> {}
101
102/**
103 * An Amazon FinSpace Managed kdb environment — the account-level container
104 * that kdb databases, clusters, users, volumes and scaling groups live in.
105 *
106 * :::caution
107 * kdb environment provisioning is slow (tens of minutes) and the service is
108 * gated to onboarded accounts. Live lifecycle tests are gated behind
109 * `AWS_TEST_FINSPACE=1`.
110 * :::
111 * ### Creating kdb Environments
112 * **Example:** Basic kdb Environment
113 * ```typescript
114 * import * as AWS from "alchemy/AWS";
115 *
116 * const key = yield* AWS.KMS.Key("KdbKey", {});
117 * const env = yield* AWS.FinSpace.KxEnvironment("Kdb", {
118 * kmsKeyId: key.keyArn,
119 * description: "managed kdb environment",
120 * });
121 * ```
122 *
123 * ### Connecting to On-Prem Networks
124 * **Example:** Attach a Transit Gateway
125 * ```typescript
126 * const env = yield* AWS.FinSpace.KxEnvironment("Kdb", {
127 * kmsKeyId: key.keyArn,
128 * transitGatewayConfiguration: {
129 * transitGatewayID: "tgw-0123456789abcdef0",
130 * routableCIDRSpace: "10.0.0.0/16",
131 * },
132 * customDNSConfiguration: [
133 * { customDNSServerName: "dns.corp.example", customDNSServerIP: "10.0.0.2" },
134 * ],
135 * });
136 * ```
137 *
138 * @resource
139 */
140export const KxEnvironment = Resource<KxEnvironment>(
141 "AWS.FinSpace.KxEnvironment",
142);
143
144const createKxEnvironmentName = (
145 id: string,
146 props: { name?: string | undefined },
147) =>
148 props.name
149 ? Effect.succeed(props.name)
150 : createPhysicalName({ id, maxLength: 255 });
151
152const fetchKxTags = Effect.fn(function* (arn: string) {
153 const response = yield* finspace
154 .listTagsForResource({ resourceArn: arn })
155 .pipe(
156 Effect.catchTag(
157 ["ResourceNotFoundException", "InvalidRequestException"],
158 () => Effect.succeed(undefined),
159 ),
160 );
161 return Object.fromEntries(
162 Object.entries(response?.tags ?? {}).flatMap(([key, value]) =>
163 value === undefined ? [] : [[key, value] as const],
164 ),
165 );
166});
167
168const isGone = (status: KxEnvironmentStatus | undefined) =>
169 status === "DELETED" ||
170 status === "DELETING" ||
171 status === "DELETE_REQUESTED";
172
173const sameJson = (a: unknown, b: unknown) =>
174 JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
175
176interface KxEnvironmentView {
177 environmentId?: string;
178 environmentArn?: string;
179 name?: string;
180 status?: KxEnvironmentStatus;
181 kmsKeyId?: string;
182 description?: string;
183 transitGatewayConfiguration?: TransitGatewayConfiguration;
184 customDNSConfiguration?: CustomDNSServer[];
185}
186
187const toKxAttributes = Effect.fn(function* (
188 env: KxEnvironmentView,
189 fallbackId: string,
190) {
191 const environmentArn = env.environmentArn ?? "";
192 const attrs: KxEnvironment["Attributes"] = {
193 environmentId: env.environmentId ?? fallbackId,
194 environmentArn,
195 name: env.name ?? "",
196 status: env.status,
197 kmsKeyId: env.kmsKeyId,
198 description: env.description,
199 transitGatewayConfiguration: env.transitGatewayConfiguration,
200 customDNSConfiguration: env.customDNSConfiguration
201 ? [...env.customDNSConfiguration]
202 : undefined,
203 tags: environmentArn ? yield* fetchKxTags(environmentArn) : {},
204 };
205 return attrs;
206});
207
208const readKxEnvironmentById = Effect.fn(function* (environmentId: string) {
209 const response = yield* finspace
210 .getKxEnvironment({ environmentId })
211 .pipe(
212 Effect.catchTag("ResourceNotFoundException", () =>
213 Effect.succeed(undefined),
214 ),
215 );
216 if (!response || isGone(response.status)) return undefined;
217 return response;
218});
219
220const findKxEnvironmentByName = Effect.fn(function* (name: string) {
221 const environments = yield* finspace.listKxEnvironments.items({}).pipe(
222 EffectStream.runCollect,
223 Effect.map((chunk) => Array.from(chunk)),
224 );
225 return environments.find((env) => env.name === name && !isGone(env.status));
226});
227
228/**
229 * A kdb environment still transitioning toward the awaited status — retried
230 * by the bounded schedule in {@link waitForKxEnvironmentStatus}.
231 */
232class KxEnvironmentNotReady extends Data.TaggedError("KxEnvironmentNotReady")<{
233 readonly environmentId: string;
234 readonly status: string | undefined;
235}> {}
236
237/**
238 * A kdb environment whose asynchronous provisioning converged to the
239 * terminal `FAILED_CREATION` status.
240 */
241export class KxEnvironmentProvisioningFailed extends Data.TaggedError(
242 "KxEnvironmentProvisioningFailed",
243)<{
244 readonly environmentId: string;
245 readonly status: string | undefined;
246}> {}
247
248// Explicitly-typed retry wrapper — an inline `Effect.retry` in provider
249// lifecycle code leaks `Retry.Return`'s conditional type into declaration
250// emit and widens the provider layer to `unknown` for every consumer of
251// `AWS.providers()`.
252const retryWhileNotReady = <A, E extends { readonly _tag: string }, R>(
253 self: Effect.Effect<A, E, R>,
254): Effect.Effect<A, E, R> =>
255 Effect.retry(self, {
256 while: (e) => e._tag === "KxEnvironmentNotReady",
257 // kdb environment provisioning is slow; poll every 20s up to ~40 min.
258 schedule: Schedule.max([
259 Schedule.spaced("20 seconds"),
260 Schedule.recurs(120),
261 ]),
262 });
263
264// Deleting an environment that is still creating/updating surfaces as a
265// Conflict — bounded retry through it. Explicitly typed for the same
266// declaration-emit reason as retryWhileNotReady.
267const retryThroughConflict = <A, E extends { readonly _tag: string }, R>(
268 self: Effect.Effect<A, E, R>,
269): Effect.Effect<A, E, R> =>
270 Effect.retry(self, {
271 while: (e) => e._tag === "ConflictException",
272 schedule: Schedule.max([Schedule.spaced("15 seconds"), Schedule.recurs(8)]),
273 });
274
275const waitForKxEnvironmentStatus = (
276 environmentId: string,
277 target: "CREATED" | "DELETED",
278) =>
279 retryWhileNotReady(
280 Effect.gen(function* () {
281 const response = yield* finspace
282 .getKxEnvironment({ environmentId })
283 .pipe(
284 Effect.catchTag("ResourceNotFoundException", () =>
285 Effect.succeed(undefined),
286 ),
287 );
288 const status = response?.status;
289 if (target === "DELETED") {
290 if (response === undefined || isGone(status)) return;
291 return yield* Effect.fail(
292 new KxEnvironmentNotReady({ environmentId, status }),
293 );
294 }
295 if (status === "CREATED") return;
296 if (status === "FAILED_CREATION") {
297 return yield* Effect.fail(
298 new KxEnvironmentProvisioningFailed({ environmentId, status }),
299 );
300 }
301 return yield* Effect.fail(
302 new KxEnvironmentNotReady({ environmentId, status }),
303 );
304 }),
305 );
306
307export const KxEnvironmentProvider = () =>
308 Provider.effect(
309 KxEnvironment,
310 Effect.gen(function* () {
311 return {
312 stables: ["environmentId", "environmentArn"],
313 list: () =>
314 Effect.gen(function* () {
315 const environments = yield* finspace.listKxEnvironments
316 .items({})
317 .pipe(
318 EffectStream.runCollect,
319 Effect.map((chunk) => Array.from(chunk)),
320 );
321 return yield* Effect.forEach(
322 environments.filter((env) => !isGone(env.status)),
323 (env) => toKxAttributes(env, env.environmentId ?? ""),
324 { concurrency: 5 },
325 );
326 }),
327 read: Effect.fn(function* ({ id, olds, output }) {
328 const env = output?.environmentId
329 ? yield* readKxEnvironmentById(output.environmentId)
330 : yield* findKxEnvironmentByName(
331 yield* createKxEnvironmentName(id, olds ?? {}),
332 );
333 if (!env) return undefined;
334 const attrs = yield* toKxAttributes(env, output?.environmentId ?? "");
335 return (yield* hasAlchemyTags(id, attrs.tags as Tags))
336 ? attrs
337 : Unowned(attrs);
338 }),
339 diff: Effect.fn(function* ({ news, olds }) {
340 if (!isResolved(news)) return;
341 if (olds === undefined) return;
342 // The KMS key is fixed at creation.
343 if (olds.kmsKeyId !== news.kmsKeyId) {
344 return { action: "replace" } as const;
345 }
346 // FinSpace does not support changing or detaching an attached
347 // network (UpdateKxEnvironmentNetwork is attach-once).
348 const hadNetwork =
349 olds.transitGatewayConfiguration !== undefined ||
350 olds.customDNSConfiguration !== undefined;
351 if (
352 hadNetwork &&
353 (!sameJson(
354 olds.transitGatewayConfiguration,
355 news.transitGatewayConfiguration,
356 ) ||
357 !sameJson(
358 olds.customDNSConfiguration,
359 news.customDNSConfiguration,
360 ))
361 ) {
362 return { action: "replace" } as const;
363 }
364 }),
365 reconcile: Effect.fn(function* ({ id, news, output, session }) {
366 if (!news) {
367 return yield* Effect.fail(
368 new Error("FinSpace KxEnvironment requires props"),
369 );
370 }
371 const name = yield* createKxEnvironmentName(id, news);
372 const internalTags = yield* createInternalTags(id);
373 const desiredTags = { ...internalTags, ...news.tags };
374
375 // Observe — prefer the cached id; fall back to a name lookup so a
376 // create whose state failed to persist is adopted, not duplicated.
377 let env: KxEnvironmentView | undefined = output?.environmentId
378 ? yield* readKxEnvironmentById(output.environmentId)
379 : yield* findKxEnvironmentByName(name);
380
381 // Ensure — create if missing, then wait for CREATED.
382 if (env === undefined) {
383 const created = yield* finspace.createKxEnvironment({
384 name,
385 description: news.description,
386 kmsKeyId: news.kmsKeyId,
387 tags: desiredTags,
388 });
389 if (!created.environmentId) {
390 return yield* Effect.fail(
391 new Error(`CreateKxEnvironment for '${name}' returned no id`),
392 );
393 }
394 yield* session.note(
395 `Creating kdb environment ${name} (${created.environmentId})...`,
396 );
397 yield* waitForKxEnvironmentStatus(created.environmentId, "CREATED");
398 env = yield* readKxEnvironmentById(created.environmentId);
399 if (env === undefined) {
400 return yield* Effect.fail(
401 new Error(`failed to read created kdb environment ${name}`),
402 );
403 }
404 }
405 const environmentId = env.environmentId;
406 if (environmentId === undefined) {
407 return yield* Effect.fail(
408 new Error(`kdb environment '${name}' has no environmentId`),
409 );
410 }
411
412 // Sync network — UpdateKxEnvironmentNetwork is attach-once: apply
413 // only when a network is desired and none is observed. (Changing
414 // an attached network is a replacement — see diff.)
415 const wantsNetwork =
416 news.transitGatewayConfiguration !== undefined ||
417 news.customDNSConfiguration !== undefined;
418 const hasNetwork =
419 env.transitGatewayConfiguration !== undefined ||
420 (env.customDNSConfiguration !== undefined &&
421 env.customDNSConfiguration.length > 0);
422 if (wantsNetwork && !hasNetwork) {
423 yield* finspace.updateKxEnvironmentNetwork({
424 environmentId,
425 transitGatewayConfiguration: news.transitGatewayConfiguration,
426 customDNSConfiguration: news.customDNSConfiguration,
427 });
428 yield* session.note(
429 `Attaching network to kdb environment ${name}...`,
430 );
431 yield* waitForKxEnvironmentStatus(environmentId, "CREATED");
432 env = (yield* readKxEnvironmentById(environmentId)) ?? env;
433 }
434
435 // Sync mutable settings — only call UpdateKxEnvironment on drift.
436 const needsUpdate =
437 name !== env.name ||
438 (news.description !== undefined &&
439 news.description !== env.description);
440 if (needsUpdate) {
441 yield* finspace.updateKxEnvironment({
442 environmentId,
443 name,
444 description: news.description,
445 });
446 yield* session.note(`Updated kdb environment ${name}`);
447 }
448
449 // Sync tags — diff against observed cloud tags.
450 const attrs = yield* toKxAttributes(env, environmentId);
451 if (attrs.environmentArn) {
452 const { removed, upsert } = diffTags(attrs.tags, desiredTags);
453 if (removed.length > 0) {
454 yield* finspace.untagResource({
455 resourceArn: attrs.environmentArn,
456 tagKeys: removed,
457 });
458 }
459 if (upsert.length > 0) {
460 yield* finspace.tagResource({
461 resourceArn: attrs.environmentArn,
462 tags: Object.fromEntries(
463 upsert.map(({ Key, Value }) => [Key, Value]),
464 ),
465 });
466 }
467 }
468
469 yield* session.note(attrs.environmentArn);
470
471 const final = yield* readKxEnvironmentById(environmentId);
472 if (!final) {
473 return yield* Effect.fail(
474 new Error(`failed to read reconciled kdb environment ${name}`),
475 );
476 }
477 return yield* toKxAttributes(final, environmentId);
478 }),
479 delete: Effect.fn(function* ({ output }) {
480 yield* retryThroughConflict(
481 finspace
482 .deleteKxEnvironment({ environmentId: output.environmentId })
483 .pipe(
484 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
485 ),
486 );
487 yield* waitForKxEnvironmentStatus(output.environmentId, "DELETED");
488 }),
489 };
490 }),
491 );