1import * as datazone from "@distilled.cloud/aws/datazone";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { unredact } from "./internal.ts";
10
11/**
12 * A name/value provisioning parameter passed to the environment's blueprint.
13 */
14export interface EnvironmentUserParameter {
15 /** The parameter name. */
16 name?: string;
17 /** The parameter value. */
18 value?: string;
19}
20
21export interface EnvironmentProps {
22 /**
23 * The identifier of the {@link Domain} the environment lives in. Accepts a
24 * domain's `domainId` output. Changing it triggers a replacement.
25 */
26 domainId: string;
27 /**
28 * The identifier of the {@link Project} that owns the environment. Accepts
29 * a project's `projectId` output. Changing it triggers a replacement.
30 */
31 projectId: string;
32 /**
33 * Name of the environment. If omitted, a deterministic physical name is
34 * generated from the app, stage, and logical ID. The name is mutable — it
35 * converges via `UpdateEnvironment` without replacement.
36 */
37 name?: string;
38 /**
39 * A description of the environment.
40 */
41 description?: string;
42 /**
43 * The identifier of the environment profile to create the environment
44 * from (V1 domains). Changing it triggers a replacement.
45 */
46 environmentProfileId?: string;
47 /**
48 * The identifier of the environment blueprint to create the environment
49 * from directly (profile-less flows). Changing it triggers a replacement.
50 */
51 environmentBlueprintId?: string;
52 /**
53 * The AWS account to provision the environment in. Defaults to the
54 * domain's account. Changing it triggers a replacement.
55 */
56 environmentAccountId?: string;
57 /**
58 * The region to provision the environment in. Changing it triggers a
59 * replacement.
60 */
61 environmentAccountRegion?: string;
62 /**
63 * Blueprint provisioning parameters (e.g. the Glue database name for
64 * `DefaultDataLake`).
65 */
66 userParameters?: EnvironmentUserParameter[];
67 /**
68 * Glossary term identifiers to attach to the environment.
69 */
70 glossaryTerms?: string[];
71}
72
73export interface Environment extends Resource<
74 "AWS.DataZone.Environment",
75 EnvironmentProps,
76 {
77 /** The unique identifier of the environment. */
78 environmentId: string;
79 /** The identifier of the domain the environment lives in. */
80 domainId: string;
81 /** The identifier of the owning project. */
82 projectId: string;
83 /** The name of the environment. */
84 name: string;
85 /** The status of the environment (`ACTIVE` once deployed). */
86 status: string | undefined;
87 /** The provider of the environment (e.g. `Amazon DataZone`). */
88 provider: string;
89 /** The AWS account the environment is provisioned in. */
90 awsAccountId: string | undefined;
91 /** The region the environment is provisioned in. */
92 awsAccountRegion: string | undefined;
93 /** The identifier of the environment profile the environment came from. */
94 environmentProfileId: string | undefined;
95 /** The identifier of the blueprint the environment came from. */
96 environmentBlueprintId: string | undefined;
97 }
98> {}
99
100/**
101 * An Amazon DataZone environment — the provisioned collection of AWS
102 * resources (Glue databases, IAM roles, Athena workgroups, ...) a project
103 * works with, deployed from an environment blueprint via CloudFormation.
104 *
105 * Environment deployment is asynchronous (minutes — DataZone drives a
106 * CloudFormation stack) and is polled to `ACTIVE` with a bounded wait. The
107 * environment's blueprint must be configured in the domain first (see
108 * {@link EnvironmentBlueprintConfiguration}).
109 *
110 * ### Creating Environments
111 * **Example:** Environment from a Profile (V1 Domains)
112 * ```typescript
113 * import * as DataZone from "alchemy/AWS/DataZone";
114 *
115 * const env = yield* DataZone.Environment("datalake-env", {
116 * domainId: domain.domainId,
117 * projectId: project.projectId,
118 * environmentProfileId: profileId,
119 * });
120 * ```
121 *
122 * **Example:** Environment with Provisioning Parameters
123 * ```typescript
124 * const env = yield* DataZone.Environment("datalake-env", {
125 * domainId: domain.domainId,
126 * projectId: project.projectId,
127 * environmentProfileId: profileId,
128 * description: "Analytics data lake environment",
129 * userParameters: [
130 * { name: "glueDbName", value: "analytics_db" },
131 * ],
132 * });
133 * ```
134 *
135 * @resource
136 */
137export const Environment = Resource<Environment>("AWS.DataZone.Environment");
138
139/** The environment deployment settled in a failed state. */
140export class EnvironmentDeploymentFailed extends Data.TaggedError(
141 "AWS.DataZone.EnvironmentDeploymentFailed",
142)<{
143 readonly environmentId: string;
144 readonly status: string;
145 readonly reason: string | undefined;
146}> {}
147
148/** Environment status values indicating an in-flight transition to wait out. */
149const ENVIRONMENT_TRANSIENT = new Set(["CREATING", "UPDATING", "DELETING"]);
150
151/** Environment status values indicating a failed deployment. */
152const ENVIRONMENT_FAILED = new Set([
153 "CREATE_FAILED",
154 "UPDATE_FAILED",
155 "VALIDATION_FAILED",
156]);
157
158export const EnvironmentProvider = () =>
159 Provider.effect(
160 Environment,
161 Effect.gen(function* () {
162 const createName = Effect.fn(function* (
163 id: string,
164 props: Pick<EnvironmentProps, "name">,
165 ) {
166 return props.name ?? (yield* createPhysicalName({ id, maxLength: 64 }));
167 });
168
169 // A deleted environment — or one inside a deleted domain — is reported
170 // as AccessDeniedException, NOT ResourceNotFoundException: DataZone
171 // evaluates domain-scoped authorization before existence. Both mean
172 // "absent".
173 const getEnvironmentOrUndefined = Effect.fn(function* (
174 domainId: string,
175 environmentId: string,
176 ) {
177 return yield* datazone
178 .getEnvironment({
179 domainIdentifier: domainId,
180 identifier: environmentId,
181 })
182 .pipe(
183 Effect.catchTag("ResourceNotFoundException", () =>
184 Effect.succeed(undefined),
185 ),
186 Effect.catchTag("AccessDeniedException", () =>
187 Effect.succeed(undefined),
188 ),
189 );
190 });
191
192 const findByName = Effect.fn(function* (
193 domainId: string,
194 projectId: string,
195 name: string,
196 ) {
197 const found = yield* datazone
198 .listEnvironments({
199 domainIdentifier: domainId,
200 projectIdentifier: projectId,
201 name,
202 })
203 .pipe(
204 Effect.catchTag("ResourceNotFoundException", () =>
205 Effect.succeed(undefined),
206 ),
207 Effect.catchTag("AccessDeniedException", () =>
208 Effect.succeed(undefined),
209 ),
210 );
211 const summary = (found?.items ?? []).find(
212 (s) => unredact(s.name) === name && s.status !== "DELETING",
213 );
214 return summary?.id;
215 });
216
217 // Poll the environment to a settled (non-transient) status. Deployment
218 // drives a CloudFormation stack — bounded at ~5 minutes.
219 const waitForSettled = Effect.fn(function* (
220 domainId: string,
221 environmentId: string,
222 ) {
223 return yield* getEnvironmentOrUndefined(domainId, environmentId).pipe(
224 Effect.repeat({
225 schedule: Schedule.fixed("5 seconds"),
226 until: (env) =>
227 env === undefined ||
228 env.status === undefined ||
229 !ENVIRONMENT_TRANSIENT.has(env.status),
230 times: 60,
231 }),
232 );
233 });
234
235 // Poll the environment until it no longer exists — teardown drives a
236 // CloudFormation stack delete, bounded at ~5 minutes.
237 const waitForGone = Effect.fn(function* (
238 domainId: string,
239 environmentId: string,
240 ) {
241 yield* getEnvironmentOrUndefined(domainId, environmentId).pipe(
242 Effect.repeat({
243 schedule: Schedule.fixed("5 seconds"),
244 until: (env) => env === undefined || env.status === "DELETED",
245 times: 60,
246 }),
247 );
248 });
249
250 const failIfDeploymentFailed = Effect.fn(function* (
251 env: datazone.GetEnvironmentOutput,
252 environmentId: string,
253 ) {
254 if (env.status !== undefined && ENVIRONMENT_FAILED.has(env.status)) {
255 return yield* new EnvironmentDeploymentFailed({
256 environmentId,
257 status: env.status,
258 reason: env.lastDeployment?.failureReason?.message,
259 });
260 }
261 return env;
262 });
263
264 const toAttributes = (
265 env: datazone.GetEnvironmentOutput,
266 environmentId: string,
267 ) => ({
268 environmentId,
269 domainId: env.domainId,
270 projectId: env.projectId,
271 name: unredact(env.name),
272 status: env.status,
273 provider: env.provider,
274 awsAccountId: env.awsAccountId,
275 awsAccountRegion: env.awsAccountRegion,
276 environmentProfileId: env.environmentProfileId,
277 environmentBlueprintId: env.environmentBlueprintId,
278 });
279
280 return Environment.Provider.of({
281 stables: [
282 "environmentId",
283 "domainId",
284 "projectId",
285 "awsAccountId",
286 "awsAccountRegion",
287 ],
288
289 // Environments are keyed by their parent domain + project — there is
290 // no account-level enumeration without those identifiers.
291 list: () => Effect.succeed([]),
292
293 read: Effect.fn(function* ({ id, olds, output }) {
294 const domainId = output?.domainId ?? olds?.domainId;
295 const projectId = output?.projectId ?? olds?.projectId;
296 if (domainId === undefined || projectId === undefined) {
297 return undefined;
298 }
299 const environmentId =
300 output?.environmentId ??
301 (yield* findByName(
302 domainId,
303 projectId,
304 yield* createName(id, olds ?? {}),
305 ));
306 if (environmentId === undefined) return undefined;
307 const env = yield* getEnvironmentOrUndefined(domainId, environmentId);
308 if (
309 env === undefined ||
310 env.status === "DELETING" ||
311 env.status === "DELETED"
312 ) {
313 return undefined;
314 }
315 // Environments have no tags — ownership is tracked purely by
316 // identity.
317 return toAttributes(env, environmentId);
318 }),
319
320 diff: Effect.fn(function* ({ news, olds }) {
321 if (!isResolved(news)) return undefined;
322 if (olds === undefined) return undefined;
323 if (
324 olds.domainId !== news.domainId ||
325 olds.projectId !== news.projectId ||
326 olds.environmentProfileId !== news.environmentProfileId ||
327 olds.environmentBlueprintId !== news.environmentBlueprintId ||
328 olds.environmentAccountId !== news.environmentAccountId ||
329 olds.environmentAccountRegion !== news.environmentAccountRegion
330 ) {
331 return { action: "replace" } as const;
332 }
333 // name, description, and glossaryTerms converge via
334 // updateEnvironment.
335 }),
336
337 reconcile: Effect.fn(function* ({ id, news, output, session }) {
338 const domainId = news.domainId;
339 const projectId = news.projectId;
340 const name = yield* createName(id, news);
341
342 // 1. OBSERVE — cloud state is authoritative; output is only an id
343 // cache. Fall back to a name lookup after state loss.
344 let environmentId = output?.environmentId;
345 let env = environmentId
346 ? yield* getEnvironmentOrUndefined(domainId, environmentId)
347 : undefined;
348 if (env === undefined) {
349 environmentId = yield* findByName(domainId, projectId, name);
350 if (environmentId !== undefined) {
351 env = yield* getEnvironmentOrUndefined(domainId, environmentId);
352 }
353 }
354
355 if (env === undefined) {
356 // 2. ENSURE — create and wait out the CloudFormation deployment.
357 const created = yield* datazone.createEnvironment({
358 domainIdentifier: domainId,
359 projectIdentifier: projectId,
360 name,
361 description: news.description,
362 environmentProfileIdentifier: news.environmentProfileId,
363 environmentBlueprintIdentifier: news.environmentBlueprintId,
364 environmentAccountIdentifier: news.environmentAccountId,
365 environmentAccountRegion: news.environmentAccountRegion,
366 userParameters: news.userParameters,
367 glossaryTerms: news.glossaryTerms,
368 });
369 environmentId =
370 created.id ?? (yield* findByName(domainId, projectId, name));
371 if (environmentId === undefined) {
372 return yield* new EnvironmentDeploymentFailed({
373 environmentId: "",
374 status: "UNKNOWN",
375 reason: `createEnvironment for ${name} returned no id`,
376 });
377 }
378 env = yield* waitForSettled(domainId, environmentId);
379 if (env === undefined) {
380 return yield* new EnvironmentDeploymentFailed({
381 environmentId,
382 status: "DELETED",
383 reason: "environment disappeared during deployment",
384 });
385 }
386 env = yield* failIfDeploymentFailed(env, environmentId);
387 } else {
388 // 3. SYNC — wait out any in-flight transition, then converge the
389 // mutable aspects by diffing OBSERVED state against desired.
390 env = (yield* waitForSettled(domainId, environmentId!)) ?? env;
391 const drifted =
392 unredact(env.name) !== name ||
393 (env.description === undefined
394 ? undefined
395 : unredact(env.description)) !==
396 (news.description ?? undefined);
397 if (drifted) {
398 yield* datazone.updateEnvironment({
399 domainIdentifier: domainId,
400 identifier: environmentId!,
401 name,
402 description: news.description,
403 glossaryTerms: news.glossaryTerms,
404 });
405 env = (yield* waitForSettled(domainId, environmentId!)) ?? env;
406 env = yield* failIfDeploymentFailed(env, environmentId!);
407 }
408 }
409
410 yield* session.note(environmentId!);
411 return toAttributes(env, environmentId!);
412 }),
413
414 delete: Effect.fn(function* ({ output }) {
415 // wait out any in-flight transition first — deleting a CREATING
416 // environment is rejected with a conflict.
417 yield* waitForSettled(output.domainId, output.environmentId);
418 yield* datazone
419 .deleteEnvironment({
420 domainIdentifier: output.domainId,
421 identifier: output.environmentId,
422 })
423 .pipe(
424 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
425 // deleting an environment whose domain is already gone surfaces
426 // as AccessDenied — auth is checked before existence.
427 Effect.catchTag("AccessDeniedException", () => Effect.void),
428 );
429 yield* waitForGone(output.domainId, output.environmentId);
430 }),
431 });
432 }),
433 );