@microagi/alchemy-gcp 0.5.1 → 0.6.0

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 (69) hide show
  1. package/lib/Auth/Credentials.d.ts +10 -4
  2. package/lib/Auth/Credentials.d.ts.map +1 -1
  3. package/lib/Auth/Credentials.js +13 -4
  4. package/lib/Auth/Credentials.js.map +1 -1
  5. package/lib/Compute/Address.d.ts +97 -0
  6. package/lib/Compute/Address.d.ts.map +1 -0
  7. package/lib/Compute/Address.js +143 -0
  8. package/lib/Compute/Address.js.map +1 -0
  9. package/lib/Compute/Firewall.d.ts +108 -0
  10. package/lib/Compute/Firewall.d.ts.map +1 -0
  11. package/lib/Compute/Firewall.js +163 -0
  12. package/lib/Compute/Firewall.js.map +1 -0
  13. package/lib/Compute/ForwardingRule.d.ts +106 -0
  14. package/lib/Compute/ForwardingRule.d.ts.map +1 -0
  15. package/lib/Compute/ForwardingRule.js +152 -0
  16. package/lib/Compute/ForwardingRule.js.map +1 -0
  17. package/lib/Compute/index.d.ts +3 -0
  18. package/lib/Compute/index.d.ts.map +1 -1
  19. package/lib/Compute/index.js +3 -0
  20. package/lib/Compute/index.js.map +1 -1
  21. package/lib/Container/Cluster.d.ts +20 -0
  22. package/lib/Container/Cluster.d.ts.map +1 -1
  23. package/lib/Container/Cluster.js +5 -2
  24. package/lib/Container/Cluster.js.map +1 -1
  25. package/lib/Container/NodePool.d.ts +26 -1
  26. package/lib/Container/NodePool.d.ts.map +1 -1
  27. package/lib/Container/NodePool.js +45 -8
  28. package/lib/Container/NodePool.js.map +1 -1
  29. package/lib/Kubernetes/Secret.d.ts +89 -0
  30. package/lib/Kubernetes/Secret.d.ts.map +1 -0
  31. package/lib/Kubernetes/Secret.js +139 -0
  32. package/lib/Kubernetes/Secret.js.map +1 -0
  33. package/lib/Kubernetes/client.d.ts +58 -0
  34. package/lib/Kubernetes/client.d.ts.map +1 -0
  35. package/lib/Kubernetes/client.js +131 -0
  36. package/lib/Kubernetes/client.js.map +1 -0
  37. package/lib/Kubernetes/index.d.ts +2 -0
  38. package/lib/Kubernetes/index.d.ts.map +1 -0
  39. package/lib/Kubernetes/index.js +2 -0
  40. package/lib/Kubernetes/index.js.map +1 -0
  41. package/lib/ManagedLustre/Instance.d.ts.map +1 -1
  42. package/lib/ManagedLustre/Instance.js +25 -5
  43. package/lib/ManagedLustre/Instance.js.map +1 -1
  44. package/lib/Providers.d.ts +1 -1
  45. package/lib/Providers.d.ts.map +1 -1
  46. package/lib/Providers.js +9 -1
  47. package/lib/Providers.js.map +1 -1
  48. package/lib/Sqladmin/Instance.d.ts.map +1 -1
  49. package/lib/Sqladmin/Instance.js +28 -6
  50. package/lib/Sqladmin/Instance.js.map +1 -1
  51. package/lib/index.d.ts +1 -0
  52. package/lib/index.d.ts.map +1 -1
  53. package/lib/index.js +1 -0
  54. package/lib/index.js.map +1 -1
  55. package/package.json +1 -1
  56. package/src/Auth/Credentials.ts +15 -5
  57. package/src/Compute/Address.ts +288 -0
  58. package/src/Compute/Firewall.ts +330 -0
  59. package/src/Compute/ForwardingRule.ts +301 -0
  60. package/src/Compute/index.ts +3 -0
  61. package/src/Container/Cluster.ts +25 -2
  62. package/src/Container/NodePool.ts +87 -13
  63. package/src/Kubernetes/Secret.ts +274 -0
  64. package/src/Kubernetes/client.ts +219 -0
  65. package/src/Kubernetes/index.ts +1 -0
  66. package/src/ManagedLustre/Instance.ts +35 -6
  67. package/src/Providers.ts +18 -0
  68. package/src/Sqladmin/Instance.ts +36 -7
  69. package/src/index.ts +1 -0
@@ -0,0 +1,330 @@
1
+ import * as compute from "@distilled.cloud/gcp/compute-v1";
2
+ import { Resource } from "alchemy";
3
+ import { Unowned } from "alchemy/AdoptPolicy";
4
+ import { deepEqual, isResolved, somePropsAreDifferent } from "alchemy/Diff";
5
+ import { createPhysicalName } from "alchemy/PhysicalName";
6
+ import * as Provider from "alchemy/Provider";
7
+ import * as Effect from "effect/Effect";
8
+ import {
9
+ descriptionHasAlchemyMarker,
10
+ gcpAlchemyDescription,
11
+ stripAlchemyMarker,
12
+ } from "../Tags.ts";
13
+ import type * as GCP from "../Providers.ts";
14
+ import { makeAwaitGlobalOperation } from "./Operations.ts";
15
+
16
+ /** One allow entry: a protocol plus optional port list. */
17
+ export type FirewallAllowed = {
18
+ /** `tcp` | `udp` | `icmp` | `esp` | `ah` | `sctp` | protocol number. */
19
+ IPProtocol: string;
20
+ /** Ports / ranges (`"8000"`, `"8000-8100"`). Omit = all ports. */
21
+ ports?: ReadonlyArray<string>;
22
+ };
23
+
24
+ /**
25
+ * A VPC firewall rule (allow rules only — `denied` is not modelled;
26
+ * add it when a consumer needs it).
27
+ *
28
+ * Firewalls have no `labels` field, so adoption gating uses the
29
+ * alchemy marker embedded in `description` (same as `GCP.Network`).
30
+ * Unlike Network, firewall `description` IS mutable, and the sync step
31
+ * keeps the marker prefix in place on every reconcile.
32
+ *
33
+ * GCP rejects rules carrying BOTH `targetTags` and
34
+ * `targetServiceAccounts` — prefer service accounts for GKE node
35
+ * targeting: node tags embed a per-cluster random suffix and go stale
36
+ * on every cluster rebuild, while the compute default SA is stable.
37
+ *
38
+ * @section Creating a firewall rule
39
+ * @example GCLB health checks → GKE pods on 8000
40
+ * ```typescript
41
+ * yield* GCP.Firewall("LbHealthChecks", {
42
+ * project: host.projectId,
43
+ * network: vpc.selfLink,
44
+ * name: "k8s-app-hc",
45
+ * sourceRanges: ["130.211.0.0/22", "35.191.0.0/16"],
46
+ * allowed: [{ IPProtocol: "tcp", ports: ["8000"] }],
47
+ * targetServiceAccounts: [nodeSa],
48
+ * });
49
+ * ```
50
+ */
51
+ export type FirewallProps = {
52
+ /** GCP project ID hosting the rule. Immutable — replace if changed. */
53
+ project: string;
54
+ /**
55
+ * Rule name. Defaults to `createPhysicalName({ id, lowercase: true,
56
+ * maxLength: 63 })`. Immutable — replace if changed.
57
+ */
58
+ name?: string;
59
+ /**
60
+ * VPC the rule attaches to — short name or selfLink. Immutable —
61
+ * replace if changed.
62
+ */
63
+ network: string;
64
+ /** User-visible description (stored after the alchemy marker). Mutable. */
65
+ description?: string;
66
+ /** INGRESS (default) or EGRESS. Immutable — replace if changed. */
67
+ direction?: "INGRESS" | "EGRESS";
68
+ /** 0–65535, lower wins. GCP default 1000. Mutable. */
69
+ priority?: number;
70
+ /** Source CIDRs (INGRESS). Mutable. */
71
+ sourceRanges?: ReadonlyArray<string>;
72
+ /** Destination CIDRs (EGRESS). Mutable. */
73
+ destinationRanges?: ReadonlyArray<string>;
74
+ /** Allow entries. Required — this resource models allow rules only. Mutable. */
75
+ allowed: ReadonlyArray<FirewallAllowed>;
76
+ /**
77
+ * Instance network tags the rule applies to. Mutually exclusive with
78
+ * `targetServiceAccounts`. Mutable.
79
+ */
80
+ targetTags?: ReadonlyArray<string>;
81
+ /**
82
+ * Service accounts whose instances the rule applies to. Preferred
83
+ * over tags for GKE nodes (stable across cluster rebuilds). Mutually
84
+ * exclusive with `targetTags`. Mutable.
85
+ */
86
+ targetServiceAccounts?: ReadonlyArray<string>;
87
+ /** Rule disabled (kept but not enforced). Mutable. */
88
+ disabled?: boolean;
89
+ };
90
+
91
+ export type FirewallAttributes = {
92
+ /** Rule name. */
93
+ name: string;
94
+ /** GCP project ID, threaded through from props for delete/read. */
95
+ project: string;
96
+ /** Server-defined URL. */
97
+ selfLink: string;
98
+ /** Server-assigned numeric id. */
99
+ id: string;
100
+ /** VPC selfLink the rule is attached to. */
101
+ network: string;
102
+ /** INGRESS / EGRESS. */
103
+ direction: string | undefined;
104
+ /** Effective priority. */
105
+ priority: number | undefined;
106
+ /** Source CIDRs. */
107
+ sourceRanges: ReadonlyArray<string> | undefined;
108
+ /** Allow entries. */
109
+ allowed: ReadonlyArray<FirewallAllowed> | undefined;
110
+ /** Target network tags. */
111
+ targetTags: ReadonlyArray<string> | undefined;
112
+ /** Target service accounts. */
113
+ targetServiceAccounts: ReadonlyArray<string> | undefined;
114
+ /** User description (alchemy marker stripped). */
115
+ description: string | undefined;
116
+ /** Disabled flag. */
117
+ disabled: boolean | undefined;
118
+ };
119
+
120
+ export type Firewall = Resource<
121
+ "GCP.Firewall",
122
+ FirewallProps,
123
+ FirewallAttributes,
124
+ never,
125
+ GCP.Providers
126
+ >;
127
+ export const Firewall = Resource<Firewall>("GCP.Firewall");
128
+
129
+ const toFirewallAttributes = (
130
+ f: compute.Firewall,
131
+ parent: { project: string },
132
+ ): FirewallAttributes => ({
133
+ name: f.name ?? "",
134
+ project: parent.project,
135
+ selfLink: f.selfLink ?? "",
136
+ id: f.id ?? "",
137
+ network: f.network ?? "",
138
+ direction: f.direction,
139
+ priority: f.priority,
140
+ sourceRanges: f.sourceRanges,
141
+ allowed: f.allowed as ReadonlyArray<FirewallAllowed> | undefined,
142
+ targetTags: f.targetTags,
143
+ targetServiceAccounts: f.targetServiceAccounts,
144
+ description: stripAlchemyMarker(f.description),
145
+ disabled: f.disabled,
146
+ });
147
+
148
+ /**
149
+ * Normalize for drift comparison: GCP reorders list fields and the
150
+ * `allowed` entries' ports — compare order-insensitively so a
151
+ * cosmetic reordering doesn't trigger a PATCH every reconcile.
152
+ */
153
+ const normalized = (f: {
154
+ priority?: number | undefined;
155
+ sourceRanges?: ReadonlyArray<string> | undefined;
156
+ destinationRanges?: ReadonlyArray<string> | undefined;
157
+ // `IPProtocol` is optional here because the SDK's observed Firewall
158
+ // type marks every field optional — desired props always set it.
159
+ allowed?: ReadonlyArray<{ IPProtocol?: string; ports?: ReadonlyArray<string> }> | undefined;
160
+ targetTags?: ReadonlyArray<string> | undefined;
161
+ targetServiceAccounts?: ReadonlyArray<string> | undefined;
162
+ disabled?: boolean | undefined;
163
+ description?: string | undefined;
164
+ }) => ({
165
+ priority: f.priority ?? 1000,
166
+ sourceRanges: [...(f.sourceRanges ?? [])].sort(),
167
+ destinationRanges: [...(f.destinationRanges ?? [])].sort(),
168
+ allowed: [...(f.allowed ?? [])]
169
+ .map((a) => ({
170
+ IPProtocol: (a.IPProtocol ?? "").toLowerCase(),
171
+ ports: [...(a.ports ?? [])].sort(),
172
+ }))
173
+ .sort((x, y) => x.IPProtocol.localeCompare(y.IPProtocol)),
174
+ targetTags: [...(f.targetTags ?? [])].sort(),
175
+ targetServiceAccounts: [...(f.targetServiceAccounts ?? [])].sort(),
176
+ disabled: f.disabled ?? false,
177
+ description: f.description,
178
+ });
179
+
180
+ export const FirewallProvider = () =>
181
+ Provider.effect(
182
+ Firewall,
183
+ Effect.gen(function* () {
184
+ const getFirewalls = yield* compute.getFirewalls;
185
+ const insertFirewalls = yield* compute.insertFirewalls;
186
+ const patchFirewalls = yield* compute.patchFirewalls;
187
+ const deleteFirewalls = yield* compute.deleteFirewalls;
188
+ const getGlobalOperations = yield* compute.getGlobalOperations;
189
+ const awaitOp = makeAwaitGlobalOperation(getGlobalOperations);
190
+
191
+ const observe = (project: string, name: string) =>
192
+ getFirewalls({ project, firewall: name }).pipe(
193
+ Effect.catchTag("NotFound", () =>
194
+ Effect.succeed(undefined as compute.Firewall | undefined),
195
+ ),
196
+ Effect.catchTag("Forbidden", () =>
197
+ Effect.succeed(undefined as compute.Firewall | undefined),
198
+ ),
199
+ );
200
+
201
+ return {
202
+ stables: ["name", "project", "selfLink", "id", "network"],
203
+ diff: Effect.fn(function* ({ news, olds = {} }) {
204
+ if (!isResolved(news)) return undefined;
205
+ if (
206
+ somePropsAreDifferent(olds as FirewallProps, news, [
207
+ "project",
208
+ "name",
209
+ "network",
210
+ "direction",
211
+ ])
212
+ ) {
213
+ return { action: "replace" } as const;
214
+ }
215
+ return undefined;
216
+ }),
217
+ reconcile: Effect.fn(function* ({ id, news, session }) {
218
+ if (news.targetTags?.length && news.targetServiceAccounts?.length) {
219
+ return yield* Effect.fail(
220
+ new Error(
221
+ "Firewall: targetTags and targetServiceAccounts are mutually exclusive (GCP rejects rules with both)",
222
+ ),
223
+ );
224
+ }
225
+ const desiredName =
226
+ news.name ??
227
+ (yield* createPhysicalName({ id, maxLength: 63 })).toLowerCase();
228
+ const desiredDescription = yield* gcpAlchemyDescription(
229
+ id,
230
+ news.description,
231
+ );
232
+
233
+ const mutableBody = (): compute.Firewall => ({
234
+ description: desiredDescription,
235
+ ...(news.priority !== undefined ? { priority: news.priority } : {}),
236
+ ...(news.sourceRanges
237
+ ? { sourceRanges: [...news.sourceRanges] }
238
+ : {}),
239
+ ...(news.destinationRanges
240
+ ? { destinationRanges: [...news.destinationRanges] }
241
+ : {}),
242
+ allowed: news.allowed.map((a) => ({
243
+ IPProtocol: a.IPProtocol,
244
+ ...(a.ports ? { ports: [...a.ports] } : {}),
245
+ })),
246
+ // Always send both target fields: switching tags → service
247
+ // accounts must CLEAR the other side, and PATCH leaves
248
+ // omitted fields untouched.
249
+ targetTags: [...(news.targetTags ?? [])],
250
+ targetServiceAccounts: [...(news.targetServiceAccounts ?? [])],
251
+ disabled: news.disabled ?? false,
252
+ });
253
+
254
+ // 1. Observe — collapse 403 to "missing" alongside 404.
255
+ let observed = yield* observe(news.project, desiredName);
256
+
257
+ // 2. Ensure — create if missing. `Conflict` covers concurrent
258
+ // creates and state-persistence races.
259
+ if (!observed) {
260
+ const op = yield* insertFirewalls({
261
+ project: news.project,
262
+ body: {
263
+ name: desiredName,
264
+ network: news.network,
265
+ ...(news.direction ? { direction: news.direction } : {}),
266
+ ...mutableBody(),
267
+ },
268
+ }).pipe(
269
+ Effect.catchTag("Conflict", () =>
270
+ Effect.succeed(undefined as compute.Operation | undefined),
271
+ ),
272
+ );
273
+ if (op?.name) yield* awaitOp(news.project, op.name, session);
274
+ observed = yield* getFirewalls({
275
+ project: news.project,
276
+ firewall: desiredName,
277
+ });
278
+ }
279
+
280
+ // 3. Sync — everything except name/network/direction is
281
+ // PATCHable. Diff against OBSERVED state (cloud is
282
+ // authoritative), order-insensitively.
283
+ const desired = mutableBody();
284
+ if (!deepEqual(normalized(observed), normalized(desired))) {
285
+ const op = yield* patchFirewalls({
286
+ project: news.project,
287
+ firewall: desiredName,
288
+ body: desired,
289
+ });
290
+ if (op.name) yield* awaitOp(news.project, op.name, session);
291
+ }
292
+
293
+ const final = yield* getFirewalls({
294
+ project: news.project,
295
+ firewall: desiredName,
296
+ });
297
+ return toFirewallAttributes(final, { project: news.project });
298
+ }),
299
+ delete: Effect.fn(function* ({ output, session }) {
300
+ yield* deleteFirewalls({
301
+ project: output.project,
302
+ firewall: output.name,
303
+ }).pipe(
304
+ Effect.flatMap((op) =>
305
+ op.name
306
+ ? awaitOp(output.project, op.name, session)
307
+ : Effect.succeed(op),
308
+ ),
309
+ Effect.catchTag("NotFound", () => Effect.void),
310
+ );
311
+ }),
312
+ read: Effect.fn(function* ({ id, output, olds }) {
313
+ const project = output?.project ?? olds?.project;
314
+ if (!project) return undefined;
315
+ const name =
316
+ output?.name ??
317
+ olds?.name ??
318
+ (yield* createPhysicalName({ id, maxLength: 63 })).toLowerCase();
319
+ const observed = yield* observe(project, name);
320
+ if (!observed) return undefined;
321
+ const attrs = toFirewallAttributes(observed, { project });
322
+ // Adoption gate via the description marker — firewalls have
323
+ // no labels field (same situation as GCP.Network).
324
+ return (yield* descriptionHasAlchemyMarker(id, observed.description))
325
+ ? attrs
326
+ : Unowned(attrs);
327
+ }),
328
+ };
329
+ }),
330
+ );
@@ -0,0 +1,301 @@
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 { makeAwaitRegionOperation } from "./Operations.ts";
12
+
13
+ /**
14
+ * A regional forwarding rule. The intended use here is a **Private
15
+ * Service Connect consumer endpoint** — binding a reserved internal
16
+ * {@link Address} to a producer *service attachment* (e.g. the one
17
+ * Cloud SQL publishes per PSC-enabled instance), so clients in this
18
+ * VPC reach the producer service at that IP.
19
+ *
20
+ * PSC endpoint rules carry no ports / load-balancing scheme — the API
21
+ * requires `loadBalancingScheme: ""` for them, which is what this
22
+ * resource sends. The endpoint must be created in the **same region as
23
+ * the service attachment**; set `allowPscGlobalAccess` so clients in
24
+ * other regions of the VPC can still use it.
25
+ *
26
+ * Other forwarding-rule shapes (L4 ILB, NetLB, proxy LBs) are not
27
+ * modelled — add props if/when needed.
28
+ *
29
+ * All connection-defining props are immutable here and trigger replace;
30
+ * a PSC endpoint is cheap to recreate. Labels are mutable.
31
+ *
32
+ * @section Creating a PSC endpoint
33
+ * @example Cloud SQL PSC endpoint with global access
34
+ * ```typescript
35
+ * const endpoint = yield* GCP.ForwardingRule("DbPscEndpoint", {
36
+ * project: hostProject.projectId,
37
+ * region: "europe-west1",
38
+ * network: vpc.selfLink,
39
+ * ipAddress: endpointIp.selfLink,
40
+ * target: "projects/<producer>/regions/europe-west1/serviceAttachments/<sa>",
41
+ * allowPscGlobalAccess: true,
42
+ * });
43
+ * ```
44
+ */
45
+ export type ForwardingRuleProps = {
46
+ /** GCP project ID hosting the rule. Immutable — replace if changed. */
47
+ project: string;
48
+ /** GCP region. Must match the target service attachment's region. Immutable — replace. */
49
+ region: string;
50
+ /**
51
+ * Rule name. Defaults to `createPhysicalName({ id, lowercase: true,
52
+ * maxLength: 63 })`. Immutable — replace if changed.
53
+ */
54
+ name?: string;
55
+ /** Description (free-form). Set on create; immutable. */
56
+ description?: string;
57
+ /**
58
+ * URL of the consumer VPC. Pass `vpc.selfLink` from a `GCP.Network`
59
+ * to make alchemy sequence the rule after the VPC. Immutable — replace.
60
+ */
61
+ network: string;
62
+ /**
63
+ * The endpoint IP — a URL of a reserved `GCP.Address` (pass
64
+ * `address.selfLink` for the dep edge) or a literal IP. Immutable —
65
+ * replace.
66
+ */
67
+ ipAddress: string;
68
+ /**
69
+ * URI of the producer service attachment to connect to
70
+ * (`projects/{p}/regions/{r}/serviceAttachments/{name}`). Immutable —
71
+ * replace.
72
+ */
73
+ target: string;
74
+ /**
75
+ * Let clients in any region of the VPC use this endpoint (PSC global
76
+ * access). Defaults to `false`. Immutable — replace.
77
+ */
78
+ allowPscGlobalAccess?: boolean;
79
+ /**
80
+ * Resource labels. Alchemy internal labels (`alchemy_app`,
81
+ * `alchemy_stage`, `alchemy_id`) are merged on top automatically and
82
+ * are reserved. Mutable via `setLabels`.
83
+ */
84
+ labels?: Record<string, string>;
85
+ };
86
+
87
+ export type ForwardingRuleAttributes = {
88
+ /** Rule name. */
89
+ name: string;
90
+ /** GCP project ID, threaded through from props for delete/read. */
91
+ project: string;
92
+ /** GCP region, threaded through from props for delete/read. */
93
+ region: string;
94
+ /** Server-defined URL. */
95
+ selfLink: string;
96
+ /** Server-assigned numeric id. */
97
+ id: string;
98
+ /** The endpoint IP (the API always returns the literal IP). */
99
+ ipAddress: string;
100
+ /** Producer service attachment URI. */
101
+ target: string | undefined;
102
+ /** Consumer VPC URL. */
103
+ network: string | undefined;
104
+ /** PSC global access flag. */
105
+ allowPscGlobalAccess: boolean;
106
+ /** Server-assigned PSC connection id. */
107
+ pscConnectionId: string | undefined;
108
+ /** PSC connection status: `ACCEPTED`, `PENDING`, `REJECTED`, `CLOSED`, … */
109
+ pscConnectionStatus: string | undefined;
110
+ /** Labels currently set, including the alchemy internals. */
111
+ labels: Record<string, string>;
112
+ };
113
+
114
+ export type ForwardingRule = Resource<
115
+ "GCP.ForwardingRule",
116
+ ForwardingRuleProps,
117
+ ForwardingRuleAttributes,
118
+ never,
119
+ GCP.Providers
120
+ >;
121
+ export const ForwardingRule = Resource<ForwardingRule>("GCP.ForwardingRule");
122
+
123
+ const toForwardingRuleAttributes = (
124
+ f: compute.ForwardingRule,
125
+ parent: { project: string; region: string },
126
+ ): ForwardingRuleAttributes => ({
127
+ name: f.name ?? "",
128
+ project: parent.project,
129
+ region: parent.region,
130
+ selfLink: f.selfLink ?? "",
131
+ id: f.id ?? "",
132
+ ipAddress: f.IPAddress ?? "",
133
+ target: f.target,
134
+ network: f.network,
135
+ allowPscGlobalAccess: f.allowPscGlobalAccess ?? false,
136
+ pscConnectionId: f.pscConnectionId,
137
+ pscConnectionStatus: f.pscConnectionStatus,
138
+ labels: { ...(f.labels ?? {}) },
139
+ });
140
+
141
+ export const ForwardingRuleProvider = () =>
142
+ Provider.effect(
143
+ ForwardingRule,
144
+ Effect.gen(function* () {
145
+ const getForwardingRules = yield* compute.getForwardingRules;
146
+ const insertForwardingRules = yield* compute.insertForwardingRules;
147
+ const deleteForwardingRules = yield* compute.deleteForwardingRules;
148
+ const setLabelsForwardingRules = yield* compute.setLabelsForwardingRules;
149
+ const getRegionOperations = yield* compute.getRegionOperations;
150
+ const awaitOp = makeAwaitRegionOperation(getRegionOperations);
151
+
152
+ const observe = (project: string, region: string, name: string) =>
153
+ getForwardingRules({ project, region, forwardingRule: name }).pipe(
154
+ Effect.catchTag("NotFound", () =>
155
+ Effect.succeed(undefined as compute.ForwardingRule | undefined),
156
+ ),
157
+ Effect.catchTag("Forbidden", () =>
158
+ Effect.succeed(undefined as compute.ForwardingRule | undefined),
159
+ ),
160
+ );
161
+
162
+ const syncLabels = Effect.fn(function* (args: {
163
+ project: string;
164
+ region: string;
165
+ observed: compute.ForwardingRule;
166
+ desired: Record<string, string>;
167
+ session: ScopedPlanStatusSession;
168
+ }) {
169
+ if (deepEqual(args.observed.labels ?? {}, args.desired)) return;
170
+ const op = yield* setLabelsForwardingRules({
171
+ project: args.project,
172
+ region: args.region,
173
+ resource: args.observed.name ?? "",
174
+ body: {
175
+ labels: args.desired,
176
+ labelFingerprint: args.observed.labelFingerprint,
177
+ },
178
+ });
179
+ if (op.name) {
180
+ yield* awaitOp(args.project, args.region, op.name, args.session);
181
+ }
182
+ });
183
+
184
+ return {
185
+ stables: ["name", "project", "region", "selfLink", "id"],
186
+ diff: Effect.fn(function* ({ news, olds = {} }) {
187
+ if (!isResolved(news)) return undefined;
188
+ if (
189
+ somePropsAreDifferent(olds as ForwardingRuleProps, news, [
190
+ "project",
191
+ "region",
192
+ "name",
193
+ "network",
194
+ "ipAddress",
195
+ "target",
196
+ "allowPscGlobalAccess",
197
+ ])
198
+ ) {
199
+ return { action: "replace" } as const;
200
+ }
201
+ return undefined;
202
+ }),
203
+ reconcile: Effect.fn(function* ({ id, news, session }) {
204
+ const internalLabels = yield* gcpInternalLabels(id);
205
+ const desiredName =
206
+ news.name ??
207
+ (yield* createPhysicalName({ id, maxLength: 63 })).toLowerCase();
208
+ const desiredLabels: Record<string, string> = {
209
+ ...(news.labels ?? {}),
210
+ ...internalLabels,
211
+ };
212
+
213
+ let observed = yield* observe(news.project, news.region, desiredName);
214
+
215
+ if (!observed) {
216
+ const body: compute.ForwardingRule = {
217
+ name: desiredName,
218
+ ...(news.description ? { description: news.description } : {}),
219
+ network: news.network,
220
+ IPAddress: news.ipAddress,
221
+ target: news.target,
222
+ // PSC consumer endpoints take no scheme — the API rejects
223
+ // anything other than the empty string for them.
224
+ loadBalancingScheme: "",
225
+ ...(news.allowPscGlobalAccess !== undefined
226
+ ? { allowPscGlobalAccess: news.allowPscGlobalAccess }
227
+ : {}),
228
+ labels: desiredLabels,
229
+ };
230
+ const op = yield* insertForwardingRules({
231
+ project: news.project,
232
+ region: news.region,
233
+ body,
234
+ }).pipe(
235
+ Effect.catchTag("Conflict", () =>
236
+ Effect.succeed(undefined as compute.Operation | undefined),
237
+ ),
238
+ );
239
+ if (op?.name) {
240
+ yield* awaitOp(news.project, news.region, op.name, session);
241
+ }
242
+ observed = yield* getForwardingRules({
243
+ project: news.project,
244
+ region: news.region,
245
+ forwardingRule: desiredName,
246
+ });
247
+ }
248
+
249
+ yield* syncLabels({
250
+ project: news.project,
251
+ region: news.region,
252
+ observed,
253
+ desired: desiredLabels,
254
+ session,
255
+ });
256
+
257
+ const final = yield* getForwardingRules({
258
+ project: news.project,
259
+ region: news.region,
260
+ forwardingRule: desiredName,
261
+ });
262
+ return toForwardingRuleAttributes(final, {
263
+ project: news.project,
264
+ region: news.region,
265
+ });
266
+ }),
267
+ delete: Effect.fn(function* ({ output, session }) {
268
+ yield* deleteForwardingRules({
269
+ project: output.project,
270
+ region: output.region,
271
+ forwardingRule: output.name,
272
+ }).pipe(
273
+ Effect.flatMap((op) =>
274
+ op.name
275
+ ? awaitOp(output.project, output.region, op.name, session)
276
+ : Effect.succeed(op),
277
+ ),
278
+ Effect.catchTag("NotFound", () => Effect.void),
279
+ );
280
+ }),
281
+ read: Effect.fn(function* ({ id, output, olds }) {
282
+ const project = output?.project ?? olds?.project;
283
+ const region = output?.region ?? olds?.region;
284
+ if (!project || !region) return undefined;
285
+ const name =
286
+ output?.name ??
287
+ olds?.name ??
288
+ (yield* createPhysicalName({ id, maxLength: 63 })).toLowerCase();
289
+ const observed = yield* observe(project, region, name);
290
+ if (!observed) return undefined;
291
+ const attrs = toForwardingRuleAttributes(observed, {
292
+ project,
293
+ region,
294
+ });
295
+ return (yield* hasAlchemyLabels(id, observed.labels))
296
+ ? attrs
297
+ : Unowned(attrs);
298
+ }),
299
+ };
300
+ }),
301
+ );
@@ -1,3 +1,6 @@
1
+ export * from "./Address.ts";
2
+ export * from "./Firewall.ts";
3
+ export * from "./ForwardingRule.ts";
1
4
  export * from "./GlobalAddress.ts";
2
5
  export * from "./Network.ts";
3
6
  export * from "./SharedVpcHost.ts";
@@ -74,6 +74,26 @@ export type ClusterProps = {
74
74
  clusterSecondaryRangeName?: string;
75
75
  servicesSecondaryRangeName?: string;
76
76
  };
77
+ /**
78
+ * Cluster-level network settings. `datapathProvider:
79
+ * "ADVANCED_DATAPATH"` enables GKE Dataplane V2 (eBPF/Cilium
80
+ * dataplane with built-in NetworkPolicy enforcement). GKE only
81
+ * honors this at cluster creation — existing clusters cannot be
82
+ * migrated in place.
83
+ *
84
+ * **Consumed only on the create path** (like `initialNodePool`):
85
+ * changing it on an existing cluster is a silent no-op, NOT a
86
+ * replace trigger. A diff-driven same-name replace would be
87
+ * destructive here — the engine creates the new generation first,
88
+ * which observe-adopts the still-existing same-name cluster, and
89
+ * end-of-deploy GC would then delete that physical cluster out from
90
+ * under the "new" generation. To rebuild with a different datapath,
91
+ * delete the cluster out-of-band and redeploy; reconcile observes
92
+ * it missing and creates with the new setting.
93
+ */
94
+ networkConfig?: {
95
+ datapathProvider?: "LEGACY_DATAPATH" | "ADVANCED_DATAPATH";
96
+ };
77
97
  /** GKE release channel. Mutable via `update` (`desiredReleaseChannel`). */
78
98
  releaseChannel?: { channel: "RAPID" | "REGULAR" | "STABLE" | "UNSPECIFIED" };
79
99
  /**
@@ -470,8 +490,10 @@ export const ClusterProvider = () =>
470
490
  if (!deepEqual(olds.ipAllocationPolicy, news.ipAllocationPolicy)) {
471
491
  return { action: "replace" } as const;
472
492
  }
473
- // `initialNodePool` is intentionally NOT part of diff — it is
474
- // consumed only on create. See ClusterProps JSDoc.
493
+ // `initialNodePool` and `networkConfig` are intentionally NOT
494
+ // part of diff — both are consumed only on create. See the
495
+ // ClusterProps JSDoc (networkConfig explains why a same-name
496
+ // replace would be destructive).
475
497
  return undefined;
476
498
  }),
477
499
  reconcile: Effect.fn(function* ({ id, news, session }) {
@@ -521,6 +543,7 @@ export const ClusterProvider = () =>
521
543
  ...(news.ipAllocationPolicy
522
544
  ? { ipAllocationPolicy: news.ipAllocationPolicy }
523
545
  : {}),
546
+ ...(news.networkConfig ? { networkConfig: news.networkConfig } : {}),
524
547
  ...(news.releaseChannel ? { releaseChannel: news.releaseChannel } : {}),
525
548
  ...(news.workloadIdentityConfig
526
549
  ? { workloadIdentityConfig: news.workloadIdentityConfig }