1import * as redis from "@distilled.cloud/gcp/redis_v1";
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 { GcpEnvironment } from "../Environment.ts";
11import type { Providers } from "../Providers.ts";
12import { waitForOperation as waitForGcpOperation } from "../Operation.ts";
13
14const MAX_NAME_LENGTH = 63;
15
16export type AclRule = {
17 /**
18 * Redis ACL username. For IAM auth this is an IAM user or service
19 * account; otherwise any Redis ACL username.
20 */
21 username?: string;
22 /**
23 * Redis OSS ACL rule string (e.g. `"on ~keys:* +get"`). See
24 * https://redis.io/docs/latest/operate/oss_and_stack/management/security/acl/
25 */
26 rule?: string;
27};
28
29export type AclPolicyProps = {
30 /**
31 * ACL policy id (the `{acl_policy}` segment of
32 * `projects/{project}/locations/{location}/aclPolicies/{acl_policy}`).
33 * If omitted, a unique name is generated from the stack, stage, and
34 * logical id. Must match `^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$` (1-63
35 * characters). Immutable — changing it replaces the policy.
36 */
37 aclPolicyId?: string;
38 /**
39 * Region (`us-central1`, `us-east1`, …). Immutable — changing it
40 * replaces the policy. `US-CENTRAL1` is accepted and normalized to
41 * `us-central1`.
42 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
43 */
44 location?: string;
45 /**
46 * Redis ACL rules applied to clusters that attach this policy.
47 */
48 rules?: AclRule[];
49};
50
51export type AclPolicy = Resource<
52 "GCP.Redis.AclPolicy",
53 AclPolicyProps,
54 {
55 /** Full resource name `projects/{project}/locations/{location}/aclPolicies/{acl_policy}`. */
56 name: string;
57 /** ACL policy id (last path segment). */
58 aclPolicyId: string;
59 /** Project id. */
60 project: string;
61 /** Region id (`us-central1`, …). */
62 location: string;
63 /** ACL rules. */
64 rules: AclRule[];
65 /** Server-reported state (`ACTIVE`, `UPDATING`, `DELETING`, …). */
66 state: string | undefined;
67 /** Server etag for optimistic concurrency. */
68 etag: string | undefined;
69 /** Deprecated drift-resolution version string, if present. */
70 version: string | undefined;
71 },
72 never,
73 Providers
74>;
75
76/**
77 * A Memorystore for Redis Cluster ACL policy.
78 *
79 * ACL policies have no labels field, so Alchemy identifies a policy by
80 * its id: a policy found under the generated id is adopted, one found
81 * under an explicit `aclPolicyId` without state is reported as unowned.
82 * `aclPolicyId` and `location` are identity — changing either replaces
83 * the policy. `rules` update in place. Create is synchronous; patch and
84 * delete return long-running operations.
85 *
86 * Attach the policy to a Redis Cluster with the cluster's `aclPolicy`
87 * field. A policy cannot be deleted while it is attached to a cluster.
88 *
89 * ### Creating an ACL Policy
90 * **Example:** Generated name
91 * ```typescript
92 * const policy = yield* GCP.Redis.AclPolicy("AppAcl", {
93 * rules: [{ username: "app", rule: "on ~keys:* +get" }],
94 * });
95 * ```
96 *
97 * **Example:** Explicit id, location, and rules
98 * ```typescript
99 * const policy = yield* GCP.Redis.AclPolicy("AppAcl", {
100 * aclPolicyId: "app-acl",
101 * location: "us-central1",
102 * rules: [
103 * { username: "app", rule: "on ~keys:* +get" },
104 * { username: "readonly", rule: "off ~* -@all" },
105 * ],
106 * });
107 * ```
108 *
109 * ### Updating Rules
110 * Change props on the same logical id; the engine keeps the physical id.
111 *
112 * **Example:** Add a command to an existing user
113 * ```typescript
114 * const policy = yield* GCP.Redis.AclPolicy("AppAcl", {
115 * location: "us-central1",
116 * rules: [{ username: "app", rule: "on ~keys:* +get +set" }],
117 * });
118 * ```
119 *
120 * @resource
121 * @category Redis
122 */
123export const AclPolicy = Resource<AclPolicy>("GCP.Redis.AclPolicy");
124
125export class AclPolicyNotResolved extends Data.TaggedError(
126 "GCP.Redis.AclPolicyNotResolved",
127)<{
128 name: string;
129}> {}
130
131export class AclPolicyNotReady extends Data.TaggedError(
132 "GCP.Redis.AclPolicyNotReady",
133)<{
134 name: string;
135 state: string;
136}> {}
137
138export class AclPolicyStillExists extends Data.TaggedError(
139 "GCP.Redis.AclPolicyStillExists",
140)<{
141 name: string;
142}> {}
143
144const lastSegment = (value: string) => {
145 const trimmed = value.replace(/\/+$/, "");
146 const parts = trimmed.split("/");
147 return parts[parts.length - 1] || trimmed;
148};
149
150const normalizeLocation = (location: string | undefined, fallback: string) =>
151 lastSegment(location ?? fallback).toLowerCase();
152
153const rfc1035 = (name: string): string => {
154 let next = name
155 .toLowerCase()
156 .replace(/[^a-z0-9-]/g, "-")
157 .replace(/-+/g, "-")
158 .replace(/^-+|-+$/g, "");
159 if (!/^[a-z]/.test(next)) next = `a${next}`;
160 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
161 if (next.length === 0) return "aclpolicy";
162 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`;
163 return next.slice(0, MAX_NAME_LENGTH);
164};
165
166const resourceName = (project: string, location: string, aclPolicyId: string) =>
167 `projects/${project}/locations/${location}/aclPolicies/${aclPolicyId}`;
168
169const parseName = (name: string, fallbackLocation: string) => {
170 const parts = name.split("/").filter((part) => part.length > 0);
171 const policiesAt = parts.lastIndexOf("aclPolicies");
172 const locationsAt = parts.lastIndexOf("locations");
173 const projectsAt = parts.lastIndexOf("projects");
174 return {
175 project:
176 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
177 location:
178 locationsAt >= 0 && parts[locationsAt + 1]
179 ? parts[locationsAt + 1]!
180 : fallbackLocation,
181 aclPolicyId:
182 policiesAt >= 0 && parts[policiesAt + 1]
183 ? parts[policiesAt + 1]!
184 : lastSegment(name),
185 };
186};
187
188const toId = (id: string, aclPolicyId: string | undefined, existing?: string) =>
189 Effect.gen(function* () {
190 return (
191 aclPolicyId ??
192 existing ??
193 rfc1035(
194 yield* createPhysicalName({
195 id,
196 maxLength: MAX_NAME_LENGTH,
197 lowercase: true,
198 }),
199 )
200 );
201 });
202
203const ruleOf = (rule: redis.AclRule | AclRule): AclRule => ({
204 username: rule.username,
205 rule: rule.rule,
206});
207
208const userRulesOf = (
209 rules: readonly (redis.AclRule | AclRule)[] | undefined,
210): AclRule[] => (rules ?? []).map(ruleOf);
211
212const rulesKey = (rules: readonly AclRule[]) =>
213 JSON.stringify(
214 [...rules]
215 .map((rule) => ({
216 username: rule.username ?? "",
217 rule: rule.rule ?? "",
218 }))
219 .sort(
220 (left, right) =>
221 left.username.localeCompare(right.username) ||
222 left.rule.localeCompare(right.rule),
223 ),
224 );
225
226const desiredRules = (news: AclPolicyProps): redis.AclRule[] =>
227 userRulesOf(news.rules);
228
229const toAttrs = (policy: redis.AclPolicy, project: string, region: string) => {
230 const name = policy.name ?? "";
231 const parsed = parseName(name, region);
232 return {
233 name,
234 aclPolicyId: parsed.aclPolicyId,
235 project: parsed.project || project,
236 location: parsed.location,
237 rules: userRulesOf(policy.rules),
238 state: policy.state,
239 etag: policy.etag,
240 version: policy.version,
241 };
242};
243
244const isPlaceholder = (policy: redis.AclPolicy) => {
245 const name = policy.name ?? "";
246 return name.endsWith("/aclPolicies/-") || name.endsWith("/aclPolicies/");
247};
248
249const getByName = (name: string) =>
250 redis
251 .getProjectsLocationsAclPolicies({ name })
252 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
253
254/** Wait for an operation through the shared GCP waiter. */
255const waitForOperation = (
256 operation: redis.Operation,
257 options?: { notFoundOk?: boolean },
258) =>
259 waitForGcpOperation(
260 operation,
261 (name) =>
262 redis
263 .getProjectsLocationsOperations({ name })
264 .pipe(
265 Effect.catchTag("NotFound", (error) =>
266 options?.notFoundOk === true
267 ? Effect.succeed<redis.Operation>({ name, done: true })
268 : Effect.fail(error),
269 ),
270 ),
271 { budget: "10 minutes" },
272 ).pipe(
273 // ALREADY_EXISTS (6): a concurrent create won the race. NOT_FOUND (5)
274 // is success for a delete.
275 Effect.catchIf(
276 (error) =>
277 error._tag === "GCP.OperationFailed" &&
278 (error.code === 6 ||
279 (options?.notFoundOk === true && error.code === 5)),
280 () => Effect.void,
281 ),
282 );
283
284const waitUntilExists = (name: string) =>
285 getByName(name).pipe(
286 Effect.flatMap((policy) =>
287 policy
288 ? Effect.succeed(policy)
289 : Effect.fail(new AclPolicyNotResolved({ name })),
290 ),
291 Effect.retry({
292 while: (error) => error._tag === "GCP.Redis.AclPolicyNotResolved",
293 times: 8,
294 schedule: Schedule.spaced("1 second"),
295 }),
296 );
297
298const waitUntilActive = (name: string) =>
299 getByName(name).pipe(
300 Effect.filterOrFail(
301 (policy): policy is redis.AclPolicy => policy !== undefined,
302 () => new AclPolicyNotResolved({ name }),
303 ),
304 Effect.filterOrFail(
305 (policy) => {
306 const state = policy.state ?? "ACTIVE";
307 return state === "ACTIVE" || state === "STATE_UNSPECIFIED";
308 },
309 (policy) =>
310 new AclPolicyNotReady({
311 name,
312 state: policy.state ?? "STATE_UNSPECIFIED",
313 }),
314 ),
315 Effect.retry({
316 while: (error) =>
317 error._tag === "GCP.Redis.AclPolicyNotReady" ||
318 error._tag === "GCP.Redis.AclPolicyNotResolved",
319 times: 10,
320 schedule: Schedule.spaced("3 seconds"),
321 }),
322 );
323
324const waitUntilGone = (name: string) =>
325 getByName(name).pipe(
326 Effect.flatMap((policy) =>
327 policy === undefined
328 ? Effect.void
329 : Effect.fail(new AclPolicyStillExists({ name })),
330 ),
331 Effect.retry({
332 while: (error) => error._tag === "GCP.Redis.AclPolicyStillExists",
333 times: 10,
334 schedule: Schedule.spaced("3 seconds"),
335 }),
336 );
337
338export const AclPolicyProvider = () =>
339 Provider.succeed(AclPolicy, {
340 stables: ["name", "aclPolicyId", "project", "location"],
341
342 diff: Effect.fn(function* ({ news, olds, output }) {
343 if (!isResolved(news)) return undefined;
344 const env = yield* GcpEnvironment.current;
345
346 const previousId = olds?.aclPolicyId ?? output?.aclPolicyId;
347 const nextId = news.aclPolicyId ?? previousId;
348 const previousLocation = normalizeLocation(
349 olds?.location ?? output?.location,
350 env.region,
351 );
352 const nextLocation = normalizeLocation(
353 news.location ?? output?.location,
354 env.region,
355 );
356 const replace =
357 (previousId !== undefined &&
358 nextId !== undefined &&
359 nextId !== previousId) ||
360 previousLocation !== nextLocation;
361 if (!replace) return undefined;
362 return { action: "replace" as const, deleteFirst: false };
363 }),
364
365 read: Effect.fn(function* ({ id, olds, output }) {
366 const env = yield* GcpEnvironment.current;
367 const aclPolicyId = yield* toId(
368 id,
369 olds?.aclPolicyId,
370 output?.aclPolicyId,
371 );
372 const location = normalizeLocation(
373 olds?.location ?? output?.location,
374 env.region,
375 );
376 const name =
377 output?.name ?? resourceName(env.project, location, aclPolicyId);
378 const existing = yield* getByName(name);
379 if (existing === undefined) return undefined;
380 const attrs = toAttrs(existing, env.project, env.region);
381 // No labels: state or the generated id proves ownership.
382 return output !== undefined || olds?.aclPolicyId === undefined
383 ? attrs
384 : Unowned(attrs);
385 }),
386
387 reconcile: Effect.fn(function* ({ id, news, output }) {
388 const env = yield* GcpEnvironment.current;
389 const aclPolicyId = yield* toId(
390 id,
391 news.aclPolicyId,
392 output?.aclPolicyId,
393 );
394 const location = normalizeLocation(
395 news.location ?? output?.location,
396 env.region,
397 );
398 const name = resourceName(env.project, location, aclPolicyId);
399 const bodyRules = desiredRules(news);
400
401 let current = yield* getByName(output?.name ?? name);
402 if (current !== undefined && (current.state ?? "") === "DELETING") {
403 yield* waitUntilGone(name);
404 current = undefined;
405 }
406
407 if (current === undefined) {
408 const created = yield* redis
409 .createProjectsLocationsAclPolicies({
410 parent: `projects/${env.project}/locations/${location}`,
411 aclPolicyId,
412 body: { rules: bodyRules },
413 })
414 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
415 current =
416 created && created.name && !isPlaceholder(created)
417 ? created
418 : yield* waitUntilExists(name);
419 }
420
421 if (current === undefined) {
422 return yield* new AclPolicyNotResolved({ name });
423 }
424
425 const state = current.state ?? "ACTIVE";
426 if (state === "UPDATING" || state === "DELETING") {
427 current = yield* waitUntilActive(name);
428 }
429
430 const rulesChanged =
431 rulesKey(userRulesOf(current.rules)) !==
432 rulesKey(userRulesOf(news.rules));
433
434 if (rulesChanged) {
435 const patched = yield* redis
436 .patchProjectsLocationsAclPolicies({
437 name,
438 updateMask: "rules",
439 body: { name, rules: bodyRules },
440 })
441 .pipe(
442 Effect.retry({
443 while: (error) => error._tag === "Conflict",
444 times: 8,
445 schedule: Schedule.spaced("2 seconds"),
446 }),
447 );
448 yield* waitForOperation(patched);
449 current = yield* waitUntilActive(name);
450 }
451
452 return toAttrs(current, env.project, env.region);
453 }),
454
455 delete: Effect.fn(function* ({ output }) {
456 const operation = yield* redis
457 .deleteProjectsLocationsAclPolicies({
458 name: output.name,
459 })
460 .pipe(
461 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
462 Effect.retry({
463 while: (error) => error._tag === "Conflict",
464 times: 8,
465 schedule: Schedule.spaced("2 seconds"),
466 }),
467 );
468 if (operation !== undefined) {
469 yield* waitForOperation(operation, { notFoundOk: true });
470 }
471 yield* waitUntilGone(output.name);
472 }),
473 });