@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,517 @@
|
|
|
1
|
+
import { ConfigError } from "@distilled.cloud/gcp";
|
|
2
|
+
import * as billing from "@distilled.cloud/gcp/cloudbilling-v1";
|
|
3
|
+
import * as crm from "@distilled.cloud/gcp/cloudresourcemanager-v3";
|
|
4
|
+
import { Resource } from "alchemy";
|
|
5
|
+
import { Unowned } from "alchemy/AdoptPolicy";
|
|
6
|
+
import type { ScopedPlanStatusSession } from "alchemy/Cli/Cli";
|
|
7
|
+
import { isResolved } from "alchemy/Diff";
|
|
8
|
+
import { createPhysicalName } from "alchemy/PhysicalName";
|
|
9
|
+
import * as Provider from "alchemy/Provider";
|
|
10
|
+
import { diffTags } from "alchemy/Tags";
|
|
11
|
+
import * as Duration from "effect/Duration";
|
|
12
|
+
import * as Effect from "effect/Effect";
|
|
13
|
+
import * as Schedule from "effect/Schedule";
|
|
14
|
+
import { gcpInternalLabels, hasAlchemyLabels } from "../Tags.ts";
|
|
15
|
+
import type * as GCP from "../Providers.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parent for a GCP project — either a folder or an organization.
|
|
19
|
+
*/
|
|
20
|
+
export type ProjectParent =
|
|
21
|
+
| { type: "folder"; id: string }
|
|
22
|
+
| { type: "organization"; id: string };
|
|
23
|
+
|
|
24
|
+
export type ProjectProps = {
|
|
25
|
+
/**
|
|
26
|
+
* Globally-unique project ID. 6-30 lowercase letters/digits/hyphens,
|
|
27
|
+
* must start with a letter, no trailing hyphens. If omitted, derived
|
|
28
|
+
* deterministically from the stack/stage/logical-id via
|
|
29
|
+
* `createPhysicalName`.
|
|
30
|
+
*
|
|
31
|
+
* Project IDs are immutable — changing this triggers a replacement.
|
|
32
|
+
*/
|
|
33
|
+
projectId?: string;
|
|
34
|
+
/**
|
|
35
|
+
* User-visible display name. 4-30 chars, mutable.
|
|
36
|
+
*/
|
|
37
|
+
displayName?: string;
|
|
38
|
+
/**
|
|
39
|
+
* The folder or organization that owns this project.
|
|
40
|
+
*
|
|
41
|
+
* Moving projects between parents is a separate API; treat as
|
|
42
|
+
* replace-only for now.
|
|
43
|
+
*/
|
|
44
|
+
parent: ProjectParent;
|
|
45
|
+
/**
|
|
46
|
+
* User-supplied labels. Alchemy-internal labels (`alchemy_app`,
|
|
47
|
+
* `alchemy_stage`, `alchemy_id`) are merged in automatically and used
|
|
48
|
+
* to gate `read`-time adoption — do not set those keys yourself.
|
|
49
|
+
*/
|
|
50
|
+
labels?: Record<string, string>;
|
|
51
|
+
/**
|
|
52
|
+
* Billing account to attach to the project. Must be the
|
|
53
|
+
* fully-qualified resource name `billingAccounts/{id}` (e.g.
|
|
54
|
+
* `billingAccounts/01A860-D25444-D4B584`) — that's what
|
|
55
|
+
* `cloudbilling.projects.updateBillingInfo` accepts in
|
|
56
|
+
* `billingAccountName`; passing the bare id returns `400 Request
|
|
57
|
+
* contains an invalid argument`. The prefix is enforced at the
|
|
58
|
+
* type level here so the wrong shape fails at compile time.
|
|
59
|
+
*
|
|
60
|
+
* Required for any paid API (GKE, Compute, etc.) — fresh projects
|
|
61
|
+
* have no billing account by default and refuse to enable paid
|
|
62
|
+
* services until one is attached.
|
|
63
|
+
*
|
|
64
|
+
* Mutable via `cloudbilling.projects.updateBillingInfo`. Set to
|
|
65
|
+
* `undefined` (omit) on a project that previously had billing
|
|
66
|
+
* attached and the resource will detach billing on next reconcile,
|
|
67
|
+
* which stops paid services on the project — be deliberate.
|
|
68
|
+
*
|
|
69
|
+
* The `cloudbilling.googleapis.com` API must be enabled on the
|
|
70
|
+
* caller's ADC quota project (typically the user's primary
|
|
71
|
+
* project), not on this target project. This is the same
|
|
72
|
+
* precondition as `cloudresourcemanager.googleapis.com` for project
|
|
73
|
+
* creation, so if you can create projects you can attach billing.
|
|
74
|
+
*/
|
|
75
|
+
billingAccount?: BillingAccountName;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Fully-qualified billing-account resource name. The
|
|
80
|
+
* `billingAccounts/` prefix is the form GCP's APIs return and accept;
|
|
81
|
+
* encoding it at the type level rules out the bare-id mistake at
|
|
82
|
+
* compile time.
|
|
83
|
+
*/
|
|
84
|
+
export type BillingAccountName = `billingAccounts/${string}`;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A GCP project under an organization or folder.
|
|
88
|
+
*
|
|
89
|
+
* Project creation is asynchronous: `createProjects` returns a
|
|
90
|
+
* long-running `Operation` which we poll until `done`. The same applies
|
|
91
|
+
* to `delete` and `patch`. Delete is soft (30-day window) on the
|
|
92
|
+
* server side; we don't undelete.
|
|
93
|
+
*
|
|
94
|
+
* Adoption: a `read` returning a project that lacks our `alchemy_*`
|
|
95
|
+
* labels is wrapped in `Unowned(attrs)`, so the engine fails the plan
|
|
96
|
+
* unless the user passed `--adopt`. This matters more for GCP than for
|
|
97
|
+
* R2 — projects carry billing.
|
|
98
|
+
*
|
|
99
|
+
* @section Creating a Project
|
|
100
|
+
* @example Project under a folder
|
|
101
|
+
* ```typescript
|
|
102
|
+
* const project = yield* GCP.Project("Research", {
|
|
103
|
+
* parent: { type: "folder", id: "264228230413" },
|
|
104
|
+
* displayName: "Research Cluster",
|
|
105
|
+
* });
|
|
106
|
+
* ```
|
|
107
|
+
*
|
|
108
|
+
* @example Project with an explicit ID
|
|
109
|
+
* ```typescript
|
|
110
|
+
* const project = yield* GCP.Project("Research", {
|
|
111
|
+
* projectId: "microagi-research-001",
|
|
112
|
+
* parent: { type: "folder", id: "264228230413" },
|
|
113
|
+
* });
|
|
114
|
+
* ```
|
|
115
|
+
*/
|
|
116
|
+
export type Project = Resource<
|
|
117
|
+
"GCP.Project",
|
|
118
|
+
ProjectProps,
|
|
119
|
+
{
|
|
120
|
+
/** Globally-unique user-assigned id (e.g. `microagi-research-001`). */
|
|
121
|
+
projectId: string;
|
|
122
|
+
/**
|
|
123
|
+
* Server-assigned numeric id (e.g. `415104041262`). String at
|
|
124
|
+
* runtime — GCP encodes int64s as JSON strings to preserve
|
|
125
|
+
* precision — but typed as `` `${number}` `` so callers can
|
|
126
|
+
* compose project-number-form references (`PsaConnection`,
|
|
127
|
+
* `ParallelstoreInstance`, `networkRef`) without manual coercion.
|
|
128
|
+
*/
|
|
129
|
+
projectNumber: `${number}`;
|
|
130
|
+
/** Resource name in the form `projects/{projectNumber}`. */
|
|
131
|
+
name: string;
|
|
132
|
+
/** Display name — may be `undefined` if never set. */
|
|
133
|
+
displayName: string | undefined;
|
|
134
|
+
/** Lifecycle state — `ACTIVE` for normal projects. */
|
|
135
|
+
state: string;
|
|
136
|
+
/** Parent resource string in the form `folders/123` / `organizations/456`. */
|
|
137
|
+
parent: string;
|
|
138
|
+
/** RFC3339 timestamp from the server. */
|
|
139
|
+
createTime: string | undefined;
|
|
140
|
+
/** Labels currently set on the project, including `alchemy_*` internals. */
|
|
141
|
+
labels: Record<string, string>;
|
|
142
|
+
/**
|
|
143
|
+
* Billing account currently attached to the project (e.g.
|
|
144
|
+
* `billingAccounts/XXXXXX-YYYYYY-ZZZZZZ`), or `undefined` when none
|
|
145
|
+
* is attached.
|
|
146
|
+
*/
|
|
147
|
+
billingAccount: BillingAccountName | undefined;
|
|
148
|
+
/**
|
|
149
|
+
* Whether the project is associated with an *open* billing account.
|
|
150
|
+
* `false` when no billing account is attached or the attached
|
|
151
|
+
* account is closed; `true` once an open account is attached and
|
|
152
|
+
* paid services can be enabled.
|
|
153
|
+
*/
|
|
154
|
+
billingEnabled: boolean;
|
|
155
|
+
},
|
|
156
|
+
never,
|
|
157
|
+
GCP.Providers
|
|
158
|
+
>;
|
|
159
|
+
|
|
160
|
+
export const Project = Resource<Project>("GCP.Project");
|
|
161
|
+
|
|
162
|
+
const parentToString = (parent: ProjectParent): string =>
|
|
163
|
+
parent.type === "folder" ? `folders/${parent.id}` : `organizations/${parent.id}`;
|
|
164
|
+
|
|
165
|
+
const createProjectId = (id: string, override: string | undefined) =>
|
|
166
|
+
Effect.gen(function* () {
|
|
167
|
+
if (override) return override;
|
|
168
|
+
// GCP project IDs: 6-30 chars, lowercase letters/digits/hyphens,
|
|
169
|
+
// must start with a letter. `createPhysicalName` already prepends
|
|
170
|
+
// the stack name (which conventionally starts with a letter), so
|
|
171
|
+
// the first-letter rule is satisfied by construction.
|
|
172
|
+
return (yield* createPhysicalName({ id, maxLength: 30 })).toLowerCase();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const toAttributes = (
|
|
176
|
+
p: crm.Project,
|
|
177
|
+
billingInfo: billing.ProjectBillingInfo | undefined,
|
|
178
|
+
): Project["Attributes"] => ({
|
|
179
|
+
projectId: p.projectId!,
|
|
180
|
+
// GCP returns the project number as a digit-only string. The cast
|
|
181
|
+
// safely narrows from `string` to `` `${number}` `` — server-side
|
|
182
|
+
// contract guarantees the shape (the empty-string fallback is a
|
|
183
|
+
// belt-and-braces guard for malformed responses; downstream code
|
|
184
|
+
// observes a project state of "ACTIVE" before consuming this).
|
|
185
|
+
projectNumber: (p.name?.replace(/^projects\//, "") ?? "0") as `${number}`,
|
|
186
|
+
name: p.name ?? `projects/${p.projectId}`,
|
|
187
|
+
displayName: p.displayName,
|
|
188
|
+
state: p.state ?? "ACTIVE",
|
|
189
|
+
parent: p.parent ?? "",
|
|
190
|
+
createTime: p.createTime,
|
|
191
|
+
labels: { ...(p.labels ?? {}) },
|
|
192
|
+
// GCP returns `billingAccountName` as a plain string but its format
|
|
193
|
+
// is always `billingAccounts/{id}` — narrow at the boundary.
|
|
194
|
+
billingAccount: (billingInfo?.billingAccountName || undefined) as
|
|
195
|
+
| BillingAccountName
|
|
196
|
+
| undefined,
|
|
197
|
+
billingEnabled: billingInfo?.billingEnabled ?? false,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
export const ProjectProvider = () =>
|
|
201
|
+
Provider.effect(
|
|
202
|
+
Project,
|
|
203
|
+
Effect.gen(function* () {
|
|
204
|
+
// Acquire SDK clients ONCE — Credentials and HttpClient are
|
|
205
|
+
// resolved at provider construction, not on every reconcile.
|
|
206
|
+
const getProjects = yield* crm.getProjects;
|
|
207
|
+
const createProjects = yield* crm.createProjects;
|
|
208
|
+
const patchProjects = yield* crm.patchProjects;
|
|
209
|
+
const deleteProjects = yield* crm.deleteProjects;
|
|
210
|
+
const getOperations = yield* crm.getOperations;
|
|
211
|
+
const getBillingInfo = yield* billing.getBillingInfoProjects;
|
|
212
|
+
const updateBillingInfo = yield* billing.updateBillingInfoProjects;
|
|
213
|
+
|
|
214
|
+
const awaitOperation = Effect.fn(function* (
|
|
215
|
+
operationName: string,
|
|
216
|
+
session: ScopedPlanStatusSession,
|
|
217
|
+
) {
|
|
218
|
+
const op = yield* getOperations({ name: operationName }).pipe(
|
|
219
|
+
Effect.flatMap((current) =>
|
|
220
|
+
current.done === true
|
|
221
|
+
? Effect.succeed(current)
|
|
222
|
+
: Effect.fail({ _tag: "OperationPending" as const }),
|
|
223
|
+
),
|
|
224
|
+
Effect.retry({
|
|
225
|
+
while: (e: { _tag?: string }) => e?._tag === "OperationPending",
|
|
226
|
+
schedule: Schedule.exponential(Duration.seconds(1), 1.5).pipe(
|
|
227
|
+
Schedule.both(Schedule.recurs(60)),
|
|
228
|
+
Schedule.tapOutput(() =>
|
|
229
|
+
session.note(`Waiting for GCP operation ${operationName}…`),
|
|
230
|
+
),
|
|
231
|
+
),
|
|
232
|
+
}),
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
if (op.error) {
|
|
236
|
+
return yield* new ConfigError({
|
|
237
|
+
message: `GCP operation ${operationName} failed: ${
|
|
238
|
+
op.error.message ?? JSON.stringify(op.error)
|
|
239
|
+
}`,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
return op;
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const observeProject = (projectId: string) =>
|
|
246
|
+
getProjects({ name: `projects/${projectId}` }).pipe(
|
|
247
|
+
// GCP returns 403 Forbidden (not 404) when a project ID is
|
|
248
|
+
// unknown or invisible to the caller — a deliberate choice to
|
|
249
|
+
// avoid leaking project-ID existence across tenants. From this
|
|
250
|
+
// provider's perspective both shapes mean "not present", so
|
|
251
|
+
// observation should treat them identically.
|
|
252
|
+
Effect.catchTag("NotFound", () =>
|
|
253
|
+
Effect.succeed(undefined as crm.Project | undefined),
|
|
254
|
+
),
|
|
255
|
+
Effect.catchTag("Forbidden", () =>
|
|
256
|
+
Effect.succeed(undefined as crm.Project | undefined),
|
|
257
|
+
),
|
|
258
|
+
// A project marked for deletion is still readable but in
|
|
259
|
+
// `DELETE_REQUESTED` state. Treat as missing so the create
|
|
260
|
+
// path runs; if the projectId is still reserved server-side
|
|
261
|
+
// the create will fail with Conflict and surface a real
|
|
262
|
+
// error.
|
|
263
|
+
Effect.map((p) =>
|
|
264
|
+
p && p.state && p.state !== "ACTIVE" ? undefined : p,
|
|
265
|
+
),
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const requireProject = (projectId: string, context: string) =>
|
|
269
|
+
observeProject(projectId).pipe(
|
|
270
|
+
Effect.flatMap((p) =>
|
|
271
|
+
p
|
|
272
|
+
? Effect.succeed(p)
|
|
273
|
+
: Effect.fail(
|
|
274
|
+
new ConfigError({
|
|
275
|
+
message: `Project ${projectId} ${context}.`,
|
|
276
|
+
}),
|
|
277
|
+
),
|
|
278
|
+
),
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
// Read current billing association. Returns `undefined` when the
|
|
282
|
+
// project is unknown to billing (NotFound) or invisible to the
|
|
283
|
+
// caller (Forbidden) — same 403→missing collapse as the project
|
|
284
|
+
// observe path.
|
|
285
|
+
const observeBilling = (projectId: string) =>
|
|
286
|
+
getBillingInfo({ name: `projects/${projectId}` }).pipe(
|
|
287
|
+
Effect.catchTag("NotFound", () =>
|
|
288
|
+
Effect.succeed(undefined as billing.ProjectBillingInfo | undefined),
|
|
289
|
+
),
|
|
290
|
+
Effect.catchTag("Forbidden", () =>
|
|
291
|
+
Effect.succeed(undefined as billing.ProjectBillingInfo | undefined),
|
|
292
|
+
),
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
// Attach / detach a billing account on the project. Pass empty
|
|
296
|
+
// string to detach (GCP's documented contract — see the long
|
|
297
|
+
// disclaimer on `updateBillingInfoProjects` warning that this
|
|
298
|
+
// disables paid services on the project). Synchronous PUT, no
|
|
299
|
+
// LRO involved.
|
|
300
|
+
const syncBilling = Effect.fn(function* (
|
|
301
|
+
projectId: string,
|
|
302
|
+
observed: billing.ProjectBillingInfo | undefined,
|
|
303
|
+
desired: string | undefined,
|
|
304
|
+
) {
|
|
305
|
+
const observedAccount = observed?.billingAccountName ?? "";
|
|
306
|
+
const desiredAccount = desired ?? "";
|
|
307
|
+
if (observedAccount === desiredAccount) {
|
|
308
|
+
return observed;
|
|
309
|
+
}
|
|
310
|
+
return yield* updateBillingInfo({
|
|
311
|
+
name: `projects/${projectId}`,
|
|
312
|
+
body: { billingAccountName: desiredAccount },
|
|
313
|
+
}).pipe(
|
|
314
|
+
// Surface the most common BadRequest mode — the billing
|
|
315
|
+
// account's per-account project-attach quota — with a
|
|
316
|
+
// remediation pointer. Soft-deleted projects continue to
|
|
317
|
+
// hold quota for 30 days, so the typical fix is a quota
|
|
318
|
+
// increase rather than waiting it out. Other 400s
|
|
319
|
+
// (malformed account name, closed account, permission
|
|
320
|
+
// mismatch) propagate untouched so the underlying API
|
|
321
|
+
// message is preserved.
|
|
322
|
+
// Surface the most common BadRequest mode with a clear
|
|
323
|
+
// remediation pointer, and re-shape every BadRequest into
|
|
324
|
+
// ConfigError so callers see one error class regardless of
|
|
325
|
+
// the underlying GCP-side detail. The two patterns:
|
|
326
|
+
//
|
|
327
|
+
// 1. Billing-account project-attach quota exhausted —
|
|
328
|
+
// every soft-deleted project still holds quota for
|
|
329
|
+
// 30 days. Real fix is a quota increase request.
|
|
330
|
+
// 2. Other 400s (malformed account name, closed
|
|
331
|
+
// account, IAM mismatch on `billing.resourceAssociations.create`)
|
|
332
|
+
// — surface the underlying GCP message.
|
|
333
|
+
Effect.catchTag("BadRequest", (e) => {
|
|
334
|
+
// GCP returns the high-level message in `e.message` and
|
|
335
|
+
// stuffs the structured detail (e.g. `QuotaFailure`) into
|
|
336
|
+
// the response body's `details[]` array — which distilled
|
|
337
|
+
// doesn't surface (only `message` is parsed in
|
|
338
|
+
// `vendor/distilled/packages/gcp/src/client/api.ts:32-35`).
|
|
339
|
+
//
|
|
340
|
+
// The single most common 4xx on `updateBillingInfo` is
|
|
341
|
+
// FAILED_PRECONDITION ("Precondition check failed.") for
|
|
342
|
+
// the billing account's per-account project-attach quota.
|
|
343
|
+
// Each successful attach — including soft-deleted projects
|
|
344
|
+
// within their 30-day retention window — counts toward
|
|
345
|
+
// this quota. There is no way to detect this case with
|
|
346
|
+
// certainty from the surfaced error, so we always include
|
|
347
|
+
// the quota-remediation hint alongside the underlying
|
|
348
|
+
// message; users who hit a different 400 will still see
|
|
349
|
+
// GCP's real reason in the prefix.
|
|
350
|
+
const underlying = e.message ?? "unknown 400 from cloudbilling.updateBillingInfo";
|
|
351
|
+
const isLikelyQuota = /precondition|FAILED_PRECONDITION/i.test(underlying);
|
|
352
|
+
const hint = isLikelyQuota
|
|
353
|
+
? " This is most often the billing account's project-attach quota — request an increase at https://support.google.com/code/contact/billing_quota_increase, or use a different billing account."
|
|
354
|
+
: "";
|
|
355
|
+
return Effect.fail(
|
|
356
|
+
new ConfigError({
|
|
357
|
+
message: `Cannot attach billing account ${desiredAccount} to project ${projectId}: ${underlying}.${hint}`,
|
|
358
|
+
}),
|
|
359
|
+
);
|
|
360
|
+
}),
|
|
361
|
+
);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
const syncMutable = Effect.fn(function* (
|
|
365
|
+
observed: crm.Project,
|
|
366
|
+
desired: { displayName: string | undefined; labels: Record<string, string> },
|
|
367
|
+
session: ScopedPlanStatusSession,
|
|
368
|
+
) {
|
|
369
|
+
const observedLabels = { ...(observed.labels ?? {}) };
|
|
370
|
+
const updateMaskFields: string[] = [];
|
|
371
|
+
if (desired.displayName !== observed.displayName) {
|
|
372
|
+
updateMaskFields.push("display_name");
|
|
373
|
+
}
|
|
374
|
+
const labelDiff = diffTags(observedLabels, desired.labels);
|
|
375
|
+
if (labelDiff.removed.length > 0 || labelDiff.upsert.length > 0) {
|
|
376
|
+
updateMaskFields.push("labels");
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (updateMaskFields.length === 0) return observed;
|
|
380
|
+
|
|
381
|
+
const op = yield* patchProjects({
|
|
382
|
+
name: observed.name!,
|
|
383
|
+
updateMask: updateMaskFields.join(","),
|
|
384
|
+
body: {
|
|
385
|
+
displayName: desired.displayName,
|
|
386
|
+
labels: desired.labels,
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
if (op.name) yield* awaitOperation(op.name, session);
|
|
390
|
+
return yield* requireProject(
|
|
391
|
+
observed.projectId!,
|
|
392
|
+
"disappeared after patch",
|
|
393
|
+
);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
stables: ["projectId", "projectNumber", "name"],
|
|
398
|
+
diff: Effect.fn(function* ({ id, news, olds = {}, output }) {
|
|
399
|
+
if (!isResolved(news)) return undefined;
|
|
400
|
+
const desiredId = yield* createProjectId(id, news.projectId);
|
|
401
|
+
const currentId =
|
|
402
|
+
output?.projectId ??
|
|
403
|
+
(yield* createProjectId(id, olds.projectId));
|
|
404
|
+
const desiredParent = parentToString(news.parent);
|
|
405
|
+
const observedParent =
|
|
406
|
+
output?.parent ??
|
|
407
|
+
(olds.parent ? parentToString(olds.parent) : desiredParent);
|
|
408
|
+
|
|
409
|
+
if (desiredId !== currentId || observedParent !== desiredParent) {
|
|
410
|
+
return { action: "replace" } as const;
|
|
411
|
+
}
|
|
412
|
+
}),
|
|
413
|
+
reconcile: Effect.fn(function* ({ id, news, session }) {
|
|
414
|
+
const projectId = yield* createProjectId(id, news.projectId);
|
|
415
|
+
const parent = parentToString(news.parent);
|
|
416
|
+
const internalLabels = yield* gcpInternalLabels(id);
|
|
417
|
+
const desiredLabels: Record<string, string> = {
|
|
418
|
+
...(news.labels ?? {}),
|
|
419
|
+
...internalLabels, // alchemy keys reserved — overwrite user values
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
// 1. Observe — cloud state is authoritative; `output`/`olds`
|
|
423
|
+
// are caches and may be stale or missing.
|
|
424
|
+
let observed = yield* observeProject(projectId);
|
|
425
|
+
|
|
426
|
+
// 2. Ensure — create if missing. `createProjects` returns an
|
|
427
|
+
// LRO; poll until done. `Conflict` covers two cases: a
|
|
428
|
+
// peer reconciler raced us, or a prior reconcile created
|
|
429
|
+
// the project but state-persistence failed. In both, the
|
|
430
|
+
// project exists — re-observe and converge.
|
|
431
|
+
if (!observed) {
|
|
432
|
+
const operation = yield* createProjects({
|
|
433
|
+
body: {
|
|
434
|
+
projectId,
|
|
435
|
+
parent,
|
|
436
|
+
displayName: news.displayName,
|
|
437
|
+
labels: desiredLabels,
|
|
438
|
+
},
|
|
439
|
+
}).pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
|
|
440
|
+
|
|
441
|
+
if (operation?.name) yield* awaitOperation(operation.name, session);
|
|
442
|
+
|
|
443
|
+
observed = yield* requireProject(
|
|
444
|
+
projectId,
|
|
445
|
+
"did not appear after create",
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// 3. Sync — displayName + labels are mutable. Diff against
|
|
450
|
+
// OBSERVED state (not `olds`) so adoption converges
|
|
451
|
+
// correctly: if a foreign label set is on the project,
|
|
452
|
+
// the patch will replace it with our desired set
|
|
453
|
+
// (including alchemy internals).
|
|
454
|
+
const synced = yield* syncMutable(
|
|
455
|
+
observed,
|
|
456
|
+
{
|
|
457
|
+
displayName: news.displayName ?? observed.displayName,
|
|
458
|
+
labels: desiredLabels,
|
|
459
|
+
},
|
|
460
|
+
session,
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
// 4. Sync billing association. Done after the project patch
|
|
464
|
+
// so the project resource is fully present before we
|
|
465
|
+
// attach billing (CRM project create is the LRO; billing
|
|
466
|
+
// update is synchronous).
|
|
467
|
+
const observedBilling = yield* observeBilling(synced.projectId!);
|
|
468
|
+
const finalBilling = yield* syncBilling(
|
|
469
|
+
synced.projectId!,
|
|
470
|
+
observedBilling,
|
|
471
|
+
news.billingAccount,
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
return toAttributes(synced, finalBilling);
|
|
475
|
+
}),
|
|
476
|
+
delete: Effect.fn(function* ({ output, session }) {
|
|
477
|
+
yield* deleteProjects({ name: `projects/${output.projectId}` }).pipe(
|
|
478
|
+
Effect.flatMap((op) =>
|
|
479
|
+
op.name ? awaitOperation(op.name, session) : Effect.succeed(op),
|
|
480
|
+
),
|
|
481
|
+
// Already gone.
|
|
482
|
+
Effect.catchTag("NotFound", () => Effect.void),
|
|
483
|
+
// GCP returns 400 with "Project ... is already in
|
|
484
|
+
// DELETE_REQUESTED" when delete is invoked on an already-
|
|
485
|
+
// soft-deleted project. Treat that as idempotent success.
|
|
486
|
+
// TODO(distilled): tag this state via patch — see finding 12.
|
|
487
|
+
// The GCP `matchError` (vendor/distilled/packages/gcp/src/client/api.ts)
|
|
488
|
+
// currently dispatches purely on HTTP status and ignores the
|
|
489
|
+
// `message.includes` matcher in the patch JSON, so a per-operation
|
|
490
|
+
// patch alone can't disambiguate this case from a real 400 without
|
|
491
|
+
// also extending `matchError` to consult the matchers.
|
|
492
|
+
Effect.catchTag("BadRequest", (e) =>
|
|
493
|
+
/already.*delet|DELETE_REQUESTED/i.test(e.message ?? "")
|
|
494
|
+
? Effect.void
|
|
495
|
+
: Effect.fail(e),
|
|
496
|
+
),
|
|
497
|
+
);
|
|
498
|
+
}),
|
|
499
|
+
read: Effect.fn(function* ({ id, output, olds }) {
|
|
500
|
+
const projectId =
|
|
501
|
+
output?.projectId ??
|
|
502
|
+
(yield* createProjectId(id, olds?.projectId));
|
|
503
|
+
const observed = yield* observeProject(projectId);
|
|
504
|
+
if (!observed) return undefined;
|
|
505
|
+
const observedBilling = yield* observeBilling(projectId);
|
|
506
|
+
const attrs = toAttributes(observed, observedBilling);
|
|
507
|
+
// Adoption gate: a project without our internal labels was
|
|
508
|
+
// created outside this stack/stage/id. The engine surfaces
|
|
509
|
+
// `Unowned(attrs)` to the user as "exists but is not ours" —
|
|
510
|
+
// adoption requires `--adopt` (or `adopt(true)`).
|
|
511
|
+
return (yield* hasAlchemyLabels(id, observed.labels))
|
|
512
|
+
? attrs
|
|
513
|
+
: Unowned(attrs);
|
|
514
|
+
}),
|
|
515
|
+
};
|
|
516
|
+
}),
|
|
517
|
+
);
|