1import * as netapp from "@distilled.cloud/gcp/netapp_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import {
11 createInternalLabels,
12 diffLabels,
13 hasAlchemyLabels,
14 toLabels,
15} from "../Labels.ts";
16import type { Providers } from "../Providers.ts";
17import {
18 fieldMask,
19 fingerprint,
20 listAtNested,
21 listLabeledPages,
22 normalizeLocation,
23 parseName,
24 replaceOnIdentity,
25 ResourceNotReady,
26 ResourceNotResolved,
27 storagePoolOf,
28 toPhysicalId,
29 userLabels,
30 volumeOf,
31 waitForDeleteOperation,
32 waitForOperation,
33 waitUntilExists,
34 waitUntilGone,
35 waitUntilReady,
36} from "./internal.ts";
37
38const DEFAULT_SCHEDULE = "HOURLY";
39const STOPPED = "STOPPED";
40const ENABLED_MIRROR_STATES = new Set([
41 "PREPARING",
42 "MIRRORED",
43 "TRANSFERRING",
44 "BASELINE_TRANSFERRING",
45 "PENDING_PEERING",
46]);
47
48export type ReplicationTieringPolicy = {
49 /**
50 * Enable or pause auto-tiering on the destination volume.
51 * @default "PAUSED"
52 */
53 tierAction?: netapp.TieringPolicyTierActionEnum | (string & {});
54 /**
55 * Days before data is eligible for tiering (2-183).
56 * @default 31
57 */
58 coolingThresholdDays?: number;
59 /**
60 * Hot-tier bypass for Flex service level.
61 * @default false
62 */
63 hotTierBypassModeEnabled?: boolean;
64};
65
66export type DestinationVolumeParameters = {
67 /**
68 * Destination storage pool. Full name
69 * `projects/{project}/locations/{location}/storagePools/{pool}`
70 * recommended (destination pools are usually in another region). A
71 * bare id is expanded in the source volume location.
72 */
73 storagePool: string;
74 /**
75 * Share name of the destination volume. Defaults to the source share
76 * name.
77 */
78 shareName?: string;
79 /**
80 * Description of the destination volume.
81 */
82 description?: string;
83 /**
84 * Destination volume id. Defaults to the source volume id.
85 */
86 volumeId?: string;
87 /**
88 * Auto-tiering policy for the destination volume. Requires auto-tiering
89 * on the destination pool.
90 */
91 tieringPolicy?: ReplicationTieringPolicy;
92};
93
94export type VolumesReplicationProps = {
95 /**
96 * Source volume. Full name
97 * `projects/{project}/locations/{location}/volumes/{volume}` or the
98 * volume id (combined with `location`). Immutable — changing it
99 * replaces the replication.
100 */
101 volume: string;
102 /**
103 * Region used when `volume` is a bare id. Immutable — changing it
104 * replaces the replication. `US-CENTRAL1` is accepted and normalized
105 * to `us-central1`.
106 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
107 */
108 location?: string;
109 /**
110 * Replication id (the `{replication}` segment of
111 * `.../volumes/{volume}/replications/{replication}`). If omitted, a
112 * unique RFC1035 name is generated from the stack, stage, and logical
113 * id. Immutable — changing it replaces the replication.
114 */
115 replicationId?: string;
116 /**
117 * Replication interval.
118 * @default "HOURLY"
119 */
120 replicationSchedule?:
121 | netapp.ReplicationReplicationScheduleEnum
122 | (string & {});
123 /**
124 * Destination volume parameters. Input-only on create. Changing
125 * `storagePool`, `volumeId`, or `shareName` replaces the replication.
126 */
127 destinationVolumeParameters: DestinationVolumeParameters;
128 /**
129 * Human-readable description.
130 */
131 description?: string;
132 /**
133 * User labels. Alchemy ownership labels are merged in automatically.
134 */
135 labels?: Record<string, string>;
136 /**
137 * On-prem cluster location for hybrid replication.
138 */
139 clusterLocation?: string;
140 /**
141 * Whether the mirror is running. `false` force-stops the relationship
142 * so the destination volume becomes read-write. Resuming overwrites
143 * destination changes with the source.
144 * @default true
145 */
146 replicationEnabled?: boolean;
147 /**
148 * Force-stop while a transfer is in progress. Required to delete a
149 * replication that is still PREPARING or TRANSFERRING.
150 * @default true
151 */
152 forceStopping?: boolean;
153 /**
154 * Delete the destination volume when this replication is destroyed.
155 * The destination volume is created as a side effect and is otherwise
156 * left behind.
157 * @default false
158 */
159 deleteDestinationVolume?: boolean;
160 /**
161 * Wait until `mirrorState` is MIRRORED (or STOPPED when disabling).
162 * Baseline transfers can take hours — leave false unless the volume
163 * is empty.
164 * @default false
165 */
166 waitForMirror?: boolean;
167};
168
169export type VolumesReplication = Resource<
170 "GCP.NetApp.VolumesReplication",
171 VolumesReplicationProps,
172 {
173 /** Full resource name. */
174 name: string;
175 /** Replication id (last path segment). */
176 replicationId: string;
177 /** Source volume resource name. */
178 volume: string;
179 /** Project id. */
180 project: string;
181 /** Location id of the source volume. */
182 location: string;
183 /** Human-readable description. */
184 description: string | undefined;
185 /** User labels (Alchemy ownership labels stripped). */
186 labels: Record<string, string>;
187 /** Replication interval. */
188 replicationSchedule: string | undefined;
189 /** On-prem cluster location, if set. */
190 clusterLocation: string | undefined;
191 /** Destination volume resource name. */
192 destinationVolume: string | undefined;
193 /** Source volume resource name reported by the API. */
194 sourceVolume: string | undefined;
195 /** SOURCE or DESTINATION. */
196 role: string | undefined;
197 /** Mirror state (PREPARING, MIRRORED, STOPPED, …). */
198 mirrorState: string | undefined;
199 /** Resource state (CREATING, READY, …). */
200 state: string | undefined;
201 /** Server-reported state details. */
202 stateDetails: string | undefined;
203 /** Whether the most recent scheduled transfer succeeded. */
204 healthy: boolean | undefined;
205 /** Hybrid replication type, if this is a hybrid relationship. */
206 hybridReplicationType: string | undefined;
207 /** Whether the mirror is currently running. */
208 replicationEnabled: boolean;
209 /** RFC3339 creation timestamp. */
210 createTime: string | undefined;
211 },
212 never,
213 Providers
214>;
215
216/**
217 * A Cloud NetApp Volumes cross-region replication. Creating it also
218 * creates a destination volume in the destination pool.
219 *
220 * Changing `replicationId`, `volume`, `location`, or destination
221 * identity (`storagePool` / `volumeId` / `shareName`) replaces the
222 * replication. Schedule, description, labels, `clusterLocation`, and
223 * `replicationEnabled` update in place.
224 *
225 * ### Creating a Replication
226 * **Example:** Hourly mirror into another region
227 * ```typescript
228 * const replica = yield* GCP.NetApp.VolumesReplication("Dr", {
229 * volume: sourceVolume.name,
230 * replicationSchedule: "HOURLY",
231 * destinationVolumeParameters: {
232 * storagePool: destPool.name,
233 * volumeId: "app-dr",
234 * shareName: "app",
235 * },
236 * labels: { env: "prod" },
237 * });
238 * ```
239 *
240 * **Example:** Ten-minute schedule with description
241 * ```typescript
242 * const replica = yield* GCP.NetApp.VolumesReplication("Dr", {
243 * volume: sourceVolume.name,
244 * replicationSchedule: "EVERY_10_MINUTES",
245 * destinationVolumeParameters: {
246 * storagePool: destPool.name,
247 * },
248 * description: "us-central1 to us-east1",
249 * });
250 * ```
251 *
252 * ### Updating a Replication
253 * **Example:** Pause the mirror
254 * ```typescript
255 * const replica = yield* GCP.NetApp.VolumesReplication("Dr", {
256 * volume: sourceVolume.name,
257 * replicationSchedule: "HOURLY",
258 * destinationVolumeParameters: {
259 * storagePool: destPool.name,
260 * },
261 * replicationEnabled: false,
262 * });
263 * ```
264 *
265 * @resource
266 * @category NetApp
267 */
268export const VolumesReplication = Resource<VolumesReplication>(
269 "GCP.NetApp.VolumesReplication",
270);
271
272const resourceName = (volume: string, replicationId: string) =>
273 `${volume}/replications/${replicationId}`;
274
275const desiredSchedule = (value: string | undefined) =>
276 (value ?? DEFAULT_SCHEDULE).toUpperCase();
277
278const destFingerprint = (params: DestinationVolumeParameters | undefined) =>
279 fingerprint({
280 storagePool: params?.storagePool,
281 volumeId: params?.volumeId,
282 shareName: params?.shareName,
283 });
284
285const isMirrorRunning = (mirrorState: string | undefined) =>
286 ENABLED_MIRROR_STATES.has((mirrorState ?? "").toUpperCase());
287
288const toAttrs = (
289 replication: netapp.Replication,
290 project: string,
291 region: string,
292) => {
293 const name = replication.name ?? "";
294 const parsed = parseName(name, "replications", region);
295 return {
296 name,
297 replicationId: parsed.id,
298 volume: parsed.parent,
299 project: parsed.project || project,
300 location: parsed.location,
301 description: replication.description,
302 labels: userLabels(replication.labels),
303 replicationSchedule: replication.replicationSchedule,
304 clusterLocation: replication.clusterLocation,
305 destinationVolume: replication.destinationVolume,
306 sourceVolume: replication.sourceVolume,
307 role: replication.role,
308 mirrorState: replication.mirrorState,
309 state: replication.state,
310 stateDetails: replication.stateDetails,
311 healthy: replication.healthy,
312 hybridReplicationType: replication.hybridReplicationType,
313 replicationEnabled: isMirrorRunning(replication.mirrorState),
314 createTime: replication.createTime,
315 };
316};
317
318const destBody = (
319 params: DestinationVolumeParameters,
320 project: string,
321 location: string,
322): netapp.DestinationVolumeParameters => ({
323 storagePool: storagePoolOf(params.storagePool, project, location),
324 shareName: params.shareName,
325 description: params.description,
326 volumeId: params.volumeId,
327 tieringPolicy: params.tieringPolicy,
328});
329
330const getByName = (name: string) =>
331 name.length === 0 || name.includes("//")
332 ? Effect.succeed(undefined)
333 : netapp
334 .getProjectsLocationsVolumesReplications({ name })
335 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
336
337const listOwned = (project: string) =>
338 listAtNested(project, "volumes/-", (parent) =>
339 listLabeledPages(
340 netapp.listProjectsLocationsVolumesReplications.pages({
341 parent,
342 pageSize: 1000,
343 }),
344 (page) => page.replications,
345 (item) => item.labels,
346 ),
347 );
348
349const waitUntilMirror = (name: string, enabled: boolean) =>
350 getByName(name).pipe(
351 Effect.filterOrFail(
352 (value): value is netapp.Replication => value !== undefined,
353 () => new ResourceNotResolved({ name }),
354 ),
355 Effect.filterOrFail(
356 (value) => {
357 const mirror = (value.mirrorState ?? "").toUpperCase();
358 return enabled
359 ? mirror === "MIRRORED" || mirror === "TRANSFERRING"
360 : mirror === STOPPED || mirror === "ABORTED";
361 },
362 (value) => new ResourceNotReady({ name, state: value.mirrorState ?? "" }),
363 ),
364 Effect.retry({
365 while: (error) =>
366 error._tag === "GCP.NetApp.ResourceNotReady" ||
367 error._tag === "GCP.NetApp.ResourceNotResolved",
368 times: 120,
369 schedule: Schedule.spaced("5 seconds"),
370 }),
371 );
372
373export const VolumesReplicationProvider = () =>
374 Provider.succeed(VolumesReplication, {
375 stables: [
376 "name",
377 "replicationId",
378 "volume",
379 "project",
380 "location",
381 "createTime",
382 ],
383
384 diff: Effect.fn(function* ({ news, olds, output }) {
385 if (!isResolved(news)) return undefined;
386 const env = yield* GcpEnvironment.current;
387 const previousLocation = normalizeLocation(
388 olds?.location ?? output?.location,
389 env.region,
390 );
391 const nextLocation = normalizeLocation(
392 news.location ?? olds?.location ?? output?.location,
393 env.region,
394 );
395 const previousVolume = volumeOf(
396 olds?.volume ?? output?.volume ?? "",
397 env.project,
398 previousLocation,
399 );
400 const nextVolume = volumeOf(
401 news.volume ?? olds?.volume ?? output?.volume ?? "",
402 env.project,
403 nextLocation,
404 );
405 const destChanged =
406 olds?.destinationVolumeParameters !== undefined &&
407 destFingerprint(news.destinationVolumeParameters) !==
408 destFingerprint(olds.destinationVolumeParameters);
409 return replaceOnIdentity({
410 previousId: olds?.replicationId ?? output?.replicationId,
411 nextId:
412 news.replicationId ?? olds?.replicationId ?? output?.replicationId,
413 previousLocation,
414 nextLocation,
415 previousParent: previousVolume,
416 nextParent: nextVolume,
417 extra: destChanged,
418 });
419 }),
420
421 read: Effect.fn(function* ({ id, olds, output }) {
422 const env = yield* GcpEnvironment.current;
423 const location = normalizeLocation(
424 olds?.location ?? output?.location,
425 env.region,
426 );
427 const volume = volumeOf(
428 olds?.volume ?? output?.volume ?? "",
429 env.project,
430 location,
431 );
432 const replicationId = yield* toPhysicalId(
433 id,
434 olds?.replicationId,
435 output?.replicationId,
436 "replication",
437 );
438 const name = output?.name ?? resourceName(volume, replicationId);
439 const existing = yield* getByName(name);
440 if (existing === undefined) return undefined;
441 const attrs = toAttrs(existing, env.project, env.region);
442 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
443 ? attrs
444 : Unowned(attrs);
445 }),
446
447 list: () =>
448 Effect.gen(function* () {
449 const env = yield* GcpEnvironment.current;
450 const items = yield* listOwned(env.project);
451 return items.map((item) => toAttrs(item, env.project, env.region));
452 }),
453
454 reconcile: Effect.fn(function* ({ id, news, output }) {
455 const env = yield* GcpEnvironment.current;
456 const locationHint = normalizeLocation(
457 news.location ?? output?.location,
458 env.region,
459 );
460 const volume = volumeOf(news.volume, env.project, locationHint);
461 const location = parseName(volume, "volumes", locationHint).location;
462 const replicationId = yield* toPhysicalId(
463 id,
464 news.replicationId,
465 output?.replicationId,
466 "replication",
467 );
468 const name = output?.name ?? resourceName(volume, replicationId);
469 const schedule = desiredSchedule(news.replicationSchedule);
470 const enabled = news.replicationEnabled ?? true;
471 const desiredLabels = {
472 ...toLabels(news.labels),
473 ...(yield* createInternalLabels(id)),
474 };
475
476 let current = yield* getByName(output?.name ?? name);
477
478 if (current === undefined) {
479 const created = yield* netapp
480 .createProjectsLocationsVolumesReplications({
481 parent: volume,
482 replicationId,
483 body: {
484 description: news.description,
485 labels: desiredLabels,
486 replicationSchedule: schedule,
487 destinationVolumeParameters: destBody(
488 news.destinationVolumeParameters,
489 env.project,
490 location,
491 ),
492 clusterLocation: news.clusterLocation,
493 },
494 })
495 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
496 if (created !== undefined) {
497 yield* waitForOperation(created);
498 }
499 current = yield* waitUntilExists(getByName(name), name);
500 }
501
502 if (current === undefined) {
503 return yield* new ResourceNotResolved({ name });
504 }
505
506 current = yield* waitUntilReady(
507 getByName(current.name ?? name),
508 current.name ?? name,
509 (item) => item.state,
510 (item) => item.stateDetails,
511 { times: 120, interval: "5 seconds" },
512 );
513
514 const observedLabels = tagRecord(current.labels);
515 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
516 const labelsChanged = upsert.length > 0 || removed.length > 0;
517 const descriptionChanged =
518 (current.description ?? "") !== (news.description ?? "");
519 const scheduleChanged =
520 desiredSchedule(current.replicationSchedule) !== schedule;
521 const clusterChanged =
522 (current.clusterLocation ?? "") !== (news.clusterLocation ?? "");
523 const mask = fieldMask([
524 labelsChanged && "labels",
525 descriptionChanged && "description",
526 scheduleChanged && "replicationSchedule",
527 clusterChanged && "clusterLocation",
528 ]);
529
530 if (mask.length > 0) {
531 const operation =
532 yield* netapp.patchProjectsLocationsVolumesReplications({
533 name: current.name ?? name,
534 updateMask: mask,
535 body: {
536 name: current.name ?? name,
537 labels: desiredLabels,
538 description: news.description,
539 replicationSchedule: schedule,
540 clusterLocation: news.clusterLocation,
541 },
542 });
543 yield* waitForOperation(operation);
544 current = yield* waitUntilReady(
545 getByName(current.name ?? name),
546 current.name ?? name,
547 (item) => item.state,
548 (item) => item.stateDetails,
549 { times: 120, interval: "5 seconds" },
550 );
551 }
552
553 const running = isMirrorRunning(current.mirrorState);
554 if (enabled && !running) {
555 const resumed = yield* netapp
556 .resumeProjectsLocationsVolumesReplications({
557 name: current.name ?? name,
558 body: {},
559 })
560 .pipe(
561 Effect.catchTag(["Conflict", "BadRequest"], () =>
562 Effect.succeed(undefined),
563 ),
564 );
565 if (resumed !== undefined) {
566 yield* waitForOperation(resumed);
567 }
568 current = yield* waitUntilReady(
569 getByName(current.name ?? name),
570 current.name ?? name,
571 (item) => item.state,
572 (item) => item.stateDetails,
573 { times: 120, interval: "5 seconds" },
574 );
575 } else if (!enabled && running) {
576 const stopped = yield* netapp
577 .stopProjectsLocationsVolumesReplications({
578 name: current.name ?? name,
579 body: { force: news.forceStopping ?? true },
580 })
581 .pipe(
582 Effect.catchTag(["Conflict", "BadRequest"], () =>
583 Effect.succeed(undefined),
584 ),
585 );
586 if (stopped !== undefined) {
587 yield* waitForOperation(stopped);
588 }
589 current = yield* waitUntilReady(
590 getByName(current.name ?? name),
591 current.name ?? name,
592 (item) => item.state,
593 (item) => item.stateDetails,
594 { times: 120, interval: "5 seconds" },
595 );
596 }
597
598 if (news.waitForMirror === true) {
599 current = yield* waitUntilMirror(current.name ?? name, enabled);
600 }
601
602 return toAttrs(current, env.project, env.region);
603 }),
604
605 delete: Effect.fn(function* ({ olds, output }) {
606 if (!output.name || output.name.includes("//")) return;
607 const current = yield* getByName(output.name);
608 if (
609 current !== undefined &&
610 (current.mirrorState ?? "").toUpperCase() !== STOPPED
611 ) {
612 const stopped = yield* netapp
613 .stopProjectsLocationsVolumesReplications({
614 name: output.name,
615 body: { force: olds?.forceStopping ?? true },
616 })
617 .pipe(
618 Effect.catchTag(["NotFound", "Conflict", "BadRequest"], () =>
619 Effect.succeed(undefined),
620 ),
621 );
622 if (stopped !== undefined) {
623 yield* waitForDeleteOperation(stopped);
624 }
625 }
626
627 const operation = yield* netapp
628 .deleteProjectsLocationsVolumesReplications({ name: output.name })
629 .pipe(
630 Effect.retry({
631 while: (error) => error._tag === "Conflict",
632 times: 8,
633 schedule: Schedule.spaced("2 seconds"),
634 }),
635 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
636 );
637 if (operation !== undefined) {
638 yield* waitForDeleteOperation(operation);
639 }
640 yield* waitUntilGone(getByName(output.name), output.name);
641
642 const destination =
643 output.destinationVolume ?? current?.destinationVolume;
644 if (olds?.deleteDestinationVolume === true && destination) {
645 const deleted = yield* netapp
646 .deleteProjectsLocationsVolumes({
647 name: destination,
648 force: true,
649 })
650 .pipe(
651 Effect.catchTag(["NotFound", "Conflict", "BadRequest"], () =>
652 Effect.succeed(undefined),
653 ),
654 );
655 if (deleted !== undefined) {
656 yield* waitForDeleteOperation(deleted);
657 }
658 }
659 }),
660 });