@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.
- package/LICENSE +201 -0
- package/README.md +88 -0
- package/lib/Auth/AuthProvider.d.ts +48 -0
- package/lib/Auth/AuthProvider.d.ts.map +1 -0
- package/lib/Auth/AuthProvider.js +84 -0
- package/lib/Auth/AuthProvider.js.map +1 -0
- package/lib/Auth/Credentials.d.ts +28 -0
- package/lib/Auth/Credentials.d.ts.map +1 -0
- package/lib/Auth/Credentials.js +41 -0
- package/lib/Auth/Credentials.js.map +1 -0
- package/lib/CloudResourceManager/Project.d.ts +143 -0
- package/lib/CloudResourceManager/Project.d.ts.map +1 -0
- package/lib/CloudResourceManager/Project.js +272 -0
- package/lib/CloudResourceManager/Project.js.map +1 -0
- package/lib/CloudResourceManager/index.d.ts +3 -0
- package/lib/CloudResourceManager/index.d.ts.map +1 -0
- package/lib/CloudResourceManager/index.js +2 -0
- package/lib/CloudResourceManager/index.js.map +1 -0
- package/lib/Compute/GlobalAddress.d.ts +101 -0
- package/lib/Compute/GlobalAddress.d.ts.map +1 -0
- package/lib/Compute/GlobalAddress.js +134 -0
- package/lib/Compute/GlobalAddress.js.map +1 -0
- package/lib/Compute/Network.d.ts +114 -0
- package/lib/Compute/Network.d.ts.map +1 -0
- package/lib/Compute/Network.js +145 -0
- package/lib/Compute/Network.js.map +1 -0
- package/lib/Compute/Operations.d.ts +34 -0
- package/lib/Compute/Operations.d.ts.map +1 -0
- package/lib/Compute/Operations.js +62 -0
- package/lib/Compute/Operations.js.map +1 -0
- package/lib/Compute/SharedVpcHost.d.ts +44 -0
- package/lib/Compute/SharedVpcHost.d.ts.map +1 -0
- package/lib/Compute/SharedVpcHost.js +71 -0
- package/lib/Compute/SharedVpcHost.js.map +1 -0
- package/lib/Compute/SharedVpcServiceProject.d.ts +46 -0
- package/lib/Compute/SharedVpcServiceProject.d.ts.map +1 -0
- package/lib/Compute/SharedVpcServiceProject.js +96 -0
- package/lib/Compute/SharedVpcServiceProject.js.map +1 -0
- package/lib/Compute/Subnetwork.d.ts +108 -0
- package/lib/Compute/Subnetwork.d.ts.map +1 -0
- package/lib/Compute/Subnetwork.js +201 -0
- package/lib/Compute/Subnetwork.js.map +1 -0
- package/lib/Compute/index.d.ts +6 -0
- package/lib/Compute/index.d.ts.map +1 -0
- package/lib/Compute/index.js +6 -0
- package/lib/Compute/index.js.map +1 -0
- package/lib/Container/Cluster.d.ts +216 -0
- package/lib/Container/Cluster.d.ts.map +1 -0
- package/lib/Container/Cluster.js +326 -0
- package/lib/Container/Cluster.js.map +1 -0
- package/lib/Container/NodePool.d.ts +214 -0
- package/lib/Container/NodePool.d.ts.map +1 -0
- package/lib/Container/NodePool.js +344 -0
- package/lib/Container/NodePool.js.map +1 -0
- package/lib/Container/Operations.d.ts +50 -0
- package/lib/Container/Operations.d.ts.map +1 -0
- package/lib/Container/Operations.js +51 -0
- package/lib/Container/Operations.js.map +1 -0
- package/lib/Container/index.d.ts +5 -0
- package/lib/Container/index.d.ts.map +1 -0
- package/lib/Container/index.js +3 -0
- package/lib/Container/index.js.map +1 -0
- package/lib/Parallelstore/Instance.d.ts +119 -0
- package/lib/Parallelstore/Instance.d.ts.map +1 -0
- package/lib/Parallelstore/Instance.js +180 -0
- package/lib/Parallelstore/Instance.js.map +1 -0
- package/lib/Parallelstore/index.d.ts +2 -0
- package/lib/Parallelstore/index.d.ts.map +1 -0
- package/lib/Parallelstore/index.js +2 -0
- package/lib/Parallelstore/index.js.map +1 -0
- package/lib/Providers.d.ts +24 -0
- package/lib/Providers.d.ts.map +1 -0
- package/lib/Providers.js +51 -0
- package/lib/Providers.js.map +1 -0
- package/lib/ServiceNetworking/PsaConnection.d.ts +84 -0
- package/lib/ServiceNetworking/PsaConnection.d.ts.map +1 -0
- package/lib/ServiceNetworking/PsaConnection.js +130 -0
- package/lib/ServiceNetworking/PsaConnection.js.map +1 -0
- package/lib/ServiceNetworking/index.d.ts +2 -0
- package/lib/ServiceNetworking/index.d.ts.map +1 -0
- package/lib/ServiceNetworking/index.js +2 -0
- package/lib/ServiceNetworking/index.js.map +1 -0
- package/lib/ServiceUsage/ApiEnable.d.ts +78 -0
- package/lib/ServiceUsage/ApiEnable.d.ts.map +1 -0
- package/lib/ServiceUsage/ApiEnable.js +132 -0
- package/lib/ServiceUsage/ApiEnable.js.map +1 -0
- package/lib/ServiceUsage/index.d.ts +3 -0
- package/lib/ServiceUsage/index.d.ts.map +1 -0
- package/lib/ServiceUsage/index.js +2 -0
- package/lib/ServiceUsage/index.js.map +1 -0
- package/lib/Tags.d.ts +57 -0
- package/lib/Tags.d.ts.map +1 -0
- package/lib/Tags.js +106 -0
- package/lib/Tags.js.map +1 -0
- package/lib/index.d.ts +11 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +10 -0
- package/lib/index.js.map +1 -0
- package/package.json +70 -0
- package/src/Auth/AuthProvider.ts +180 -0
- package/src/Auth/Credentials.ts +69 -0
- package/src/CloudResourceManager/Project.ts +517 -0
- package/src/CloudResourceManager/index.ts +2 -0
- package/src/Compute/GlobalAddress.ts +283 -0
- package/src/Compute/Network.ts +306 -0
- package/src/Compute/Operations.ts +108 -0
- package/src/Compute/SharedVpcHost.ts +141 -0
- package/src/Compute/SharedVpcServiceProject.ts +185 -0
- package/src/Compute/Subnetwork.ts +375 -0
- package/src/Compute/index.ts +5 -0
- package/src/Container/Cluster.ts +599 -0
- package/src/Container/NodePool.ts +620 -0
- package/src/Container/Operations.ts +88 -0
- package/src/Container/index.ts +8 -0
- package/src/Parallelstore/Instance.ts +371 -0
- package/src/Parallelstore/index.ts +1 -0
- package/src/Providers.ts +84 -0
- package/src/ServiceNetworking/PsaConnection.ts +269 -0
- package/src/ServiceNetworking/index.ts +1 -0
- package/src/ServiceUsage/ApiEnable.ts +267 -0
- package/src/ServiceUsage/index.ts +2 -0
- package/src/Tags.ts +130 -0
- 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
|
+
);
|
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";
|