@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,269 @@
1
+ import { ConfigError } from "@distilled.cloud/gcp";
2
+ import * as sn from "@distilled.cloud/gcp/servicenetworking-v1";
3
+ import { Resource } from "alchemy";
4
+ import type { ScopedPlanStatusSession } from "alchemy/Cli/Cli";
5
+ import { deepEqual, isResolved } from "alchemy/Diff";
6
+ import * as Provider from "alchemy/Provider";
7
+ import * as Duration from "effect/Duration";
8
+ import * as Effect from "effect/Effect";
9
+ import * as Schedule from "effect/Schedule";
10
+ import type { NetworkRef } from "../Compute/Network.ts";
11
+ import type * as GCP from "../Providers.ts";
12
+
13
+ /**
14
+ * A **Private Services Access (PSA)** connection that peers a Google
15
+ * service-producer VPC into the consumer VPC, drawing from one or more
16
+ * pre-allocated `GCP.GlobalAddress` ranges.
17
+ *
18
+ * Identity: `(network, service)`. There can only be one PSA connection
19
+ * per `(VPC, service)` pair, so the resource is keyed by both. The
20
+ * peering name (used in delete/patch URLs) is server-assigned —
21
+ * canonically `servicenetworking-googleapis-com` for the standard
22
+ * Service Networking peering — and is read off the live connection
23
+ * rather than reconstructed.
24
+ *
25
+ * Service Networking operations use a CRM-style `Operation.done:
26
+ * boolean`, not the compute `.status: "DONE"` shape, so this resource
27
+ * uses its own polling helper rather than the Compute one.
28
+ *
29
+ * @section Setting up PSA for Parallelstore
30
+ * @example
31
+ * ```typescript
32
+ * // Reserve the range first.
33
+ * const psaRange = yield* GCP.GlobalAddress("PsaRange", {
34
+ * project: hostProject.projectId,
35
+ * network: vpc.selfLink,
36
+ * purpose: "VPC_PEERING",
37
+ * addressType: "INTERNAL",
38
+ * prefixLength: 20,
39
+ * });
40
+ *
41
+ * // Then peer. `networkRef` enforces the project-number form at the
42
+ * // type level — `Project.projectNumber` is typed as `${number}` so
43
+ * // this composes without manual casts.
44
+ * yield* GCP.PsaConnection("Psa", {
45
+ * network: GCP.networkRef(hostProject.projectNumber, vpc.name),
46
+ * reservedPeeringRanges: [psaRange.name],
47
+ * });
48
+ * ```
49
+ *
50
+ * The `network` field is typed as `NetworkRef` —
51
+ * `projects/{projectNumber}/global/networks/{name}` — because the
52
+ * service producer's backend resolves peerings against the numeric
53
+ * project id, not the project id string. Use `networkRef` to build
54
+ * the value; passing a project ID will fail to typecheck.
55
+ */
56
+ export type PsaConnectionProps = {
57
+ /**
58
+ * Fully-qualified consumer VPC in project-number form. Build with
59
+ * `networkRef(project.projectNumber, vpc.name)` from `Compute/Network.ts`
60
+ * — that helper composes the right shape and the type system rejects
61
+ * passing a project ID. Immutable — replace.
62
+ */
63
+ network: NetworkRef;
64
+ /**
65
+ * Service Networking service. Defaults to `servicenetworking.googleapis.com`
66
+ * (the only producer that PSA-aware GCP services like Parallelstore,
67
+ * Cloud SQL, Memorystore peer through).
68
+ */
69
+ service?: string;
70
+ /**
71
+ * Names of `GCP.GlobalAddress` ranges (with `purpose=VPC_PEERING`) to
72
+ * cede to the service producer. Mutable via `patchServicesConnections`.
73
+ * Removing a range previously assigned requires `force=true` server-
74
+ * side; this provider always passes `force=true` on patch to honour
75
+ * the user's declared shape.
76
+ */
77
+ reservedPeeringRanges: ReadonlyArray<string>;
78
+ };
79
+
80
+ export type PsaConnectionAttributes = {
81
+ /** Consumer VPC URL, threaded through from props. */
82
+ network: NetworkRef;
83
+ /** `services/<service>` resource name, threaded through from props. */
84
+ serviceName: string;
85
+ /** Server-assigned peering name (e.g. `servicenetworking-googleapis-com`). */
86
+ peering: string;
87
+ /** Reserved peering ranges currently in use. */
88
+ reservedPeeringRanges: ReadonlyArray<string>;
89
+ };
90
+
91
+ export type PsaConnection = Resource<
92
+ "GCP.PsaConnection",
93
+ PsaConnectionProps,
94
+ PsaConnectionAttributes,
95
+ never,
96
+ GCP.Providers
97
+ >;
98
+ export const PsaConnection = Resource<PsaConnection>("GCP.PsaConnection");
99
+
100
+ const DEFAULT_SERVICE = "servicenetworking.googleapis.com";
101
+
102
+ const serviceParent = (service: string) =>
103
+ service.startsWith("services/") ? service : `services/${service}`;
104
+
105
+ export const PsaConnectionProvider = () =>
106
+ Provider.effect(
107
+ PsaConnection,
108
+ Effect.gen(function* () {
109
+ const listConnections = yield* sn.listServicesConnections;
110
+ const createConnection = yield* sn.createServicesConnections;
111
+ const patchConnection = yield* sn.patchServicesConnections;
112
+ const deleteConnection = yield* sn.deleteConnectionServicesConnections;
113
+ const getOperations = yield* sn.getOperations;
114
+
115
+ const awaitOperation = Effect.fn(function* (
116
+ operationName: string,
117
+ session: ScopedPlanStatusSession,
118
+ ) {
119
+ const op = yield* getOperations({ name: operationName }).pipe(
120
+ Effect.flatMap((current) =>
121
+ current.done === true
122
+ ? Effect.succeed(current)
123
+ : Effect.fail({ _tag: "OperationPending" as const }),
124
+ ),
125
+ Effect.retry({
126
+ while: (e: { _tag?: string }) => e?._tag === "OperationPending",
127
+ schedule: Schedule.exponential(Duration.seconds(2), 1.5).pipe(
128
+ Schedule.either(Schedule.spaced(Duration.seconds(15))),
129
+ // PSA peering creates can take 5–10 min while the
130
+ // service producer provisions its side. 80 × 15s ≈ 20 min.
131
+ Schedule.both(Schedule.recurs(80)),
132
+ Schedule.tapOutput(() =>
133
+ session.note(`Waiting for ServiceNetworking operation ${operationName}…`),
134
+ ),
135
+ ),
136
+ }),
137
+ );
138
+ if (op.error) {
139
+ return yield* new ConfigError({
140
+ message: `ServiceNetworking operation ${operationName} failed: ${
141
+ op.error.message ?? JSON.stringify(op.error)
142
+ }`,
143
+ });
144
+ }
145
+ return op;
146
+ });
147
+
148
+ // List connections in the consumer VPC and return the matching
149
+ // one (there can be at most one connection per `(network, service)`
150
+ // pair). 404/403 collapse to "not present".
151
+ const observe = (parent: string, network: string) =>
152
+ listConnections({ parent, network }).pipe(
153
+ Effect.map((res) =>
154
+ (res.connections ?? []).find((c) => c.network === network),
155
+ ),
156
+ Effect.catchTag("NotFound", () =>
157
+ Effect.succeed(undefined as sn.Connection | undefined),
158
+ ),
159
+ Effect.catchTag("Forbidden", () =>
160
+ Effect.succeed(undefined as sn.Connection | undefined),
161
+ ),
162
+ );
163
+
164
+ return {
165
+ stables: ["network", "serviceName", "peering"],
166
+ diff: Effect.fn(function* ({ news, olds = {} }) {
167
+ if (!isResolved(news)) return undefined;
168
+ const oldsService = olds.service ?? DEFAULT_SERVICE;
169
+ const newsService = news.service ?? DEFAULT_SERVICE;
170
+ if (
171
+ (olds.network !== undefined && olds.network !== news.network) ||
172
+ (olds.service !== undefined && oldsService !== newsService)
173
+ ) {
174
+ return { action: "replace" } as const;
175
+ }
176
+ return undefined;
177
+ }),
178
+ reconcile: Effect.fn(function* ({ news, session }) {
179
+ const service = news.service ?? DEFAULT_SERVICE;
180
+ const parent = serviceParent(service);
181
+ const desired = [...news.reservedPeeringRanges].sort();
182
+
183
+ let observed = yield* observe(parent, news.network);
184
+
185
+ if (!observed) {
186
+ const op = yield* createConnection({
187
+ parent,
188
+ body: {
189
+ network: news.network,
190
+ reservedPeeringRanges: desired,
191
+ },
192
+ }).pipe(
193
+ Effect.catchTag("Conflict", () =>
194
+ Effect.succeed(undefined as sn.Operation | undefined),
195
+ ),
196
+ );
197
+ if (op?.name) yield* awaitOperation(op.name, session);
198
+ observed = yield* observe(parent, news.network);
199
+ }
200
+
201
+ if (!observed) {
202
+ return yield* new ConfigError({
203
+ message: `PSA connection on ${news.network} did not appear after create.`,
204
+ });
205
+ }
206
+
207
+ // Sync reserved ranges. `force=true` is required when removing
208
+ // a previously-assigned range; we pass it unconditionally to
209
+ // keep the patch idempotent regardless of which direction the
210
+ // diff went.
211
+ const observedRanges = [...(observed.reservedPeeringRanges ?? [])].sort();
212
+ if (!deepEqual(observedRanges, desired) && observed.peering) {
213
+ const op = yield* patchConnection({
214
+ name: `${parent}/connections/${observed.peering}`,
215
+ force: true,
216
+ updateMask: "reservedPeeringRanges",
217
+ body: {
218
+ network: news.network,
219
+ reservedPeeringRanges: desired,
220
+ },
221
+ });
222
+ if (op.name) yield* awaitOperation(op.name, session);
223
+ observed = yield* observe(parent, news.network);
224
+ }
225
+
226
+ return {
227
+ network: news.network,
228
+ serviceName: parent,
229
+ peering: observed?.peering ?? "",
230
+ reservedPeeringRanges: observed?.reservedPeeringRanges ?? desired,
231
+ };
232
+ }),
233
+ delete: Effect.fn(function* ({ output, session }) {
234
+ if (!output.peering) return; // Never fully created.
235
+ yield* deleteConnection({
236
+ name: `${output.serviceName}/connections/${output.peering}`,
237
+ body: { consumerNetwork: output.network },
238
+ }).pipe(
239
+ Effect.flatMap((op) =>
240
+ op.name ? awaitOperation(op.name, session) : Effect.succeed(op),
241
+ ),
242
+ Effect.catchTag("NotFound", () => Effect.void),
243
+ // `BadRequest` may surface when the PSA range is still in
244
+ // use by a managed service (Parallelstore, Cloud SQL, …)
245
+ // — that's a real precondition the user must resolve, so
246
+ // propagate it.
247
+ );
248
+ }),
249
+ read: Effect.fn(function* ({ output, olds }) {
250
+ const network = output?.network ?? olds?.network;
251
+ const service = olds?.service ?? DEFAULT_SERVICE;
252
+ const parent = output?.serviceName ?? serviceParent(service);
253
+ if (!network) return undefined;
254
+ const observed = yield* observe(parent, network);
255
+ if (!observed) return undefined;
256
+ // No ownership concept: PSA connections are unique per
257
+ // `(network, service)`, and the operation can only be
258
+ // performed by the consumer-VPC project owner. Adoption is
259
+ // permissive — same stance as `ApiEnable`.
260
+ return {
261
+ network,
262
+ serviceName: parent,
263
+ peering: observed.peering ?? "",
264
+ reservedPeeringRanges: observed.reservedPeeringRanges ?? [],
265
+ };
266
+ }),
267
+ };
268
+ }),
269
+ );
@@ -0,0 +1 @@
1
+ export * from "./PsaConnection.ts";
@@ -0,0 +1,267 @@
1
+ import { ConfigError } from "@distilled.cloud/gcp";
2
+ import * as su from "@distilled.cloud/gcp/serviceusage-v1";
3
+ import { Resource } from "alchemy";
4
+ import type { ScopedPlanStatusSession } from "alchemy/Cli/Cli";
5
+ import { isResolved } from "alchemy/Diff";
6
+ import * as Provider from "alchemy/Provider";
7
+ import * as Duration from "effect/Duration";
8
+ import * as Effect from "effect/Effect";
9
+ import * as Schedule from "effect/Schedule";
10
+ import type * as GCP from "../Providers.ts";
11
+
12
+ /**
13
+ * Enable a Google Cloud API on a project (`serviceusage.googleapis.com`
14
+ * `services.enable`). Required before any operation against the
15
+ * underlying API will succeed — e.g. a fresh `GCP.Project` cannot host
16
+ * a `GCP.Cluster` until `container.googleapis.com` is enabled.
17
+ *
18
+ * The resource is keyed by `(project, service)`; both are immutable —
19
+ * changing either replaces.
20
+ *
21
+ * `delete` calls `services.disable`, which GCP rejects with HTTP 400
22
+ * (`FAILED_PRECONDITION`) if the service is already disabled or has
23
+ * dependent services in use. We treat "already disabled" as success
24
+ * (idempotent teardown). Other 400s propagate so the user sees the
25
+ * real reason (typically: another service depends on this one).
26
+ */
27
+ export type ApiEnableProps = {
28
+ /** GCP project ID hosting the API enablement. Immutable — replace. */
29
+ project: string;
30
+ /**
31
+ * Fully-qualified service name, e.g. `container.googleapis.com`,
32
+ * `compute.googleapis.com`, `iam.googleapis.com`. Immutable — replace.
33
+ */
34
+ service: string;
35
+ };
36
+
37
+ export type ApiEnableAttributes = {
38
+ /** GCP project ID, threaded through from props. */
39
+ project: string;
40
+ /** Service name, threaded through from props. */
41
+ service: string;
42
+ /** Resource name in the form `projects/{project}/services/{service}`. */
43
+ name: string;
44
+ /** Lifecycle state — `"ENABLED"` once the LRO completes. */
45
+ state: string;
46
+ };
47
+
48
+ /**
49
+ * @section Enabling APIs
50
+ *
51
+ * **Dependency edges are explicit.** Alchemy sequences resources by the
52
+ * Output references they consume. To make a downstream resource (e.g.
53
+ * `GCP.Cluster`) wait for the API enable, route its `project` prop
54
+ * through `apiEnable.project` rather than `project.projectId`:
55
+ *
56
+ * @example Container API for GKE
57
+ * ```typescript
58
+ * const project = yield* GCP.Project("Research", { ... });
59
+ * const containerApi = yield* GCP.ApiEnable("ContainerApi", {
60
+ * project: project.projectId,
61
+ * service: "container.googleapis.com",
62
+ * });
63
+ * const cluster = yield* GCP.Cluster("Main", {
64
+ * project: containerApi.project, // ← edge through ApiEnable, not Project
65
+ * location: "us-central1-a",
66
+ * // ...
67
+ * });
68
+ * ```
69
+ *
70
+ * If the cluster instead read `project.projectId`, alchemy would
71
+ * schedule the cluster create in parallel with the API enable and the
72
+ * GKE call would fail with `Forbidden: <X> API has not been used in
73
+ * project ... before or it is disabled.`
74
+ *
75
+ * @example Multiple APIs on a project
76
+ * ```typescript
77
+ * const containerApi = yield* GCP.ApiEnable("ContainerApi",
78
+ * { project: project.projectId, service: "container.googleapis.com" });
79
+ * const computeApi = yield* GCP.ApiEnable("ComputeApi",
80
+ * { project: project.projectId, service: "compute.googleapis.com" });
81
+ * // Then route downstream resources through the API enables that gate them:
82
+ * const cluster = yield* GCP.Cluster("Main", { project: containerApi.project, ... });
83
+ * ```
84
+ */
85
+ export type ApiEnable = Resource<
86
+ "GCP.ApiEnable",
87
+ ApiEnableProps,
88
+ ApiEnableAttributes,
89
+ never,
90
+ GCP.Providers
91
+ >;
92
+
93
+ export const ApiEnable = Resource<ApiEnable>("GCP.ApiEnable");
94
+
95
+ const fqServiceName = (project: string, service: string) =>
96
+ `projects/${project}/services/${service}`;
97
+
98
+ export const ApiEnableProvider = () =>
99
+ Provider.effect(
100
+ ApiEnable,
101
+ Effect.gen(function* () {
102
+ const getServices = yield* su.getServices;
103
+ const enableServices = yield* su.enableServices;
104
+ const disableServices = yield* su.disableServices;
105
+ const getOperations = yield* su.getOperations;
106
+
107
+ // Service Usage `Operation` carries `.done: boolean` (matching CRM,
108
+ // NOT Container's `.status: "DONE"`), so we can't reuse the
109
+ // Container `makeAwaitOperation`. Mirror Project.ts's polling
110
+ // schedule (cheap polls for fast LROs; service enable usually
111
+ // resolves in <30s).
112
+ const awaitOperation = Effect.fn(function* (
113
+ operationName: string,
114
+ session: ScopedPlanStatusSession,
115
+ ) {
116
+ const op = yield* getOperations({ name: operationName }).pipe(
117
+ Effect.flatMap((current) =>
118
+ current.done === true
119
+ ? Effect.succeed(current)
120
+ : Effect.fail({ _tag: "OperationPending" as const }),
121
+ ),
122
+ Effect.retry({
123
+ while: (e: { _tag?: string }) => e?._tag === "OperationPending",
124
+ schedule: Schedule.exponential(Duration.seconds(1), 1.5).pipe(
125
+ Schedule.either(Schedule.spaced(Duration.seconds(15))),
126
+ Schedule.both(Schedule.recurs(60)),
127
+ Schedule.tapOutput(() =>
128
+ session.note(`Waiting for ServiceUsage operation ${operationName}…`),
129
+ ),
130
+ ),
131
+ }),
132
+ );
133
+ if (op.error) {
134
+ return yield* new ConfigError({
135
+ message: `ServiceUsage operation ${operationName} failed: ${
136
+ op.error.message ?? JSON.stringify(op.error)
137
+ }`,
138
+ });
139
+ }
140
+ return op;
141
+ });
142
+
143
+ return {
144
+ // No physical-name generation: identity is `(project, service)`.
145
+ // Both are static strings on `news`/`olds`/`output`.
146
+ stables: ["project", "service", "name"],
147
+ diff: Effect.fn(function* ({ news, olds = {} }) {
148
+ if (!isResolved(news)) return undefined;
149
+ if (
150
+ (olds.project !== undefined && olds.project !== news.project) ||
151
+ (olds.service !== undefined && olds.service !== news.service)
152
+ ) {
153
+ return { action: "replace" } as const;
154
+ }
155
+ return undefined;
156
+ }),
157
+ reconcile: Effect.fn(function* ({ news, session }) {
158
+ const name = fqServiceName(news.project, news.service);
159
+
160
+ // `getServices` returns the service entity even when disabled
161
+ // (state="DISABLED"), so 404 here means an invalid service
162
+ // name, not "not enabled". Treat NotFound and Forbidden as
163
+ // "needs enable attempt" — the enable call will surface the
164
+ // real error.
165
+ let observed = yield* getServices({ name }).pipe(
166
+ Effect.catchTag("NotFound", () =>
167
+ Effect.succeed(undefined as su.GoogleApiServiceusageV1Service | undefined),
168
+ ),
169
+ Effect.catchTag("Forbidden", () =>
170
+ Effect.succeed(undefined as su.GoogleApiServiceusageV1Service | undefined),
171
+ ),
172
+ );
173
+
174
+ if (!observed || observed.state !== "ENABLED") {
175
+ // `Conflict` here covers concurrent enable / state-persistence
176
+ // race; fall through and re-observe.
177
+ const op = yield* enableServices({ name, body: {} }).pipe(
178
+ Effect.catchTag("Conflict", () =>
179
+ Effect.succeed(undefined as su.Operation | undefined),
180
+ ),
181
+ );
182
+ if (op?.name) yield* awaitOperation(op.name, session);
183
+ observed = yield* getServices({ name });
184
+ }
185
+
186
+ return {
187
+ project: news.project,
188
+ service: news.service,
189
+ name: observed.name ?? name,
190
+ state: observed.state ?? "ENABLED",
191
+ };
192
+ }),
193
+ delete: Effect.fn(function* ({ output, session }) {
194
+ const name = fqServiceName(output.project, output.service);
195
+ yield* disableServices({
196
+ name,
197
+ body: { disableDependentServices: false },
198
+ }).pipe(
199
+ Effect.flatMap((op) =>
200
+ op.name ? awaitOperation(op.name, session) : Effect.succeed(op),
201
+ ),
202
+ Effect.catchTag("NotFound", () => Effect.void),
203
+ // GCP returns HTTP 400 in two important cases on disable
204
+ // that we treat as idempotent success:
205
+ //
206
+ // 1. Service already disabled. The resource is gone from
207
+ // the user's perspective; alchemy is removing it from
208
+ // state.
209
+ //
210
+ // 2. "Resources are found when disabling service(s) X.
211
+ // Before you can disable, please delete the following
212
+ // resources first: …" — this is GCP's eventual-
213
+ // consistency lag: a sibling resource (cluster, node
214
+ // pool) was deleted via its own API but ServiceUsage's
215
+ // "what's still using this service" view hasn't
216
+ // propagated yet. The cluster is gone; alchemy state is
217
+ // being torn down; if the project itself is also being
218
+ // destroyed (the typical case in `stack.destroy`), all
219
+ // API state goes with it. Failing here would leave a
220
+ // half-torn-down stack the user has to clean up by
221
+ // hand.
222
+ //
223
+ // Other 400s (e.g. dependent *services* still enabled, or
224
+ // a true API-level rejection) propagate so the user sees
225
+ // the real failure.
226
+ Effect.catchTag("BadRequest", (e) =>
227
+ /already.*disabled|not.*currently enabled|FAILED_PRECONDITION|Resources are found/i.test(
228
+ e.message ?? "",
229
+ )
230
+ ? Effect.void
231
+ : Effect.fail(e),
232
+ ),
233
+ );
234
+ }),
235
+ read: Effect.fn(function* ({ output, olds }) {
236
+ const project = output?.project ?? olds?.project;
237
+ const service = output?.service ?? olds?.service;
238
+ if (!project || !service) return undefined;
239
+ const name = fqServiceName(project, service);
240
+ const observed = yield* getServices({ name }).pipe(
241
+ Effect.catchTag("NotFound", () =>
242
+ Effect.succeed(undefined as su.GoogleApiServiceusageV1Service | undefined),
243
+ ),
244
+ Effect.catchTag("Forbidden", () =>
245
+ Effect.succeed(undefined as su.GoogleApiServiceusageV1Service | undefined),
246
+ ),
247
+ );
248
+ if (!observed) return undefined;
249
+ // A DISABLED service is functionally absent from this
250
+ // resource's perspective — the engine treats `undefined` as
251
+ // "needs reconcile" and the next plan will re-enable.
252
+ if (observed.state !== "ENABLED") return undefined;
253
+ // No `alchemy_*` ownership labels available on services
254
+ // (no labels field), so we can't gate via `Unowned`.
255
+ // API enablement is project-scoped state, not a labeled
256
+ // resource — adoption is fine: any project we can read from
257
+ // has the API enabled regardless of who turned it on.
258
+ return {
259
+ project,
260
+ service,
261
+ name: observed.name ?? name,
262
+ state: observed.state,
263
+ };
264
+ }),
265
+ };
266
+ }),
267
+ );
@@ -0,0 +1,2 @@
1
+ export { ApiEnable, ApiEnableProvider } from "./ApiEnable.ts";
2
+ export type { ApiEnableAttributes, ApiEnableProps } from "./ApiEnable.ts";
package/src/Tags.ts ADDED
@@ -0,0 +1,130 @@
1
+ import * as Effect from "effect/Effect";
2
+ import { Stack } from "alchemy/Stack";
3
+ import { Stage } from "alchemy/Stage";
4
+
5
+ /**
6
+ * GCP labels are stricter than AWS tags:
7
+ *
8
+ * - **Keys** must match `[a-z][a-z0-9_-]{0,62}`. Alchemy's default
9
+ * `createInternalTags` returns keys like `alchemy::stack` — `:` is
10
+ * illegal in a GCP label key, so we use `_` separators here.
11
+ * - **Values** must match `[a-z0-9_-]{0,63}`. We lowercase and replace
12
+ * any out-of-range character with `-`.
13
+ *
14
+ * The label values together with the keys are what `read` uses to
15
+ * decide whether a project is owned by this stack/stage/id (returning
16
+ * plain attrs) or foreign (returning `Unowned(attrs)`).
17
+ *
18
+ * @see {@link https://cloud.google.com/resource-manager/docs/creating-managing-labels#requirements}
19
+ */
20
+
21
+ const LABEL_KEY_APP = "alchemy_app";
22
+ const LABEL_KEY_STAGE = "alchemy_stage";
23
+ const LABEL_KEY_ID = "alchemy_id";
24
+
25
+ const sanitizeLabelValue = (raw: string): string => {
26
+ const lowered = raw.toLowerCase();
27
+ const replaced = lowered.replace(/[^a-z0-9_-]/g, "-");
28
+ return replaced.slice(0, 63);
29
+ };
30
+
31
+ /**
32
+ * Compute the alchemy-internal label set for a Resource. Merge the
33
+ * result with user-provided labels (user wins on key collision is
34
+ * deliberately disallowed — alchemy keys are reserved).
35
+ */
36
+ export const gcpInternalLabels = Effect.fnUntraced(function* (id: string) {
37
+ const stack = yield* Stack;
38
+ const stage = yield* Stage;
39
+ return {
40
+ [LABEL_KEY_APP]: sanitizeLabelValue(stack.name),
41
+ [LABEL_KEY_STAGE]: sanitizeLabelValue(stage),
42
+ [LABEL_KEY_ID]: sanitizeLabelValue(id),
43
+ } satisfies Record<string, string>;
44
+ });
45
+
46
+ /**
47
+ * True when `labels` contains all three alchemy-internal keys with
48
+ * values matching this stack/stage/id. Used in `read` to decide
49
+ * adoption ownership.
50
+ */
51
+ export const hasAlchemyLabels = Effect.fnUntraced(function* (
52
+ id: string,
53
+ labels: Record<string, string> | undefined,
54
+ ) {
55
+ if (!labels) return false;
56
+ const expected = yield* gcpInternalLabels(id);
57
+ return (
58
+ labels[LABEL_KEY_APP] === expected[LABEL_KEY_APP] &&
59
+ labels[LABEL_KEY_STAGE] === expected[LABEL_KEY_STAGE] &&
60
+ labels[LABEL_KEY_ID] === expected[LABEL_KEY_ID]
61
+ );
62
+ });
63
+
64
+ export {
65
+ LABEL_KEY_APP as ALCHEMY_LABEL_APP,
66
+ LABEL_KEY_STAGE as ALCHEMY_LABEL_STAGE,
67
+ LABEL_KEY_ID as ALCHEMY_LABEL_ID,
68
+ };
69
+
70
+ /**
71
+ * Some GCP resources (compute Networks, Subnetworks) carry no labels
72
+ * field, so the label-based adoption gate cannot apply. For these we
73
+ * encode the same `(app, stage, id)` triple as a sentinel inside the
74
+ * resource's `description` field — created on insert and read back on
75
+ * adoption.
76
+ *
77
+ * Format: `[alchemy:app=<app>,stage=<stage>,id=<id>] <user description>`.
78
+ * The leading bracketed segment is what `descriptionHasAlchemyMarker`
79
+ * matches; everything after the closing `]` is the user-supplied
80
+ * description and is preserved as-is. We never *update* this marker
81
+ * because both Network and Subnetwork treat `description` as immutable
82
+ * after create — the marker is only ever written on insert.
83
+ */
84
+ const sanitizeMarkerValue = (raw: string): string =>
85
+ raw.toLowerCase().replace(/[^a-z0-9_-]/g, "-").slice(0, 63);
86
+
87
+ const buildMarkerPrefix = (app: string, stage: string, id: string): string =>
88
+ `[alchemy:app=${sanitizeMarkerValue(app)},stage=${sanitizeMarkerValue(stage)},id=${sanitizeMarkerValue(id)}]`;
89
+
90
+ /**
91
+ * Compose `[alchemy:...] <userDescription?>`. Pass through to the
92
+ * resource's `description` body field at create time.
93
+ */
94
+ export const gcpAlchemyDescription = Effect.fnUntraced(function* (
95
+ id: string,
96
+ userDescription?: string,
97
+ ) {
98
+ const stack = yield* Stack;
99
+ const stage = yield* Stage;
100
+ const prefix = buildMarkerPrefix(stack.name, stage, id);
101
+ return userDescription ? `${prefix} ${userDescription}` : prefix;
102
+ });
103
+
104
+ /**
105
+ * True when `description` begins with an alchemy marker matching this
106
+ * stack/stage/id. Used in `read` for resources that lack a labels
107
+ * field.
108
+ */
109
+ export const descriptionHasAlchemyMarker = Effect.fnUntraced(function* (
110
+ id: string,
111
+ description: string | undefined,
112
+ ) {
113
+ if (!description) return false;
114
+ const stack = yield* Stack;
115
+ const stage = yield* Stage;
116
+ const expected = buildMarkerPrefix(stack.name, stage, id);
117
+ return description.startsWith(expected);
118
+ });
119
+
120
+ /**
121
+ * Strip the leading alchemy marker (if any) and return what remains —
122
+ * used when surfacing the user-visible description back through
123
+ * Attributes. Matches whether the marker was generated by us or not;
124
+ * if there's no marker, returns the description unchanged.
125
+ */
126
+ export const stripAlchemyMarker = (description: string | undefined): string | undefined => {
127
+ if (!description) return description;
128
+ const m = description.match(/^\[alchemy:[^\]]+\]\s?/);
129
+ return m ? description.slice(m[0].length) || undefined : description;
130
+ };
package/src/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ export { Credentials, fromADC, fromAuthProvider } from "./Auth/Credentials.ts";
2
+ export {
3
+ GCP_AUTH_PROVIDER_NAME,
4
+ GCPAuth,
5
+ } from "./Auth/AuthProvider.ts";
6
+ export type {
7
+ GCPAuthConfig,
8
+ GCPResolvedCredentials,
9
+ } from "./Auth/AuthProvider.ts";
10
+ export * from "./CloudResourceManager/index.ts";
11
+ export * from "./Compute/index.ts";
12
+ export * from "./Container/index.ts";
13
+ export * from "./Parallelstore/index.ts";
14
+ export * from "./ServiceNetworking/index.ts";
15
+ export * from "./ServiceUsage/index.ts";
16
+ export * from "./Providers.ts";