@intentius/chant-lexicon-gcp 0.124.33 → 0.124.35
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/dist/api/read-client.d.ts +7 -3
- package/dist/api/read-client.d.ts.map +1 -1
- package/dist/api/read-client.js +36 -15
- package/dist/composites/cloud-run-service.d.ts +2 -0
- package/dist/composites/cloud-run-service.d.ts.map +1 -1
- package/dist/composites/cloud-run-service.js +4 -1
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/deep-observe.js +125 -1
- package/dist/describe-resources.d.ts +4 -3
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/describe-resources.js +8 -7
- package/dist/health.js +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/op/activities/gcp-apply.d.ts +62 -18
- package/dist/op/activities/gcp-apply.d.ts.map +1 -1
- package/dist/op/activities/gcp-apply.js +118 -18
- package/dist/op/activities/gcp-compute.d.ts +56 -0
- package/dist/op/activities/gcp-compute.d.ts.map +1 -0
- package/dist/op/activities/gcp-compute.js +313 -0
- package/dist/op/activities/gcp-iam.d.ts +82 -0
- package/dist/op/activities/gcp-iam.d.ts.map +1 -0
- package/dist/op/activities/gcp-iam.js +215 -0
- package/dist/op/activities/index.d.ts +1 -1
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/index.js +1 -1
- package/dist/serializer.d.ts.map +1 -1
- package/dist/serializer.js +36 -1
- package/package.json +2 -2
- package/src/api/read-client.ts +35 -16
- package/src/composites/cloud-run-service.ts +6 -0
- package/src/composites/composites.test.ts +7 -0
- package/src/deep-observe.test.ts +16 -1
- package/src/deep-observe.ts +125 -1
- package/src/describe-resources.test.ts +1 -1
- package/src/describe-resources.ts +8 -6
- package/src/health.ts +1 -1
- package/src/op/activities/gcp-apply.test.ts +53 -0
- package/src/op/activities/gcp-apply.ts +157 -24
- package/src/op/activities/gcp-compute.test.ts +212 -0
- package/src/op/activities/gcp-compute.ts +319 -0
- package/src/op/activities/gcp-iam.test.ts +152 -0
- package/src/op/activities/gcp-iam.ts +284 -0
- package/src/op/activities/index.ts +1 -0
- package/src/project-resolution.test.ts +41 -0
- package/src/serializer-pseudo.test.ts +38 -0
- package/src/serializer.ts +43 -3
package/src/deep-observe.ts
CHANGED
|
@@ -182,9 +182,133 @@ const CNRM_REFINERS: Record<string, (tree: CnrmTree, ctx: RefineContext) => void
|
|
|
182
182
|
// name. The declared side writes it as `location`.
|
|
183
183
|
const m = ctx.restName ? /\/locations\/([^/]+)\//.exec(ctx.restName) : null;
|
|
184
184
|
if (m) tree.location = m[1];
|
|
185
|
+
// #4240: v2 returns the account as an email; the declared side writes a
|
|
186
|
+
// `serviceAccountRef`, `name` for an account in the project.
|
|
187
|
+
const template = tree.template as Record<string, unknown> | undefined;
|
|
188
|
+
if (template && typeof template.serviceAccount === "string") {
|
|
189
|
+
const email = template.serviceAccount;
|
|
190
|
+
const suffix = `@${ctx.project}.iam.gserviceaccount.com`;
|
|
191
|
+
template.serviceAccountRef = email.endsWith(suffix) ? { name: email.slice(0, -suffix.length) } : { external: email };
|
|
192
|
+
delete template.serviceAccount;
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
// #4233: the inverse of gcp-compute.ts's body builders. Compute returns refs
|
|
196
|
+
// as full URLs and a region as a URL; CNRM writes `*Ref: { name }` and a
|
|
197
|
+
// bare `location` (`"global"` when the resource has no region).
|
|
198
|
+
IAMPolicyMember(tree) {
|
|
199
|
+
delete tree.etag;
|
|
200
|
+
delete tree.resource;
|
|
201
|
+
},
|
|
202
|
+
ComputeAddress(tree) {
|
|
203
|
+
computeLocation(tree);
|
|
204
|
+
},
|
|
205
|
+
ComputeRegionNetworkEndpointGroup(tree) {
|
|
206
|
+
if (typeof tree.region === "string") tree.region = lastSegment(tree.region);
|
|
207
|
+
const cloudRun = tree.cloudRun as Record<string, unknown> | undefined;
|
|
208
|
+
if (cloudRun && typeof cloudRun.service === "string") {
|
|
209
|
+
cloudRun.serviceRef = { name: cloudRun.service };
|
|
210
|
+
delete cloudRun.service;
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
ComputeBackendService(tree) {
|
|
214
|
+
computeLocation(tree);
|
|
215
|
+
if (tree.enableCDN !== undefined) {
|
|
216
|
+
tree.enableCdn = tree.enableCDN;
|
|
217
|
+
delete tree.enableCDN;
|
|
218
|
+
}
|
|
219
|
+
const draining = tree.connectionDraining as { drainingTimeoutSec?: unknown } | undefined;
|
|
220
|
+
if (draining) {
|
|
221
|
+
if (draining.drainingTimeoutSec !== undefined) tree.connectionDrainingTimeoutSec = draining.drainingTimeoutSec;
|
|
222
|
+
delete tree.connectionDraining;
|
|
223
|
+
}
|
|
224
|
+
if (Array.isArray(tree.backends)) {
|
|
225
|
+
tree.backend = (tree.backends as Array<Record<string, unknown>>).map((b) => {
|
|
226
|
+
const { group, ...rest } = b;
|
|
227
|
+
return { ...rest, ...(typeof group === "string" ? { group: refOf(group) } : {}) };
|
|
228
|
+
});
|
|
229
|
+
delete tree.backends;
|
|
230
|
+
}
|
|
231
|
+
if (Array.isArray(tree.healthChecks)) {
|
|
232
|
+
tree.healthChecks = (tree.healthChecks as unknown[]).map((h) => (typeof h === "string" ? { healthCheckRef: { name: lastSegment(h) } } : h));
|
|
233
|
+
}
|
|
234
|
+
if (typeof tree.securityPolicy === "string") {
|
|
235
|
+
tree.securityPolicyRef = { name: lastSegment(tree.securityPolicy) };
|
|
236
|
+
delete tree.securityPolicy;
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
ComputeURLMap(tree) {
|
|
240
|
+
computeLocation(tree);
|
|
241
|
+
if (typeof tree.defaultService === "string") tree.defaultService = refOf(tree.defaultService);
|
|
242
|
+
if (Array.isArray(tree.hostRules)) {
|
|
243
|
+
tree.hostRule = tree.hostRules;
|
|
244
|
+
delete tree.hostRules;
|
|
245
|
+
}
|
|
246
|
+
if (Array.isArray(tree.pathMatchers)) {
|
|
247
|
+
tree.pathMatcher = (tree.pathMatchers as Array<Record<string, unknown>>).map((m) => {
|
|
248
|
+
const { defaultService, pathRules, ...rest } = m;
|
|
249
|
+
return {
|
|
250
|
+
...rest,
|
|
251
|
+
...(typeof defaultService === "string" ? { defaultService: refOf(defaultService) } : {}),
|
|
252
|
+
...(Array.isArray(pathRules)
|
|
253
|
+
? {
|
|
254
|
+
pathRule: (pathRules as Array<Record<string, unknown>>).map(({ service, ...r }) => ({
|
|
255
|
+
...r,
|
|
256
|
+
...(typeof service === "string" ? { service: refOf(service) } : {}),
|
|
257
|
+
})),
|
|
258
|
+
}
|
|
259
|
+
: {}),
|
|
260
|
+
};
|
|
261
|
+
});
|
|
262
|
+
delete tree.pathMatchers;
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
ComputeTargetHTTPProxy(tree) {
|
|
266
|
+
computeLocation(tree);
|
|
267
|
+
if (typeof tree.urlMap === "string") {
|
|
268
|
+
tree.urlMapRef = { name: lastSegment(tree.urlMap) };
|
|
269
|
+
delete tree.urlMap;
|
|
270
|
+
}
|
|
185
271
|
},
|
|
272
|
+
ComputeForwardingRule(tree) {
|
|
273
|
+
computeLocation(tree);
|
|
274
|
+
if (tree.IPProtocol !== undefined) {
|
|
275
|
+
tree.ipProtocol = tree.IPProtocol;
|
|
276
|
+
delete tree.IPProtocol;
|
|
277
|
+
}
|
|
278
|
+
if (typeof tree.target === "string") tree.target = refOf(tree.target);
|
|
279
|
+
if (typeof tree.backendService === "string") {
|
|
280
|
+
tree.backendServiceRef = { name: lastSegment(tree.backendService) };
|
|
281
|
+
delete tree.backendService;
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
function lastSegment(url: string): string {
|
|
287
|
+
return url.slice(url.lastIndexOf("/") + 1);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Compute's `region` URL as CNRM's `location`; no region means a global resource. */
|
|
291
|
+
function computeLocation(tree: CnrmTree): void {
|
|
292
|
+
tree.location = typeof tree.region === "string" ? lastSegment(tree.region) : "global";
|
|
293
|
+
delete tree.region;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** The `*Ref` a compute URL names, by its collection (`.../backendServices/web` -> `{ backendServiceRef: { name: "web" } }`). */
|
|
297
|
+
const REF_KEY_BY_COLLECTION: Record<string, string> = {
|
|
298
|
+
networkEndpointGroups: "networkEndpointGroupRef",
|
|
299
|
+
backendServices: "backendServiceRef",
|
|
300
|
+
backendBuckets: "backendBucketRef",
|
|
301
|
+
targetHttpProxies: "targetHTTPProxyRef",
|
|
302
|
+
targetHttpsProxies: "targetHTTPSProxyRef",
|
|
303
|
+
instanceGroups: "instanceGroupRef",
|
|
186
304
|
};
|
|
187
305
|
|
|
306
|
+
function refOf(url: string): unknown {
|
|
307
|
+
const parts = url.split("/");
|
|
308
|
+
const key = REF_KEY_BY_COLLECTION[parts[parts.length - 2] ?? ""];
|
|
309
|
+
return key ? { [key]: { name: parts[parts.length - 1] } } : url;
|
|
310
|
+
}
|
|
311
|
+
|
|
188
312
|
/**
|
|
189
313
|
* A REST payload as a CNRM-shaped property tree, in the declared side's
|
|
190
314
|
* vocabulary. The generic reshape runs first, then the full resource path is
|
|
@@ -244,7 +368,7 @@ export async function observeResourcesDeepGcp(options: GcpDeepObserveOptions): P
|
|
|
244
368
|
let client: GcpReadClientOptions;
|
|
245
369
|
try {
|
|
246
370
|
client = {
|
|
247
|
-
project: resolveReadProject(gvk.kind, name, metadata, manifestProjects),
|
|
371
|
+
project: resolveReadProject(gvk.kind, name, metadata, manifestProjects, props),
|
|
248
372
|
...(endpoint ? { endpoint } : {}),
|
|
249
373
|
};
|
|
250
374
|
} catch (err) {
|
|
@@ -80,7 +80,7 @@ describe("gcp describeResources — direct REST (#1209)", () => {
|
|
|
80
80
|
|
|
81
81
|
test("a kind the applier has no mapper for is unsupported-kind, not absent", async () => {
|
|
82
82
|
const out = await read(
|
|
83
|
-
makeEntities([{ name: "e", entityType: "GCP::Compute::
|
|
83
|
+
makeEntities([{ name: "e", entityType: "GCP::Compute::Network", props: { metadata: { name: "net" } } }]),
|
|
84
84
|
);
|
|
85
85
|
expect(out.unobserved?.e?.reason).toBe("unsupported-kind");
|
|
86
86
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
@@ -40,7 +40,7 @@ import type { ObservationResult, ResourceMetadata, UnobservedEntity } from "@int
|
|
|
40
40
|
import { observation } from "@intentius/chant/observation";
|
|
41
41
|
import { hasOwnershipMarker, classifyOwnership, readOwnership, type ChannelKeys } from "@intentius/chant/ownership";
|
|
42
42
|
import { getResource, mapperForKind, GcpReadError, isNotFound, type GcpReadClientOptions } from "./api/read-client";
|
|
43
|
-
import { resolveGcpProject, parseManifest } from "./op/activities/gcp-apply";
|
|
43
|
+
import { resolveGcpProject, parseManifest, projectRefProject } from "./op/activities/gcp-apply";
|
|
44
44
|
import { resolveGVK } from "./serializer";
|
|
45
45
|
|
|
46
46
|
/**
|
|
@@ -105,7 +105,7 @@ export function manifestProjectAnnotations(buildOutput: string | undefined): Map
|
|
|
105
105
|
if (!buildOutput) return out;
|
|
106
106
|
try {
|
|
107
107
|
for (const doc of parseManifest(buildOutput, "<build output>")) {
|
|
108
|
-
const project = doc.metadata?.annotations?.["cnrm.cloud.google.com/project-id"];
|
|
108
|
+
const project = projectRefProject(doc.spec) ?? doc.metadata?.annotations?.["cnrm.cloud.google.com/project-id"];
|
|
109
109
|
if (doc.kind && doc.metadata?.name && typeof project === "string") {
|
|
110
110
|
out.set(`${doc.kind}/${doc.metadata.name}`, project);
|
|
111
111
|
}
|
|
@@ -119,8 +119,9 @@ export function manifestProjectAnnotations(buildOutput: string | undefined): Map
|
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
/**
|
|
122
|
-
* The project a read should target:
|
|
123
|
-
*
|
|
122
|
+
* The project a read should target: the entity's `projectRef.external`,
|
|
123
|
+
* `GOOGLE_CLOUD_PROJECT`, the entity's own annotation (via
|
|
124
|
+
* `resolveGcpProject`, the applier's rule), then the built
|
|
124
125
|
* manifest's merged annotation. Throws the applier's own error when nothing
|
|
125
126
|
* resolves, so the `no-binding` detail names both knobs.
|
|
126
127
|
*/
|
|
@@ -129,9 +130,10 @@ export function resolveReadProject(
|
|
|
129
130
|
name: string,
|
|
130
131
|
metadata: { annotations?: Record<string, string> } | undefined,
|
|
131
132
|
manifestProjects: Map<string, string>,
|
|
133
|
+
props?: Record<string, unknown>,
|
|
132
134
|
): string {
|
|
133
135
|
try {
|
|
134
|
-
return resolveGcpProject({ kind, metadata });
|
|
136
|
+
return resolveGcpProject({ kind, metadata, ...(props ? { spec: props } : {}) });
|
|
135
137
|
} catch (err) {
|
|
136
138
|
const fromManifest = manifestProjects.get(`${kind}/${name}`);
|
|
137
139
|
if (fromManifest) return fromManifest;
|
|
@@ -230,7 +232,7 @@ export async function describeResources(options: {
|
|
|
230
232
|
let client: GcpReadClientOptions;
|
|
231
233
|
try {
|
|
232
234
|
client = {
|
|
233
|
-
project: resolveReadProject(gvk.kind, name, metadata, manifestProjects),
|
|
235
|
+
project: resolveReadProject(gvk.kind, name, metadata, manifestProjects, props),
|
|
234
236
|
...(endpoint ? { endpoint } : {}),
|
|
235
237
|
};
|
|
236
238
|
} catch (err) {
|
package/src/health.ts
CHANGED
|
@@ -91,7 +91,7 @@ export async function observeHealthGcp(options: GcpHealthOptions): Promise<Healt
|
|
|
91
91
|
}
|
|
92
92
|
let project: string;
|
|
93
93
|
try {
|
|
94
|
-
project = resolveReadProject(gvk.kind, name, metadata, manifestProjects);
|
|
94
|
+
project = resolveReadProject(gvk.kind, name, metadata, manifestProjects, props);
|
|
95
95
|
} catch (err) {
|
|
96
96
|
unobserved[entityName] = { type: entityType, reason: "no-binding", detail: err instanceof Error ? err.message : String(err) };
|
|
97
97
|
return;
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
bucketInsertBody,
|
|
6
6
|
pubSubTopicBody,
|
|
7
7
|
cloudRunServiceBody,
|
|
8
|
+
serviceAccountEmail,
|
|
8
9
|
resolveGcpProject,
|
|
9
10
|
resolveGcpEndpoint,
|
|
10
11
|
applyResource,
|
|
@@ -118,6 +119,13 @@ describe("mappers build correct plans (#706)", () => {
|
|
|
118
119
|
|
|
119
120
|
test("registry keys match each mapper's kind", () => {
|
|
120
121
|
expect(Object.keys(MAPPERS).sort()).toEqual([
|
|
122
|
+
"ComputeAddress",
|
|
123
|
+
"ComputeBackendService",
|
|
124
|
+
"ComputeForwardingRule",
|
|
125
|
+
"ComputeRegionNetworkEndpointGroup",
|
|
126
|
+
"ComputeTargetHTTPProxy",
|
|
127
|
+
"ComputeURLMap",
|
|
128
|
+
"IAMPolicyMember",
|
|
121
129
|
"IAMServiceAccount",
|
|
122
130
|
"PubSubSubscription",
|
|
123
131
|
"PubSubTopic",
|
|
@@ -136,6 +144,35 @@ describe("Cloud Run mapper + LRO (#706)", () => {
|
|
|
136
144
|
});
|
|
137
145
|
});
|
|
138
146
|
|
|
147
|
+
test("cloudRunServiceBody sends serviceAccountRef as template.serviceAccount (#4240)", () => {
|
|
148
|
+
const withRef = (ref: Record<string, string>) => ({
|
|
149
|
+
...RUN_SERVICE,
|
|
150
|
+
spec: { ...(RUN_SERVICE.spec as object), template: { containers: [{ image: "gcr.io/x/hello" }], serviceAccountRef: ref } },
|
|
151
|
+
});
|
|
152
|
+
expect(cloudRunServiceBody(withRef({ name: "run-sa" }), "p")).toEqual({
|
|
153
|
+
template: { containers: [{ image: "gcr.io/x/hello" }], serviceAccount: "run-sa@p.iam.gserviceaccount.com" },
|
|
154
|
+
});
|
|
155
|
+
expect(cloudRunServiceBody(withRef({ external: "x@other.iam.gserviceaccount.com" }), "p")).toEqual({
|
|
156
|
+
template: { containers: [{ image: "gcr.io/x/hello" }], serviceAccount: "x@other.iam.gserviceaccount.com" },
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("serviceAccountEmail: external as given or from a path, name in the project", () => {
|
|
161
|
+
expect(serviceAccountEmail({ external: "projects/o/serviceAccounts/x@o.iam.gserviceaccount.com" })).toBe("x@o.iam.gserviceaccount.com");
|
|
162
|
+
expect(serviceAccountEmail({ name: "run-sa" }, "p")).toBe("run-sa@p.iam.gserviceaccount.com");
|
|
163
|
+
expect(serviceAccountEmail(undefined, "p")).toBeUndefined();
|
|
164
|
+
expect(() => serviceAccountEmail({ name: "run-sa" })).toThrow(/needs a project/);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("RunService plan sends the service account email on create and update", () => {
|
|
168
|
+
const resource = {
|
|
169
|
+
...RUN_SERVICE,
|
|
170
|
+
spec: { ...(RUN_SERVICE.spec as object), template: { containers: [{ image: "i" }], serviceAccountRef: { name: "run-sa" } } },
|
|
171
|
+
};
|
|
172
|
+
const plan = cloudRunServiceMapper.plan(resource, { base: "http://x", project: "p" });
|
|
173
|
+
expect((plan.create.body as { template: { serviceAccount: string } }).template.serviceAccount).toBe("run-sa@p.iam.gserviceaccount.com");
|
|
174
|
+
});
|
|
175
|
+
|
|
139
176
|
test("RunService plan → POST v2 services?serviceId, async operation present", () => {
|
|
140
177
|
const plan = cloudRunServiceMapper.plan(RUN_SERVICE, { base: "http://x", project: "p" });
|
|
141
178
|
expect(plan.getUrl).toBe("http://x/v2/projects/p/locations/us-central1/services/hello-svc");
|
|
@@ -234,6 +271,22 @@ describe("resolveGcpProject (#711)", () => {
|
|
|
234
271
|
expect(() => resolveGcpProject({ metadata: { name: "b" } }, {} as NodeJS.ProcessEnv))
|
|
235
272
|
.toThrow(/no GCP project/);
|
|
236
273
|
});
|
|
274
|
+
|
|
275
|
+
test("spec.projectRef.external wins over env and the annotation (#4201)", () => {
|
|
276
|
+
const run = { ...BUCKET, spec: { projectRef: { external: "ref-project" } } };
|
|
277
|
+
expect(resolveGcpProject(run, { GOOGLE_CLOUD_PROJECT: "env-project" } as NodeJS.ProcessEnv)).toBe("ref-project");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("projectRef.external may be projects/<id>", () => {
|
|
281
|
+
const run = { metadata: { name: "r" }, spec: { projectRef: { external: "projects/three-cloud" } } };
|
|
282
|
+
expect(resolveGcpProject(run, {} as NodeJS.ProcessEnv)).toBe("three-cloud");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("an unresolved GCP.ProjectId in projectRef reads GCP_PROJECT_ID, else falls through", () => {
|
|
286
|
+
const run = { ...BUCKET, spec: { projectRef: { external: { Ref: "GCP::ProjectId" } } } };
|
|
287
|
+
expect(resolveGcpProject(run, { GCP_PROJECT_ID: "pseudo-project" } as NodeJS.ProcessEnv)).toBe("pseudo-project");
|
|
288
|
+
expect(resolveGcpProject(run, {} as NodeJS.ProcessEnv)).toBe("annotated-project");
|
|
289
|
+
});
|
|
237
290
|
});
|
|
238
291
|
|
|
239
292
|
describe("resolveGcpEndpoint (#1449)", () => {
|
|
@@ -3,6 +3,15 @@ import { parseYAML } from "@intentius/chant/yaml";
|
|
|
3
3
|
import { sleep } from "@intentius/chant/op";
|
|
4
4
|
import { hasOwnershipMarker, OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
|
|
5
5
|
import { GCP_RESOURCE_OWNERSHIP_KEYS } from "../../ownership";
|
|
6
|
+
import { iamPolicyMemberMapper } from "./gcp-iam";
|
|
7
|
+
import {
|
|
8
|
+
backendServiceMapper,
|
|
9
|
+
computeAddressMapper,
|
|
10
|
+
forwardingRuleMapper,
|
|
11
|
+
regionNetworkEndpointGroupMapper,
|
|
12
|
+
targetHttpProxyMapper,
|
|
13
|
+
urlMapMapper,
|
|
14
|
+
} from "./gcp-compute";
|
|
6
15
|
import {
|
|
7
16
|
applyResult,
|
|
8
17
|
type ApplyResult,
|
|
@@ -120,6 +129,29 @@ export interface ListSpec {
|
|
|
120
129
|
items(responseBody: unknown): Array<{ name: string; labels?: Record<string, string> | null }>;
|
|
121
130
|
}
|
|
122
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Where a mapper works: the API base, the project, and the rest of the applied
|
|
134
|
+
* manifest, so a `*Ref` to a resource declared alongside can take that
|
|
135
|
+
* resource's scope. The read path has no manifest.
|
|
136
|
+
*/
|
|
137
|
+
export interface MapperCtx {
|
|
138
|
+
base: string;
|
|
139
|
+
project: string;
|
|
140
|
+
manifest?: readonly GcpResource[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A kind whose apply is not GET-then-create (#4233). IAMPolicyMember is a
|
|
145
|
+
* member inside another resource's policy, so it reads, merges and writes that
|
|
146
|
+
* policy instead. `read` answers like a GET: 200 with a body when present, 404
|
|
147
|
+
* when not.
|
|
148
|
+
*/
|
|
149
|
+
export interface CustomOps {
|
|
150
|
+
apply(resource: GcpResource, ctx: MapperCtx, http: GcpHttp, signal?: AbortSignal): Promise<{ created: boolean; updated: boolean }>;
|
|
151
|
+
delete(resource: GcpResource, ctx: MapperCtx, http: GcpHttp, signal?: AbortSignal): Promise<boolean>;
|
|
152
|
+
read(resource: GcpResource, ctx: MapperCtx, http: GcpHttp, signal?: AbortSignal): Promise<{ status: number; text: string }>;
|
|
153
|
+
}
|
|
154
|
+
|
|
123
155
|
/** Maps one CNRM kind to its GCP REST operations. The unit of the dispatch table. */
|
|
124
156
|
export interface ResourceMapper {
|
|
125
157
|
/** CNRM kind this handles. */
|
|
@@ -127,7 +159,11 @@ export interface ResourceMapper {
|
|
|
127
159
|
/** Real-GCP API host, used when no `endpoint` override is given. */
|
|
128
160
|
defaultHost: string;
|
|
129
161
|
/** Build the GET + create plan for one resource against `base`. */
|
|
130
|
-
plan(resource: GcpResource, ctx:
|
|
162
|
+
plan(resource: GcpResource, ctx: MapperCtx): ApplyPlan;
|
|
163
|
+
/** False for a resource of this kind chant cannot act on (an IAM binding on a target with no policy endpoint): reported, not attempted. */
|
|
164
|
+
accepts?(resource: GcpResource, ctx: MapperCtx): boolean;
|
|
165
|
+
/** Present when apply, delete and read are not the plan's GET-then-create. */
|
|
166
|
+
custom?: CustomOps;
|
|
131
167
|
/** Present for async resources whose create returns a long-running operation. */
|
|
132
168
|
operation?: OperationSpec;
|
|
133
169
|
/** Present when the kind can be pruned (list live → delete chant-owned orphans). */
|
|
@@ -245,14 +281,37 @@ export const pubSubTopicMapper: ResourceMapper = {
|
|
|
245
281
|
},
|
|
246
282
|
};
|
|
247
283
|
|
|
248
|
-
/**
|
|
249
|
-
|
|
250
|
-
|
|
284
|
+
/**
|
|
285
|
+
* Map a CNRM RunService to a Cloud Run v2 Service body. Pure. CNRM names the
|
|
286
|
+
* service account with `template.serviceAccountRef`; v2 takes
|
|
287
|
+
* `template.serviceAccount` as an email (#4240).
|
|
288
|
+
*/
|
|
289
|
+
export function cloudRunServiceBody(resource: GcpResource, project?: string): { template?: unknown } {
|
|
290
|
+
const spec = (resource.spec ?? {}) as { template?: Record<string, unknown> };
|
|
251
291
|
const body: { template?: unknown } = {};
|
|
252
|
-
if (spec.template) body
|
|
292
|
+
if (!spec.template) return body;
|
|
293
|
+
const { serviceAccountRef, ...template } = spec.template as { serviceAccountRef?: { name?: string; external?: string } };
|
|
294
|
+
const email = serviceAccountEmail(serviceAccountRef, project);
|
|
295
|
+
body.template = email ? { ...template, serviceAccount: email } : template;
|
|
253
296
|
return body;
|
|
254
297
|
}
|
|
255
298
|
|
|
299
|
+
/**
|
|
300
|
+
* The email a CNRM `serviceAccountRef` names: `external` as given (an email,
|
|
301
|
+
* or a `projects/<p>/serviceAccounts/<email>` path), `name` as an account id
|
|
302
|
+
* in the project.
|
|
303
|
+
*/
|
|
304
|
+
export function serviceAccountEmail(ref: { name?: string; external?: string } | undefined, project?: string): string | undefined {
|
|
305
|
+
if (!ref) return undefined;
|
|
306
|
+
if (ref.external) return ref.external.split("/").pop();
|
|
307
|
+
if (ref.name) {
|
|
308
|
+
if (ref.name.includes("@")) return ref.name;
|
|
309
|
+
if (!project) throw new Error(`serviceAccountRef { name: "${ref.name}" } needs a project to form the account's email`);
|
|
310
|
+
return `${ref.name}@${project}.iam.gserviceaccount.com`;
|
|
311
|
+
}
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
|
|
256
315
|
export const cloudRunServiceMapper: ResourceMapper = {
|
|
257
316
|
kind: "RunService",
|
|
258
317
|
defaultHost: "https://run.googleapis.com",
|
|
@@ -269,11 +328,11 @@ export const cloudRunServiceMapper: ResourceMapper = {
|
|
|
269
328
|
create: {
|
|
270
329
|
method: "POST",
|
|
271
330
|
url: `${services}?serviceId=${encodeURIComponent(name)}`,
|
|
272
|
-
body: cloudRunServiceBody(resource),
|
|
331
|
+
body: cloudRunServiceBody(resource, project),
|
|
273
332
|
},
|
|
274
333
|
// Cloud Run reconciles with a PATCH of the service; like create it returns
|
|
275
334
|
// a long-running operation, polled via the mapper's `operation`.
|
|
276
|
-
update: { method: "PATCH", url, body: cloudRunServiceBody(resource) },
|
|
335
|
+
update: { method: "PATCH", url, body: cloudRunServiceBody(resource, project) },
|
|
277
336
|
};
|
|
278
337
|
},
|
|
279
338
|
};
|
|
@@ -368,6 +427,14 @@ export const MAPPERS: Record<string, ResourceMapper> = {
|
|
|
368
427
|
SecretManagerSecret: secretManagerSecretMapper,
|
|
369
428
|
IAMServiceAccount: gcpServiceAccountMapper,
|
|
370
429
|
RunService: cloudRunServiceMapper,
|
|
430
|
+
// #4233: a binding member, and the Cloud Run load balancer.
|
|
431
|
+
IAMPolicyMember: iamPolicyMemberMapper,
|
|
432
|
+
ComputeAddress: computeAddressMapper,
|
|
433
|
+
ComputeRegionNetworkEndpointGroup: regionNetworkEndpointGroupMapper,
|
|
434
|
+
ComputeBackendService: backendServiceMapper,
|
|
435
|
+
ComputeURLMap: urlMapMapper,
|
|
436
|
+
ComputeTargetHTTPProxy: targetHttpProxyMapper,
|
|
437
|
+
ComputeForwardingRule: forwardingRuleMapper,
|
|
371
438
|
};
|
|
372
439
|
|
|
373
440
|
/**
|
|
@@ -376,7 +443,29 @@ export const MAPPERS: Record<string, ResourceMapper> = {
|
|
|
376
443
|
* refs point outside the manifest and are ignored for ordering. Pure.
|
|
377
444
|
*/
|
|
378
445
|
export function referencedNames(resource: GcpResource): string[] {
|
|
379
|
-
|
|
446
|
+
return [...new Set(referencedResources(resource).map((r) => r.name))];
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* The CNRM kind a `*Ref` key points at, where the key alone says. A ref that
|
|
451
|
+
* carries its own `kind` (`resourceRef`) uses that. Two resources of different
|
|
452
|
+
* kinds may share a name (a RunService `web` and its NEG `web`), so ordering
|
|
453
|
+
* matches on kind when it can.
|
|
454
|
+
*/
|
|
455
|
+
const REF_KINDS: Record<string, string> = {
|
|
456
|
+
topicRef: "PubSubTopic",
|
|
457
|
+
serviceRef: "RunService",
|
|
458
|
+
serviceAccountRef: "IAMServiceAccount",
|
|
459
|
+
addressRef: "ComputeAddress",
|
|
460
|
+
networkEndpointGroupRef: "ComputeRegionNetworkEndpointGroup",
|
|
461
|
+
backendServiceRef: "ComputeBackendService",
|
|
462
|
+
urlMapRef: "ComputeURLMap",
|
|
463
|
+
targetHTTPProxyRef: "ComputeTargetHTTPProxy",
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
/** The local resources this resource references via `*Ref` fields, each with its kind when known. Pure. */
|
|
467
|
+
export function referencedResources(resource: GcpResource): Array<{ name: string; kind?: string }> {
|
|
468
|
+
const refs: Array<{ name: string; kind?: string }> = [];
|
|
380
469
|
const visit = (v: unknown, key?: string): void => {
|
|
381
470
|
if (Array.isArray(v)) {
|
|
382
471
|
for (const x of v) visit(x, key);
|
|
@@ -385,13 +474,14 @@ export function referencedNames(resource: GcpResource): string[] {
|
|
|
385
474
|
if (v && typeof v === "object") {
|
|
386
475
|
const obj = v as Record<string, unknown>;
|
|
387
476
|
if (key && /Refs?$/.test(key) && typeof obj.name === "string") {
|
|
388
|
-
|
|
477
|
+
const kind = typeof obj.kind === "string" ? obj.kind : REF_KINDS[key];
|
|
478
|
+
refs.push(kind ? { name: obj.name, kind } : { name: obj.name });
|
|
389
479
|
}
|
|
390
480
|
for (const [k, val] of Object.entries(obj)) visit(val, k);
|
|
391
481
|
}
|
|
392
482
|
};
|
|
393
483
|
visit(resource.spec);
|
|
394
|
-
return
|
|
484
|
+
return refs;
|
|
395
485
|
}
|
|
396
486
|
|
|
397
487
|
/**
|
|
@@ -401,10 +491,10 @@ export function referencedNames(resource: GcpResource): string[] {
|
|
|
401
491
|
* Pure — a DFS post-order.
|
|
402
492
|
*/
|
|
403
493
|
export function orderByReferences(resources: GcpResource[]): GcpResource[] {
|
|
404
|
-
const byName = new Map<string, GcpResource>();
|
|
494
|
+
const byName = new Map<string, GcpResource[]>();
|
|
405
495
|
for (const r of resources) {
|
|
406
496
|
const n = r.metadata?.name;
|
|
407
|
-
if (n) byName.set(n, r);
|
|
497
|
+
if (n) byName.set(n, [...(byName.get(n) ?? []), r]);
|
|
408
498
|
}
|
|
409
499
|
const ordered: GcpResource[] = [];
|
|
410
500
|
const done = new Set<GcpResource>();
|
|
@@ -413,9 +503,10 @@ export function orderByReferences(resources: GcpResource[]): GcpResource[] {
|
|
|
413
503
|
if (done.has(r)) return;
|
|
414
504
|
if (active.has(r)) throw new Error(`reference cycle involving ${r.metadata?.name ?? "?"}`);
|
|
415
505
|
active.add(r);
|
|
416
|
-
for (const ref of
|
|
417
|
-
const
|
|
418
|
-
|
|
506
|
+
for (const ref of referencedResources(r)) {
|
|
507
|
+
const named = (byName.get(ref.name) ?? []).filter((d) => d !== r);
|
|
508
|
+
const deps = ref.kind ? named.filter((d) => d.kind === ref.kind) : named;
|
|
509
|
+
for (const dep of deps) visit(dep);
|
|
419
510
|
}
|
|
420
511
|
active.delete(r);
|
|
421
512
|
done.add(r);
|
|
@@ -443,11 +534,34 @@ export function resolveGcpEndpoint(
|
|
|
443
534
|
}
|
|
444
535
|
|
|
445
536
|
/** Resolve the project: `GOOGLE_CLOUD_PROJECT` env, else the CNRM project-id annotation. Pure. */
|
|
537
|
+
/**
|
|
538
|
+
* The project a resource names in `spec.projectRef.external` (#4201): a
|
|
539
|
+
* project id or `projects/<id>`. A `GCP.ProjectId` pseudo-parameter there
|
|
540
|
+
* (`{ Ref: "GCP::ProjectId" }`, unresolved in discovery output) reads
|
|
541
|
+
* `GCP_PROJECT_ID`, as the serializer does.
|
|
542
|
+
*/
|
|
543
|
+
export function projectRefProject(spec: Record<string, unknown> | undefined, env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
544
|
+
const ref = spec?.projectRef;
|
|
545
|
+
if (ref === null || typeof ref !== "object") return undefined;
|
|
546
|
+
let external = (ref as Record<string, unknown>).external;
|
|
547
|
+
if (external !== null && typeof external === "object" && typeof (external as { toJSON?: unknown }).toJSON === "function") {
|
|
548
|
+
external = (external as { toJSON(): unknown }).toJSON();
|
|
549
|
+
}
|
|
550
|
+
if (external !== null && typeof external === "object" && (external as Record<string, unknown>).Ref === "GCP::ProjectId") {
|
|
551
|
+
return env.GCP_PROJECT_ID || undefined;
|
|
552
|
+
}
|
|
553
|
+
if (typeof external !== "string" || external === "") return undefined;
|
|
554
|
+
return external.startsWith("projects/") ? external.slice("projects/".length).split("/")[0] : external;
|
|
555
|
+
}
|
|
556
|
+
|
|
446
557
|
export function resolveGcpProject(resource: GcpResource, env: NodeJS.ProcessEnv = process.env): string {
|
|
447
|
-
const project =
|
|
558
|
+
const project =
|
|
559
|
+
projectRefProject(resource.spec, env) ||
|
|
560
|
+
env.GOOGLE_CLOUD_PROJECT ||
|
|
561
|
+
resource.metadata?.annotations?.[PROJECT_ID_ANNOTATION];
|
|
448
562
|
if (!project) {
|
|
449
563
|
throw new Error(
|
|
450
|
-
`no GCP project — set GOOGLE_CLOUD_PROJECT or the ${PROJECT_ID_ANNOTATION} annotation`,
|
|
564
|
+
`no GCP project — set spec.projectRef.external, GOOGLE_CLOUD_PROJECT or the ${PROJECT_ID_ANNOTATION} annotation`,
|
|
451
565
|
);
|
|
452
566
|
}
|
|
453
567
|
return project;
|
|
@@ -480,12 +594,16 @@ const defaultHttp: GcpHttp = async (method, url, body, signal) => {
|
|
|
480
594
|
export async function applyResource(
|
|
481
595
|
mapper: ResourceMapper,
|
|
482
596
|
resource: GcpResource,
|
|
483
|
-
ctx:
|
|
597
|
+
ctx: MapperCtx,
|
|
484
598
|
http: GcpHttp = defaultHttp,
|
|
485
599
|
signal?: AbortSignal,
|
|
486
600
|
): Promise<{ kind: string; name: string; created: boolean; updated: boolean }> {
|
|
487
|
-
const plan = mapper.plan(resource, ctx);
|
|
488
601
|
const name = resource.metadata?.name ?? "?";
|
|
602
|
+
if (mapper.custom) {
|
|
603
|
+
const result = await mapper.custom.apply(resource, ctx, http, signal);
|
|
604
|
+
return { kind: mapper.kind, name, ...result };
|
|
605
|
+
}
|
|
606
|
+
const plan = mapper.plan(resource, ctx);
|
|
489
607
|
const get = await http("GET", plan.getUrl, undefined, signal);
|
|
490
608
|
|
|
491
609
|
if (get.status === 200) {
|
|
@@ -689,7 +807,7 @@ export interface GcpNotPrunable {
|
|
|
689
807
|
*/
|
|
690
808
|
export async function pruneOrphans(
|
|
691
809
|
desired: GcpResource[],
|
|
692
|
-
resolve: (mapper: ResourceMapper, resource?: GcpResource) =>
|
|
810
|
+
resolve: (mapper: ResourceMapper, resource?: GcpResource) => MapperCtx,
|
|
693
811
|
http: GcpHttp = defaultHttp,
|
|
694
812
|
signal?: AbortSignal,
|
|
695
813
|
): Promise<{
|
|
@@ -757,9 +875,10 @@ export async function gcpApply(
|
|
|
757
875
|
}> {
|
|
758
876
|
const resources = orderByReferences(parseManifest(readFileSync(args.manifestPath, "utf8"), args.manifestPath));
|
|
759
877
|
const endpoint = resolveGcpEndpoint(args);
|
|
760
|
-
const resolve = (mapper: ResourceMapper, resource?: GcpResource) => ({
|
|
878
|
+
const resolve = (mapper: ResourceMapper, resource?: GcpResource): MapperCtx => ({
|
|
761
879
|
base: (endpoint ?? mapper.defaultHost).replace(/\/$/, ""),
|
|
762
880
|
project: args.project ?? (resource ? resolveGcpProject(resource) : ""),
|
|
881
|
+
manifest: resources,
|
|
763
882
|
});
|
|
764
883
|
|
|
765
884
|
const applied: Array<{ kind: string; name: string; created: boolean; updated: boolean }> = [];
|
|
@@ -773,6 +892,11 @@ export async function gcpApply(
|
|
|
773
892
|
continue;
|
|
774
893
|
}
|
|
775
894
|
const ctx = resolve(mapper, r);
|
|
895
|
+
if (mapper.accepts && !mapper.accepts(r, ctx)) {
|
|
896
|
+
console.log(`skip: ${r.kind}/${r.metadata?.name ?? "?"} has nothing chant can apply it to`);
|
|
897
|
+
notAttempted.push({ kind: r.kind as string, name: r.metadata?.name ?? "?", reason: "unsupported-kind" });
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
776
900
|
const result = await applyResource(mapper, r, ctx, http, signal);
|
|
777
901
|
const verb = result.created ? "created" : result.updated ? "updated" : "unchanged";
|
|
778
902
|
console.log(`${verb}: ${result.kind}/${result.name} (${ctx.base})`);
|
|
@@ -793,12 +917,15 @@ export async function gcpApply(
|
|
|
793
917
|
export async function deleteResource(
|
|
794
918
|
mapper: ResourceMapper,
|
|
795
919
|
resource: GcpResource,
|
|
796
|
-
ctx:
|
|
920
|
+
ctx: MapperCtx,
|
|
797
921
|
http: GcpHttp = defaultHttp,
|
|
798
922
|
signal?: AbortSignal,
|
|
799
923
|
): Promise<{ kind: string; name: string; deleted: boolean }> {
|
|
800
|
-
const plan = mapper.plan(resource, ctx);
|
|
801
924
|
const name = resource.metadata?.name ?? "?";
|
|
925
|
+
if (mapper.custom) {
|
|
926
|
+
return { kind: mapper.kind, name, deleted: await mapper.custom.delete(resource, ctx, http, signal) };
|
|
927
|
+
}
|
|
928
|
+
const plan = mapper.plan(resource, ctx);
|
|
802
929
|
const res = await http("DELETE", plan.getUrl, undefined, signal);
|
|
803
930
|
if (res.status === 404) {
|
|
804
931
|
return { kind: mapper.kind, name, deleted: false };
|
|
@@ -847,7 +974,13 @@ export async function gcpDelete(
|
|
|
847
974
|
}
|
|
848
975
|
const base = (resolveGcpEndpoint(args) ?? mapper.defaultHost).replace(/\/$/, "");
|
|
849
976
|
const project = args.project ?? resolveGcpProject(r);
|
|
850
|
-
const
|
|
977
|
+
const ctx: MapperCtx = { base, project, manifest: resources };
|
|
978
|
+
if (mapper.accepts && !mapper.accepts(r, ctx)) {
|
|
979
|
+
console.log(`skip: ${r.kind}/${r.metadata?.name ?? "?"} has nothing chant can delete it from`);
|
|
980
|
+
notAttempted.push({ kind: r.kind as string, name: r.metadata?.name ?? "?", reason: "unsupported-kind" });
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
const result = await deleteResource(mapper, r, ctx, http, signal);
|
|
851
984
|
console.log(`${result.deleted ? "deleted" : "absent"}: ${result.kind}/${result.name} (${base})`);
|
|
852
985
|
deleted.push(result);
|
|
853
986
|
}
|