1import * as file from "@distilled.cloud/gcp/file_v1";
2import * as Data from "effect/Data";
3import type { GcpOpContext } from "@distilled.cloud/gcp/Protocol";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { tagRecord } from "../../Tags.ts";
13import { GcpEnvironment } from "../Environment.ts";
14import {
15 createInternalLabels,
16 diffLabels,
17 hasAlchemyLabels,
18 stripInternalLabels,
19 toLabels,
20} from "../Labels.ts";
21import type { Providers } from "../Providers.ts";
22import { waitForOperation as waitForLongRunningOperation } from "../Operation.ts";
23
24const DEFAULT_ZONAL_LOCATION = "us-central1-a";
25const DEFAULT_TIER = "BASIC_HDD";
26const DEFAULT_PROTOCOL = "NFS_V3";
27const DEFAULT_CONNECT_MODE = "DIRECT_PEERING";
28const DEFAULT_NETWORK = "default";
29const DEFAULT_SHARE_NAME = "share1";
30const DEFAULT_MODE = "MODE_IPV4";
31const MAX_NAME_LENGTH = 63;
32
33const REGIONAL_TIERS = new Set(["ENTERPRISE", "REGIONAL"]);
34
35export type PscConfig = {
36 /**
37 * Consumer service project for the Private Service Connect endpoint
38 * (Shared VPC). Defaults to the VPC host project.
39 */
40 endpointProject?: string;
41};
42
43export type NetworkConfig = {
44 /**
45 * VPC network name (`default`) or path
46 * (`projects/{project}/global/networks/{network}`). Immutable.
47 * @default "default"
48 */
49 network?: string;
50 /**
51 * Internet protocol versions assigned to the instance.
52 * @default ["MODE_IPV4"]
53 */
54 modes?: Array<file.NetworkConfigModesItemEnum | (string & {})>;
55 /**
56 * Reserved IP CIDR (`DIRECT_PEERING`) or allocated range name
57 * (`PRIVATE_SERVICE_ACCESS`). Immutable. BASIC uses a /29, Enterprise a
58 * /26, High Scale a /24. If omitted, Filestore picks a free block.
59 */
60 reservedIpRange?: string;
61 /**
62 * Network connect mode. Immutable.
63 * @default "DIRECT_PEERING"
64 */
65 connectMode?: file.NetworkConfigConnectModeEnum | (string & {});
66 /**
67 * Private Service Connect configuration. Set only when `connectMode` is
68 * `PRIVATE_SERVICE_CONNECT`.
69 */
70 pscConfig?: PscConfig;
71};
72
73export type NfsExportOptions = {
74 /**
75 * Root squash. `NO_ROOT_SQUASH` (default) allows root; `ROOT_SQUASH`
76 * maps root to `anonUid`/`anonGid`.
77 */
78 squashMode?: file.NfsExportOptionsSquashModeEnum | (string & {});
79 /**
80 * Source VPC for `ipRanges`. Required for Private Service Connect.
81 * Must match `NetworkConfig.network` when set.
82 */
83 network?: string;
84 /**
85 * Anonymous group id used with `ROOT_SQUASH`. Default 65534.
86 */
87 anonGid?: number;
88 /**
89 * IPv4 addresses or CIDR ranges allowed to mount the share. Max 64
90 * across all export options on a share. Overlaps are rejected.
91 */
92 ipRanges?: string[];
93 /**
94 * Mount access. `READ_WRITE` (default) or `READ_ONLY`.
95 */
96 accessMode?: file.NfsExportOptionsAccessModeEnum | (string & {});
97 /**
98 * Anonymous user id used with `ROOT_SQUASH`. Default 65534.
99 */
100 anonUid?: number;
101};
102
103export type FileShareConfig = {
104 /**
105 * Share name. 1-16 characters for Basic, 1-63 otherwise. Lowercase
106 * letters, numbers, and underscores; must start with a letter.
107 * Immutable — changing it replaces the instance.
108 * @default "share1"
109 */
110 name?: string;
111 /**
112 * Capacity in GiB (1024^3 bytes). BASIC_HDD minimum is 1024, BASIC_SSD
113 * 2560, HIGH_SCALE_SSD 10240. Omit to use the tier minimum.
114 */
115 capacityGb?: number;
116 /**
117 * NFS export options. Max 10 per share. Omit to allow all clients with
118 * `READ_WRITE` and `NO_ROOT_SQUASH`.
119 */
120 nfsExportOptions?: NfsExportOptions[];
121 /**
122 * Filestore backup to restore
123 * (`projects/{project}/locations/{location}/backups/{backup}`).
124 * Immutable.
125 */
126 sourceBackup?: string;
127 /**
128 * BackupDR backup to restore. Immutable.
129 */
130 sourceBackupdrBackup?: string;
131};
132
133export type IopsPerTb = {
134 /** Maximum IOPS per TiB of capacity. */
135 maxIopsPerTb?: number;
136};
137
138export type FixedIops = {
139 /** Fixed provisioned IOPS. Must be a multiple of 1000. */
140 maxIops?: number;
141};
142
143export type PerformanceConfig = {
144 /**
145 * Provision IOPS as `capacityTiB * maxIopsPerTb`. Mutually exclusive
146 * with `fixedIops`.
147 */
148 iopsPerTb?: IopsPerTb;
149 /**
150 * Fixed provisioned IOPS, independent of capacity. Mutually exclusive
151 * with `iopsPerTb`.
152 */
153 fixedIops?: FixedIops;
154};
155
156export type LdapConfig = {
157 /** LDAP servers as DNS names or IP addresses (one format, not mixed). */
158 servers?: string[];
159 /** LDAP domain (`example.com`). */
160 domain?: string;
161 /** Optional groups OU to speed LDAP lookups. */
162 groupsOu?: string;
163 /** Optional users OU to speed LDAP lookups. */
164 usersOu?: string;
165};
166
167export type DirectoryServicesConfig = {
168 /** LDAP configuration for NFSv4.1 Kerberos. */
169 ldap?: LdapConfig;
170};
171
172export type ReplicaConfig = {
173 /**
174 * Source instance
175 * (`projects/{project}/locations/{location}/instances/{instance}`).
176 * Required when creating a standby replica.
177 */
178 peerInstance?: string;
179};
180
181export type Replication = {
182 /** Replication role. New replicas must set `STANDBY`. */
183 role?: file.ReplicationRoleEnum | (string & {});
184 /** Replica peers. The API supports a single replica. */
185 replicas?: ReplicaConfig[];
186};
187
188export type PerformanceLimits = {
189 /** Maximum read IOPS. */
190 maxReadIops: number | undefined;
191 /** Maximum read throughput in bytes per second. */
192 maxReadThroughputBps: number | undefined;
193 /** Maximum IOPS. */
194 maxIops: number | undefined;
195 /** Maximum write IOPS. */
196 maxWriteIops: number | undefined;
197 /** Maximum write throughput in bytes per second. */
198 maxWriteThroughputBps: number | undefined;
199};
200
201export type InstanceProps = {
202 /**
203 * Instance id (the `{instance}` segment of
204 * `projects/{project}/locations/{location}/instances/{instance}`).
205 * If omitted, a unique RFC1035 name is generated from the stack, stage,
206 * and logical id. Must be 1-63 characters, start with a letter, and end
207 * with a letter or digit. Immutable — changing it replaces the instance.
208 */
209 instanceId?: string;
210 /**
211 * Location of the instance. Basic, Zonal, and High Scale use a zone
212 * (`us-central1-a`). Enterprise and Regional use a region
213 * (`us-central1`). Immutable — changing it replaces the instance.
214 * `US-CENTRAL1-A` is accepted and normalized to `us-central1-a`.
215 * @default "us-central1-a" (zonal tiers) or the stack's GCP region
216 * (`GCP.Region`, profile region, `us-central1`) for regional tiers
217 */
218 location?: string;
219 /**
220 * Service tier. Immutable — changing it replaces the instance.
221 * `STANDARD` is an alias for `BASIC_HDD`; `PREMIUM` for `BASIC_SSD`.
222 * @default "BASIC_HDD"
223 */
224 tier?: file.InstanceTierEnum | (string & {});
225 /**
226 * File protocol for every share. Immutable.
227 * @default "NFS_V3"
228 */
229 protocol?: file.InstanceProtocolEnum | (string & {});
230 /**
231 * Human-readable description (2048 characters or less).
232 */
233 description?: string;
234 /**
235 * User labels. Alchemy ownership labels are merged in automatically.
236 */
237 labels?: Record<string, string>;
238 /**
239 * File shares. The API supports a single share. Omit to create
240 * `share1` at the tier minimum capacity.
241 */
242 fileShares?: FileShareConfig[];
243 /**
244 * VPC networks. The API supports a single network. Immutable — changing
245 * network, modes, connect mode, or reserved range replaces the instance.
246 * @default [{ network: "default", modes: ["MODE_IPV4"] }]
247 */
248 networks?: NetworkConfig[];
249 /**
250 * Customer-managed KMS key for at-rest encryption
251 * (`projects/{project}/locations/{location}/keyRings/{keyRing}/cryptoKeys/{cryptoKey}`).
252 * Immutable.
253 */
254 kmsKeyName?: string;
255 /**
256 * Custom performance. Only for tiers that report
257 * `customPerformanceSupported`.
258 */
259 performanceConfig?: PerformanceConfig;
260 /**
261 * Directory Services (LDAP) for `NFS_V4_1`. Immutable.
262 */
263 directoryServices?: DirectoryServicesConfig;
264 /**
265 * Block `instances.delete` until protection is cleared.
266 * @default false
267 */
268 deletionProtectionEnabled?: boolean;
269 /**
270 * Reason recorded when deletion protection is enabled.
271 */
272 deletionProtectionReason?: string;
273 /**
274 * Replication configuration. Create-time only; new replicas set
275 * `role: "STANDBY"`.
276 */
277 replication?: Replication;
278 /**
279 * Resource Manager tags (namespaced key to short value). Input-only and
280 * immutable.
281 */
282 tags?: Record<string, string>;
283};
284
285export type Instance = Resource<
286 "GCP.Filestore.Instance",
287 InstanceProps,
288 {
289 /** Full resource name `projects/{project}/locations/{location}/instances/{instance}`. */
290 name: string;
291 /** Instance id (last path segment). */
292 instanceId: string;
293 /** Project id. */
294 project: string;
295 /** Location id (zone or region). */
296 location: string;
297 /** Human-readable description. */
298 description: string | undefined;
299 /** User labels (Alchemy ownership labels stripped). */
300 labels: Record<string, string>;
301 /** Service tier. */
302 tier: string;
303 /** File protocol. */
304 protocol: string | undefined;
305 /** Server-reported state (`READY`, `CREATING`, …). */
306 state: string | undefined;
307 /** Extra status text, if any. */
308 statusMessage: string | undefined;
309 /** File shares currently configured. */
310 fileShares: FileShareConfig[];
311 /** VPC networks, including assigned `ipAddresses`. */
312 networks: Array<
313 NetworkConfig & {
314 /** Assigned IPv4/IPv6 addresses. */
315 ipAddresses: string[];
316 }
317 >;
318 /** Customer-managed KMS key, if any. */
319 kmsKeyName: string | undefined;
320 /** Custom performance configuration currently applied. */
321 performanceConfig: PerformanceConfig | undefined;
322 /** Enforced performance limits. */
323 performanceLimits: PerformanceLimits | undefined;
324 /** Directory Services configuration. */
325 directoryServices: DirectoryServicesConfig | undefined;
326 /** Whether deletion protection is enabled. */
327 deletionProtectionEnabled: boolean;
328 /** Reason for deletion protection. */
329 deletionProtectionReason: string | undefined;
330 /** Whether the instance accepts `performanceConfig`. */
331 customPerformanceSupported: boolean | undefined;
332 /** Minimum capacity in GiB. */
333 minCapacityGb: number | undefined;
334 /** Maximum capacity in GiB. */
335 maxCapacityGb: number | undefined;
336 /** Capacity step size in GiB. */
337 capacityStepSizeGb: number | undefined;
338 /** Server etag. */
339 etag: string | undefined;
340 /** RFC3339 creation timestamp. */
341 createTime: string | undefined;
342 /** Replication configuration currently applied. */
343 replication: Replication | undefined;
344 /** Reasons the instance is `SUSPENDED`, if any. */
345 suspensionReasons: string[];
346 },
347 never,
348 Providers
349>;
350
351/**
352 * A Cloud Filestore instance (NFS file share).
353 *
354 * Changing `instanceId`, `location`, `tier`, `protocol`, `kmsKeyName`,
355 * share `name`/`sourceBackup`, or network identity (network, modes,
356 * connect mode, reserved range) replaces the instance. Description,
357 * labels, share capacity, NFS export options, performance, and deletion
358 * protection update in place.
359 *
360 * Provisioning a Basic HDD 1 TiB instance typically takes 5-10 minutes.
361 *
362 * ### Creating an Instance
363 * **Example:** Generated name, Basic HDD 1 TiB
364 * ```typescript
365 * const nfs = yield* GCP.Filestore.Instance("Nfs", {});
366 * ```
367 *
368 * **Example:** Explicit id, labels, and description
369 * ```typescript
370 * const nfs = yield* GCP.Filestore.Instance("Nfs", {
371 * instanceId: "app-nfs",
372 * location: "us-central1-a",
373 * tier: "BASIC_HDD",
374 * fileShares: [{ name: "share1", capacityGb: 1024 }],
375 * networks: [{ network: "default", modes: ["MODE_IPV4"] }],
376 * description: "app file share",
377 * labels: { env: "prod" },
378 * });
379 * ```
380 *
381 * ### NFS export options
382 * **Example:** Restrict clients and enable root squash
383 * ```typescript
384 * const nfs = yield* GCP.Filestore.Instance("Nfs", {
385 * fileShares: [{
386 * name: "share1",
387 * capacityGb: 1024,
388 * nfsExportOptions: [{
389 * accessMode: "READ_WRITE",
390 * squashMode: "ROOT_SQUASH",
391 * ipRanges: ["10.0.0.0/8"],
392 * }],
393 * }],
394 * });
395 * ```
396 *
397 * @resource
398 * @category Filestore
399 */
400export const Instance = Resource<Instance>("GCP.Filestore.Instance");
401
402export class InstanceNotResolved extends Data.TaggedError(
403 "GCP.Filestore.InstanceNotResolved",
404)<{
405 name: string;
406}> {}
407
408export class InstanceNotReady extends Data.TaggedError(
409 "GCP.Filestore.InstanceNotReady",
410)<{
411 name: string;
412 state: string;
413}> {}
414
415export class InstanceFailed extends Data.TaggedError(
416 "GCP.Filestore.InstanceFailed",
417)<{
418 name: string;
419 state: string;
420 statusMessage: string | undefined;
421}> {}
422
423export class InstanceStillExists extends Data.TaggedError(
424 "GCP.Filestore.InstanceStillExists",
425)<{
426 name: string;
427}> {}
428
429const lastSegment = (value: string) => {
430 const trimmed = value.replace(/\/+$/, "");
431 const parts = trimmed.split("/");
432 return parts[parts.length - 1] || trimmed;
433};
434
435const canonicalizeTier = (tier: string | undefined) => {
436 const value = (tier ?? DEFAULT_TIER).toUpperCase();
437 if (value === "TIER_UNSPECIFIED") return DEFAULT_TIER;
438 if (value === "STANDARD") return "BASIC_HDD";
439 if (value === "PREMIUM") return "BASIC_SSD";
440 return value;
441};
442
443const isRegionalTier = (tier: string | undefined) =>
444 REGIONAL_TIERS.has(canonicalizeTier(tier));
445
446const defaultLocationFor = (tier: string | undefined, region: string) =>
447 isRegionalTier(tier) ? region : DEFAULT_ZONAL_LOCATION;
448
449const normalizeLocation = (
450 location: string | undefined,
451 tier: string | undefined,
452 region: string,
453) => lastSegment(location ?? defaultLocationFor(tier, region)).toLowerCase();
454
455const normalizeProtocol = (protocol: string | undefined) => {
456 const value = (protocol ?? DEFAULT_PROTOCOL).toUpperCase();
457 return value === "FILE_PROTOCOL_UNSPECIFIED" ? DEFAULT_PROTOCOL : value;
458};
459
460const normalizeConnectMode = (mode: string | undefined) => {
461 const value = (mode ?? DEFAULT_CONNECT_MODE).toUpperCase();
462 return value === "CONNECT_MODE_UNSPECIFIED" ? DEFAULT_CONNECT_MODE : value;
463};
464
465const defaultCapacityGb = (tier: string | undefined) => {
466 const value = canonicalizeTier(tier);
467 if (value === "BASIC_SSD") return 2560;
468 if (value === "HIGH_SCALE_SSD") return 10240;
469 return 1024;
470};
471
472const rfc1035 = (name: string): string => {
473 let next = name
474 .toLowerCase()
475 .replace(/[^a-z0-9-]/g, "-")
476 .replace(/-+/g, "-")
477 .replace(/^-+|-+$/g, "");
478 if (!/^[a-z]/.test(next)) next = `f${next}`;
479 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
480 if (next.length === 0) return "instance";
481 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`;
482 return next.slice(0, MAX_NAME_LENGTH);
483};
484
485const resourceName = (project: string, location: string, instanceId: string) =>
486 `projects/${project}/locations/${location}/instances/${instanceId}`;
487
488const parseName = (name: string) => {
489 const parts = name.split("/").filter((part) => part.length > 0);
490 const instancesAt = parts.lastIndexOf("instances");
491 const locationsAt = parts.lastIndexOf("locations");
492 const projectsAt = parts.lastIndexOf("projects");
493 return {
494 project:
495 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
496 location:
497 locationsAt >= 0 && parts[locationsAt + 1]
498 ? parts[locationsAt + 1]!
499 : DEFAULT_ZONAL_LOCATION,
500 instanceId:
501 instancesAt >= 0 && parts[instancesAt + 1]
502 ? parts[instancesAt + 1]!
503 : lastSegment(name),
504 };
505};
506
507const userLabels = (
508 labels: Record<string, string | undefined> | null | undefined,
509): Record<string, string> => stripInternalLabels(tagRecord(labels));
510
511const toId = (id: string, instanceId: string | undefined, existing?: string) =>
512 Effect.gen(function* () {
513 return (
514 instanceId ??
515 existing ??
516 rfc1035(
517 yield* createPhysicalName({
518 id,
519 maxLength: MAX_NAME_LENGTH,
520 lowercase: true,
521 }),
522 )
523 );
524 });
525
526const asNumber = (value: string | number | undefined): number | undefined => {
527 if (value === undefined) return undefined;
528 const n = typeof value === "number" ? value : Number(value);
529 return Number.isFinite(n) ? n : undefined;
530};
531
532const asIntString = (value: number | string | undefined): string | undefined =>
533 value === undefined ? undefined : String(value);
534
535const stringsOf = (
536 values: ReadonlyArray<string | undefined> | null | undefined,
537): string[] =>
538 (values ?? []).filter((value): value is string => value !== undefined);
539
540const networkId = (network: string | undefined) =>
541 lastSegment(network ?? DEFAULT_NETWORK);
542
543const modesOf = (
544 modes: ReadonlyArray<string | undefined> | null | undefined,
545): string[] => {
546 const values = stringsOf(modes).map((mode) => mode.toUpperCase());
547 return values.length > 0 ? values : [DEFAULT_MODE];
548};
549
550const modesKey = (
551 modes: ReadonlyArray<string | undefined> | null | undefined,
552) => [...modesOf(modes)].sort().join("\0");
553
554const nfsOf = (
555 options: file.NfsExportOptions | NfsExportOptions,
556): NfsExportOptions => ({
557 squashMode: options.squashMode,
558 network: options.network,
559 anonGid: asNumber(options.anonGid),
560 ipRanges: options.ipRanges ? [...options.ipRanges] : undefined,
561 accessMode: options.accessMode,
562 anonUid: asNumber(options.anonUid),
563});
564
565const nfsKey = (options: NfsExportOptions) =>
566 JSON.stringify({
567 squashMode: (options.squashMode ?? "NO_ROOT_SQUASH").toUpperCase(),
568 network: options.network ?? "",
569 anonGid: options.anonGid ?? "",
570 ipRanges: [...(options.ipRanges ?? [])].sort(),
571 accessMode: (options.accessMode ?? "READ_WRITE").toUpperCase(),
572 anonUid: options.anonUid ?? "",
573 });
574
575const shareOf = (
576 share: file.FileShareConfig | FileShareConfig,
577 tier: string | undefined,
578): FileShareConfig => ({
579 name: share.name ?? DEFAULT_SHARE_NAME,
580 capacityGb: asNumber(share.capacityGb) ?? defaultCapacityGb(tier),
581 nfsExportOptions: share.nfsExportOptions?.map(nfsOf),
582 sourceBackup: share.sourceBackup,
583 sourceBackupdrBackup: share.sourceBackupdrBackup,
584});
585
586const sharesOf = (
587 shares: ReadonlyArray<file.FileShareConfig | FileShareConfig> | undefined,
588 tier: string | undefined,
589): FileShareConfig[] => {
590 if (shares === undefined || shares.length === 0) {
591 return [
592 {
593 name: DEFAULT_SHARE_NAME,
594 capacityGb: defaultCapacityGb(tier),
595 },
596 ];
597 }
598 return shares.map((share) => shareOf(share, tier));
599};
600
601const shareIdentityKey = (share: FileShareConfig) =>
602 JSON.stringify({
603 name: share.name ?? DEFAULT_SHARE_NAME,
604 sourceBackup: share.sourceBackup ?? "",
605 sourceBackupdrBackup: share.sourceBackupdrBackup ?? "",
606 });
607
608const shareCapacity = (share: FileShareConfig) => share.capacityGb ?? 0;
609
610const networkOf = (
611 network: file.NetworkConfig | NetworkConfig,
612): NetworkConfig & { ipAddresses: string[] } => ({
613 network: network.network ?? DEFAULT_NETWORK,
614 modes: modesOf(network.modes),
615 reservedIpRange: network.reservedIpRange,
616 connectMode: network.connectMode,
617 pscConfig: network.pscConfig
618 ? { endpointProject: network.pscConfig.endpointProject }
619 : undefined,
620 ipAddresses: "ipAddresses" in network ? stringsOf(network.ipAddresses) : [],
621});
622
623const networksOf = (
624 networks: ReadonlyArray<file.NetworkConfig | NetworkConfig> | undefined,
625): Array<NetworkConfig & { ipAddresses: string[] }> => {
626 if (networks === undefined || networks.length === 0) {
627 return [
628 {
629 network: DEFAULT_NETWORK,
630 modes: [DEFAULT_MODE],
631 ipAddresses: [],
632 },
633 ];
634 }
635 return networks.map(networkOf);
636};
637
638const networkIdentityKey = (
639 network: NetworkConfig,
640 options?: { includeReserved?: boolean },
641) =>
642 JSON.stringify({
643 network: networkId(network.network),
644 modes: modesKey(network.modes),
645 connectMode: normalizeConnectMode(network.connectMode),
646 reservedIpRange:
647 options?.includeReserved === true ? (network.reservedIpRange ?? "") : "",
648 endpointProject: network.pscConfig?.endpointProject ?? "",
649 });
650
651const performanceOf = (
652 config: file.PerformanceConfig | PerformanceConfig | undefined,
653): PerformanceConfig | undefined => {
654 if (config === undefined) return undefined;
655 const iopsPerTb = config.iopsPerTb
656 ? { maxIopsPerTb: asNumber(config.iopsPerTb.maxIopsPerTb) }
657 : undefined;
658 const fixedIops = config.fixedIops
659 ? { maxIops: asNumber(config.fixedIops.maxIops) }
660 : undefined;
661 if (iopsPerTb === undefined && fixedIops === undefined) return undefined;
662 return { iopsPerTb, fixedIops };
663};
664
665const performanceKey = (
666 config: file.PerformanceConfig | PerformanceConfig | undefined,
667) =>
668 JSON.stringify({
669 iopsPerTb: performanceOf(config)?.iopsPerTb?.maxIopsPerTb ?? "",
670 fixedIops: performanceOf(config)?.fixedIops?.maxIops ?? "",
671 });
672
673const ldapOf = (
674 ldap: file.LdapConfig | LdapConfig | undefined,
675): LdapConfig | undefined => {
676 if (ldap === undefined) return undefined;
677 return {
678 servers: ldap.servers ? [...ldap.servers] : undefined,
679 domain: ldap.domain,
680 groupsOu: ldap.groupsOu,
681 usersOu: ldap.usersOu,
682 };
683};
684
685const directoryOf = (
686 config: file.DirectoryServicesConfig | DirectoryServicesConfig | undefined,
687): DirectoryServicesConfig | undefined => {
688 if (config === undefined) return undefined;
689 const ldap = ldapOf(config.ldap);
690 if (ldap === undefined) return undefined;
691 return { ldap };
692};
693
694const directoryKey = (
695 config: file.DirectoryServicesConfig | DirectoryServicesConfig | undefined,
696) =>
697 JSON.stringify({
698 servers: [...(directoryOf(config)?.ldap?.servers ?? [])].sort(),
699 domain: directoryOf(config)?.ldap?.domain ?? "",
700 groupsOu: directoryOf(config)?.ldap?.groupsOu ?? "",
701 usersOu: directoryOf(config)?.ldap?.usersOu ?? "",
702 });
703
704const replicationOf = (
705 config: file.Replication | Replication | undefined,
706): Replication | undefined => {
707 if (config === undefined) return undefined;
708 return {
709 role: config.role,
710 replicas: config.replicas?.map((replica) => ({
711 peerInstance: replica.peerInstance,
712 })),
713 };
714};
715
716const replicationKey = (config: file.Replication | Replication | undefined) =>
717 JSON.stringify({
718 role: (replicationOf(config)?.role ?? "").toUpperCase(),
719 peers: (replicationOf(config)?.replicas ?? [])
720 .map((replica) => replica.peerInstance ?? "")
721 .sort(),
722 });
723
724const limitsOf = (
725 limits: file.PerformanceLimits | undefined,
726): PerformanceLimits | undefined => {
727 if (limits === undefined) return undefined;
728 return {
729 maxReadIops: asNumber(limits.maxReadIops),
730 maxReadThroughputBps: asNumber(limits.maxReadThroughputBps),
731 maxIops: asNumber(limits.maxIops),
732 maxWriteIops: asNumber(limits.maxWriteIops),
733 maxWriteThroughputBps: asNumber(limits.maxWriteThroughputBps),
734 };
735};
736
737const toApiNfs = (options: NfsExportOptions): file.NfsExportOptions => ({
738 squashMode: options.squashMode,
739 network: options.network,
740 anonGid: asIntString(options.anonGid),
741 ipRanges: options.ipRanges,
742 accessMode: options.accessMode,
743 anonUid: asIntString(options.anonUid),
744});
745
746const toApiShare = (share: FileShareConfig): file.FileShareConfig => ({
747 name: share.name,
748 capacityGb: asIntString(share.capacityGb),
749 nfsExportOptions: share.nfsExportOptions?.map(toApiNfs),
750 sourceBackup: share.sourceBackup,
751 sourceBackupdrBackup: share.sourceBackupdrBackup,
752});
753
754const toApiNetwork = (network: NetworkConfig): file.NetworkConfig => ({
755 network: network.network,
756 modes: network.modes,
757 reservedIpRange: network.reservedIpRange,
758 connectMode: network.connectMode,
759 pscConfig: network.pscConfig,
760});
761
762const toApiPerformance = (
763 config: PerformanceConfig | undefined,
764): file.PerformanceConfig | undefined => {
765 if (config === undefined) return undefined;
766 return {
767 iopsPerTb:
768 config.iopsPerTb === undefined
769 ? undefined
770 : { maxIopsPerTb: asIntString(config.iopsPerTb.maxIopsPerTb) },
771 fixedIops:
772 config.fixedIops === undefined
773 ? undefined
774 : { maxIops: asIntString(config.fixedIops.maxIops) },
775 };
776};
777
778const toAttrs = (instance: file.Instance, project: string) => {
779 const name = instance.name ?? "";
780 const parsed = parseName(name);
781 const tier = canonicalizeTier(instance.tier);
782 return {
783 name,
784 instanceId: parsed.instanceId,
785 project: parsed.project || project,
786 location: parsed.location,
787 description: instance.description,
788 labels: userLabels(instance.labels),
789 tier,
790 protocol: instance.protocol,
791 state: instance.state,
792 statusMessage: instance.statusMessage,
793 fileShares: sharesOf(instance.fileShares, tier),
794 networks: networksOf(instance.networks),
795 kmsKeyName: instance.kmsKeyName,
796 performanceConfig: performanceOf(instance.performanceConfig),
797 performanceLimits: limitsOf(instance.performanceLimits),
798 directoryServices: directoryOf(instance.directoryServices),
799 deletionProtectionEnabled: instance.deletionProtectionEnabled === true,
800 deletionProtectionReason: instance.deletionProtectionReason,
801 customPerformanceSupported: instance.customPerformanceSupported,
802 minCapacityGb: asNumber(instance.minCapacityGb),
803 maxCapacityGb: asNumber(instance.maxCapacityGb),
804 capacityStepSizeGb: asNumber(instance.capacityStepSizeGb),
805 etag: instance.etag,
806 createTime: instance.createTime,
807 replication: replicationOf(instance.replication),
808 suspensionReasons: stringsOf(instance.suspensionReasons),
809 };
810};
811
812const isPlaceholder = (instance: file.Instance) => {
813 const name = instance.name ?? "";
814 return (
815 name.length === 0 ||
816 name.endsWith("/instances/-") ||
817 name.endsWith("/instances/")
818 );
819};
820
821const getByName = (name: string) =>
822 file
823 .getProjectsLocationsInstances({ name })
824 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
825
826/**
827 * Wait on a Filestore instance long-running operation (5–20 minutes).
828 * `ALREADY_EXISTS` (a create race) counts as success; so does `NOT_FOUND`
829 * when `notFoundOk` (deletes). Returns the final operation.
830 */
831const waitForOperation = (
832 operation: file.Operation,
833 options?: { notFoundOk?: boolean },
834) =>
835 Effect.suspend(() => {
836 let latest = operation;
837 return waitForLongRunningOperation(
838 operation,
839 (name) => {
840 const get = file.getProjectsLocationsOperations({ name }).pipe(
841 Effect.tap((current) =>
842 Effect.sync(() => {
843 latest = current;
844 }),
845 ),
846 );
847 const observe: Effect.Effect<
848 file.Operation,
849 file.GetProjectsLocationsOperationsError,
850 GcpOpContext
851 > =
852 options?.notFoundOk === true
853 ? get.pipe(
854 Effect.catchTag("NotFound", () =>
855 Effect.succeed<file.Operation>({ name, done: true }),
856 ),
857 )
858 : get.pipe(
859 // A just-returned operation can briefly 404 on read.
860 Effect.retry({
861 while: (error) => error._tag === "NotFound",
862 times: 5,
863 schedule: Schedule.exponential("250 millis"),
864 }),
865 );
866 return observe;
867 },
868 { budget: "30 minutes", interval: "10 seconds" },
869 ).pipe(
870 Effect.map(() => latest),
871 // google.rpc.Code ALREADY_EXISTS = 6, NOT_FOUND = 5.
872 Effect.catchTag("GCP.OperationFailed", (error) =>
873 error.code === 6 || (options?.notFoundOk === true && error.code === 5)
874 ? Effect.succeed(latest)
875 : Effect.fail(error),
876 ),
877 );
878 });
879
880const waitUntilExists = (name: string) =>
881 getByName(name).pipe(
882 Effect.flatMap((instance) =>
883 instance
884 ? Effect.succeed(instance)
885 : Effect.fail(new InstanceNotResolved({ name })),
886 ),
887 Effect.retry({
888 while: (error) => error._tag === "GCP.Filestore.InstanceNotResolved",
889 times: 8,
890 schedule: Schedule.spaced("1 second"),
891 }),
892 );
893
894const waitUntilReady = (name: string) =>
895 getByName(name).pipe(
896 Effect.filterOrFail(
897 (instance): instance is file.Instance => instance !== undefined,
898 () => new InstanceNotResolved({ name }),
899 ),
900 Effect.filterOrFail(
901 (instance) => (instance.state ?? "") !== "ERROR",
902 (instance) =>
903 new InstanceFailed({
904 name,
905 state: instance.state ?? "ERROR",
906 statusMessage: instance.statusMessage,
907 }),
908 ),
909 Effect.filterOrFail(
910 (instance) => (instance.state ?? "STATE_UNSPECIFIED") === "READY",
911 (instance) =>
912 new InstanceNotReady({
913 name,
914 state: instance.state ?? "STATE_UNSPECIFIED",
915 }),
916 ),
917 Effect.retry({
918 while: (error) =>
919 error._tag === "GCP.Filestore.InstanceNotReady" ||
920 error._tag === "GCP.Filestore.InstanceNotResolved",
921 // Instances take 5–20 minutes to provision.
922 times: 120,
923 schedule: Schedule.spaced("10 seconds"),
924 }),
925 );
926
927const waitUntilGone = (name: string) =>
928 getByName(name).pipe(
929 Effect.flatMap((instance) =>
930 instance === undefined
931 ? Effect.void
932 : Effect.fail(new InstanceStillExists({ name })),
933 ),
934 Effect.retry({
935 while: (error) => error._tag === "GCP.Filestore.InstanceStillExists",
936 // Instance deletion takes several minutes.
937 times: 120,
938 schedule: Schedule.spaced("10 seconds"),
939 }),
940 );
941
942const desiredShares = (news: InstanceProps, tier: string): FileShareConfig[] =>
943 sharesOf(news.fileShares, tier);
944
945const desiredNetworks = (news: InstanceProps) => networksOf(news.networks);
946
947const toCreateBody = (
948 news: InstanceProps,
949 desiredLabels: Record<string, string>,
950 tier: string,
951 protocol: string,
952): file.Instance => ({
953 description: news.description,
954 labels: desiredLabels,
955 tier,
956 protocol,
957 fileShares: desiredShares(news, tier).map(toApiShare),
958 networks: desiredNetworks(news).map(toApiNetwork),
959 kmsKeyName: news.kmsKeyName,
960 performanceConfig: toApiPerformance(news.performanceConfig),
961 directoryServices: news.directoryServices,
962 deletionProtectionEnabled: news.deletionProtectionEnabled === true,
963 deletionProtectionReason: news.deletionProtectionReason,
964 replication: news.replication,
965 tags: news.tags,
966});
967
968const sharesIdentityChanged = (
969 observed: FileShareConfig[],
970 desired: FileShareConfig[],
971) =>
972 observed.length !== desired.length ||
973 observed.some(
974 (share, index) =>
975 shareIdentityKey(share) !== shareIdentityKey(desired[index]!),
976 );
977
978const sharesMutableChanged = (
979 observed: FileShareConfig[],
980 desired: FileShareConfig[],
981) =>
982 observed.length !== desired.length ||
983 observed.some((share, index) => {
984 const next = desired[index];
985 if (next === undefined) return true;
986 if (shareCapacity(share) !== shareCapacity(next)) return true;
987 if (next.nfsExportOptions === undefined) return false;
988 return (
989 (share.nfsExportOptions ?? []).map(nfsKey).join("\0") !==
990 next.nfsExportOptions.map(nfsKey).join("\0")
991 );
992 });
993
994const sharesForPatch = (
995 desired: FileShareConfig[],
996 observed: FileShareConfig[],
997): FileShareConfig[] =>
998 desired.map((share, index) => ({
999 ...share,
1000 nfsExportOptions:
1001 share.nfsExportOptions ?? observed[index]?.nfsExportOptions,
1002 }));
1003
1004const networksIdentityChanged = (
1005 observed: Array<NetworkConfig>,
1006 desired: Array<NetworkConfig>,
1007) => {
1008 if (observed.length !== desired.length) return true;
1009 return observed.some((network, index) => {
1010 const next = desired[index]!;
1011 const includeReserved = next.reservedIpRange !== undefined;
1012 return (
1013 networkIdentityKey(network, { includeReserved }) !==
1014 networkIdentityKey(next, { includeReserved })
1015 );
1016 });
1017};
1018
1019export const InstanceProvider = () =>
1020 Provider.succeed(Instance, {
1021 stables: ["name", "instanceId", "project", "location", "createTime"],
1022
1023 diff: Effect.fn(function* ({ news, olds, output }) {
1024 if (!isResolved(news)) return undefined;
1025 const env = yield* GcpEnvironment.current;
1026
1027 const previousId = olds?.instanceId ?? output?.instanceId;
1028 const nextId = news.instanceId ?? previousId;
1029 const previousTier = canonicalizeTier(olds?.tier ?? output?.tier);
1030 const nextTier = canonicalizeTier(news.tier ?? output?.tier);
1031 const previousLocation = normalizeLocation(
1032 olds?.location ?? output?.location,
1033 previousTier,
1034 env.region,
1035 );
1036 const nextLocation = normalizeLocation(
1037 news.location ?? output?.location,
1038 nextTier,
1039 env.region,
1040 );
1041 const previousProtocol = normalizeProtocol(
1042 olds?.protocol ?? output?.protocol,
1043 );
1044 const nextProtocol = normalizeProtocol(news.protocol ?? output?.protocol);
1045 const previousKey = olds?.kmsKeyName ?? output?.kmsKeyName ?? "";
1046 const nextKey = news.kmsKeyName ?? previousKey;
1047 const previousShares = sharesOf(
1048 olds?.fileShares ?? output?.fileShares,
1049 previousTier,
1050 );
1051 const nextShares = sharesOf(
1052 news.fileShares ?? olds?.fileShares ?? output?.fileShares,
1053 nextTier,
1054 );
1055 const previousNetworks = networksOf(olds?.networks ?? output?.networks);
1056 const nextNetworks = networksOf(
1057 news.networks ?? olds?.networks ?? output?.networks,
1058 );
1059 const previousDirectory = directoryKey(
1060 olds?.directoryServices ?? output?.directoryServices,
1061 );
1062 const nextDirectory = directoryKey(
1063 news.directoryServices ??
1064 olds?.directoryServices ??
1065 output?.directoryServices,
1066 );
1067 const previousReplication = replicationKey(
1068 olds?.replication ?? output?.replication,
1069 );
1070 const nextReplication = replicationKey(
1071 news.replication ?? olds?.replication ?? output?.replication,
1072 );
1073
1074 const replace =
1075 (previousId !== undefined &&
1076 nextId !== undefined &&
1077 nextId !== previousId) ||
1078 previousLocation !== nextLocation ||
1079 previousTier !== nextTier ||
1080 previousProtocol !== nextProtocol ||
1081 previousKey !== nextKey ||
1082 sharesIdentityChanged(previousShares, nextShares) ||
1083 networksIdentityChanged(previousNetworks, nextNetworks) ||
1084 previousDirectory !== nextDirectory ||
1085 previousReplication !== nextReplication;
1086
1087 if (!replace) return undefined;
1088 return {
1089 action: "replace" as const,
1090 deleteFirst:
1091 previousLocation === nextLocation &&
1092 previousId !== undefined &&
1093 nextId === previousId,
1094 };
1095 }),
1096
1097 read: Effect.fn(function* ({ id, olds, output }) {
1098 const env = yield* GcpEnvironment.current;
1099 const instanceId = yield* toId(id, olds?.instanceId, output?.instanceId);
1100 const tier = canonicalizeTier(olds?.tier ?? output?.tier);
1101 const location = normalizeLocation(
1102 olds?.location ?? output?.location,
1103 tier,
1104 env.region,
1105 );
1106 const name =
1107 output?.name ?? resourceName(env.project, location, instanceId);
1108 const existing = yield* getByName(name);
1109 if (existing === undefined) return undefined;
1110 const attrs = toAttrs(existing, env.project);
1111 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
1112 ? attrs
1113 : Unowned(attrs);
1114 }),
1115
1116 list: () =>
1117 Effect.gen(function* () {
1118 const env = yield* GcpEnvironment.current;
1119 return yield* file.listProjectsLocationsInstances
1120 .pages({
1121 parent: `projects/${env.project}/locations/-`,
1122 pageSize: 1000,
1123 })
1124 .pipe(
1125 Stream.flatMap((page) => Stream.fromIterable(page.instances ?? [])),
1126 Stream.filter(
1127 (instance) =>
1128 !isPlaceholder(instance) &&
1129 Object.keys(instance.labels ?? {}).some((key) =>
1130 key.startsWith("alchemy-"),
1131 ),
1132 ),
1133 Stream.map((instance) => toAttrs(instance, env.project)),
1134 Stream.runCollect,
1135 Effect.map((chunk) => Array.from(chunk)),
1136 Effect.catchTag("NotFound", () => Effect.succeed([])),
1137 );
1138 }),
1139
1140 reconcile: Effect.fn(function* ({ id, news, output }) {
1141 const env = yield* GcpEnvironment.current;
1142 const instanceId = yield* toId(id, news.instanceId, output?.instanceId);
1143 const tier = canonicalizeTier(news.tier);
1144 const location = normalizeLocation(
1145 news.location ?? output?.location,
1146 tier,
1147 env.region,
1148 );
1149 const protocol = normalizeProtocol(news.protocol);
1150 const name = resourceName(env.project, location, instanceId);
1151 const desiredLabels = {
1152 ...toLabels(news.labels),
1153 ...(yield* createInternalLabels(id)),
1154 };
1155 const desiredProtection = news.deletionProtectionEnabled === true;
1156
1157 let current = yield* getByName(output?.name ?? name);
1158
1159 if (current !== undefined && (current.state ?? "") === "DELETING") {
1160 yield* waitUntilGone(current.name ?? name);
1161 current = undefined;
1162 }
1163
1164 if (current === undefined) {
1165 const created = yield* file
1166 .createProjectsLocationsInstances({
1167 parent: `projects/${env.project}/locations/${location}`,
1168 instanceId,
1169 body: toCreateBody(news, desiredLabels, tier, protocol),
1170 })
1171 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
1172 if (created !== undefined) {
1173 yield* waitForOperation(created);
1174 }
1175 current = yield* waitUntilExists(name);
1176 }
1177
1178 if (current === undefined) {
1179 return yield* new InstanceNotResolved({ name });
1180 }
1181
1182 if ((current.state ?? "") === "ERROR") {
1183 return yield* new InstanceFailed({
1184 name,
1185 state: current.state ?? "ERROR",
1186 statusMessage: current.statusMessage,
1187 });
1188 }
1189
1190 if ((current.state ?? "") !== "READY") {
1191 current = yield* waitUntilReady(name);
1192 }
1193
1194 const observedLabels = tagRecord(current.labels);
1195 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
1196 const labelsChanged = upsert.length > 0 || removed.length > 0;
1197 const descriptionChanged =
1198 (current.description ?? "") !== (news.description ?? "");
1199 const protectionChanged =
1200 (current.deletionProtectionEnabled === true) !== desiredProtection;
1201 const reasonChanged =
1202 (current.deletionProtectionReason ?? "") !==
1203 (news.deletionProtectionReason ?? "");
1204 const observedShares = sharesOf(current.fileShares, tier);
1205 const nextShares = desiredShares(news, tier);
1206 const fileSharesChanged =
1207 news.fileShares !== undefined &&
1208 sharesMutableChanged(observedShares, nextShares);
1209 const performanceChanged =
1210 news.performanceConfig !== undefined &&
1211 performanceKey(current.performanceConfig) !==
1212 performanceKey(news.performanceConfig);
1213
1214 if (
1215 labelsChanged ||
1216 descriptionChanged ||
1217 protectionChanged ||
1218 reasonChanged ||
1219 fileSharesChanged ||
1220 performanceChanged
1221 ) {
1222 const updateMask = [
1223 labelsChanged ? "labels" : undefined,
1224 descriptionChanged ? "description" : undefined,
1225 fileSharesChanged ? "file_shares" : undefined,
1226 performanceChanged ? "performance_config" : undefined,
1227 protectionChanged ? "deletion_protection_enabled" : undefined,
1228 reasonChanged ? "deletion_protection_reason" : undefined,
1229 ].filter((field): field is string => field !== undefined);
1230
1231 const patched = yield* file.patchProjectsLocationsInstances({
1232 name,
1233 updateMask: updateMask.join(","),
1234 body: {
1235 name,
1236 labels: desiredLabels,
1237 description: news.description,
1238 fileShares: sharesForPatch(nextShares, observedShares).map(
1239 toApiShare,
1240 ),
1241 performanceConfig: toApiPerformance(news.performanceConfig),
1242 deletionProtectionEnabled: desiredProtection,
1243 deletionProtectionReason: news.deletionProtectionReason,
1244 },
1245 });
1246 yield* waitForOperation(patched);
1247 current = yield* waitUntilReady(name);
1248 }
1249
1250 return toAttrs(current, env.project);
1251 }),
1252
1253 delete: Effect.fn(function* ({ output }) {
1254 const existing = yield* getByName(output.name);
1255 if (existing === undefined) return;
1256
1257 if (existing.deletionProtectionEnabled === true) {
1258 const patched = yield* file
1259 .patchProjectsLocationsInstances({
1260 name: output.name,
1261 updateMask:
1262 "deletion_protection_enabled,deletion_protection_reason",
1263 body: {
1264 name: output.name,
1265 deletionProtectionEnabled: false,
1266 deletionProtectionReason: "",
1267 },
1268 })
1269 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
1270 if (patched !== undefined) {
1271 yield* waitForOperation(patched, { notFoundOk: true });
1272 }
1273 }
1274
1275 const operation = yield* file
1276 .deleteProjectsLocationsInstances({
1277 name: output.name,
1278 force: true,
1279 })
1280 .pipe(
1281 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
1282 Effect.retry({
1283 while: (error) => error._tag === "Conflict",
1284 times: 8,
1285 schedule: Schedule.spaced("5 seconds"),
1286 }),
1287 );
1288 if (operation !== undefined) {
1289 yield* waitForOperation(operation, { notFoundOk: true });
1290 }
1291 yield* waitUntilGone(output.name);
1292 }),
1293 });