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 { 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 {
11 createInternalTags,
12 diffTags,
13 hasAlchemyTags,
14 type Tags,
15} from "../../Tags.ts";
16import type { Providers } from "../Providers.ts";
17
18export type EnvironmentStatus = finspace.EnvironmentStatus;
19export type FederationMode = finspace.FederationMode;
20export type FederationParameters = finspace.FederationParameters;
21export type SuperuserParameters = finspace.SuperuserParameters;
22
23export interface EnvironmentProps {
24 /**
25 * Name of the FinSpace environment.
26 * @default ${app}-${id}-${stage}-${suffix}
27 */
28 name?: string;
29 /**
30 * A description of the environment.
31 */
32 description?: string;
33 /**
34 * The KMS key id used to encrypt data in the environment. Changing it
35 * replaces the environment.
36 */
37 kmsKeyId?: string;
38 /**
39 * Authentication mode for the environment — `FEDERATED` (SAML) or `LOCAL`.
40 */
41 federationMode?: FederationMode;
42 /**
43 * SAML federation configuration, required when `federationMode` is
44 * `FEDERATED`.
45 */
46 federationParameters?: FederationParameters;
47 /**
48 * Configuration of the superuser created with the environment. Only used
49 * at creation; changing it replaces the environment.
50 */
51 superuserParameters?: SuperuserParameters;
52 /**
53 * ARNs of the sample data bundles to install. Only used at creation;
54 * changing it replaces the environment.
55 */
56 dataBundles?: string[];
57 /**
58 * Tags to associate with the environment.
59 */
60 tags?: Record<string, string>;
61}
62
63export interface Environment extends Resource<
64 "AWS.FinSpace.Environment",
65 EnvironmentProps,
66 {
67 /**
68 * Service-assigned unique identifier of the environment.
69 */
70 environmentId: string;
71 /**
72 * ARN of the environment.
73 */
74 environmentArn: string;
75 /**
76 * The environment's name.
77 */
78 name: string;
79 /**
80 * Current lifecycle status of the environment.
81 */
82 status: EnvironmentStatus | undefined;
83 /**
84 * Sign-in URL for the web application of the environment.
85 */
86 environmentUrl: string | undefined;
87 /**
88 * URL of the integrated SageMaker Studio domain.
89 */
90 sageMakerStudioDomainUrl: string | undefined;
91 /**
92 * ID of the KMS key encrypting the environment.
93 */
94 kmsKeyId: string | undefined;
95 /**
96 * AWS account ID of the dedicated service account associated with the
97 * environment.
98 */
99 dedicatedServiceAccountId: string | undefined;
100 /**
101 * Current tags reported for the environment.
102 */
103 tags: Record<string, string>;
104 },
105 never,
106 Providers
107> {}
108
109/**
110 * An Amazon FinSpace environment — a managed data management and analytics
111 * workspace for the financial services industry.
112 *
113 * :::caution
114 * FinSpace is closed to new customers and environment provisioning takes
115 * ~20 minutes and bills while it exists. Live lifecycle tests are gated
116 * behind `AWS_TEST_FINSPACE=1`.
117 * :::
118 * ### Creating Environments
119 * **Example:** Basic Environment
120 * ```typescript
121 * import * as AWS from "alchemy/AWS";
122 *
123 * const env = yield* AWS.FinSpace.Environment("Analytics", {
124 * description: "research analytics environment",
125 * });
126 * ```
127 *
128 * **Example:** Federated Environment
129 * ```typescript
130 * const env = yield* AWS.FinSpace.Environment("Analytics", {
131 * federationMode: "FEDERATED",
132 * federationParameters: {
133 * samlMetadataURL: "https://idp.example.com/metadata.xml",
134 * federationProviderName: "idp.example.com",
135 * applicationCallBackURL: "https://example.com/callback",
136 * },
137 * });
138 * ```
139 *
140 * @resource
141 */
142export const Environment = Resource<Environment>("AWS.FinSpace.Environment");
143
144const createEnvironmentName = (
145 id: string,
146 props: { name?: string | undefined },
147) =>
148 props.name
149 ? Effect.succeed(props.name)
150 : createPhysicalName({ id, maxLength: 255 });
151
152const fetchTags = 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 toAttributes = Effect.fn(function* (
169 env: finspace.Environment,
170 fallbackId: string,
171) {
172 const environmentId = env.environmentId ?? fallbackId;
173 const environmentArn = env.environmentArn ?? "";
174 const attrs: Environment["Attributes"] = {
175 environmentId,
176 environmentArn,
177 name: env.name ?? "",
178 status: env.status,
179 environmentUrl: env.environmentUrl,
180 sageMakerStudioDomainUrl: env.sageMakerStudioDomainUrl,
181 kmsKeyId: env.kmsKeyId,
182 dedicatedServiceAccountId: env.dedicatedServiceAccountId,
183 tags: environmentArn ? yield* fetchTags(environmentArn) : {},
184 };
185 return attrs;
186});
187
188const isGone = (status: EnvironmentStatus | undefined) =>
189 status === "DELETED" ||
190 status === "DELETING" ||
191 status === "DELETE_REQUESTED";
192
193const readEnvironmentById = Effect.fn(function* (environmentId: string) {
194 const response = yield* finspace
195 .getEnvironment({ environmentId })
196 .pipe(
197 Effect.catchTag("ResourceNotFoundException", () =>
198 Effect.succeed(undefined),
199 ),
200 );
201 const env = response?.environment;
202 if (!env || isGone(env.status)) return undefined;
203 return env;
204});
205
206// listEnvironments is not marked paginated in the Smithy model — paginate by
207// hand, page-bounded, treating an absent/empty nextToken as terminal.
208const listAllEnvironments = Effect.gen(function* () {
209 const environments: finspace.Environment[] = [];
210 let nextToken: string | undefined;
211 for (let page = 0; page < 20; page++) {
212 const response = yield* finspace.listEnvironments({ nextToken });
213 environments.push(...(response.environments ?? []));
214 if (!response.nextToken) break;
215 nextToken = response.nextToken;
216 }
217 return environments;
218});
219
220const findEnvironmentByName = Effect.fn(function* (name: string) {
221 const environments = yield* listAllEnvironments;
222 return environments.find((env) => env.name === name && !isGone(env.status));
223});
224
225/**
226 * An environment still transitioning toward the awaited status — retried by
227 * the bounded schedule in {@link waitForEnvironmentStatus}.
228 */
229class EnvironmentNotReady extends Data.TaggedError("EnvironmentNotReady")<{
230 readonly environmentId: string;
231 readonly status: string | undefined;
232}> {}
233
234/**
235 * An environment whose asynchronous provisioning converged to the terminal
236 * `FAILED_CREATION` status.
237 */
238export class EnvironmentProvisioningFailed extends Data.TaggedError(
239 "EnvironmentProvisioningFailed",
240)<{
241 readonly environmentId: string;
242 readonly status: string | undefined;
243}> {}
244
245// Explicitly-typed retry wrapper — an inline `Effect.retry` in provider
246// lifecycle code leaks `Retry.Return`'s conditional type into declaration
247// emit and widens the provider layer to `unknown` for every consumer of
248// `AWS.providers()`.
249const retryWhileNotReady = <A, E extends { readonly _tag: string }, R>(
250 self: Effect.Effect<A, E, R>,
251): Effect.Effect<A, E, R> =>
252 Effect.retry(self, {
253 while: (e) => e._tag === "EnvironmentNotReady",
254 // Environment provisioning is slow (~20 min); poll every 20s up to ~40 min.
255 schedule: Schedule.max([
256 Schedule.spaced("20 seconds"),
257 Schedule.recurs(120),
258 ]),
259 });
260
261const waitForEnvironmentStatus = (
262 environmentId: string,
263 target: "CREATED" | "DELETED",
264) =>
265 retryWhileNotReady(
266 Effect.gen(function* () {
267 const response = yield* finspace
268 .getEnvironment({ environmentId })
269 .pipe(
270 Effect.catchTag("ResourceNotFoundException", () =>
271 Effect.succeed(undefined),
272 ),
273 );
274 const status = response?.environment?.status;
275 if (target === "DELETED") {
276 if (response === undefined || isGone(status)) return;
277 return yield* Effect.fail(
278 new EnvironmentNotReady({ environmentId, status }),
279 );
280 }
281 if (status === "CREATED") return;
282 if (status === "FAILED_CREATION" || status === "SUSPENDED") {
283 return yield* Effect.fail(
284 new EnvironmentProvisioningFailed({ environmentId, status }),
285 );
286 }
287 return yield* Effect.fail(
288 new EnvironmentNotReady({ environmentId, status }),
289 );
290 }),
291 );
292
293export const EnvironmentProvider = () =>
294 Provider.effect(
295 Environment,
296 Effect.gen(function* () {
297 return {
298 stables: ["environmentId", "environmentArn"],
299 list: () =>
300 Effect.gen(function* () {
301 const environments = yield* listAllEnvironments;
302 const hydrated = yield* Effect.forEach(
303 environments.filter((env) => !isGone(env.status)),
304 (env) => toAttributes(env, env.environmentId ?? ""),
305 { concurrency: 5 },
306 );
307 return hydrated;
308 }),
309 read: Effect.fn(function* ({ id, olds, output }) {
310 const env = output?.environmentId
311 ? yield* readEnvironmentById(output.environmentId)
312 : yield* findEnvironmentByName(
313 yield* createEnvironmentName(id, olds ?? {}),
314 );
315 if (!env) return undefined;
316 const attrs = yield* toAttributes(env, output?.environmentId ?? "");
317 return (yield* hasAlchemyTags(id, attrs.tags as Tags))
318 ? attrs
319 : Unowned(attrs);
320 }),
321 diff: Effect.fn(function* ({ news, olds }) {
322 if (!isResolved(news)) return;
323 if (olds === undefined) return;
324 // KMS key, superuser and data bundles are fixed at creation.
325 if (
326 olds.kmsKeyId !== news.kmsKeyId ||
327 JSON.stringify(olds.superuserParameters) !==
328 JSON.stringify(news.superuserParameters) ||
329 JSON.stringify(olds.dataBundles) !==
330 JSON.stringify(news.dataBundles)
331 ) {
332 return { action: "replace" } as const;
333 }
334 }),
335 reconcile: Effect.fn(function* ({ id, news, output, session }) {
336 const props = news ?? {};
337 const name = yield* createEnvironmentName(id, props);
338 const internalTags = yield* createInternalTags(id);
339 const desiredTags = { ...internalTags, ...props.tags };
340
341 // Observe — prefer the cached id; fall back to a name lookup so a
342 // create whose state failed to persist is adopted, not duplicated.
343 let env = output?.environmentId
344 ? yield* readEnvironmentById(output.environmentId)
345 : yield* findEnvironmentByName(name);
346
347 // Ensure — create if missing, then wait for CREATED.
348 if (env === undefined) {
349 const created = yield* finspace.createEnvironment({
350 name,
351 description: props.description,
352 kmsKeyId: props.kmsKeyId,
353 federationMode: props.federationMode,
354 federationParameters: props.federationParameters,
355 superuserParameters: props.superuserParameters,
356 dataBundles: props.dataBundles,
357 tags: desiredTags,
358 });
359 if (!created.environmentId) {
360 return yield* Effect.fail(
361 new Error(`CreateEnvironment for '${name}' returned no id`),
362 );
363 }
364 yield* session.note(
365 `Creating FinSpace environment ${name} (${created.environmentId})...`,
366 );
367 yield* waitForEnvironmentStatus(created.environmentId, "CREATED");
368 env = yield* readEnvironmentById(created.environmentId);
369 if (env === undefined) {
370 return yield* Effect.fail(
371 new Error(`failed to read created environment ${name}`),
372 );
373 }
374 }
375 const environmentId = env.environmentId;
376 if (environmentId === undefined) {
377 return yield* Effect.fail(
378 new Error(`environment '${name}' has no environmentId`),
379 );
380 }
381
382 // Sync mutable settings — only call UpdateEnvironment on drift.
383 const needsUpdate =
384 name !== env.name ||
385 (props.description !== undefined &&
386 props.description !== env.description) ||
387 (props.federationMode !== undefined &&
388 props.federationMode !== env.federationMode);
389 if (needsUpdate) {
390 yield* finspace.updateEnvironment({
391 environmentId,
392 name,
393 description: props.description,
394 federationMode: props.federationMode,
395 federationParameters: props.federationParameters,
396 });
397 yield* session.note(`Updated FinSpace environment ${name}`);
398 }
399
400 // Sync tags — diff against observed cloud tags.
401 const attrs = yield* toAttributes(env, environmentId);
402 if (attrs.environmentArn) {
403 const { removed, upsert } = diffTags(attrs.tags, desiredTags);
404 if (removed.length > 0) {
405 yield* finspace.untagResource({
406 resourceArn: attrs.environmentArn,
407 tagKeys: removed,
408 });
409 }
410 if (upsert.length > 0) {
411 yield* finspace.tagResource({
412 resourceArn: attrs.environmentArn,
413 tags: Object.fromEntries(
414 upsert.map(({ Key, Value }) => [Key, Value]),
415 ),
416 });
417 }
418 }
419
420 yield* session.note(attrs.environmentArn);
421
422 const final = yield* readEnvironmentById(environmentId);
423 if (!final) {
424 return yield* Effect.fail(
425 new Error(`failed to read reconciled environment ${name}`),
426 );
427 }
428 return yield* toAttributes(final, environmentId);
429 }),
430 delete: Effect.fn(function* ({ output }) {
431 yield* finspace
432 .deleteEnvironment({ environmentId: output.environmentId })
433 .pipe(
434 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
435 );
436 // Deletion is irreversible once initiated; wait until the service
437 // reports it gone (DELETE_REQUESTED/DELETING/DELETED or NotFound).
438 yield* waitForEnvironmentStatus(output.environmentId, "DELETED");
439 }),
440 };
441 }),
442 );