1import * as emr from "@distilled.cloud/aws/emr";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { isResolved } from "../../Diff.ts";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9
10export interface SecurityConfigurationProps {
11 /**
12 * Name of the security configuration. If omitted, a deterministic physical
13 * name is generated. Changing the name replaces the configuration.
14 */
15 securityConfigurationName?: string;
16 /**
17 * The security configuration document — encryption, authentication
18 * (Kerberos), authorization, and instance-metadata settings — as a plain
19 * object or a pre-serialized JSON string. The document is immutable in
20 * EMR; content changes are converged by deleting and recreating the
21 * configuration under the same name (clusters capture the document at
22 * launch, so running clusters are unaffected).
23 */
24 securityConfiguration: Record<string, unknown> | string;
25}
26
27export interface SecurityConfiguration extends Resource<
28 "AWS.EMR.SecurityConfiguration",
29 SecurityConfigurationProps,
30 {
31 /** The name of the security configuration. */
32 securityConfigurationName: string;
33 /** The JSON document of the security configuration. */
34 securityConfiguration: string;
35 },
36 never,
37 Providers
38> {}
39
40/**
41 * An Amazon EMR security configuration — a reusable JSON document of
42 * encryption, authentication, and instance-metadata settings referenced by
43 * name when launching a {@link Cluster}.
44 *
45 * Clusters capture the configuration at launch, so editing a configuration
46 * only affects clusters launched afterwards.
47 * ### Creating a Security Configuration
48 * **Example:** Require IMDSv2 on Cluster Instances
49 * ```typescript
50 * const config = yield* SecurityConfiguration("Imds", {
51 * securityConfiguration: {
52 * InstanceMetadataServiceConfiguration: {
53 * MinimumInstanceMetadataServiceVersion: 2,
54 * HttpPutResponseHopLimit: 1,
55 * },
56 * },
57 * });
58 * ```
59 *
60 * **Example:** Encryption Settings
61 * ```typescript
62 * const config = yield* SecurityConfiguration("Encryption", {
63 * securityConfiguration: {
64 * EncryptionConfiguration: {
65 * EnableInTransitEncryption: false,
66 * EnableAtRestEncryption: true,
67 * AtRestEncryptionConfiguration: {
68 * S3EncryptionConfiguration: { EncryptionMode: "SSE-S3" },
69 * },
70 * },
71 * },
72 * });
73 * ```
74 *
75 * ### Using with a Cluster
76 * **Example:** Reference by Name at Launch
77 * ```typescript
78 * const cluster = yield* Cluster("Secure", {
79 * releaseLabel: "emr-7.5.0",
80 * serviceRole: serviceRole.roleName,
81 * jobFlowRole: instanceProfile.instanceProfileName,
82 * securityConfiguration: config.securityConfigurationName,
83 * });
84 * ```
85 *
86 * @resource
87 */
88export const SecurityConfiguration = Resource<SecurityConfiguration>(
89 "AWS.EMR.SecurityConfiguration",
90);
91
92/** Serialize the user-facing document prop to the wire JSON string. */
93const toDocument = (doc: Record<string, unknown> | string): string =>
94 typeof doc === "string" ? doc : JSON.stringify(doc);
95
96/** Key-order-insensitive canonical form for content comparison. */
97const stableStringify = (value: unknown): string => {
98 if (Array.isArray(value)) {
99 return `[${value.map(stableStringify).join(",")}]`;
100 }
101 if (value !== null && typeof value === "object") {
102 return `{${Object.keys(value as Record<string, unknown>)
103 .sort()
104 .map(
105 (key) =>
106 `${JSON.stringify(key)}:${stableStringify((value as Record<string, unknown>)[key])}`,
107 )
108 .join(",")}}`;
109 }
110 return JSON.stringify(value);
111};
112
113const canonicalDocument = (doc: string): string => {
114 try {
115 return stableStringify(JSON.parse(doc));
116 } catch {
117 return doc;
118 }
119};
120
121export const SecurityConfigurationProvider = () =>
122 Provider.effect(
123 SecurityConfiguration,
124 Effect.gen(function* () {
125 const toName = (
126 id: string,
127 props: Partial<SecurityConfigurationProps>,
128 ) =>
129 props.securityConfigurationName
130 ? Effect.succeed(props.securityConfigurationName)
131 : createPhysicalName({ id, maxLength: 64 });
132
133 const readConfiguration = Effect.fn(function* (name: string) {
134 return yield* emr
135 .describeSecurityConfiguration({ Name: name })
136 .pipe(
137 Effect.catchTag("SecurityConfigurationNotFound", () =>
138 Effect.succeed(undefined),
139 ),
140 );
141 });
142
143 const toAttrs = (name: string, document: string) => ({
144 securityConfigurationName: name,
145 securityConfiguration: document,
146 });
147
148 return {
149 stables: ["securityConfigurationName"],
150
151 diff: Effect.fn(function* ({ id, olds, news }) {
152 if (!isResolved(news)) return undefined;
153 const n = news;
154 const o = olds;
155 if (n === undefined || o === undefined) return undefined;
156 if ((yield* toName(id, o)) !== (yield* toName(id, n))) {
157 return { action: "replace" } as const;
158 }
159 // Content changes converge in place (reconcile deletes and
160 // recreates the immutable document under the same name).
161 }),
162
163 read: Effect.fn(function* ({ id, olds, output }) {
164 const name =
165 output?.securityConfigurationName ??
166 (yield* toName(id, olds ?? {}));
167 const found = yield* readConfiguration(name);
168 if (found === undefined) return undefined;
169 // Security configurations don't support tags, so an existing
170 // document under our derived name is treated as ours.
171 return toAttrs(name, found.SecurityConfiguration ?? "");
172 }),
173
174 reconcile: Effect.fn(function* ({ id, news, output, session }) {
175 const props = news!;
176 const name =
177 output?.securityConfigurationName ?? (yield* toName(id, props));
178 const desired = toDocument(props.securityConfiguration);
179
180 // 1. Observe — cloud state is authoritative.
181 const observed = yield* readConfiguration(name);
182
183 // 2/3. Ensure + sync: the document is immutable, so a content
184 // change is converged by deleting the old document and
185 // recreating it under the same name. A concurrent create of
186 // identical content surfaces as AlreadyExists — that's a
187 // race, not a failure.
188 if (
189 observed?.SecurityConfiguration !== undefined &&
190 canonicalDocument(observed.SecurityConfiguration) !==
191 canonicalDocument(desired)
192 ) {
193 yield* emr
194 .deleteSecurityConfiguration({ Name: name })
195 .pipe(
196 Effect.catchTag(
197 "SecurityConfigurationNotFound",
198 () => Effect.void,
199 ),
200 );
201 }
202 if (
203 observed?.SecurityConfiguration === undefined ||
204 canonicalDocument(observed.SecurityConfiguration) !==
205 canonicalDocument(desired)
206 ) {
207 yield* emr
208 .createSecurityConfiguration({
209 Name: name,
210 SecurityConfiguration: desired,
211 })
212 .pipe(
213 Effect.catchTag(
214 "SecurityConfigurationAlreadyExists",
215 () => Effect.void,
216 ),
217 );
218 }
219
220 // 4. Return fresh attributes.
221 const final = yield* readConfiguration(name);
222 yield* session.note(name);
223 return toAttrs(name, final?.SecurityConfiguration ?? desired);
224 }),
225
226 delete: Effect.fn(function* ({ output }) {
227 yield* emr
228 .deleteSecurityConfiguration({
229 Name: output.securityConfigurationName,
230 })
231 .pipe(
232 Effect.catchTag(
233 "SecurityConfigurationNotFound",
234 () => Effect.void,
235 ),
236 );
237 }),
238
239 list: () =>
240 emr.listSecurityConfigurations.items({}).pipe(
241 Stream.runCollect,
242 Effect.map((chunk) =>
243 Array.from(chunk).flatMap((summary) =>
244 summary.Name ? [summary.Name] : [],
245 ),
246 ),
247 Effect.flatMap(
248 Effect.forEach(
249 (name) =>
250 readConfiguration(name).pipe(
251 Effect.map((found) =>
252 found === undefined
253 ? []
254 : [toAttrs(name, found.SecurityConfiguration ?? "")],
255 ),
256 ),
257 { concurrency: 4 },
258 ),
259 ),
260 Effect.map((results) => results.flat()),
261 ),
262 };
263 }),
264 );