1import * as memorydb from "@distilled.cloud/aws/memorydb";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
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 { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
11import type { Providers } from "../Providers.ts";
12import { readMemoryDbTags } from "./internal.ts";
13
14export interface ACLProps {
15 /**
16 * Name of the Access Control List. Must be 1-40 alphanumeric characters. If
17 * omitted, a deterministic physical name is generated. Changing the name
18 * replaces the ACL.
19 */
20 aclName?: string;
21 /**
22 * Names of the {@link User}s in this ACL. The account's built-in `default`
23 * user belongs to the reserved `open-access` ACL and cannot be added to a
24 * custom ACL — reference your own {@link User}s here instead.
25 * @default [] (an empty ACL)
26 */
27 userNames?: string[];
28 /**
29 * User-defined tags for the ACL.
30 */
31 tags?: Record<string, string>;
32}
33
34export interface ACL extends Resource<
35 "AWS.MemoryDB.ACL",
36 ACLProps,
37 {
38 /** Name of the ACL. */
39 aclName: string;
40 /** ARN of the ACL. */
41 aclArn: string;
42 /** Current lifecycle status (e.g. `creating`, `active`). */
43 status: string;
44 /** Names of the users in the ACL. */
45 userNames: string[];
46 /** Minimum engine version the ACL is compatible with. */
47 minimumEngineVersion: string | undefined;
48 /** Tags on the ACL (user + internal Alchemy tags). */
49 tags: Record<string, string>;
50 },
51 never,
52 Providers
53> {}
54
55/**
56 * A MemoryDB Access Control List (ACL) — a named collection of {@link User}s
57 * that a cluster authenticates against. Attach an ACL to a
58 * {@link Cluster} via `aclName`.
59 *
60 * ACLs are free and provision quickly.
61 * ### Creating an ACL
62 * **Example:** ACL with a Custom User
63 * ```typescript
64 * const appUser = yield* User("AppUser", {
65 * authenticationMode: { type: "password", passwords: [appPassword] },
66 * accessString: "on ~* +@all",
67 * });
68 * const acl = yield* ACL("AppAcl", {
69 * userNames: [appUser.userName],
70 * });
71 * ```
72 *
73 * @resource
74 */
75export const ACL = Resource<ACL>("AWS.MemoryDB.ACL");
76
77export const ACLProvider = () =>
78 Provider.effect(
79 ACL,
80 Effect.gen(function* () {
81 const toName = (id: string, props: ACLProps) =>
82 props.aclName
83 ? Effect.succeed(props.aclName)
84 : createPhysicalName({ id, maxLength: 40, lowercase: true });
85
86 const desiredUsers = (props: ACLProps): string[] => props.userNames ?? [];
87
88 const readAcl = Effect.fn(function* (name: string) {
89 const response = yield* memorydb
90 .describeACLs({ ACLName: name })
91 .pipe(
92 Effect.catchTag("ACLNotFoundFault", () =>
93 Effect.succeed(undefined),
94 ),
95 );
96 return response?.ACLs?.[0];
97 });
98
99 // Wait for the ACL to leave a transitional state before mutating or
100 // deleting it (avoids InvalidACLStateFault).
101 const waitUntilActive = Effect.fn(function* (name: string) {
102 return yield* readAcl(name).pipe(
103 Effect.flatMap((acl) => {
104 if (
105 acl !== undefined &&
106 acl.Status !== "active" &&
107 acl.Status !== undefined
108 ) {
109 return Effect.fail(
110 new Error(`ACL '${name}' not active (${acl.Status})`),
111 );
112 }
113 return Effect.succeed(acl);
114 }),
115 Effect.retry({
116 schedule: Schedule.max([
117 Schedule.fixed("5 seconds"),
118 Schedule.recurs(24),
119 ]),
120 }),
121 );
122 });
123
124 const toAttrs = Effect.fn(function* (acl: memorydb.ACL) {
125 if (!acl.Name || !acl.ARN) {
126 return yield* Effect.fail(
127 new Error(`ACL '${acl.Name}' is missing its ARN`),
128 );
129 }
130 return {
131 aclName: acl.Name,
132 aclArn: acl.ARN,
133 status: acl.Status ?? "active",
134 userNames: [...(acl.UserNames ?? [])],
135 minimumEngineVersion: acl.MinimumEngineVersion,
136 tags: yield* readMemoryDbTags(acl.ARN),
137 };
138 });
139
140 return {
141 stables: ["aclName", "aclArn"],
142
143 diff: Effect.fn(function* ({ id, olds, news }) {
144 if (!isResolved(news)) return undefined;
145 if (
146 (yield* toName(id, olds ?? {})) !== (yield* toName(id, news ?? {}))
147 ) {
148 return { action: "replace" } as const;
149 }
150 }),
151
152 read: Effect.fn(function* ({ id, olds, output }) {
153 const name = output?.aclName ?? (yield* toName(id, olds ?? {}));
154 const acl = yield* readAcl(name);
155 if (!acl?.ARN) return undefined;
156 const attrs = yield* toAttrs(acl);
157 return (yield* hasAlchemyTags(id, attrs.tags))
158 ? attrs
159 : Unowned(attrs);
160 }),
161
162 reconcile: Effect.fn(function* ({ id, news, output, session }) {
163 const props = news ?? {};
164 const name = output?.aclName ?? (yield* toName(id, props));
165 const internalTags = yield* createInternalTags(id);
166 const desiredTags = { ...internalTags, ...props.tags };
167 const desired = desiredUsers(props);
168
169 // 1. Observe — cloud state is authoritative.
170 let observed = yield* readAcl(name);
171
172 // 2. Ensure — create if missing; tolerate AlreadyExists as a race.
173 if (observed === undefined) {
174 yield* memorydb
175 .createACL({
176 ACLName: name,
177 UserNames: desired,
178 Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
179 Key,
180 Value,
181 })),
182 })
183 .pipe(
184 Effect.catchTag("ACLAlreadyExistsFault", () => Effect.void),
185 );
186 observed = yield* waitUntilActive(name);
187 }
188 if (observed === undefined) {
189 return yield* Effect.fail(
190 new Error(`ACL '${name}' not found after create`),
191 );
192 }
193
194 // 3. Sync — compute the user membership delta from OBSERVED state.
195 const observedUsers = new Set(observed.UserNames ?? []);
196 const desiredSet = new Set(desired);
197 const toAdd = desired.filter((u) => !observedUsers.has(u));
198 const toRemove = [...observedUsers].filter((u) => !desiredSet.has(u));
199 if (toAdd.length > 0 || toRemove.length > 0) {
200 yield* memorydb.updateACL({
201 ACLName: name,
202 ...(toAdd.length > 0 ? { UserNamesToAdd: toAdd } : {}),
203 ...(toRemove.length > 0 ? { UserNamesToRemove: toRemove } : {}),
204 });
205 observed = (yield* waitUntilActive(name)) ?? observed;
206 }
207
208 // 3b. Sync tags — diff against OBSERVED cloud tags.
209 const arn = observed.ARN;
210 if (arn) {
211 const observedTags = yield* readMemoryDbTags(arn);
212 const { removed, upsert } = diffTags(observedTags, desiredTags);
213 if (upsert.length > 0) {
214 yield* memorydb.tagResource({ ResourceArn: arn, Tags: upsert });
215 }
216 if (removed.length > 0) {
217 yield* memorydb.untagResource({
218 ResourceArn: arn,
219 TagKeys: removed,
220 });
221 }
222 }
223
224 yield* session.note(name);
225 return yield* toAttrs(observed);
226 }),
227
228 delete: Effect.fn(function* ({ output }) {
229 const name = output.aclName;
230 // The ACL must be detached from any cluster first; while it is still
231 // in use (or mid-modify) deletion rejects with InvalidACLStateFault.
232 // Retry bounded; NotFound is success.
233 yield* waitUntilActive(name).pipe(Effect.catch(() => Effect.void));
234 yield* memorydb.deleteACL({ ACLName: name }).pipe(
235 Effect.catchTag("ACLNotFoundFault", () => Effect.void),
236 Effect.retry({
237 while: (e) => e._tag === "InvalidACLStateFault",
238 schedule: Schedule.max([
239 Schedule.fixed("5 seconds"),
240 Schedule.recurs(12),
241 ]),
242 }),
243 );
244 }),
245
246 list: () =>
247 memorydb.describeACLs.pages({}).pipe(
248 Stream.runCollect,
249 Effect.map((chunk) =>
250 Array.from(chunk).flatMap((page) =>
251 (page.ACLs ?? []).filter(
252 (acl) =>
253 acl.Name !== undefined &&
254 acl.ARN !== undefined &&
255 // `open-access` is the AWS-managed default ACL that
256 // always exists and can never be deleted — keep it out
257 // of enumeration for account-wide teardown (nuke).
258 acl.Name !== "open-access",
259 ),
260 ),
261 ),
262 Effect.flatMap(
263 Effect.forEach((acl) => toAttrs(acl), { concurrency: 4 }),
264 ),
265 ),
266 };
267 }),
268 );