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 * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { tagRecord } from "../../Tags.ts";
12import { GcpEnvironment } from "../Environment.ts";
13import {
14 createInternalLabels,
15 diffLabels,
16 hasAlchemyLabels,
17 stripInternalLabels,
18 toLabels,
19} from "../Labels.ts";
20import type { Providers } from "../Providers.ts";
21import { waitForOperation as waitForGcpOperation } from "../Operation.ts";
22
23const DEFAULT_TIER = "BASIC";
24const DEFAULT_CONNECT_MODE = "DIRECT_PEERING";
25const DEFAULT_TRANSIT_ENCRYPTION = "DISABLED";
26const DEFAULT_MEMORY_SIZE_GB = 1;
27
28/**
29 * Memorystore create, delete, and resize operations take minutes (a BASIC
30 * 1 GiB create is ~4 minutes). Poll every 10s for up to 12 minutes.
31 */
32const LONG_POLL = { times: 72, schedule: Schedule.spaced("10 seconds") };
33const MAX_NAME_LENGTH = 40;
34
35export type RedisTimeOfDay = {
36 /** Hours of day in 24-hour format (`0`–`23`). */
37 hours?: number;
38 /** Minutes of hour (`0`–`59`). */
39 minutes?: number;
40 /** Seconds of minute (`0`–`59`). */
41 seconds?: number;
42 /** Fractional seconds, in nanoseconds. */
43 nanos?: number;
44};
45
46export type WeeklyMaintenanceWindow = {
47 /** Day of week that maintenance occurs (`MONDAY` … `SUNDAY`). */
48 day?: redis.WeeklyMaintenanceWindowDayEnum | (string & {});
49 /** Start time of the window in UTC. */
50 startTime?: RedisTimeOfDay;
51};
52
53export type MaintenancePolicy = {
54 /** Description of the policy. Max 512 characters. */
55 description?: string;
56 /** Weekly windows. Current API maximum is one window. */
57 weeklyMaintenanceWindow?: WeeklyMaintenanceWindow[];
58};
59
60export type PersistenceConfig = {
61 /**
62 * Persistence mode. `DISABLED` deletes existing snapshots; `RDB` enables
63 * RDB snapshots.
64 */
65 persistenceMode?: redis.PersistenceConfigPersistenceModeEnum | (string & {});
66 /**
67 * Period between RDB snapshots (`ONE_HOUR`, `SIX_HOURS`, `TWELVE_HOURS`,
68 * `TWENTY_FOUR_HOURS`).
69 */
70 rdbSnapshotPeriod?:
71 | redis.PersistenceConfigRdbSnapshotPeriodEnum
72 | (string & {});
73 /**
74 * Alignment timestamp for snapshots (RFC3339). If omitted, the current
75 * time is used.
76 */
77 rdbSnapshotStartTime?: string;
78};
79
80export type InstanceProps = {
81 /**
82 * Instance id (the `{instance}` segment of
83 * `projects/{project}/locations/{location}/instances/{instance}`).
84 * If omitted, a unique name is generated from the stack, stage, and
85 * logical id. Must match `^[a-z][a-z0-9-]{0,38}[a-z0-9]$` (1-40
86 * characters). Immutable — changing it replaces the instance.
87 */
88 instanceId?: string;
89 /**
90 * Region (`us-central1`, `us-east1`, …). Immutable — changing it
91 * replaces the instance. `US-CENTRAL1` is accepted and normalized to
92 * `us-central1`.
93 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
94 */
95 location?: string;
96 /**
97 * Zone to provision the primary node in (e.g. `us-central1-a`).
98 * Immutable. If omitted, Memorystore picks a zone.
99 */
100 locationId?: string;
101 /**
102 * Additional zone for STANDARD_HA. Immutable. Must differ from
103 * `locationId`.
104 */
105 alternativeLocationId?: string;
106 /**
107 * User-facing display name.
108 */
109 displayName?: string;
110 /**
111 * User labels. Alchemy ownership labels are merged in automatically.
112 */
113 labels?: Record<string, string>;
114 /**
115 * Redis software version (`REDIS_7_0`, `REDIS_7_2`, …). Upgrading uses
116 * `instances.upgrade`; downgrading replaces the instance.
117 */
118 redisVersion?: string;
119 /**
120 * Reserved IP CIDR (`DIRECT_PEERING`) or allocated range name
121 * (`PRIVATE_SERVICE_ACCESS`). Immutable.
122 */
123 reservedIpRange?: string;
124 /**
125 * Extra IP range used when enabling read replicas (`auto` or a CIDR /
126 * allocated range name).
127 */
128 secondaryIpRange?: string;
129 /**
130 * Redis config parameters (`maxmemory-policy`, `notify-keyspace-events`,
131 * …). See Memorystore supported parameters.
132 */
133 redisConfigs?: Record<string, string>;
134 /**
135 * Service tier. Immutable — changing it replaces the instance.
136 * @default "BASIC"
137 */
138 tier?: redis.InstanceTierEnum | (string & {});
139 /**
140 * Memory size in GiB.
141 * @default 1
142 */
143 memorySizeGb?: number;
144 /**
145 * VPC network
146 * (`projects/{project}/global/networks/{network}`). Immutable. Defaults
147 * to the project `default` network.
148 */
149 authorizedNetwork?: string;
150 /**
151 * Network connect mode. Immutable.
152 * @default "DIRECT_PEERING"
153 */
154 connectMode?: redis.InstanceConnectModeEnum | (string & {});
155 /**
156 * Enable OSS Redis AUTH.
157 * @default false
158 */
159 authEnabled?: boolean;
160 /**
161 * In-transit TLS mode. Immutable.
162 * @default "DISABLED"
163 */
164 transitEncryptionMode?:
165 | redis.InstanceTransitEncryptionModeEnum
166 | (string & {});
167 /**
168 * Maintenance policy. If omitted, Memorystore may perform maintenance
169 * at any time.
170 */
171 maintenancePolicy?: MaintenancePolicy;
172 /**
173 * Replica count. BASIC is `0`. STANDARD_HA without read replicas is
174 * `1`. STANDARD_HA with read replicas is `1`–`5`.
175 */
176 replicaCount?: number;
177 /**
178 * Read replica mode. Create-time only — changing it replaces the
179 * instance.
180 */
181 readReplicasMode?: redis.InstanceReadReplicasModeEnum | (string & {});
182 /**
183 * Customer-managed KMS key for at-rest encryption
184 * (`projects/{project}/locations/{location}/keyRings/{keyRing}/cryptoKeys/{cryptoKey}`). Immutable.
185 */
186 customerManagedKey?: string;
187 /**
188 * RDB persistence configuration.
189 */
190 persistenceConfig?: PersistenceConfig;
191 /**
192 * Self-service maintenance version (e.g. `"20210712_00_00"`).
193 */
194 maintenanceVersion?: string;
195};
196
197export type Instance = Resource<
198 "GCP.Redis.Instance",
199 InstanceProps,
200 {
201 /** Full resource name `projects/{project}/locations/{location}/instances/{instance}`. */
202 name: string;
203 /** Instance id (last path segment). */
204 instanceId: string;
205 /** Project id. */
206 project: string;
207 /** Region id (`us-central1`, …). */
208 location: string;
209 /** User-facing display name. */
210 displayName: string | undefined;
211 /** User labels (Alchemy ownership labels stripped). */
212 labels: Record<string, string>;
213 /** Provisioned primary zone. */
214 locationId: string | undefined;
215 /** Additional STANDARD_HA zone. */
216 alternativeLocationId: string | undefined;
217 /** Current primary zone (may change after failover). */
218 currentLocationId: string | undefined;
219 /** Redis software version. */
220 redisVersion: string | undefined;
221 /** Reserved IP CIDR or allocated range name. */
222 reservedIpRange: string | undefined;
223 /** Extra IP range for read replicas. */
224 secondaryIpRange: string | undefined;
225 /** Redis endpoint hostname or IP. */
226 host: string | undefined;
227 /** Redis endpoint port. */
228 port: number | undefined;
229 /** Server-reported state (`READY`, `CREATING`, …). */
230 state: string | undefined;
231 /** Extra status text, if any. */
232 statusMessage: string | undefined;
233 /** Redis config parameters currently applied. */
234 redisConfigs: Record<string, string>;
235 /** Service tier (`BASIC`, `STANDARD_HA`). */
236 tier: string;
237 /** Memory size in GiB. */
238 memorySizeGb: number;
239 /** Authorized VPC network. */
240 authorizedNetwork: string | undefined;
241 /** IAM identity used for import/export. */
242 persistenceIamIdentity: string | undefined;
243 /** Network connect mode. */
244 connectMode: string | undefined;
245 /** Whether OSS Redis AUTH is enabled. */
246 authEnabled: boolean;
247 /** In-transit TLS mode. */
248 transitEncryptionMode: string | undefined;
249 /** Replica count. */
250 replicaCount: number | undefined;
251 /** Per-node info. */
252 nodes: redis.NodeInfo[];
253 /** Read replica endpoint hostname (STANDARD_HA). */
254 readEndpoint: string | undefined;
255 /** Read replica endpoint port. */
256 readEndpointPort: number | undefined;
257 /** Read replica mode. */
258 readReplicasMode: string | undefined;
259 /** Customer-managed KMS key, if any. */
260 customerManagedKey: string | undefined;
261 /** Persistence configuration currently applied. */
262 persistenceConfig: PersistenceConfig | undefined;
263 /** Self-service maintenance version. */
264 maintenanceVersion: string | undefined;
265 /** RFC3339 creation timestamp. */
266 createTime: string | undefined;
267 },
268 never,
269 Providers
270>;
271
272/**
273 * A Memorystore for Redis instance.
274 *
275 * Changing `instanceId`, `location`, `tier`, `connectMode`,
276 * `authorizedNetwork`, `reservedIpRange`, `locationId`,
277 * `alternativeLocationId`, `transitEncryptionMode`, `customerManagedKey`,
278 * or `readReplicasMode` replaces the instance. Redis version upgrades use
279 * `instances.upgrade`; a downgrade replaces the instance.
280 *
281 * Provisioning typically takes several minutes.
282 *
283 * ### Creating an Instance
284 * **Example:** Generated name, BASIC 1 GiB
285 * ```typescript
286 * const cache = yield* GCP.Redis.Instance("Cache", {});
287 * ```
288 *
289 * **Example:** Explicit id, labels, and AUTH
290 * ```typescript
291 * const cache = yield* GCP.Redis.Instance("Cache", {
292 * instanceId: "app-cache",
293 * location: "us-central1",
294 * memorySizeGb: 1,
295 * authEnabled: true,
296 * labels: { env: "prod" },
297 * });
298 * ```
299 *
300 * ### Binding from a Function
301 * **Example:** ReadWriteRedis from a Function
302 * ```typescript
303 * export class Api extends GCP.Function<Api>()(
304 * "Api",
305 * { main: import.meta.url },
306 * Effect.gen(function* () {
307 * const cache = yield* GCP.Redis.Instance("Cache", { memorySizeGb: 1 });
308 * const redis = yield* GCP.Redis.ReadWriteRedis(cache);
309 * return {
310 * fetch: Effect.gen(function* () {
311 * yield* redis.set("k", "v").pipe(Effect.orDie);
312 * return HttpServerResponse.text("ok");
313 * }),
314 * };
315 * }).pipe(Effect.provide([GCP.Redis.ReadWriteRedisHttp])),
316 * ) {}
317 * ```
318 *
319 * ### Destroying an Instance
320 * **Example:** Delete waits out CREATING
321 * ```typescript
322 * const cache = yield* GCP.Redis.Instance("Cache", { memorySizeGb: 1 });
323 * ```
324 * `alchemy destroy` waits for `CREATING` to finish, then retries `Conflict` until the instance is gone.
325 *
326 * ### High Availability
327 * **Example:** STANDARD_HA across two zones
328 * ```typescript
329 * const cache = yield* GCP.Redis.Instance("Cache", {
330 * tier: "STANDARD_HA",
331 * locationId: "us-central1-a",
332 * alternativeLocationId: "us-central1-f",
333 * memorySizeGb: 1,
334 * });
335 * ```
336 *
337 * @resource
338 * @category Redis
339 */
340export const Instance = Resource<Instance>("GCP.Redis.Instance");
341
342export class InstanceNotResolved extends Data.TaggedError(
343 "GCP.Redis.InstanceNotResolved",
344)<{
345 name: string;
346}> {}
347
348export class InstanceNotReady extends Data.TaggedError(
349 "GCP.Redis.InstanceNotReady",
350)<{
351 name: string;
352 state: string;
353}> {}
354
355export class InstanceOperationPending extends Data.TaggedError(
356 "GCP.Redis.InstanceOperationPending",
357)<{
358 operation: string;
359}> {}
360
361export class InstanceStillExists extends Data.TaggedError(
362 "GCP.Redis.InstanceStillExists",
363)<{
364 name: string;
365}> {}
366
367const lastSegment = (value: string) => {
368 const trimmed = value.replace(/\/+$/, "");
369 const parts = trimmed.split("/");
370 return parts[parts.length - 1] || trimmed;
371};
372
373const normalizeLocation = (location: string | undefined, fallback: string) =>
374 lastSegment(location ?? fallback).toLowerCase();
375
376const normalizeTier = (tier: string | undefined) => {
377 const value = (tier ?? DEFAULT_TIER).toUpperCase();
378 return value === "TIER_UNSPECIFIED" ? DEFAULT_TIER : value;
379};
380
381const normalizeConnectMode = (mode: string | undefined) => {
382 const value = (mode ?? DEFAULT_CONNECT_MODE).toUpperCase();
383 return value === "CONNECT_MODE_UNSPECIFIED" ? DEFAULT_CONNECT_MODE : value;
384};
385
386const normalizeTransit = (mode: string | undefined) => {
387 const value = (mode ?? DEFAULT_TRANSIT_ENCRYPTION).toUpperCase();
388 return value === "TRANSIT_ENCRYPTION_MODE_UNSPECIFIED"
389 ? DEFAULT_TRANSIT_ENCRYPTION
390 : value;
391};
392
393const normalizeReadReplicas = (mode: string | undefined) =>
394 (mode ?? "READ_REPLICAS_DISABLED").toUpperCase();
395
396const rfc1035 = (name: string): string => {
397 let next = name
398 .toLowerCase()
399 .replace(/[^a-z0-9-]/g, "-")
400 .replace(/-+/g, "-")
401 .replace(/^-+|-+$/g, "");
402 if (!/^[a-z]/.test(next)) next = `r${next}`;
403 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
404 if (next.length === 0) return "instance";
405 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`;
406 return next.slice(0, MAX_NAME_LENGTH);
407};
408
409const resourceName = (project: string, location: string, instanceId: string) =>
410 `projects/${project}/locations/${location}/instances/${instanceId}`;
411
412const parseName = (name: string, fallbackLocation: string) => {
413 const parts = name.split("/").filter((part) => part.length > 0);
414 const instancesAt = parts.lastIndexOf("instances");
415 const locationsAt = parts.lastIndexOf("locations");
416 const projectsAt = parts.lastIndexOf("projects");
417 return {
418 project:
419 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
420 location:
421 locationsAt >= 0 && parts[locationsAt + 1]
422 ? parts[locationsAt + 1]!
423 : fallbackLocation,
424 instanceId:
425 instancesAt >= 0 && parts[instancesAt + 1]
426 ? parts[instancesAt + 1]!
427 : lastSegment(name),
428 };
429};
430
431const userLabels = (
432 labels: Record<string, string | undefined> | null | undefined,
433): Record<string, string> => stripInternalLabels(tagRecord(labels));
434
435const toId = (id: string, instanceId: string | undefined, existing?: string) =>
436 Effect.gen(function* () {
437 return (
438 instanceId ??
439 existing ??
440 rfc1035(
441 yield* createPhysicalName({
442 id,
443 maxLength: MAX_NAME_LENGTH,
444 lowercase: true,
445 }),
446 )
447 );
448 });
449
450const configsOf = (
451 configs: Record<string, string | undefined> | null | undefined,
452): Record<string, string> =>
453 Object.fromEntries(
454 Object.entries(configs ?? {}).filter(
455 (entry): entry is [string, string] => entry[1] !== undefined,
456 ),
457 );
458
459const configsKey = (
460 configs: Record<string, string | undefined> | null | undefined,
461) =>
462 JSON.stringify(
463 Object.fromEntries(
464 Object.entries(configsOf(configs)).sort(([a], [b]) => a.localeCompare(b)),
465 ),
466 );
467
468const persistenceOf = (
469 config: redis.PersistenceConfig | PersistenceConfig | undefined,
470): PersistenceConfig | undefined => {
471 if (config === undefined) return undefined;
472 return {
473 persistenceMode: config.persistenceMode,
474 rdbSnapshotPeriod: config.rdbSnapshotPeriod,
475 rdbSnapshotStartTime: config.rdbSnapshotStartTime,
476 };
477};
478
479const persistenceKey = (
480 config: redis.PersistenceConfig | PersistenceConfig | undefined,
481) => {
482 const value = persistenceOf(config);
483 return JSON.stringify({
484 persistenceMode: (value?.persistenceMode ?? "").toUpperCase(),
485 rdbSnapshotPeriod: (value?.rdbSnapshotPeriod ?? "").toUpperCase(),
486 rdbSnapshotStartTime: value?.rdbSnapshotStartTime ?? "",
487 });
488};
489
490const windowKey = (window: WeeklyMaintenanceWindow) =>
491 JSON.stringify({
492 day: (window.day ?? "").toUpperCase(),
493 hours: window.startTime?.hours ?? 0,
494 minutes: window.startTime?.minutes ?? 0,
495 seconds: window.startTime?.seconds ?? 0,
496 nanos: window.startTime?.nanos ?? 0,
497 });
498
499const maintenanceKey = (policy: MaintenancePolicy | undefined) =>
500 JSON.stringify({
501 description: policy?.description ?? "",
502 windows: (policy?.weeklyMaintenanceWindow ?? []).map(windowKey),
503 });
504
505const parseRedisVersion = (version: string | undefined) => {
506 if (version === undefined) return undefined;
507 const match = version.toUpperCase().match(/^REDIS_(\d+)_(\d+|X)$/);
508 if (!match) return undefined;
509 const minor = match[2] === "X" ? 99 : Number(match[2]);
510 return Number(match[1]) * 100 + minor;
511};
512
513const versionDecreasing = (previous: string | undefined, next: string) => {
514 const oldN = parseRedisVersion(previous);
515 const newN = parseRedisVersion(next);
516 if (oldN === undefined || newN === undefined) return previous !== next;
517 return newN < oldN;
518};
519
520const toAttrs = (instance: redis.Instance, project: string, region: string) => {
521 const name = instance.name ?? "";
522 const parsed = parseName(name, region);
523 return {
524 name,
525 instanceId: parsed.instanceId,
526 project: parsed.project || project,
527 location: parsed.location,
528 displayName: instance.displayName,
529 labels: userLabels(instance.labels),
530 locationId: instance.locationId,
531 alternativeLocationId: instance.alternativeLocationId,
532 currentLocationId: instance.currentLocationId,
533 redisVersion: instance.redisVersion,
534 reservedIpRange: instance.reservedIpRange,
535 secondaryIpRange: instance.secondaryIpRange,
536 host: instance.host,
537 port: instance.port,
538 state: instance.state,
539 statusMessage: instance.statusMessage,
540 redisConfigs: configsOf(instance.redisConfigs),
541 tier: normalizeTier(instance.tier),
542 memorySizeGb: instance.memorySizeGb ?? DEFAULT_MEMORY_SIZE_GB,
543 authorizedNetwork: instance.authorizedNetwork,
544 persistenceIamIdentity: instance.persistenceIamIdentity,
545 connectMode: instance.connectMode,
546 authEnabled: instance.authEnabled === true,
547 transitEncryptionMode: instance.transitEncryptionMode,
548 replicaCount: instance.replicaCount,
549 nodes: instance.nodes ?? [],
550 readEndpoint: instance.readEndpoint,
551 readEndpointPort: instance.readEndpointPort,
552 readReplicasMode: instance.readReplicasMode,
553 customerManagedKey: instance.customerManagedKey,
554 persistenceConfig: persistenceOf(instance.persistenceConfig),
555 maintenanceVersion: instance.maintenanceVersion,
556 createTime: instance.createTime,
557 };
558};
559
560const isPlaceholder = (instance: redis.Instance) => {
561 const name = instance.name ?? "";
562 return name.endsWith("/instances/-") || name.endsWith("/instances/");
563};
564
565const getByName = (name: string) =>
566 redis
567 .getProjectsLocationsInstances({ name })
568 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
569
570/** Wait for an operation through the shared GCP waiter. */
571const waitForOperation = (
572 operation: redis.Operation,
573 options?: { notFoundOk?: boolean },
574) =>
575 waitForGcpOperation(
576 operation,
577 (name) =>
578 redis
579 .getProjectsLocationsOperations({ name })
580 .pipe(
581 Effect.catchTag("NotFound", (error) =>
582 options?.notFoundOk === true
583 ? Effect.succeed<redis.Operation>({ name, done: true })
584 : Effect.fail(error),
585 ),
586 ),
587 { budget: "30 minutes" },
588 ).pipe(
589 // ALREADY_EXISTS (6): a concurrent create won the race. NOT_FOUND (5)
590 // is success for a delete.
591 Effect.catchIf(
592 (error) =>
593 error._tag === "GCP.OperationFailed" &&
594 (error.code === 6 ||
595 (options?.notFoundOk === true && error.code === 5)),
596 () => Effect.void,
597 ),
598 );
599
600const waitUntilExists = (name: string) =>
601 getByName(name).pipe(
602 Effect.flatMap((instance) =>
603 instance
604 ? Effect.succeed(instance)
605 : Effect.fail(new InstanceNotResolved({ name })),
606 ),
607 Effect.retry({
608 while: (error) => error._tag === "GCP.Redis.InstanceNotResolved",
609 times: 8,
610 schedule: Schedule.spaced("1 second"),
611 }),
612 );
613
614const waitUntilReady = (name: string) =>
615 getByName(name).pipe(
616 Effect.filterOrFail(
617 (instance): instance is redis.Instance => instance !== undefined,
618 () => new InstanceNotResolved({ name }),
619 ),
620 Effect.filterOrFail(
621 (instance) => (instance.state ?? "STATE_UNSPECIFIED") === "READY",
622 (instance) =>
623 new InstanceNotReady({
624 name,
625 state: instance.state ?? "STATE_UNSPECIFIED",
626 }),
627 ),
628 Effect.retry({
629 while: (error) =>
630 error._tag === "GCP.Redis.InstanceNotReady" ||
631 error._tag === "GCP.Redis.InstanceNotResolved",
632 ...LONG_POLL,
633 }),
634 );
635
636const waitUntilGone = (name: string) =>
637 getByName(name).pipe(
638 Effect.flatMap((instance) =>
639 instance === undefined
640 ? Effect.void
641 : Effect.fail(new InstanceStillExists({ name })),
642 ),
643 Effect.retry({
644 while: (error) => error._tag === "GCP.Redis.InstanceStillExists",
645 ...LONG_POLL,
646 }),
647 );
648
649const toCreateBody = (
650 news: InstanceProps,
651 desiredLabels: Record<string, string>,
652 tier: string,
653 connectMode: string,
654 transitEncryptionMode: string,
655 memorySizeGb: number,
656 authEnabled: boolean,
657): redis.Instance => ({
658 displayName: news.displayName,
659 labels: desiredLabels,
660 locationId: news.locationId,
661 alternativeLocationId: news.alternativeLocationId,
662 redisVersion: news.redisVersion,
663 reservedIpRange: news.reservedIpRange,
664 secondaryIpRange: news.secondaryIpRange,
665 redisConfigs: news.redisConfigs,
666 tier,
667 memorySizeGb,
668 authorizedNetwork: news.authorizedNetwork,
669 connectMode,
670 authEnabled,
671 transitEncryptionMode,
672 maintenancePolicy: news.maintenancePolicy,
673 replicaCount: news.replicaCount,
674 readReplicasMode: news.readReplicasMode,
675 customerManagedKey: news.customerManagedKey,
676 persistenceConfig: news.persistenceConfig,
677});
678
679export const InstanceProvider = () =>
680 Provider.succeed(Instance, {
681 stables: ["name", "instanceId", "project", "location", "createTime"],
682
683 diff: Effect.fn(function* ({ news, olds, output }) {
684 if (!isResolved(news)) return undefined;
685 const env = yield* GcpEnvironment.current;
686
687 const previousId = olds?.instanceId ?? output?.instanceId;
688 const nextId = news.instanceId ?? previousId;
689 const previousLocation = normalizeLocation(
690 olds?.location ?? output?.location,
691 env.region,
692 );
693 const nextLocation = normalizeLocation(
694 news.location ?? output?.location,
695 env.region,
696 );
697 const previousTier = normalizeTier(olds?.tier ?? output?.tier);
698 const nextTier = normalizeTier(news.tier ?? output?.tier);
699 const previousConnect = normalizeConnectMode(
700 olds?.connectMode ?? output?.connectMode,
701 );
702 const nextConnect = normalizeConnectMode(
703 news.connectMode ?? output?.connectMode,
704 );
705 const previousTransit = normalizeTransit(
706 olds?.transitEncryptionMode ?? output?.transitEncryptionMode,
707 );
708 const nextTransit = normalizeTransit(
709 news.transitEncryptionMode ?? output?.transitEncryptionMode,
710 );
711 const previousNetwork =
712 olds?.authorizedNetwork ?? output?.authorizedNetwork ?? "";
713 const nextNetwork = news.authorizedNetwork ?? previousNetwork;
714 const previousRange =
715 olds?.reservedIpRange ?? output?.reservedIpRange ?? "";
716 const nextRange = news.reservedIpRange ?? previousRange;
717 const previousZone = olds?.locationId ?? output?.locationId ?? "";
718 const nextZone = news.locationId ?? previousZone;
719 const previousAlt =
720 olds?.alternativeLocationId ?? output?.alternativeLocationId ?? "";
721 const nextAlt = news.alternativeLocationId ?? previousAlt;
722 const previousCmek =
723 olds?.customerManagedKey ?? output?.customerManagedKey ?? "";
724 const nextCmek = news.customerManagedKey ?? previousCmek;
725 const previousReplicas = normalizeReadReplicas(
726 olds?.readReplicasMode ?? output?.readReplicasMode,
727 );
728 const nextReplicas = normalizeReadReplicas(
729 news.readReplicasMode ?? output?.readReplicasMode,
730 );
731 const previousVersion = olds?.redisVersion ?? output?.redisVersion;
732 const nextVersion = news.redisVersion;
733 const downgrade =
734 nextVersion !== undefined &&
735 previousVersion !== undefined &&
736 versionDecreasing(previousVersion, nextVersion);
737
738 const replace =
739 (previousId !== undefined &&
740 nextId !== undefined &&
741 nextId !== previousId) ||
742 previousLocation !== nextLocation ||
743 previousTier !== nextTier ||
744 previousConnect !== nextConnect ||
745 previousTransit !== nextTransit ||
746 previousNetwork !== nextNetwork ||
747 previousRange !== nextRange ||
748 previousZone !== nextZone ||
749 previousAlt !== nextAlt ||
750 previousCmek !== nextCmek ||
751 previousReplicas !== nextReplicas ||
752 downgrade;
753
754 if (!replace) return undefined;
755 return {
756 action: "replace" as const,
757 deleteFirst:
758 previousLocation === nextLocation &&
759 previousId !== undefined &&
760 nextId === previousId,
761 };
762 }),
763
764 read: Effect.fn(function* ({ id, olds, output }) {
765 const env = yield* GcpEnvironment.current;
766 const instanceId = yield* toId(id, olds?.instanceId, output?.instanceId);
767 const location = normalizeLocation(
768 olds?.location ?? output?.location,
769 env.region,
770 );
771 const name =
772 output?.name ?? resourceName(env.project, location, instanceId);
773 const existing = yield* getByName(name);
774 if (existing === undefined) return undefined;
775 const attrs = toAttrs(existing, env.project, env.region);
776 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
777 ? attrs
778 : Unowned(attrs);
779 }),
780
781 list: () =>
782 Effect.gen(function* () {
783 const env = yield* GcpEnvironment.current;
784 return yield* redis.listProjectsLocationsInstances
785 .pages({
786 parent: `projects/${env.project}/locations/-`,
787 pageSize: 1000,
788 })
789 .pipe(
790 Stream.flatMap((page) => Stream.fromIterable(page.instances ?? [])),
791 Stream.filter(
792 (instance) =>
793 !isPlaceholder(instance) &&
794 Object.keys(instance.labels ?? {}).some((key) =>
795 key.startsWith("alchemy-"),
796 ),
797 ),
798 Stream.map((instance) =>
799 toAttrs(instance, env.project, env.region),
800 ),
801 Stream.runCollect,
802 Effect.map((chunk) => Array.from(chunk)),
803 Effect.catchTag("NotFound", () => Effect.succeed([])),
804 );
805 }),
806
807 reconcile: Effect.fn(function* ({ id, news, output }) {
808 const env = yield* GcpEnvironment.current;
809 const instanceId = yield* toId(id, news.instanceId, output?.instanceId);
810 const location = normalizeLocation(
811 news.location ?? output?.location,
812 env.region,
813 );
814 const name = resourceName(env.project, location, instanceId);
815 const tier = normalizeTier(news.tier);
816 const connectMode = normalizeConnectMode(news.connectMode);
817 const transitEncryptionMode = normalizeTransit(
818 news.transitEncryptionMode,
819 );
820 const memorySizeGb = news.memorySizeGb ?? DEFAULT_MEMORY_SIZE_GB;
821 const authEnabled = news.authEnabled === true;
822 const desiredLabels = {
823 ...toLabels(news.labels),
824 ...(yield* createInternalLabels(id)),
825 };
826
827 let current = yield* getByName(output?.name ?? name);
828
829 if (current === undefined) {
830 const created = yield* redis
831 .createProjectsLocationsInstances({
832 parent: `projects/${env.project}/locations/${location}`,
833 instanceId,
834 body: toCreateBody(
835 news,
836 desiredLabels,
837 tier,
838 connectMode,
839 transitEncryptionMode,
840 memorySizeGb,
841 authEnabled,
842 ),
843 })
844 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
845 if (created !== undefined) {
846 yield* waitForOperation(created);
847 }
848 current = yield* waitUntilExists(name);
849 }
850
851 if (current === undefined) {
852 return yield* new InstanceNotResolved({ name });
853 }
854
855 if ((current.state ?? "") !== "READY") {
856 current = yield* waitUntilReady(name);
857 }
858
859 const desiredVersion = news.redisVersion;
860 const currentVersion = current.redisVersion;
861 if (
862 desiredVersion !== undefined &&
863 currentVersion !== undefined &&
864 desiredVersion !== currentVersion &&
865 !versionDecreasing(currentVersion, desiredVersion)
866 ) {
867 const upgraded = yield* redis.upgradeProjectsLocationsInstances({
868 name,
869 body: { redisVersion: desiredVersion },
870 });
871 yield* waitForOperation(upgraded);
872 current = yield* waitUntilReady(name);
873 }
874
875 const observedLabels = tagRecord(current.labels);
876 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
877 const labelsChanged = upsert.length > 0 || removed.length > 0;
878 const displayNameChanged =
879 (current.displayName ?? "") !== (news.displayName ?? "");
880 const memoryChanged =
881 (current.memorySizeGb ?? DEFAULT_MEMORY_SIZE_GB) !== memorySizeGb;
882 const authChanged = (current.authEnabled === true) !== authEnabled;
883 const configsChanged =
884 news.redisConfigs !== undefined &&
885 configsKey(current.redisConfigs) !== configsKey(news.redisConfigs);
886 const persistenceChanged =
887 news.persistenceConfig !== undefined &&
888 persistenceKey(current.persistenceConfig) !==
889 persistenceKey(news.persistenceConfig);
890 const maintenanceChanged =
891 news.maintenancePolicy !== undefined &&
892 maintenanceKey(current.maintenancePolicy) !==
893 maintenanceKey(news.maintenancePolicy);
894 const replicaChanged =
895 news.replicaCount !== undefined &&
896 (current.replicaCount ?? 0) !== news.replicaCount;
897 const secondaryChanged =
898 news.secondaryIpRange !== undefined &&
899 (current.secondaryIpRange ?? "") !== news.secondaryIpRange;
900 const maintenanceVersionChanged =
901 news.maintenanceVersion !== undefined &&
902 (current.maintenanceVersion ?? "") !== news.maintenanceVersion;
903
904 if (
905 labelsChanged ||
906 displayNameChanged ||
907 memoryChanged ||
908 authChanged ||
909 configsChanged ||
910 persistenceChanged ||
911 maintenanceChanged ||
912 replicaChanged ||
913 secondaryChanged ||
914 maintenanceVersionChanged
915 ) {
916 const updateMask = [
917 labelsChanged ? "labels" : undefined,
918 displayNameChanged ? "displayName" : undefined,
919 memoryChanged ? "memorySizeGb" : undefined,
920 authChanged ? "authEnabled" : undefined,
921 configsChanged ? "redisConfig" : undefined,
922 persistenceChanged ? "persistenceConfig" : undefined,
923 maintenanceChanged ? "maintenancePolicy" : undefined,
924 replicaChanged ? "replica_count" : undefined,
925 secondaryChanged ? "secondaryIpRange" : undefined,
926 maintenanceVersionChanged ? "maintenanceVersion" : undefined,
927 ].filter((field): field is string => field !== undefined);
928
929 const patched = yield* redis.patchProjectsLocationsInstances({
930 name,
931 updateMask: updateMask.join(","),
932 body: {
933 name,
934 labels: desiredLabels,
935 displayName: news.displayName,
936 memorySizeGb,
937 authEnabled,
938 redisConfigs: news.redisConfigs,
939 persistenceConfig: news.persistenceConfig,
940 maintenancePolicy: news.maintenancePolicy,
941 replicaCount: news.replicaCount,
942 secondaryIpRange: news.secondaryIpRange,
943 maintenanceVersion: news.maintenanceVersion,
944 },
945 });
946 yield* waitForOperation(patched);
947 current = yield* waitUntilReady(name);
948 }
949
950 return toAttrs(current, env.project, env.region);
951 }),
952
953 delete: Effect.fn(function* ({ output }) {
954 yield* getByName(output.name).pipe(
955 Effect.filterOrFail(
956 (instance) =>
957 instance === undefined || (instance.state ?? "") !== "CREATING",
958 () => new InstanceOperationPending({ operation: output.name }),
959 ),
960 Effect.retry({
961 while: (error) => error._tag === "GCP.Redis.InstanceOperationPending",
962 ...LONG_POLL,
963 }),
964 );
965 const operation = yield* redis
966 .deleteProjectsLocationsInstances({ name: output.name })
967 .pipe(
968 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
969 Effect.retry({
970 while: (error) => error._tag === "Conflict",
971 times: 24,
972 schedule: Schedule.spaced("8 seconds"),
973 }),
974 );
975 if (operation !== undefined) {
976 yield* waitForOperation(operation, { notFoundOk: true });
977 }
978 yield* waitUntilGone(output.name);
979 }),
980 });