1import * as memcache from "@distilled.cloud/gcp/memcache_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 { waitForOperation as waitForGcpOperation } from "../Operation.ts";
14import {
15 createInternalLabels,
16 diffLabels,
17 hasAlchemyLabels,
18 stripInternalLabels,
19 toLabels,
20} from "../Labels.ts";
21import type { Providers } from "../Providers.ts";
22
23const DEFAULT_NODE_COUNT = 1;
24const DEFAULT_CPU_COUNT = 1;
25const DEFAULT_MEMORY_SIZE_MB = 1024;
26const MAX_NAME_LENGTH = 40;
27
28export type TimeOfDay = {
29 /** Hours of day in 24-hour format (`0`–`23`). */
30 hours?: number;
31 /** Minutes of hour (`0`–`59`). */
32 minutes?: number;
33 /** Seconds of minute (`0`–`59`). */
34 seconds?: number;
35 /** Fractional seconds, in nanoseconds. */
36 nanos?: number;
37};
38
39export type WeeklyMaintenanceWindow = {
40 /** Day of week that maintenance occurs (`MONDAY` … `SUNDAY`). */
41 day?: memcache.WeeklyMaintenanceWindowDayEnum | (string & {});
42 /** Start time of the window in UTC. */
43 startTime?: TimeOfDay;
44 /** Window length as a duration string (e.g. `"10800s"` for 3 hours). */
45 duration?: string;
46};
47
48export type MaintenancePolicy = {
49 /** Description of the policy. Max 512 characters. */
50 description?: string;
51 /** Weekly windows. Current API maximum is one window. */
52 weeklyMaintenanceWindow?: WeeklyMaintenanceWindow[];
53};
54
55export type NodeConfig = {
56 /**
57 * vCPUs per Memcached node.
58 * @default 1
59 */
60 cpuCount?: number;
61 /**
62 * Memory per node in MiB.
63 * @default 1024
64 */
65 memorySizeMb?: number;
66};
67
68export type InstanceProps = {
69 /**
70 * Instance id (the `{instance}` segment of
71 * `projects/{project}/locations/{location}/instances/{instance}`).
72 * If omitted, a unique name is generated from the stack, stage, and
73 * logical id. Must match `^[a-z][a-z0-9-]{0,38}[a-z0-9]$` (1-40
74 * characters). Immutable — changing it replaces the instance.
75 */
76 instanceId?: string;
77 /**
78 * Region (`us-central1`, `us-east1`, …). Immutable — changing it
79 * replaces the instance. `US-CENTRAL1` is accepted and normalized to
80 * `us-central1`.
81 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
82 */
83 location?: string;
84 /**
85 * User-facing display name. Max 80 characters.
86 */
87 displayName?: string;
88 /**
89 * User labels. Alchemy ownership labels are merged in automatically.
90 */
91 labels?: Record<string, string>;
92 /**
93 * VPC network
94 * (`projects/{project}/global/networks/{network}`). Immutable. Defaults
95 * to the project `default` network.
96 */
97 authorizedNetwork?: string;
98 /**
99 * Zones to place nodes in. Nodes are spread evenly. Immutable. If
100 * omitted, Memorystore uses every zone in the region.
101 */
102 zones?: string[];
103 /**
104 * Number of Memcached nodes.
105 * @default 1
106 */
107 nodeCount?: number;
108 /**
109 * Per-node CPU and memory. Immutable — changing it replaces the
110 * instance.
111 */
112 nodeConfig?: NodeConfig;
113 /**
114 * Memcached software version (`MEMCACHE_1_5`, `MEMCACHE_1_6_15`).
115 * Upgrading uses `instances.upgrade`; downgrading replaces the instance.
116 */
117 memcacheVersion?: memcache.InstanceMemcacheVersionEnum | (string & {});
118 /**
119 * User-defined memcached process parameters (`max_item_size`, …).
120 * Staged with `updateParameters` and applied with `applyParameters`.
121 */
122 parameters?: Record<string, string>;
123 /**
124 * Maintenance policy. If omitted, Memorystore may perform maintenance
125 * at any time.
126 */
127 maintenancePolicy?: MaintenancePolicy;
128 /**
129 * Allocated private-service-access range ids (e.g. `"default"`).
130 * Immutable.
131 */
132 reservedIpRangeId?: string[];
133 /**
134 * Self-service maintenance version (e.g. `"20210712_00_00"`).
135 */
136 maintenanceVersion?: string;
137};
138
139export type Instance = Resource<
140 "GCP.Memcache.Instance",
141 InstanceProps,
142 {
143 /** Full resource name `projects/{project}/locations/{location}/instances/{instance}`. */
144 name: string;
145 /** Instance id (last path segment). */
146 instanceId: string;
147 /** Project id. */
148 project: string;
149 /** Region id (`us-central1`, …). */
150 location: string;
151 /** User-facing display name. */
152 displayName: string | undefined;
153 /** User labels (Alchemy ownership labels stripped). */
154 labels: Record<string, string>;
155 /** Authorized VPC network. */
156 authorizedNetwork: string | undefined;
157 /** Zones nodes were provisioned in. */
158 zones: string[];
159 /** Number of Memcached nodes. */
160 nodeCount: number;
161 /** Per-node CPU and memory currently applied. */
162 nodeConfig: NodeConfig;
163 /** Memcached software version. */
164 memcacheVersion: string | undefined;
165 /** Full memcached version string (e.g. `memcached-1.5.16`). */
166 memcacheFullVersion: string | undefined;
167 /** User-defined memcached process parameters currently staged. */
168 parameters: Record<string, string>;
169 /** Server-assigned parameter-set id. */
170 parameterId: string | undefined;
171 /** Per-node info. */
172 memcacheNodes: memcache.Node[];
173 /** Server-reported state (`READY`, `CREATING`, …). */
174 state: string | undefined;
175 /** Discovery API endpoint. */
176 discoveryEndpoint: string | undefined;
177 /** Allocated private-service-access range ids. */
178 reservedIpRangeId: string[];
179 /** Self-service maintenance version. */
180 maintenanceVersion: string | undefined;
181 /** Effective maintenance version. */
182 effectiveMaintenanceVersion: string | undefined;
183 /** RFC3339 creation timestamp. */
184 createTime: string | undefined;
185 /** RFC3339 last-update timestamp. */
186 updateTime: string | undefined;
187 },
188 never,
189 Providers
190>;
191
192/**
193 * A Memorystore for Memcached instance.
194 *
195 * Changing `instanceId`, `location`, `authorizedNetwork`, `zones`,
196 * `nodeConfig`, or `reservedIpRangeId` replaces the instance. Memcached
197 * version upgrades use `instances.upgrade`; a downgrade replaces the
198 * instance.
199 *
200 * Provisioning typically takes several minutes.
201 *
202 * ### Creating an Instance
203 * **Example:** Generated name, 1 node
204 * ```typescript
205 * const cache = yield* GCP.Memcache.Instance("Cache", {
206 * nodeCount: 1,
207 * nodeConfig: { cpuCount: 1, memorySizeMb: 1024 },
208 * });
209 * ```
210 *
211 * **Example:** Explicit id, labels, and display name
212 * ```typescript
213 * const cache = yield* GCP.Memcache.Instance("Cache", {
214 * instanceId: "app-memcache",
215 * location: "us-central1",
216 * displayName: "app cache",
217 * labels: { env: "prod" },
218 * nodeCount: 1,
219 * nodeConfig: { cpuCount: 1, memorySizeMb: 1024 },
220 * });
221 * ```
222 *
223 * ### Scaling Nodes
224 * **Example:** Increase node count in place
225 * ```typescript
226 * const cache = yield* GCP.Memcache.Instance("Cache", {
227 * nodeCount: 2,
228 * nodeConfig: { cpuCount: 1, memorySizeMb: 1024 },
229 * });
230 * ```
231 *
232 * @resource
233 * @category Memcache
234 */
235export const Instance = Resource<Instance>("GCP.Memcache.Instance");
236
237export class InstanceNotResolved extends Data.TaggedError(
238 "GCP.Memcache.InstanceNotResolved",
239)<{
240 name: string;
241}> {}
242
243export class InstanceNotReady extends Data.TaggedError(
244 "GCP.Memcache.InstanceNotReady",
245)<{
246 name: string;
247 state: string;
248}> {}
249
250export class InstanceStillExists extends Data.TaggedError(
251 "GCP.Memcache.InstanceStillExists",
252)<{
253 name: string;
254}> {}
255
256const lastSegment = (value: string) => {
257 const trimmed = value.replace(/\/+$/, "");
258 const parts = trimmed.split("/");
259 return parts[parts.length - 1] || trimmed;
260};
261
262const normalizeLocation = (
263 location: string | undefined,
264 defaultLocation: string,
265) => lastSegment(location ?? defaultLocation).toLowerCase();
266
267const rfc1035 = (name: string): string => {
268 let next = name
269 .toLowerCase()
270 .replace(/[^a-z0-9-]/g, "-")
271 .replace(/-+/g, "-")
272 .replace(/^-+|-+$/g, "");
273 if (!/^[a-z]/.test(next)) next = `m${next}`;
274 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
275 if (next.length === 0) return "instance";
276 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`;
277 return next.slice(0, MAX_NAME_LENGTH);
278};
279
280const resourceName = (project: string, location: string, instanceId: string) =>
281 `projects/${project}/locations/${location}/instances/${instanceId}`;
282
283const parseName = (name: string, defaultLocation: string) => {
284 const parts = name.split("/").filter((part) => part.length > 0);
285 const instancesAt = parts.lastIndexOf("instances");
286 const locationsAt = parts.lastIndexOf("locations");
287 const projectsAt = parts.lastIndexOf("projects");
288 return {
289 project:
290 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
291 location:
292 locationsAt >= 0 && parts[locationsAt + 1]
293 ? parts[locationsAt + 1]!
294 : defaultLocation,
295 instanceId:
296 instancesAt >= 0 && parts[instancesAt + 1]
297 ? parts[instancesAt + 1]!
298 : lastSegment(name),
299 };
300};
301
302const userLabels = (
303 labels: Record<string, string | undefined> | null | undefined,
304): Record<string, string> => stripInternalLabels(tagRecord(labels));
305
306const toId = (id: string, instanceId: string | undefined, existing?: string) =>
307 Effect.gen(function* () {
308 return (
309 instanceId ??
310 existing ??
311 rfc1035(
312 yield* createPhysicalName({
313 id,
314 maxLength: MAX_NAME_LENGTH,
315 lowercase: true,
316 }),
317 )
318 );
319 });
320
321const paramsOf = (
322 params: Record<string, string | undefined> | null | undefined,
323): Record<string, string> =>
324 Object.fromEntries(
325 Object.entries(params ?? {}).filter(
326 (entry): entry is [string, string] => entry[1] !== undefined,
327 ),
328 );
329
330const paramsKey = (
331 params: Record<string, string | undefined> | null | undefined,
332) =>
333 JSON.stringify(
334 Object.fromEntries(
335 Object.entries(paramsOf(params)).sort(([a], [b]) => a.localeCompare(b)),
336 ),
337 );
338
339const listKey = (values: readonly string[] | undefined) =>
340 JSON.stringify(
341 [...(values ?? [])].map((value) => value.toLowerCase()).sort(),
342 );
343
344const nodeConfigOf = (config: NodeConfig | undefined): NodeConfig => ({
345 cpuCount: config?.cpuCount ?? DEFAULT_CPU_COUNT,
346 memorySizeMb: config?.memorySizeMb ?? DEFAULT_MEMORY_SIZE_MB,
347});
348
349const nodeConfigKey = (config: NodeConfig | undefined) => {
350 const value = nodeConfigOf(config);
351 return JSON.stringify({
352 cpuCount: value.cpuCount,
353 memorySizeMb: value.memorySizeMb,
354 });
355};
356
357const windowKey = (window: WeeklyMaintenanceWindow) =>
358 JSON.stringify({
359 day: (window.day ?? "").toUpperCase(),
360 hours: window.startTime?.hours ?? 0,
361 minutes: window.startTime?.minutes ?? 0,
362 seconds: window.startTime?.seconds ?? 0,
363 nanos: window.startTime?.nanos ?? 0,
364 duration: window.duration ?? "",
365 });
366
367const maintenanceOf = (
368 policy:
369 | memcache.GoogleCloudMemcacheV1MaintenancePolicy
370 | MaintenancePolicy
371 | undefined,
372): MaintenancePolicy | undefined => {
373 if (policy === undefined) return undefined;
374 return {
375 description: policy.description,
376 weeklyMaintenanceWindow: policy.weeklyMaintenanceWindow,
377 };
378};
379
380const maintenanceKey = (policy: MaintenancePolicy | undefined) =>
381 JSON.stringify({
382 description: policy?.description ?? "",
383 windows: (policy?.weeklyMaintenanceWindow ?? []).map(windowKey),
384 });
385
386const parseMemcacheVersion = (version: string | undefined) => {
387 if (version === undefined) return undefined;
388 const match = version
389 .toUpperCase()
390 .match(/^MEMCACHE_(\d+)_(\d+)(?:_(\d+))?$/);
391 if (!match) return undefined;
392 return (
393 Number(match[1]) * 10_000 + Number(match[2]) * 100 + Number(match[3] ?? 0)
394 );
395};
396
397const versionDecreasing = (previous: string | undefined, next: string) => {
398 const oldN = parseMemcacheVersion(previous);
399 const newN = parseMemcacheVersion(next);
400 if (oldN === undefined || newN === undefined) return previous !== next;
401 return newN < oldN;
402};
403
404const toAttrs = (
405 instance: memcache.Instance,
406 project: string,
407 region: string,
408) => {
409 const name = instance.name ?? "";
410 const parsed = parseName(name, region);
411 return {
412 name,
413 instanceId: parsed.instanceId,
414 project: parsed.project || project,
415 location: parsed.location,
416 displayName: instance.displayName,
417 labels: userLabels(instance.labels),
418 authorizedNetwork: instance.authorizedNetwork,
419 zones: instance.zones ?? [],
420 nodeCount: instance.nodeCount ?? DEFAULT_NODE_COUNT,
421 nodeConfig: nodeConfigOf(instance.nodeConfig),
422 memcacheVersion: instance.memcacheVersion,
423 memcacheFullVersion: instance.memcacheFullVersion,
424 parameters: paramsOf(instance.parameters?.params),
425 parameterId: instance.parameters?.id,
426 memcacheNodes: instance.memcacheNodes ?? [],
427 state: instance.state,
428 discoveryEndpoint: instance.discoveryEndpoint,
429 reservedIpRangeId: instance.reservedIpRangeId ?? [],
430 maintenanceVersion: instance.maintenanceVersion,
431 effectiveMaintenanceVersion: instance.effectiveMaintenanceVersion,
432 createTime: instance.createTime,
433 updateTime: instance.updateTime,
434 };
435};
436
437const isPlaceholder = (instance: memcache.Instance) => {
438 const name = instance.name ?? "";
439 return name.endsWith("/instances/-") || name.endsWith("/instances/");
440};
441
442const getByName = (name: string) =>
443 memcache
444 .getProjectsLocationsInstances({ name })
445 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
446
447/**
448 * Wait for a Memorystore for Memcached operation; instance create,
449 * resize, and upgrade take 10–20 minutes. With `notFoundOk`, NOT_FOUND
450 * (code 5) and an operation that is already gone count as success
451 * (delete race).
452 */
453const waitForOperation = (
454 operation: memcache.Operation,
455 options?: { notFoundOk?: boolean },
456) =>
457 waitForGcpOperation(
458 operation,
459 (name) => memcache.getProjectsLocationsOperations({ name }),
460 { budget: "30 minutes", interval: "10 seconds" },
461 ).pipe(
462 Effect.catchIf(
463 (error) =>
464 options?.notFoundOk === true &&
465 (error._tag === "NotFound" ||
466 (error._tag === "GCP.OperationFailed" && error.code === 5)),
467 () => Effect.succeed(operation),
468 ),
469 );
470
471const waitUntilExists = (name: string) =>
472 getByName(name).pipe(
473 Effect.flatMap((instance) =>
474 instance
475 ? Effect.succeed(instance)
476 : Effect.fail(new InstanceNotResolved({ name })),
477 ),
478 Effect.retry({
479 while: (error) => error._tag === "GCP.Memcache.InstanceNotResolved",
480 times: 8,
481 schedule: Schedule.spaced("1 second"),
482 }),
483 );
484
485const waitUntilReady = (name: string) =>
486 getByName(name).pipe(
487 Effect.filterOrFail(
488 (instance): instance is memcache.Instance => instance !== undefined,
489 () => new InstanceNotResolved({ name }),
490 ),
491 Effect.filterOrFail(
492 (instance) => (instance.state ?? "STATE_UNSPECIFIED") === "READY",
493 (instance) =>
494 new InstanceNotReady({
495 name,
496 state: instance.state ?? "STATE_UNSPECIFIED",
497 }),
498 ),
499 Effect.retry({
500 while: (error) =>
501 error._tag === "GCP.Memcache.InstanceNotReady" ||
502 error._tag === "GCP.Memcache.InstanceNotResolved",
503 times: 10,
504 schedule: Schedule.spaced("8 seconds"),
505 }),
506 );
507
508const waitUntilGone = (name: string) =>
509 getByName(name).pipe(
510 Effect.flatMap((instance) =>
511 instance === undefined
512 ? Effect.void
513 : Effect.fail(new InstanceStillExists({ name })),
514 ),
515 Effect.retry({
516 while: (error) => error._tag === "GCP.Memcache.InstanceStillExists",
517 times: 10,
518 schedule: Schedule.spaced("8 seconds"),
519 }),
520 );
521
522const toCreateBody = (
523 news: InstanceProps,
524 desiredLabels: Record<string, string>,
525 nodeCount: number,
526 nodeConfig: NodeConfig,
527): memcache.Instance => ({
528 displayName: news.displayName,
529 labels: desiredLabels,
530 authorizedNetwork: news.authorizedNetwork,
531 zones: news.zones,
532 nodeCount,
533 nodeConfig,
534 memcacheVersion: news.memcacheVersion,
535 parameters:
536 news.parameters === undefined ? undefined : { params: news.parameters },
537 maintenancePolicy: news.maintenancePolicy,
538 reservedIpRangeId: news.reservedIpRangeId,
539});
540
541export const InstanceProvider = () =>
542 Provider.succeed(Instance, {
543 stables: ["name", "instanceId", "project", "location", "createTime"],
544
545 diff: Effect.fn(function* ({ news, olds, output }) {
546 if (!isResolved(news)) return undefined;
547 const env = yield* GcpEnvironment.current;
548
549 const previousId = olds?.instanceId ?? output?.instanceId;
550 const nextId = news.instanceId ?? previousId;
551 const previousLocation = normalizeLocation(
552 olds?.location ?? output?.location,
553 env.region,
554 );
555 const nextLocation = normalizeLocation(
556 news.location ?? output?.location,
557 env.region,
558 );
559 const previousNetwork =
560 olds?.authorizedNetwork ?? output?.authorizedNetwork ?? "";
561 const nextNetwork = news.authorizedNetwork ?? previousNetwork;
562 const previousZones = listKey(olds?.zones ?? output?.zones);
563 const nextZones =
564 news.zones !== undefined ? listKey(news.zones) : previousZones;
565 const previousNodeConfig = nodeConfigKey(
566 olds?.nodeConfig ?? output?.nodeConfig,
567 );
568 const nextNodeConfig = nodeConfigKey(
569 news.nodeConfig ?? output?.nodeConfig,
570 );
571 const previousRanges = listKey(
572 olds?.reservedIpRangeId ?? output?.reservedIpRangeId,
573 );
574 const nextRanges =
575 news.reservedIpRangeId !== undefined
576 ? listKey(news.reservedIpRangeId)
577 : previousRanges;
578 const previousVersion = olds?.memcacheVersion ?? output?.memcacheVersion;
579 const nextVersion = news.memcacheVersion;
580 const downgrade =
581 nextVersion !== undefined &&
582 previousVersion !== undefined &&
583 versionDecreasing(previousVersion, nextVersion);
584
585 const replace =
586 (previousId !== undefined &&
587 nextId !== undefined &&
588 nextId !== previousId) ||
589 previousLocation !== nextLocation ||
590 previousNetwork !== nextNetwork ||
591 previousZones !== nextZones ||
592 previousNodeConfig !== nextNodeConfig ||
593 previousRanges !== nextRanges ||
594 downgrade;
595
596 if (!replace) return undefined;
597 return {
598 action: "replace" as const,
599 deleteFirst:
600 previousLocation === nextLocation &&
601 previousId !== undefined &&
602 nextId === previousId,
603 };
604 }),
605
606 read: Effect.fn(function* ({ id, olds, output }) {
607 const env = yield* GcpEnvironment.current;
608 const instanceId = yield* toId(id, olds?.instanceId, output?.instanceId);
609 const location = normalizeLocation(
610 olds?.location ?? output?.location,
611 env.region,
612 );
613 const name =
614 output?.name ?? resourceName(env.project, location, instanceId);
615 const existing = yield* getByName(name);
616 if (existing === undefined) return undefined;
617 const attrs = toAttrs(existing, env.project, env.region);
618 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
619 ? attrs
620 : Unowned(attrs);
621 }),
622
623 list: () =>
624 Effect.gen(function* () {
625 const env = yield* GcpEnvironment.current;
626 return yield* memcache.listProjectsLocationsInstances
627 .pages({
628 parent: `projects/${env.project}/locations/-`,
629 pageSize: 1000,
630 })
631 .pipe(
632 Stream.flatMap((page) => Stream.fromIterable(page.instances ?? [])),
633 Stream.filter(
634 (instance) =>
635 !isPlaceholder(instance) &&
636 Object.keys(instance.labels ?? {}).some((key) =>
637 key.startsWith("alchemy-"),
638 ),
639 ),
640 Stream.map((instance) =>
641 toAttrs(instance, env.project, env.region),
642 ),
643 Stream.runCollect,
644 Effect.map((chunk) => Array.from(chunk)),
645 Effect.catchTag("NotFound", () => Effect.succeed([])),
646 );
647 }),
648
649 reconcile: Effect.fn(function* ({ id, news, output }) {
650 const env = yield* GcpEnvironment.current;
651 const instanceId = yield* toId(id, news.instanceId, output?.instanceId);
652 const location = normalizeLocation(
653 news.location ?? output?.location,
654 env.region,
655 );
656 const name = resourceName(env.project, location, instanceId);
657 const nodeCount = news.nodeCount ?? DEFAULT_NODE_COUNT;
658 const nodeConfig = nodeConfigOf(news.nodeConfig);
659 const desiredLabels = {
660 ...toLabels(news.labels),
661 ...(yield* createInternalLabels(id)),
662 };
663
664 let current = yield* getByName(output?.name ?? name);
665
666 if (current === undefined) {
667 const created = yield* memcache
668 .createProjectsLocationsInstances({
669 parent: `projects/${env.project}/locations/${location}`,
670 instanceId,
671 body: toCreateBody(news, desiredLabels, nodeCount, nodeConfig),
672 })
673 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
674 if (created !== undefined) {
675 yield* waitForOperation(created);
676 }
677 current = yield* waitUntilExists(name);
678 }
679
680 if (current === undefined) {
681 return yield* new InstanceNotResolved({ name });
682 }
683
684 if ((current.state ?? "") !== "READY") {
685 current = yield* waitUntilReady(name);
686 }
687
688 const desiredVersion = news.memcacheVersion;
689 const currentVersion = current.memcacheVersion;
690 if (
691 desiredVersion !== undefined &&
692 currentVersion !== undefined &&
693 desiredVersion !== currentVersion &&
694 !versionDecreasing(currentVersion, desiredVersion)
695 ) {
696 const upgraded = yield* memcache.upgradeProjectsLocationsInstances({
697 name,
698 body: { memcacheVersion: desiredVersion },
699 });
700 yield* waitForOperation(upgraded);
701 current = yield* waitUntilReady(name);
702 }
703
704 const observedLabels = tagRecord(current.labels);
705 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
706 const labelsChanged = upsert.length > 0 || removed.length > 0;
707 const displayNameChanged =
708 (current.displayName ?? "") !== (news.displayName ?? "");
709 const desiredNodeCount = news.nodeCount ?? current.nodeCount ?? nodeCount;
710 const nodeCountChanged =
711 (current.nodeCount ?? DEFAULT_NODE_COUNT) !== desiredNodeCount;
712 const maintenanceChanged =
713 news.maintenancePolicy !== undefined &&
714 maintenanceKey(maintenanceOf(current.maintenancePolicy)) !==
715 maintenanceKey(news.maintenancePolicy);
716 const maintenanceVersionChanged =
717 news.maintenanceVersion !== undefined &&
718 (current.maintenanceVersion ?? "") !== news.maintenanceVersion;
719
720 if (
721 labelsChanged ||
722 displayNameChanged ||
723 nodeCountChanged ||
724 maintenanceChanged ||
725 maintenanceVersionChanged
726 ) {
727 const updateMask = [
728 labelsChanged ? "labels" : undefined,
729 displayNameChanged ? "displayName" : undefined,
730 nodeCountChanged ? "nodeCount" : undefined,
731 maintenanceChanged ? "maintenancePolicy" : undefined,
732 maintenanceVersionChanged ? "maintenanceVersion" : undefined,
733 ].filter((field): field is string => field !== undefined);
734
735 const patched = yield* memcache.patchProjectsLocationsInstances({
736 name,
737 updateMask: updateMask.join(","),
738 body: {
739 name,
740 labels: desiredLabels,
741 displayName: news.displayName,
742 nodeCount: desiredNodeCount,
743 maintenancePolicy: news.maintenancePolicy,
744 maintenanceVersion: news.maintenanceVersion,
745 },
746 });
747 yield* waitForOperation(patched);
748 current = yield* waitUntilReady(name);
749 }
750
751 const parametersChanged =
752 news.parameters !== undefined &&
753 paramsKey(current.parameters?.params) !== paramsKey(news.parameters);
754 if (parametersChanged) {
755 const updated =
756 yield* memcache.updateParametersProjectsLocationsInstances({
757 name,
758 body: {
759 updateMask: "parameters",
760 parameters: { params: news.parameters },
761 },
762 });
763 yield* waitForOperation(updated);
764 const applied =
765 yield* memcache.applyParametersProjectsLocationsInstances({
766 name,
767 body: { applyAll: true },
768 });
769 yield* waitForOperation(applied);
770 current = yield* waitUntilReady(name);
771 }
772
773 return toAttrs(current, env.project, env.region);
774 }),
775
776 delete: Effect.fn(function* ({ output }) {
777 const operation = yield* memcache
778 .deleteProjectsLocationsInstances({ name: output.name })
779 .pipe(
780 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
781 Effect.retry({
782 while: (error) => error._tag === "Conflict",
783 times: 8,
784 schedule: Schedule.spaced("5 seconds"),
785 }),
786 );
787 if (operation !== undefined) {
788 yield* waitForOperation(operation, { notFoundOk: true });
789 }
790 yield* waitUntilGone(output.name);
791 }),
792 });