@microagi/alchemy-gcp 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +88 -0
  3. package/lib/Auth/AuthProvider.d.ts +48 -0
  4. package/lib/Auth/AuthProvider.d.ts.map +1 -0
  5. package/lib/Auth/AuthProvider.js +84 -0
  6. package/lib/Auth/AuthProvider.js.map +1 -0
  7. package/lib/Auth/Credentials.d.ts +28 -0
  8. package/lib/Auth/Credentials.d.ts.map +1 -0
  9. package/lib/Auth/Credentials.js +41 -0
  10. package/lib/Auth/Credentials.js.map +1 -0
  11. package/lib/CloudResourceManager/Project.d.ts +143 -0
  12. package/lib/CloudResourceManager/Project.d.ts.map +1 -0
  13. package/lib/CloudResourceManager/Project.js +272 -0
  14. package/lib/CloudResourceManager/Project.js.map +1 -0
  15. package/lib/CloudResourceManager/index.d.ts +3 -0
  16. package/lib/CloudResourceManager/index.d.ts.map +1 -0
  17. package/lib/CloudResourceManager/index.js +2 -0
  18. package/lib/CloudResourceManager/index.js.map +1 -0
  19. package/lib/Compute/GlobalAddress.d.ts +101 -0
  20. package/lib/Compute/GlobalAddress.d.ts.map +1 -0
  21. package/lib/Compute/GlobalAddress.js +134 -0
  22. package/lib/Compute/GlobalAddress.js.map +1 -0
  23. package/lib/Compute/Network.d.ts +114 -0
  24. package/lib/Compute/Network.d.ts.map +1 -0
  25. package/lib/Compute/Network.js +145 -0
  26. package/lib/Compute/Network.js.map +1 -0
  27. package/lib/Compute/Operations.d.ts +34 -0
  28. package/lib/Compute/Operations.d.ts.map +1 -0
  29. package/lib/Compute/Operations.js +62 -0
  30. package/lib/Compute/Operations.js.map +1 -0
  31. package/lib/Compute/SharedVpcHost.d.ts +44 -0
  32. package/lib/Compute/SharedVpcHost.d.ts.map +1 -0
  33. package/lib/Compute/SharedVpcHost.js +71 -0
  34. package/lib/Compute/SharedVpcHost.js.map +1 -0
  35. package/lib/Compute/SharedVpcServiceProject.d.ts +46 -0
  36. package/lib/Compute/SharedVpcServiceProject.d.ts.map +1 -0
  37. package/lib/Compute/SharedVpcServiceProject.js +96 -0
  38. package/lib/Compute/SharedVpcServiceProject.js.map +1 -0
  39. package/lib/Compute/Subnetwork.d.ts +108 -0
  40. package/lib/Compute/Subnetwork.d.ts.map +1 -0
  41. package/lib/Compute/Subnetwork.js +201 -0
  42. package/lib/Compute/Subnetwork.js.map +1 -0
  43. package/lib/Compute/index.d.ts +6 -0
  44. package/lib/Compute/index.d.ts.map +1 -0
  45. package/lib/Compute/index.js +6 -0
  46. package/lib/Compute/index.js.map +1 -0
  47. package/lib/Container/Cluster.d.ts +216 -0
  48. package/lib/Container/Cluster.d.ts.map +1 -0
  49. package/lib/Container/Cluster.js +326 -0
  50. package/lib/Container/Cluster.js.map +1 -0
  51. package/lib/Container/NodePool.d.ts +214 -0
  52. package/lib/Container/NodePool.d.ts.map +1 -0
  53. package/lib/Container/NodePool.js +344 -0
  54. package/lib/Container/NodePool.js.map +1 -0
  55. package/lib/Container/Operations.d.ts +50 -0
  56. package/lib/Container/Operations.d.ts.map +1 -0
  57. package/lib/Container/Operations.js +51 -0
  58. package/lib/Container/Operations.js.map +1 -0
  59. package/lib/Container/index.d.ts +5 -0
  60. package/lib/Container/index.d.ts.map +1 -0
  61. package/lib/Container/index.js +3 -0
  62. package/lib/Container/index.js.map +1 -0
  63. package/lib/Parallelstore/Instance.d.ts +119 -0
  64. package/lib/Parallelstore/Instance.d.ts.map +1 -0
  65. package/lib/Parallelstore/Instance.js +180 -0
  66. package/lib/Parallelstore/Instance.js.map +1 -0
  67. package/lib/Parallelstore/index.d.ts +2 -0
  68. package/lib/Parallelstore/index.d.ts.map +1 -0
  69. package/lib/Parallelstore/index.js +2 -0
  70. package/lib/Parallelstore/index.js.map +1 -0
  71. package/lib/Providers.d.ts +24 -0
  72. package/lib/Providers.d.ts.map +1 -0
  73. package/lib/Providers.js +51 -0
  74. package/lib/Providers.js.map +1 -0
  75. package/lib/ServiceNetworking/PsaConnection.d.ts +84 -0
  76. package/lib/ServiceNetworking/PsaConnection.d.ts.map +1 -0
  77. package/lib/ServiceNetworking/PsaConnection.js +130 -0
  78. package/lib/ServiceNetworking/PsaConnection.js.map +1 -0
  79. package/lib/ServiceNetworking/index.d.ts +2 -0
  80. package/lib/ServiceNetworking/index.d.ts.map +1 -0
  81. package/lib/ServiceNetworking/index.js +2 -0
  82. package/lib/ServiceNetworking/index.js.map +1 -0
  83. package/lib/ServiceUsage/ApiEnable.d.ts +78 -0
  84. package/lib/ServiceUsage/ApiEnable.d.ts.map +1 -0
  85. package/lib/ServiceUsage/ApiEnable.js +132 -0
  86. package/lib/ServiceUsage/ApiEnable.js.map +1 -0
  87. package/lib/ServiceUsage/index.d.ts +3 -0
  88. package/lib/ServiceUsage/index.d.ts.map +1 -0
  89. package/lib/ServiceUsage/index.js +2 -0
  90. package/lib/ServiceUsage/index.js.map +1 -0
  91. package/lib/Tags.d.ts +57 -0
  92. package/lib/Tags.d.ts.map +1 -0
  93. package/lib/Tags.js +106 -0
  94. package/lib/Tags.js.map +1 -0
  95. package/lib/index.d.ts +11 -0
  96. package/lib/index.d.ts.map +1 -0
  97. package/lib/index.js +10 -0
  98. package/lib/index.js.map +1 -0
  99. package/package.json +70 -0
  100. package/src/Auth/AuthProvider.ts +180 -0
  101. package/src/Auth/Credentials.ts +69 -0
  102. package/src/CloudResourceManager/Project.ts +517 -0
  103. package/src/CloudResourceManager/index.ts +2 -0
  104. package/src/Compute/GlobalAddress.ts +283 -0
  105. package/src/Compute/Network.ts +306 -0
  106. package/src/Compute/Operations.ts +108 -0
  107. package/src/Compute/SharedVpcHost.ts +141 -0
  108. package/src/Compute/SharedVpcServiceProject.ts +185 -0
  109. package/src/Compute/Subnetwork.ts +375 -0
  110. package/src/Compute/index.ts +5 -0
  111. package/src/Container/Cluster.ts +599 -0
  112. package/src/Container/NodePool.ts +620 -0
  113. package/src/Container/Operations.ts +88 -0
  114. package/src/Container/index.ts +8 -0
  115. package/src/Parallelstore/Instance.ts +371 -0
  116. package/src/Parallelstore/index.ts +1 -0
  117. package/src/Providers.ts +84 -0
  118. package/src/ServiceNetworking/PsaConnection.ts +269 -0
  119. package/src/ServiceNetworking/index.ts +1 -0
  120. package/src/ServiceUsage/ApiEnable.ts +267 -0
  121. package/src/ServiceUsage/index.ts +2 -0
  122. package/src/Tags.ts +130 -0
  123. package/src/index.ts +16 -0
@@ -0,0 +1,283 @@
1
+ import * as compute from "@distilled.cloud/gcp/compute-v1";
2
+ import { Resource } from "alchemy";
3
+ import { Unowned } from "alchemy/AdoptPolicy";
4
+ import type { ScopedPlanStatusSession } from "alchemy/Cli/Cli";
5
+ import { deepEqual, isResolved, somePropsAreDifferent } from "alchemy/Diff";
6
+ import { createPhysicalName } from "alchemy/PhysicalName";
7
+ import * as Provider from "alchemy/Provider";
8
+ import * as Effect from "effect/Effect";
9
+ import { gcpInternalLabels, hasAlchemyLabels } from "../Tags.ts";
10
+ import type * as GCP from "../Providers.ts";
11
+ import { makeAwaitGlobalOperation } from "./Operations.ts";
12
+
13
+ /**
14
+ * A global compute Address. The intended use here is **Private Services
15
+ * Access (PSA)** — reserving an internal IP range that
16
+ * `servicenetworking.services.connections.create` consumes to peer
17
+ * Google's service-producer VPC into the host VPC. That requires the
18
+ * specific shape `addressType=INTERNAL`, `purpose=VPC_PEERING`,
19
+ * `network=<vpc selfLink>`, plus an explicit `prefixLength`.
20
+ *
21
+ * Other purposes (`PRIVATE_SERVICE_CONNECT`, `GCE_ENDPOINT`, regional
22
+ * external addresses) are not modelled in this resource — add them in
23
+ * separate resources if/when needed.
24
+ *
25
+ * Adoption uses the labels field (Address carries `labels` +
26
+ * `labelFingerprint`).
27
+ *
28
+ * @section Reserving a PSA range
29
+ * @example /20 reserved for `servicenetworking.googleapis.com`
30
+ * ```typescript
31
+ * const psaRange = yield* GCP.GlobalAddress("PsaRange", {
32
+ * project: hostProject.projectId,
33
+ * network: vpc.selfLink,
34
+ * purpose: "VPC_PEERING",
35
+ * addressType: "INTERNAL",
36
+ * prefixLength: 20,
37
+ * });
38
+ * ```
39
+ */
40
+ export type GlobalAddressProps = {
41
+ /** GCP project ID hosting the address. Immutable — replace if changed. */
42
+ project: string;
43
+ /**
44
+ * Address name. Defaults to `createPhysicalName({ id, lowercase: true,
45
+ * maxLength: 63 })`. Immutable — replace if changed.
46
+ */
47
+ name?: string;
48
+ /** Description (free-form). */
49
+ description?: string;
50
+ /**
51
+ * Internal vs external. Defaults to `INTERNAL` (the only value that
52
+ * makes sense for a PSA reservation). Immutable — replace.
53
+ */
54
+ addressType?: "INTERNAL" | "EXTERNAL";
55
+ /**
56
+ * Address purpose. For PSA use `VPC_PEERING`. Immutable — replace.
57
+ */
58
+ purpose?:
59
+ | "VPC_PEERING"
60
+ | "GCE_ENDPOINT"
61
+ | "PRIVATE_SERVICE_CONNECT"
62
+ | "DNS_RESOLVER";
63
+ /**
64
+ * URL of the VPC the address belongs to (PSA requires this).
65
+ * Immutable — replace.
66
+ */
67
+ network?: string;
68
+ /**
69
+ * Prefix length for a range allocation (PSA always allocates a
70
+ * range, not a single address). Required for `purpose=VPC_PEERING`.
71
+ * Immutable — replace.
72
+ */
73
+ prefixLength?: number;
74
+ /**
75
+ * Explicit address (or first address of the range) to reserve. If
76
+ * omitted, GCP picks one inside the VPC's available space.
77
+ * Immutable — replace.
78
+ */
79
+ address?: string;
80
+ /**
81
+ * Resource labels. Alchemy internal labels (`alchemy_app`,
82
+ * `alchemy_stage`, `alchemy_id`) are merged on top automatically and
83
+ * are reserved. Mutable via `setLabels`.
84
+ */
85
+ labels?: Record<string, string>;
86
+ };
87
+
88
+ export type GlobalAddressAttributes = {
89
+ /** Address name. */
90
+ name: string;
91
+ /** GCP project ID, threaded through from props for delete/read. */
92
+ project: string;
93
+ /** Server-defined URL. */
94
+ selfLink: string;
95
+ /** Server-assigned numeric id. */
96
+ id: string;
97
+ /** The reserved IP (or first IP of the range). */
98
+ address: string;
99
+ /** Prefix length, when reserving a range. */
100
+ prefixLength: number | undefined;
101
+ /** Address type. */
102
+ addressType: string | undefined;
103
+ /** Purpose. */
104
+ purpose: string | undefined;
105
+ /** VPC URL. */
106
+ network: string | undefined;
107
+ /** Status: `RESERVING`, `RESERVED`, `IN_USE`. */
108
+ status: string | undefined;
109
+ /** Labels currently set, including the alchemy internals. */
110
+ labels: Record<string, string>;
111
+ };
112
+
113
+ export type GlobalAddress = Resource<
114
+ "GCP.GlobalAddress",
115
+ GlobalAddressProps,
116
+ GlobalAddressAttributes,
117
+ never,
118
+ GCP.Providers
119
+ >;
120
+ export const GlobalAddress = Resource<GlobalAddress>("GCP.GlobalAddress");
121
+
122
+ const toGlobalAddressAttributes = (
123
+ a: compute.Address,
124
+ parent: { project: string },
125
+ ): GlobalAddressAttributes => ({
126
+ name: a.name ?? "",
127
+ project: parent.project,
128
+ selfLink: a.selfLink ?? "",
129
+ id: a.id ?? "",
130
+ address: a.address ?? "",
131
+ prefixLength: a.prefixLength,
132
+ addressType: a.addressType,
133
+ purpose: a.purpose,
134
+ network: a.network,
135
+ status: a.status,
136
+ labels: { ...(a.labels ?? {}) },
137
+ });
138
+
139
+ export const GlobalAddressProvider = () =>
140
+ Provider.effect(
141
+ GlobalAddress,
142
+ Effect.gen(function* () {
143
+ const getGlobalAddresses = yield* compute.getGlobalAddresses;
144
+ const insertGlobalAddresses = yield* compute.insertGlobalAddresses;
145
+ const deleteGlobalAddresses = yield* compute.deleteGlobalAddresses;
146
+ const setLabelsGlobalAddresses = yield* compute.setLabelsGlobalAddresses;
147
+ const getGlobalOperations = yield* compute.getGlobalOperations;
148
+ const awaitOp = makeAwaitGlobalOperation(getGlobalOperations);
149
+
150
+ const observe = (project: string, name: string) =>
151
+ getGlobalAddresses({ project, address: name }).pipe(
152
+ Effect.catchTag("NotFound", () =>
153
+ Effect.succeed(undefined as compute.Address | undefined),
154
+ ),
155
+ Effect.catchTag("Forbidden", () =>
156
+ Effect.succeed(undefined as compute.Address | undefined),
157
+ ),
158
+ );
159
+
160
+ const syncLabels = Effect.fn(function* (args: {
161
+ project: string;
162
+ observed: compute.Address;
163
+ desired: Record<string, string>;
164
+ session: ScopedPlanStatusSession;
165
+ }) {
166
+ if (deepEqual(args.observed.labels ?? {}, args.desired)) return;
167
+ const op = yield* setLabelsGlobalAddresses({
168
+ project: args.project,
169
+ resource: args.observed.name ?? "",
170
+ body: {
171
+ labels: args.desired,
172
+ labelFingerprint: args.observed.labelFingerprint,
173
+ },
174
+ });
175
+ if (op.name) yield* awaitOp(args.project, op.name, args.session);
176
+ });
177
+
178
+ return {
179
+ stables: ["name", "project", "selfLink", "id", "address"],
180
+ diff: Effect.fn(function* ({ news, olds = {} }) {
181
+ if (!isResolved(news)) return undefined;
182
+ if (
183
+ somePropsAreDifferent(olds as GlobalAddressProps, news, [
184
+ "project",
185
+ "name",
186
+ "addressType",
187
+ "purpose",
188
+ "network",
189
+ "prefixLength",
190
+ "address",
191
+ ])
192
+ ) {
193
+ return { action: "replace" } as const;
194
+ }
195
+ return undefined;
196
+ }),
197
+ reconcile: Effect.fn(function* ({ id, news, session }) {
198
+ const internalLabels = yield* gcpInternalLabels(id);
199
+ const desiredName =
200
+ news.name ??
201
+ (yield* createPhysicalName({ id, maxLength: 63 })).toLowerCase();
202
+ const desiredLabels: Record<string, string> = {
203
+ ...(news.labels ?? {}),
204
+ ...internalLabels,
205
+ };
206
+
207
+ let observed = yield* observe(news.project, desiredName);
208
+
209
+ if (!observed) {
210
+ const body: compute.Address = {
211
+ name: desiredName,
212
+ ...(news.description ? { description: news.description } : {}),
213
+ addressType: news.addressType ?? "INTERNAL",
214
+ ...(news.purpose ? { purpose: news.purpose } : {}),
215
+ ...(news.network ? { network: news.network } : {}),
216
+ ...(news.prefixLength !== undefined
217
+ ? { prefixLength: news.prefixLength }
218
+ : {}),
219
+ ...(news.address ? { address: news.address } : {}),
220
+ labels: desiredLabels,
221
+ };
222
+ const op = yield* insertGlobalAddresses({
223
+ project: news.project,
224
+ body,
225
+ }).pipe(
226
+ Effect.catchTag("Conflict", () =>
227
+ Effect.succeed(undefined as compute.Operation | undefined),
228
+ ),
229
+ );
230
+ if (op?.name) yield* awaitOp(news.project, op.name, session);
231
+ observed = yield* getGlobalAddresses({
232
+ project: news.project,
233
+ address: desiredName,
234
+ });
235
+ }
236
+
237
+ yield* syncLabels({
238
+ project: news.project,
239
+ observed,
240
+ desired: desiredLabels,
241
+ session,
242
+ });
243
+
244
+ const final = yield* getGlobalAddresses({
245
+ project: news.project,
246
+ address: desiredName,
247
+ });
248
+ return toGlobalAddressAttributes(final, { project: news.project });
249
+ }),
250
+ delete: Effect.fn(function* ({ output, session }) {
251
+ yield* deleteGlobalAddresses({
252
+ project: output.project,
253
+ address: output.name,
254
+ }).pipe(
255
+ Effect.flatMap((op) =>
256
+ op.name
257
+ ? awaitOp(output.project, op.name, session)
258
+ : Effect.succeed(op),
259
+ ),
260
+ Effect.catchTag("NotFound", () => Effect.void),
261
+ // 400 with "is being used by ..." comes back when a PSA
262
+ // connection still references the range. The user has to
263
+ // delete the connection first; surface the error rather
264
+ // than silently swallowing.
265
+ );
266
+ }),
267
+ read: Effect.fn(function* ({ id, output, olds }) {
268
+ const project = output?.project ?? olds?.project;
269
+ if (!project) return undefined;
270
+ const name =
271
+ output?.name ??
272
+ olds?.name ??
273
+ (yield* createPhysicalName({ id, maxLength: 63 })).toLowerCase();
274
+ const observed = yield* observe(project, name);
275
+ if (!observed) return undefined;
276
+ const attrs = toGlobalAddressAttributes(observed, { project });
277
+ return (yield* hasAlchemyLabels(id, observed.labels))
278
+ ? attrs
279
+ : Unowned(attrs);
280
+ }),
281
+ };
282
+ }),
283
+ );
@@ -0,0 +1,306 @@
1
+ import * as compute from "@distilled.cloud/gcp/compute-v1";
2
+ import { Resource } from "alchemy";
3
+ import { Unowned } from "alchemy/AdoptPolicy";
4
+ import type { ScopedPlanStatusSession } from "alchemy/Cli/Cli";
5
+ import { isResolved, somePropsAreDifferent } from "alchemy/Diff";
6
+ import { createPhysicalName } from "alchemy/PhysicalName";
7
+ import * as Output from "alchemy/Output";
8
+ import * as Provider from "alchemy/Provider";
9
+ import * as Effect from "effect/Effect";
10
+ import {
11
+ descriptionHasAlchemyMarker,
12
+ gcpAlchemyDescription,
13
+ stripAlchemyMarker,
14
+ } from "../Tags.ts";
15
+ import type * as GCP from "../Providers.ts";
16
+ import { makeAwaitGlobalOperation } from "./Operations.ts";
17
+
18
+ /**
19
+ * Fully-qualified VPC reference in **project-number** form:
20
+ * `projects/{projectNumber}/global/networks/{name}`.
21
+ *
22
+ * Several GCP service-producer APIs (servicenetworking PSA,
23
+ * Parallelstore) require this exact shape — the *project number*, not
24
+ * project ID — because they resolve peerings against numeric ids that
25
+ * are stable across project renames. Compose with `networkRef`:
26
+ *
27
+ * ```typescript
28
+ * const ref = networkRef(project.projectNumber, vpc.name);
29
+ * // typed as Output<NetworkRef>, accepted by GCP.PsaConnection /
30
+ * // GCP.ParallelstoreInstance via the Resource constructor's
31
+ * // InputProps widening.
32
+ * ```
33
+ *
34
+ * `Project.projectNumber` is typed as `` `${number}` `` precisely so
35
+ * this template literal type composes without a runtime coercion at
36
+ * the call site.
37
+ */
38
+ export type NetworkRef = `projects/${number}/global/networks/${string}`;
39
+
40
+ /**
41
+ * Build a project-number-form `NetworkRef`. Lifted through `Output`
42
+ * so it composes with `Project.projectNumber` (an `Output<\`${number}\`>`)
43
+ * and `Network.name` (an `Output<string>`) coming from other resource
44
+ * constructors. The `` `${number}` `` argument type prevents
45
+ * accidentally passing `Project.projectId` here — TS rejects strings
46
+ * that don't match the numeric shape.
47
+ *
48
+ * Plain literal arguments still work: `networkRef("415104041262", "main")`
49
+ * is wrapped via `Output.asOutput` and resolves immediately at apply
50
+ * time.
51
+ */
52
+ export const networkRef = (
53
+ projectNumber: `${number}` | Output.Output<`${number}`>,
54
+ networkName: string | Output.Output<string>,
55
+ ): Output.Output<NetworkRef> =>
56
+ Output.all(
57
+ Output.asOutput(projectNumber),
58
+ Output.asOutput(networkName),
59
+ ).pipe(
60
+ Output.map(
61
+ ([num, name]) =>
62
+ `projects/${num}/global/networks/${name}` as NetworkRef,
63
+ ),
64
+ );
65
+
66
+ /**
67
+ * A VPC Network in custom-mode (`autoCreateSubnetworks=false`). Auto-mode
68
+ * networks are intentionally not exposed — every cluster/parallelstore
69
+ * deployment in this provider expects explicit subnets in known regions.
70
+ *
71
+ * Compute Networks have no `labels` field, so adoption gating uses an
72
+ * alchemy marker embedded in the `description` field instead (see
73
+ * `gcpAlchemyDescription`). Network `description` is set at create time
74
+ * and is treated as immutable — the marker survives for the resource's
75
+ * lifetime.
76
+ *
77
+ * @section Creating a VPC
78
+ * @example Custom-mode VPC for a Shared VPC host
79
+ * ```typescript
80
+ * const vpc = yield* GCP.Network("ResearchVpc", {
81
+ * project: hostProject.projectId,
82
+ * routingMode: "GLOBAL",
83
+ * });
84
+ * ```
85
+ */
86
+ export type NetworkProps = {
87
+ /** GCP project ID hosting the network. Immutable — replace if changed. */
88
+ project: string;
89
+ /**
90
+ * Network name. Defaults to `createPhysicalName({ id, lowercase: true,
91
+ * maxLength: 63 })`. Must match `[a-z]([-a-z0-9]*[a-z0-9])?`.
92
+ * Immutable — replace if changed.
93
+ */
94
+ name?: string;
95
+ /**
96
+ * User-visible description. Stored in the resource's `description`
97
+ * field after the alchemy ownership marker prefix. Treated as
98
+ * immutable by GCP after create — changes here do NOT replace.
99
+ */
100
+ description?: string;
101
+ /**
102
+ * `auto` mode: GCP allocates one subnet per region with predetermined
103
+ * CIDRs. `custom` mode (default): you create subnetworks explicitly
104
+ * via `GCP.Subnetwork`. Immutable — switching modes requires replace.
105
+ */
106
+ autoCreateSubnetworks?: boolean;
107
+ /**
108
+ * Network-wide routing config. Cloud Routers in REGIONAL mode advertise
109
+ * only same-region subnets; GLOBAL mode advertises across regions.
110
+ * Mutable via `patchNetworks` (the only field GCP allows mutating
111
+ * post-create on a VPC).
112
+ */
113
+ routingMode?: "REGIONAL" | "GLOBAL";
114
+ /**
115
+ * MTU in bytes. Range 1300-8896. Defaults to 1460. Common values:
116
+ * 1500 (internet default), 8896 (jumbo frames). Immutable — replace.
117
+ */
118
+ mtu?: number;
119
+ };
120
+
121
+ export type NetworkAttributes = {
122
+ /** Network name. */
123
+ name: string;
124
+ /** GCP project ID, threaded through from props for delete/read. */
125
+ project: string;
126
+ /** Server-defined URL for the resource. */
127
+ selfLink: string;
128
+ /** Server-assigned numeric id. */
129
+ id: string;
130
+ /** User-visible description (with the alchemy marker stripped off). */
131
+ description: string | undefined;
132
+ /** Custom (false) vs auto (true) subnet mode. */
133
+ autoCreateSubnetworks: boolean;
134
+ /** Active routing mode. */
135
+ routingMode: string | undefined;
136
+ /** Active MTU in bytes. */
137
+ mtu: number | undefined;
138
+ };
139
+
140
+ export type Network = Resource<
141
+ "GCP.Network",
142
+ NetworkProps,
143
+ NetworkAttributes,
144
+ never,
145
+ GCP.Providers
146
+ >;
147
+ export const Network = Resource<Network>("GCP.Network");
148
+
149
+ const toNetworkAttributes = (
150
+ n: compute.Network,
151
+ parent: { project: string },
152
+ ): NetworkAttributes => ({
153
+ name: n.name ?? "",
154
+ project: parent.project,
155
+ selfLink: n.selfLink ?? "",
156
+ id: n.id ?? "",
157
+ description: stripAlchemyMarker(n.description),
158
+ autoCreateSubnetworks: n.autoCreateSubnetworks ?? false,
159
+ routingMode: n.routingConfig?.routingMode,
160
+ mtu: n.mtu,
161
+ });
162
+
163
+ export const NetworkProvider = () =>
164
+ Provider.effect(
165
+ Network,
166
+ Effect.gen(function* () {
167
+ const getNetworks = yield* compute.getNetworks;
168
+ const insertNetworks = yield* compute.insertNetworks;
169
+ const patchNetworks = yield* compute.patchNetworks;
170
+ const deleteNetworks = yield* compute.deleteNetworks;
171
+ const getGlobalOperations = yield* compute.getGlobalOperations;
172
+ const awaitOp = makeAwaitGlobalOperation(getGlobalOperations);
173
+
174
+ const observe = (project: string, name: string) =>
175
+ getNetworks({ project, network: name }).pipe(
176
+ Effect.catchTag("NotFound", () =>
177
+ Effect.succeed(undefined as compute.Network | undefined),
178
+ ),
179
+ Effect.catchTag("Forbidden", () =>
180
+ Effect.succeed(undefined as compute.Network | undefined),
181
+ ),
182
+ );
183
+
184
+ const syncRoutingMode = Effect.fn(function* (args: {
185
+ project: string;
186
+ name: string;
187
+ observed: compute.Network;
188
+ desired: NetworkProps["routingMode"];
189
+ session: ScopedPlanStatusSession;
190
+ }) {
191
+ if (!args.desired) return;
192
+ if (args.observed.routingConfig?.routingMode === args.desired) return;
193
+ const op = yield* patchNetworks({
194
+ project: args.project,
195
+ network: args.name,
196
+ body: { routingConfig: { routingMode: args.desired } },
197
+ });
198
+ if (op.name) yield* awaitOp(args.project, op.name, args.session);
199
+ });
200
+
201
+ return {
202
+ stables: ["name", "project", "selfLink", "id"],
203
+ diff: Effect.fn(function* ({ news, olds = {} }) {
204
+ if (!isResolved(news)) return undefined;
205
+ if (
206
+ somePropsAreDifferent(olds as NetworkProps, news, [
207
+ "project",
208
+ "name",
209
+ "autoCreateSubnetworks",
210
+ "mtu",
211
+ ])
212
+ ) {
213
+ return { action: "replace" } as const;
214
+ }
215
+ return undefined;
216
+ }),
217
+ reconcile: Effect.fn(function* ({ id, news, session }) {
218
+ const desiredName =
219
+ news.name ??
220
+ (yield* createPhysicalName({ id, maxLength: 63 })).toLowerCase();
221
+ const desiredDescription = yield* gcpAlchemyDescription(
222
+ id,
223
+ news.description,
224
+ );
225
+
226
+ // 1. Observe — collapse 403 to "missing" alongside 404.
227
+ let observed = yield* observe(news.project, desiredName);
228
+
229
+ // 2. Ensure — create if missing. `autoCreateSubnetworks`
230
+ // defaults to *true* on the GCP side (auto-mode VPC), but
231
+ // we default to false because every consumer in this
232
+ // provider creates explicit subnets. `Conflict` covers
233
+ // concurrent creates and state-persistence races.
234
+ if (!observed) {
235
+ const body: compute.Network = {
236
+ name: desiredName,
237
+ description: desiredDescription,
238
+ autoCreateSubnetworks: news.autoCreateSubnetworks ?? false,
239
+ ...(news.mtu !== undefined ? { mtu: news.mtu } : {}),
240
+ ...(news.routingMode
241
+ ? { routingConfig: { routingMode: news.routingMode } }
242
+ : {}),
243
+ };
244
+ const op = yield* insertNetworks({
245
+ project: news.project,
246
+ body,
247
+ }).pipe(
248
+ Effect.catchTag("Conflict", () =>
249
+ Effect.succeed(undefined as compute.Operation | undefined),
250
+ ),
251
+ );
252
+ if (op?.name) yield* awaitOp(news.project, op.name, session);
253
+ observed = yield* getNetworks({
254
+ project: news.project,
255
+ network: desiredName,
256
+ });
257
+ }
258
+
259
+ // 3. Sync — only routing config can be mutated post-create.
260
+ // Description, MTU, autoCreateSubnetworks are all locked.
261
+ yield* syncRoutingMode({
262
+ project: news.project,
263
+ name: desiredName,
264
+ observed,
265
+ desired: news.routingMode,
266
+ session,
267
+ });
268
+
269
+ const final = yield* getNetworks({
270
+ project: news.project,
271
+ network: desiredName,
272
+ });
273
+ return toNetworkAttributes(final, { project: news.project });
274
+ }),
275
+ delete: Effect.fn(function* ({ output, session }) {
276
+ yield* deleteNetworks({
277
+ project: output.project,
278
+ network: output.name,
279
+ }).pipe(
280
+ Effect.flatMap((op) =>
281
+ op.name
282
+ ? awaitOp(output.project, op.name, session)
283
+ : Effect.succeed(op),
284
+ ),
285
+ Effect.catchTag("NotFound", () => Effect.void),
286
+ );
287
+ }),
288
+ read: Effect.fn(function* ({ id, output, olds }) {
289
+ const project = output?.project ?? olds?.project;
290
+ if (!project) return undefined;
291
+ const name =
292
+ output?.name ??
293
+ olds?.name ??
294
+ (yield* createPhysicalName({ id, maxLength: 63 })).toLowerCase();
295
+ const observed = yield* observe(project, name);
296
+ if (!observed) return undefined;
297
+ const attrs = toNetworkAttributes(observed, { project });
298
+ // Adoption gate via the description marker — networks have no
299
+ // labels field (verified against compute-v1 SDK Network type).
300
+ return (yield* descriptionHasAlchemyMarker(id, observed.description))
301
+ ? attrs
302
+ : Unowned(attrs);
303
+ }),
304
+ };
305
+ }),
306
+ );
@@ -0,0 +1,108 @@
1
+ import { ConfigError } from "@distilled.cloud/gcp";
2
+ import * as compute from "@distilled.cloud/gcp/compute-v1";
3
+ import type { ScopedPlanStatusSession } from "alchemy/Cli/Cli";
4
+ import * as Duration from "effect/Duration";
5
+ import * as Effect from "effect/Effect";
6
+ import * as Schedule from "effect/Schedule";
7
+
8
+ /**
9
+ * Resolved callable signatures for the compute LRO getters. We hold both
10
+ * — global resources (networks, addresses with `addressType=INTERNAL`
11
+ * `purpose=VPC_PEERING`) emit Operations under
12
+ * `projects/{p}/global/operations/{op}`, while regional resources
13
+ * (subnetworks) emit Operations under
14
+ * `projects/{p}/regions/{r}/operations/{op}`. The `Operation.name`
15
+ * returned by the create/update/delete calls is a *bare* id (e.g.
16
+ * `operation-1700000000000-...`); callers thread back project (and
17
+ * region) at the await site.
18
+ */
19
+ type GetGlobalOperations = Effect.Success<typeof compute.getGlobalOperations>;
20
+ type GetRegionOperations = Effect.Success<typeof compute.getRegionOperations>;
21
+
22
+ const formatOperationError = (op: compute.Operation): string => {
23
+ const errors = op.error?.errors ?? [];
24
+ if (errors.length === 0) return JSON.stringify(op.error ?? {});
25
+ return errors
26
+ .map((e) => `${e.code ?? "?"}${e.location ? ` @ ${e.location}` : ""}: ${e.message ?? "?"}`)
27
+ .join("; ");
28
+ };
29
+
30
+ const isDone = (op: compute.Operation): boolean => op.status === "DONE";
31
+
32
+ const pollSchedule = (label: string, session: ScopedPlanStatusSession) =>
33
+ Schedule.exponential(Duration.seconds(2), 1.5).pipe(
34
+ Schedule.either(Schedule.spaced(Duration.seconds(15))),
35
+ // 80 × ≤15s ≈ 20 min — long enough for a network create (~1 min),
36
+ // a parallelstore-adjacent global op, or a 10-minute parallelstore
37
+ // delete (longest LRO observed in this provider's surface).
38
+ Schedule.both(Schedule.recurs(80)),
39
+ Schedule.tapOutput(() =>
40
+ session.note(`Waiting for Compute operation ${label}…`),
41
+ ),
42
+ );
43
+
44
+ /**
45
+ * Build a poller for compute global operations
46
+ * (`projects/{p}/global/operations/{op}`). Same exponential-then-spaced
47
+ * schedule as the GKE poller.
48
+ */
49
+ export const makeAwaitGlobalOperation = (getGlobalOperations: GetGlobalOperations) =>
50
+ Effect.fn(function* (
51
+ project: string,
52
+ operationName: string,
53
+ session: ScopedPlanStatusSession,
54
+ ) {
55
+ const label = `${project}/global/${operationName}`;
56
+ const op = yield* getGlobalOperations({ project, operation: operationName }).pipe(
57
+ Effect.flatMap((current) =>
58
+ isDone(current)
59
+ ? Effect.succeed(current)
60
+ : Effect.fail({ _tag: "OperationPending" as const }),
61
+ ),
62
+ Effect.retry({
63
+ while: (e: { _tag?: string }) => e?._tag === "OperationPending",
64
+ schedule: pollSchedule(label, session),
65
+ }),
66
+ );
67
+ if (op.error) {
68
+ return yield* new ConfigError({
69
+ message: `Compute global operation ${operationName} failed: ${formatOperationError(op)}`,
70
+ });
71
+ }
72
+ return op;
73
+ });
74
+
75
+ /**
76
+ * Build a poller for compute regional operations
77
+ * (`projects/{p}/regions/{r}/operations/{op}`).
78
+ */
79
+ export const makeAwaitRegionOperation = (getRegionOperations: GetRegionOperations) =>
80
+ Effect.fn(function* (
81
+ project: string,
82
+ region: string,
83
+ operationName: string,
84
+ session: ScopedPlanStatusSession,
85
+ ) {
86
+ const label = `${project}/${region}/${operationName}`;
87
+ const op = yield* getRegionOperations({
88
+ project,
89
+ region,
90
+ operation: operationName,
91
+ }).pipe(
92
+ Effect.flatMap((current) =>
93
+ isDone(current)
94
+ ? Effect.succeed(current)
95
+ : Effect.fail({ _tag: "OperationPending" as const }),
96
+ ),
97
+ Effect.retry({
98
+ while: (e: { _tag?: string }) => e?._tag === "OperationPending",
99
+ schedule: pollSchedule(label, session),
100
+ }),
101
+ );
102
+ if (op.error) {
103
+ return yield* new ConfigError({
104
+ message: `Compute region operation ${operationName} failed: ${formatOperationError(op)}`,
105
+ });
106
+ }
107
+ return op;
108
+ });