1import * as Config from "effect/Config";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import * as redis from "@distilled.cloud/gcp/redis_v1";
5import type { Url } from "../../Redis/index.ts";
6import { UrlMissing as RedisUrlMissing } from "../../Redis/index.ts";
7import * as Output from "../../Output.ts";
8import { bindGcpHost, type GcpIamGrant } from "../Host.ts";
9import type { Instance } from "./Instance.ts";
10
11export const REDIS_URL_ENV = "REDIS_URL";
12
13/**
14 * Shared scaffolding for Memorystore Redis RESP bindings.
15 *
16 * Deploy time transports only the instance's endpoint (`redis[s]://host:port`)
17 * and, when AUTH is enabled, its resource name through the host's
18 * RuntimeContext. The AUTH string itself never enters the host's
19 * configuration: the runtime fetches it once per instance with
20 * `instances.getAuthString`. That permission only exists in
21 * `roles/redis.admin`, so the host is granted `roles/redis.admin` on the
22 * project under an IAM Condition matching the one instance (only when AUTH
23 * is enabled; otherwise `roles/redis.viewer` under the same condition).
24 * `REDIS_URL` in the environment is only a fallback.
25 *
26 * NOT exported from `index.ts`.
27 */
28
29const redisUrlFromEnv = Config.Redacted(REDIS_URL_ENV).pipe(
30 Effect.map((value) => Redacted.value(value)),
31);
32
33const endpointOf = (instance: Instance) =>
34 Output.map(
35 Output.all(instance.host, instance.port, instance.transitEncryptionMode),
36 ([host, port, mode]) =>
37 host === undefined || host.length === 0
38 ? ""
39 : `${mode === "SERVER_AUTHENTICATION" ? "rediss" : "redis"}://${host}:${port ?? 6379}`,
40 );
41
42/** The instance name when AUTH is enabled, else `""`. */
43const authInstanceOf = (instance: Instance) =>
44 Output.map(
45 Output.all(instance.name, instance.authEnabled),
46 ([name, authEnabled]) => (authEnabled ? name : ""),
47 );
48
49const scopedCondition = (name: string) => ({
50 title: "alchemy-scoped",
51 expression: `resource.name == "${name}" || resource.name.startsWith("${name}/")`,
52});
53
54const authGrantOf = (instance: Instance) =>
55 Output.map(
56 Output.all(instance.name, instance.authEnabled),
57 ([name, authEnabled]): GcpIamGrant => ({
58 role: authEnabled ? "roles/redis.admin" : "roles/redis.viewer",
59 condition: scopedCondition(name),
60 }),
61 );
62
63const withPassword = (endpoint: string, password: string) =>
64 password.length === 0
65 ? endpoint
66 : endpoint.replace("://", `://:${encodeURIComponent(password)}@`);
67
68export const makeRedisBinding = <Client>(options: {
69 tag: string;
70 makeClient: (url: Url) => Client;
71}) =>
72 Effect.gen(function* () {
73 const getAuthString = yield* redis.getAuthStringProjectsLocationsInstances;
74 return Effect.fn(function* (instance: Instance) {
75 yield* bindGcpHost({
76 tag: options.tag,
77 resource: instance,
78 iam: [authGrantOf(instance)],
79 });
80 // A composed Output's binding key is its inspect string, which embeds
81 // the mapping functions' source — and that differs between the
82 // deploy-time source and the bundled runtime. Name it per instance.
83 const endpoint = yield* Output.named(
84 endpointOf(instance),
85 `GCP_REDIS_ENDPOINT_${instance.FQN}`,
86 );
87 const authInstance = yield* Output.named(
88 authInstanceOf(instance),
89 `GCP_REDIS_AUTH_INSTANCE_${instance.FQN}`,
90 );
91 const missing = new RedisUrlMissing({ name: instance.LogicalId });
92 // One AUTH lookup per instance for the life of the runtime; a failed
93 // lookup is not cached, so the next command retries it.
94 let cachedPassword: string | undefined;
95 const password = Effect.suspend(() =>
96 cachedPassword !== undefined
97 ? Effect.succeed(cachedPassword)
98 : Effect.gen(function* () {
99 const name = yield* authInstance;
100 const auth =
101 typeof name === "string" && name.length > 0
102 ? yield* getAuthString({ name })
103 : undefined;
104 cachedPassword = auth?.authString ?? "";
105 return cachedPassword;
106 }),
107 );
108 const url: Url = Effect.gen(function* () {
109 const value = yield* endpoint;
110 if (typeof value !== "string" || value.length === 0) {
111 return yield* redisUrlFromEnv.pipe(Effect.mapError(() => missing));
112 }
113 const secret = yield* password.pipe(
114 Effect.tapError((cause) =>
115 Effect.logError(
116 `Memorystore AUTH lookup failed for ${instance.LogicalId}`,
117 cause,
118 ),
119 ),
120 Effect.mapError(() => missing),
121 );
122 return withPassword(value, secret);
123 });
124 return options.makeClient(url);
125 });
126 });