@intentius/chant-lexicon-gcp 0.124.35 → 0.124.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/api/read-client.d.ts +2 -1
  2. package/dist/api/read-client.d.ts.map +1 -1
  3. package/dist/api/read-client.js +2 -1
  4. package/dist/deep-observe.d.ts.map +1 -1
  5. package/dist/deep-observe.js +56 -11
  6. package/dist/describe-resources.d.ts +20 -6
  7. package/dist/describe-resources.d.ts.map +1 -1
  8. package/dist/describe-resources.js +40 -26
  9. package/dist/health.d.ts.map +1 -1
  10. package/dist/health.js +6 -9
  11. package/dist/integrity.json +2 -2
  12. package/dist/manifest.json +1 -1
  13. package/dist/op/activities/gcp-apply.d.ts +14 -1
  14. package/dist/op/activities/gcp-apply.d.ts.map +1 -1
  15. package/dist/op/activities/gcp-apply.js +49 -8
  16. package/dist/op/activities/gcp-compute.d.ts +10 -0
  17. package/dist/op/activities/gcp-compute.d.ts.map +1 -1
  18. package/dist/op/activities/gcp-compute.js +39 -3
  19. package/dist/op/activities/gcp-iam.d.ts.map +1 -1
  20. package/dist/op/activities/gcp-iam.js +2 -0
  21. package/dist/serializer.d.ts +18 -0
  22. package/dist/serializer.d.ts.map +1 -1
  23. package/dist/serializer.js +72 -16
  24. package/package.json +2 -2
  25. package/src/api/read-client.ts +2 -1
  26. package/src/deep-observe.test.ts +27 -1
  27. package/src/deep-observe.ts +52 -12
  28. package/src/describe-resources.test.ts +6 -5
  29. package/src/describe-resources.ts +43 -24
  30. package/src/health.ts +7 -10
  31. package/src/op/activities/gcp-apply.test.ts +48 -3
  32. package/src/op/activities/gcp-apply.ts +49 -9
  33. package/src/op/activities/gcp-compute.test.ts +94 -0
  34. package/src/op/activities/gcp-compute.ts +37 -2
  35. package/src/op/activities/gcp-iam.ts +2 -0
  36. package/src/project-config.test.ts +119 -0
  37. package/src/read-refs.test.ts +135 -0
  38. package/src/serializer.test.ts +26 -0
  39. package/src/serializer.ts +73 -15
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * Compute Engine mappers for the Cloud Run load balancer (#4233): ComputeAddress,
3
3
  * ComputeRegionNetworkEndpointGroup (serverless NEGs), ComputeBackendService,
4
- * ComputeURLMap, ComputeTargetHTTPProxy and ComputeForwardingRule.
4
+ * ComputeURLMap, ComputeTargetHTTPProxy and ComputeForwardingRule; and for a
5
+ * declared network (#4248): ComputeNetwork (always global) and
6
+ * ComputeSubnetwork (regional, by its CNRM `region`).
5
7
  *
6
8
  * Each maps a CNRM spec onto the compute v1 insert body. A resource is global
7
9
  * or regional by its CNRM `location` (`"global"` or a region; a NEG has
@@ -158,6 +160,38 @@ export function negBody(resource, ctx) {
158
160
  body.subnetwork = refUrl("subnetworkRef", spec.subnetworkRef, ctx, scope);
159
161
  return body;
160
162
  }
163
+ /**
164
+ * ComputeNetwork → networks.insert body. A network is global. CNRM's
165
+ * `autoCreateSubnetworks` defaults to false, where compute's default is true,
166
+ * so it is always sent. Pure.
167
+ */
168
+ export function networkBody(resource) {
169
+ const spec = (resource.spec ?? {});
170
+ const body = {
171
+ name: computeName(resource),
172
+ autoCreateSubnetworks: spec.autoCreateSubnetworks === true,
173
+ ...pick(spec, ["description", "mtu", "networkFirewallPolicyEnforcementOrder"]),
174
+ };
175
+ if (typeof spec.routingMode === "string")
176
+ body.routingConfig = { routingMode: spec.routingMode };
177
+ return body;
178
+ }
179
+ /** ComputeSubnetwork → subnetworks.insert body. CNRM's `secondaryIpRange` and `logConfig` become compute's `secondaryIpRanges` and an enabled `logConfig`. Pure. */
180
+ export function subnetworkBody(resource, ctx) {
181
+ const spec = (resource.spec ?? {});
182
+ const scope = scopeOf(spec);
183
+ const body = {
184
+ name: computeName(resource),
185
+ ...pick(spec, ["ipCidrRange", "description", "privateIpGoogleAccess", "purpose", "role", "stackType"]),
186
+ };
187
+ if (isObj(spec.networkRef))
188
+ body.network = refUrl("networkRef", spec.networkRef, ctx, scope);
189
+ if (Array.isArray(spec.secondaryIpRange))
190
+ body.secondaryIpRanges = spec.secondaryIpRange;
191
+ if (isObj(spec.logConfig))
192
+ body.logConfig = { enable: true, ...spec.logConfig };
193
+ return body;
194
+ }
161
195
  /** ComputeBackendService → backendServices.insert body. Pure. */
162
196
  export function backendServiceBody(resource, ctx) {
163
197
  const spec = (resource.spec ?? {});
@@ -292,13 +326,13 @@ export const computeOperation = {
292
326
  return errors?.length ? errors.map((e) => e.message ?? e.code ?? "error").join("; ") : undefined;
293
327
  },
294
328
  };
295
- function computeMapper(kind, collection, build, regionalCollection = collection) {
329
+ function computeMapper(kind, collection, build, regionalCollection = collection, scopeFor = (spec) => scopeOf(spec)) {
296
330
  return {
297
331
  kind,
298
332
  defaultHost: COMPUTE_HOST,
299
333
  operation: computeOperation,
300
334
  plan(resource, ctx) {
301
- const scope = scopeOf(resource.spec);
335
+ const scope = scopeFor(resource.spec);
302
336
  const coll = `${ctx.base}/compute/v1/${scopePath(ctx.project, scope)}/${scope === "global" ? collection : regionalCollection}`;
303
337
  const name = computeName(resource);
304
338
  return { getUrl: `${coll}/${enc(name)}`, create: { method: "POST", url: coll, body: build(resource, ctx) } };
@@ -311,3 +345,5 @@ export const backendServiceMapper = computeMapper("ComputeBackendService", "back
311
345
  export const urlMapMapper = computeMapper("ComputeURLMap", "urlMaps", urlMapBody);
312
346
  export const targetHttpProxyMapper = computeMapper("ComputeTargetHTTPProxy", "targetHttpProxies", targetHttpProxyBody);
313
347
  export const forwardingRuleMapper = computeMapper("ComputeForwardingRule", "forwardingRules", forwardingRuleBody);
348
+ export const networkMapper = computeMapper("ComputeNetwork", "networks", networkBody, "networks", () => "global");
349
+ export const subnetworkMapper = computeMapper("ComputeSubnetwork", "subnetworks", subnetworkBody);
@@ -1 +1 @@
1
- {"version":3,"file":"gcp-iam.d.ts","sourceRoot":"","sources":["../../../src/op/activities/gcp-iam.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,KAAK,EAAW,WAAW,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAExE,iDAAiD;AACjD,MAAM,WAAW,mBAAmB;IAClC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACvF,SAAS,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC1E,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,UAAU,OAAO;IACf,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,SAAS,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CAC3E;AAED,UAAU,MAAM;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,OAAO,EAAE,CAAC;CACtB;AAED,mDAAmD;AACnD,MAAM,WAAW,SAAS;IACxB,8CAA8C;IAC9C,IAAI,EAAE,MAAM,CAAC;IACb,yEAAyE;IACzE,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK;QAAE,MAAM,EAAE,KAAK,GAAG,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;IAC/E,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK;QAAE,MAAM,EAAE,MAAM,GAAG,KAAK,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,CAAA;KAAE,CAAC;CAC/F;AAED,gHAAgH;AAChH,eAAO,MAAM,gBAAgB,+BAA+B,CAAC;AAqB7D;;;;GAIG;AACH,wBAAgB,SAAS,CACvB,IAAI,EAAE,mBAAmB,EACzB,OAAO,EAAE,MAAM,EACf,QAAQ,GAAE,SAAS,WAAW,EAAO,EACrC,GAAG,GAAE,MAAM,CAAC,UAAwB,GACnC,SAAS,GAAG,SAAS,CAgDvB;AAYD,uFAAuF;AACvF,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,mBAAmB,GAAG,OAAO,CAElF;AAED,8FAA8F;AAC9F,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,mBAAmB,GAAG,MAAM,CAY5E;AAED,kGAAkG;AAClG,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,mBAAmB,GAAG,MAAM,CAS/E;AAkED,eAAO,MAAM,qBAAqB,EAAE,cA8CnC,CAAC"}
1
+ {"version":3,"file":"gcp-iam.d.ts","sourceRoot":"","sources":["../../../src/op/activities/gcp-iam.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,KAAK,EAAW,WAAW,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAExE,iDAAiD;AACjD,MAAM,WAAW,mBAAmB;IAClC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACvF,SAAS,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC1E,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,UAAU,OAAO;IACf,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,SAAS,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CAC3E;AAED,UAAU,MAAM;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,OAAO,EAAE,CAAC;CACtB;AAED,mDAAmD;AACnD,MAAM,WAAW,SAAS;IACxB,8CAA8C;IAC9C,IAAI,EAAE,MAAM,CAAC;IACb,yEAAyE;IACzE,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK;QAAE,MAAM,EAAE,KAAK,GAAG,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;IAC/E,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK;QAAE,MAAM,EAAE,MAAM,GAAG,KAAK,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,CAAA;KAAE,CAAC;CAC/F;AAED,gHAAgH;AAChH,eAAO,MAAM,gBAAgB,+BAA+B,CAAC;AAqB7D;;;;GAIG;AACH,wBAAgB,SAAS,CACvB,IAAI,EAAE,mBAAmB,EACzB,OAAO,EAAE,MAAM,EACf,QAAQ,GAAE,SAAS,WAAW,EAAO,EACrC,GAAG,GAAE,MAAM,CAAC,UAAwB,GACnC,SAAS,GAAG,SAAS,CAgDvB;AAYD,uFAAuF;AACvF,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,mBAAmB,GAAG,OAAO,CAElF;AAED,8FAA8F;AAC9F,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,mBAAmB,GAAG,MAAM,CAY5E;AAED,kGAAkG;AAClG,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,mBAAmB,GAAG,MAAM,CAS/E;AAkED,eAAO,MAAM,qBAAqB,EAAE,cAgDnC,CAAC"}
@@ -202,6 +202,8 @@ export const iamPolicyMemberMapper = {
202
202
  status: 200,
203
203
  // `resourceRef` is the declared one: the policy read is that target's, so the binding is on it.
204
204
  text: JSON.stringify({
205
+ // The binding has no id of its own; this one names it, so a read reports a physical id (#4251).
206
+ selfLink: `${target.path}#${spec.role}#${spec.member}`,
205
207
  member: spec.member,
206
208
  role: spec.role,
207
209
  resource: target.path,
@@ -4,6 +4,7 @@
4
4
  * Converts Chant declarables to multi-document K8s YAML output
5
5
  * for Config Connector resources with apiVersion, kind, metadata, and spec.
6
6
  */
7
+ import type { Declarable } from "@intentius/chant/declarable";
7
8
  import type { Serializer } from "@intentius/chant/serializer";
8
9
  /**
9
10
  * Resolve entityType to apiVersion and kind.
@@ -17,8 +18,25 @@ export declare function resolveGVK(entityType: string): {
17
18
  apiVersion: string;
18
19
  kind: string;
19
20
  } | null;
21
+ /**
22
+ * The metadata.name a resource is written with: a string `metadata.name` in its
23
+ * props, else its logical id in kebab case.
24
+ */
25
+ export declare function cnrmName(logical: string, entity: Declarable): string;
26
+ /** {@link cnrmName} from a resource's props, for callers holding props rather than the declarable. */
27
+ export declare function cnrmNameOf(logical: string, props: unknown): string;
28
+ /**
29
+ * Declared props with each reference to another resource replaced as the
30
+ * serializer writes it (#4251): an attribute reference (`neg.name`) becomes the
31
+ * target's metadata.name, and a resource placed whole becomes `{ name }`.
32
+ * `logicalOf` maps a target's props object to its logical id, for a reference
33
+ * that does not carry one. Other values, pseudo-parameters included, are kept.
34
+ */
35
+ export declare function resolveCnrmRefs(value: unknown, logicalOf: (props: unknown) => string | undefined): unknown;
20
36
  /**
21
37
  * GCP Config Connector YAML serializer implementation.
22
38
  */
23
39
  export declare const gcpSerializer: Serializer;
40
+ /** The `gcp.project` a chant.config declares, if any (#4252). */
41
+ export declare function gcpConfigProject(config: Record<string, unknown> | undefined): string | undefined;
24
42
  //# sourceMappingURL=serializer.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAMH,OAAO,KAAK,EAAE,UAAU,EAAsC,MAAM,6BAA6B,CAAC;AAiClG;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,UAAU,EAAE,MAAM,GAAG;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAW1F;AAsED;;GAEG;AACH,eAAO,MAAM,aAAa,EAAE,UAiG3B,CAAC"}
1
+ {"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAG9D,OAAO,KAAK,EAAE,UAAU,EAAsC,MAAM,6BAA6B,CAAC;AAiClG;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,UAAU,EAAE,MAAM,GAAG;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAW1F;AA+CD;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,GAAG,MAAM,CAEpE;AAED,sGAAsG;AACtG,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,MAAM,CAGlE;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,MAAM,GAAG,SAAS,GAAG,OAAO,CAkB1G;AA4BD;;GAEG;AACH,eAAO,MAAM,aAAa,EAAE,UAsG3B,CAAC;AAIF,iEAAiE;AACjE,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAGhG"}
@@ -6,7 +6,8 @@
6
6
  */
7
7
  import { createRequire } from "module";
8
8
  import { moduleSourceUrl } from "@intentius/chant/module-source";
9
- import { isPropertyDeclarable, isResourceDeclarable } from "@intentius/chant/declarable";
9
+ import { isDeclarable, isPropertyDeclarable, isResourceDeclarable } from "@intentius/chant/declarable";
10
+ import { AttrRef } from "@intentius/chant/attrref";
10
11
  import { ownershipEntries, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
11
12
  import { walkValue } from "@intentius/chant/serializer-walker";
12
13
  import { emitYAML } from "@intentius/chant/yaml";
@@ -62,15 +63,15 @@ function deriveGVKFromType(entityType) {
62
63
  * GCP visitor for the generic serializer walker.
63
64
  */
64
65
  function gcpVisitor(entityNames) {
66
+ // Config Connector resolves a `*Ref: { name }` by the target's metadata.name,
67
+ // which is its declared name or else the kebab-cased logical id (#4242).
68
+ const cnrmNames = new Map();
69
+ for (const [entity, logical] of entityNames)
70
+ cnrmNames.set(logical, cnrmName(logical, entity));
71
+ const resolve = (logical) => cnrmNames.get(logical) ?? logical;
65
72
  return {
66
- attrRef: (name, _attr) => {
67
- // For Config Connector, references resolve to metadata.name
68
- return name;
69
- },
70
- resourceRef: (name) => {
71
- // Config Connector uses resourceRef pattern: { name: "resource-name" }
72
- return { name };
73
- },
73
+ attrRef: (name, _attr) => resolve(name),
74
+ resourceRef: (name) => ({ name: resolve(name) }),
74
75
  propertyDeclarable: (entity, walk) => {
75
76
  if (!isResourceDeclarable(entity) || typeof entity.props !== "object" || entity.props === null) {
76
77
  return undefined;
@@ -86,6 +87,51 @@ function gcpVisitor(entityNames) {
86
87
  },
87
88
  };
88
89
  }
90
+ /**
91
+ * The metadata.name a resource is written with: a string `metadata.name` in its
92
+ * props, else its logical id in kebab case.
93
+ */
94
+ export function cnrmName(logical, entity) {
95
+ return cnrmNameOf(logical, isResourceDeclarable(entity) ? entity.props : undefined);
96
+ }
97
+ /** {@link cnrmName} from a resource's props, for callers holding props rather than the declarable. */
98
+ export function cnrmNameOf(logical, props) {
99
+ const declared = props?.metadata?.name;
100
+ return typeof declared === "string" && declared.length > 0 ? declared : kebabName(logical);
101
+ }
102
+ /**
103
+ * Declared props with each reference to another resource replaced as the
104
+ * serializer writes it (#4251): an attribute reference (`neg.name`) becomes the
105
+ * target's metadata.name, and a resource placed whole becomes `{ name }`.
106
+ * `logicalOf` maps a target's props object to its logical id, for a reference
107
+ * that does not carry one. Other values, pseudo-parameters included, are kept.
108
+ */
109
+ export function resolveCnrmRefs(value, logicalOf) {
110
+ if (value instanceof AttrRef) {
111
+ const parent = value.parent.deref();
112
+ const props = parent && isResourceDeclarable(parent) ? parent.props : undefined;
113
+ const logical = value.getLogicalName() ?? logicalOf(props);
114
+ return logical === undefined ? value : cnrmNameOf(logical, props);
115
+ }
116
+ if (Array.isArray(value))
117
+ return value.map((v) => resolveCnrmRefs(v, logicalOf));
118
+ if (value === null || typeof value !== "object")
119
+ return value;
120
+ if (isDeclarable(value)) {
121
+ const props = isResourceDeclarable(value) ? value.props : undefined;
122
+ const logical = logicalOf(props);
123
+ return logical === undefined ? value : { name: cnrmNameOf(logical, props) };
124
+ }
125
+ if (INTRINSIC_MARKER in value)
126
+ return value;
127
+ const out = {};
128
+ for (const [k, v] of Object.entries(value))
129
+ out[k] = resolveCnrmRefs(v, logicalOf);
130
+ return out;
131
+ }
132
+ function kebabName(logical) {
133
+ return logical.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
134
+ }
89
135
  /**
90
136
  * Convert a value to YAML-compatible form using the walker.
91
137
  */
@@ -121,7 +167,11 @@ export const gcpSerializer = {
121
167
  let defaultLabelEntries = context?.ownership
122
168
  ? { ...ownershipEntries(LABEL_OWNERSHIP_KEYS, context.ownership) }
123
169
  : {};
124
- let defaultAnnotationEntries = {};
170
+ // #4252: a member's project, once, in chant.config's `gcp.project`. It
171
+ // resolves GCP.ProjectId and is written as each resource's project-id
172
+ // annotation, which apply and the readers take ahead of the env vars.
173
+ const configProject = gcpConfigProject(context?.config);
174
+ let defaultAnnotationEntries = configProject ? { [PROJECT_ID_ANNOTATION]: configProject } : {};
125
175
  for (const [, entity] of entities) {
126
176
  if (isDefaultLabels(entity)) {
127
177
  defaultLabelEntries = { ...defaultLabelEntries, ...entity.labels };
@@ -141,7 +191,7 @@ export const gcpSerializer = {
141
191
  const gvk = resolveGVK(entityType);
142
192
  if (!gvk)
143
193
  continue;
144
- const props = resolvePseudoParameters(toYAMLValue(isResourceDeclarable(entity) ? entity.props : undefined, entityNames), name, unresolved);
194
+ const props = resolvePseudoParameters(toYAMLValue(isResourceDeclarable(entity) ? entity.props : undefined, entityNames), name, unresolved, configProject);
145
195
  if (!props)
146
196
  continue;
147
197
  // Build the Config Connector manifest structure
@@ -152,7 +202,7 @@ export const gcpSerializer = {
152
202
  // Build metadata
153
203
  const metadata = props.metadata ?? {};
154
204
  if (!metadata.name) {
155
- metadata.name = name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
205
+ metadata.name = kebabName(name);
156
206
  }
157
207
  // Merge default labels
158
208
  if (Object.keys(defaultLabelEntries).length > 0) {
@@ -189,6 +239,12 @@ export const gcpSerializer = {
189
239
  return warnings.length > 0 ? { primary, warnings } : primary;
190
240
  },
191
241
  };
242
+ const PROJECT_ID_ANNOTATION = "cnrm.cloud.google.com/project-id";
243
+ /** The `gcp.project` a chant.config declares, if any (#4252). */
244
+ export function gcpConfigProject(config) {
245
+ const project = config?.gcp?.project;
246
+ return typeof project === "string" && project.length > 0 ? project : undefined;
247
+ }
192
248
  /**
193
249
  * Pseudo-parameter → environment variable mapping.
194
250
  * Config Connector annotations must be plain strings, so PseudoParameters
@@ -206,16 +262,16 @@ const PSEUDO_ENV_MAP = {
206
262
  * the API as an object. A variable that isn't set is recorded, and the
207
263
  * build refuses with every one named.
208
264
  */
209
- function resolvePseudoParameters(value, entity, unresolved) {
265
+ function resolvePseudoParameters(value, entity, unresolved, project) {
210
266
  if (Array.isArray(value))
211
- return value.map((v) => resolvePseudoParameters(v, entity, unresolved));
267
+ return value.map((v) => resolvePseudoParameters(v, entity, unresolved, project));
212
268
  if (value === null || typeof value !== "object")
213
269
  return value;
214
270
  const obj = value;
215
271
  const keys = Object.keys(obj);
216
272
  if (keys.length === 1 && keys[0] === "Ref" && typeof obj.Ref === "string" && obj.Ref in PSEUDO_ENV_MAP) {
217
273
  const { envVar } = PSEUDO_ENV_MAP[obj.Ref];
218
- const resolved = process.env[envVar];
274
+ const resolved = (obj.Ref === "GCP::ProjectId" ? project : undefined) ?? process.env[envVar];
219
275
  if (resolved)
220
276
  return resolved;
221
277
  unresolved.push({ entity, ref: obj.Ref, envVar });
@@ -223,7 +279,7 @@ function resolvePseudoParameters(value, entity, unresolved) {
223
279
  }
224
280
  const out = {};
225
281
  for (const [k, v] of Object.entries(obj))
226
- out[k] = resolvePseudoParameters(v, entity, unresolved);
282
+ out[k] = resolvePseudoParameters(v, entity, unresolved, project);
227
283
  return out;
228
284
  }
229
285
  function unresolvedMessage(unresolved) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-gcp",
3
- "version": "0.124.35",
3
+ "version": "0.124.37",
4
4
  "description": "Google Cloud lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -76,6 +76,6 @@
76
76
  "typescript": "^5.9.3"
77
77
  },
78
78
  "peerDependencies": {
79
- "@intentius/chant": "^0.124.35"
79
+ "@intentius/chant": "^0.124.37"
80
80
  }
81
81
  }
@@ -24,7 +24,8 @@
24
24
  * PubSubTopic, PubSubSubscription, SecretManagerSecret, IAMServiceAccount,
25
25
  * RunService, IAMPolicyMember and the Cloud Run load balancer kinds
26
26
  * (ComputeAddress, ComputeRegionNetworkEndpointGroup, ComputeBackendService,
27
- * ComputeURLMap, ComputeTargetHTTPProxy, ComputeForwardingRule; #4233). An
27
+ * ComputeURLMap, ComputeTargetHTTPProxy, ComputeForwardingRule; #4233), and
28
+ * ComputeNetwork and ComputeSubnetwork (#4248). An
28
29
  * IAMPolicyMember is read through its target's policy and is present when the
29
30
  * policy grants its member the role. The kubectl path could fetch any CNRM kind the cluster knew
30
31
  * about, so this is narrower on paper. It is not narrower in practice for
@@ -139,6 +139,32 @@ describe("toCnrmTree — each REST shape in the declared vocabulary (#1210)", ()
139
139
  expect(other.template).toEqual({ serviceAccountRef: { external: "x@other.iam.gserviceaccount.com" } });
140
140
  });
141
141
 
142
+ test("run service: vpcAccess network and subnetwork paths map back to refs (#4249)", () => {
143
+ const tree = toCnrmTree(
144
+ "RunService",
145
+ {
146
+ name: "projects/my-project/locations/europe-west1/services/web",
147
+ template: {
148
+ vpcAccess: {
149
+ egress: "PRIVATE_RANGES_ONLY",
150
+ networkInterfaces: [
151
+ { network: "projects/my-project/global/networks/vpc", subnetwork: "projects/my-project/regions/europe-west1/subnetworks/apps" },
152
+ { network: "projects/other/global/networks/shared", subnetwork: "apps" },
153
+ ],
154
+ },
155
+ },
156
+ },
157
+ ctx,
158
+ ) as { template: { vpcAccess: unknown } };
159
+ expect(tree.template.vpcAccess).toEqual({
160
+ egress: "PRIVATE_RANGES_ONLY",
161
+ networkInterfaces: [
162
+ { networkRef: { name: "vpc" }, subnetworkRef: { name: "apps" } },
163
+ { networkRef: { external: "projects/other/global/networks/shared" }, subnetworkRef: { name: "apps" } },
164
+ ],
165
+ });
166
+ });
167
+
142
168
  test("bucket: GCS's iamConfiguration and lifecycle map onto the CNRM field names", () => {
143
169
  const tree = toCnrmTree(
144
170
  "StorageBucket",
@@ -250,7 +276,7 @@ describe("observeResourcesDeepGcp — over REST (#1209)", () => {
250
276
  });
251
277
 
252
278
  test("a kind the applier cannot write is unsupported-kind, and is never queried", async () => {
253
- const out = await observe(entities([["e", "GCP::Compute::Network", { metadata: { name: "n" } }]]));
279
+ const out = await observe(entities([["e", "GCP::Compute::Firewall", { metadata: { name: "n" } }]]));
254
280
  expect(out.unobserved?.e?.reason).toBe("unsupported-kind");
255
281
  expect(fetchMock).not.toHaveBeenCalled();
256
282
  });
@@ -61,7 +61,7 @@ import type {
61
61
  } from "@intentius/chant/lexicon";
62
62
  import { deepObservation, normalizeDeepProperties } from "@intentius/chant/deep-observation";
63
63
  import { hasOwnershipMarker, type ChannelKeys } from "@intentius/chant/ownership";
64
- import { deriveGVK, manifestProjectAnnotations, resolveReadProject } from "./describe-resources";
64
+ import { deriveGVK, manifestProjectAnnotations, readTargets, resolveReadProject } from "./describe-resources";
65
65
  import { getResource, mapperForKind, GcpReadError, isNotFound, type GcpReadClientOptions } from "./api/read-client";
66
66
  import { gcpDeepNormalizationHooks } from "./deep-observe-hooks";
67
67
 
@@ -191,6 +191,24 @@ const CNRM_REFINERS: Record<string, (tree: CnrmTree, ctx: RefineContext) => void
191
191
  template.serviceAccountRef = email.endsWith(suffix) ? { name: email.slice(0, -suffix.length) } : { external: email };
192
192
  delete template.serviceAccount;
193
193
  }
194
+ // #4249: Direct VPC egress paths back to CNRM's refs, `name` for the
195
+ // project's own network and subnetwork, `external` otherwise.
196
+ const vpcAccess = template?.vpcAccess as { networkInterfaces?: unknown } | undefined;
197
+ if (vpcAccess && Array.isArray(vpcAccess.networkInterfaces)) {
198
+ const own = (value: string, pattern: RegExp): { name: string } | { external: string } => {
199
+ if (!value.includes("/")) return { name: value };
200
+ const hit = pattern.exec(value);
201
+ return hit && hit[1] === ctx.project ? { name: hit[2] } : { external: value };
202
+ };
203
+ vpcAccess.networkInterfaces = (vpcAccess.networkInterfaces as Array<Record<string, unknown>>).map((i) => {
204
+ const { network, subnetwork, ...rest } = i;
205
+ return {
206
+ ...rest,
207
+ ...(typeof network === "string" ? { networkRef: own(network, /^projects\/([^/]+)\/global\/networks\/([^/]+)$/) } : {}),
208
+ ...(typeof subnetwork === "string" ? { subnetworkRef: own(subnetwork, /^projects\/([^/]+)\/regions\/[^/]+\/subnetworks\/([^/]+)$/) } : {}),
209
+ };
210
+ });
211
+ }
194
212
  },
195
213
  // #4233: the inverse of gcp-compute.ts's body builders. Compute returns refs
196
214
  // as full URLs and a region as a URL; CNRM writes `*Ref: { name }` and a
@@ -269,6 +287,35 @@ const CNRM_REFINERS: Record<string, (tree: CnrmTree, ctx: RefineContext) => void
269
287
  delete tree.urlMap;
270
288
  }
271
289
  },
290
+ // #4248: a network is global and has no location; compute nests the routing
291
+ // mode and lists the subnetworks it holds, which CNRM declares separately.
292
+ ComputeNetwork(tree) {
293
+ const routing = tree.routingConfig as { routingMode?: unknown } | undefined;
294
+ if (routing) {
295
+ if (routing.routingMode !== undefined) tree.routingMode = routing.routingMode;
296
+ delete tree.routingConfig;
297
+ }
298
+ delete tree.subnetworks;
299
+ delete tree.gatewayIPv4;
300
+ },
301
+ ComputeSubnetwork(tree) {
302
+ if (typeof tree.region === "string") tree.region = lastSegment(tree.region);
303
+ if (typeof tree.network === "string") {
304
+ tree.networkRef = { name: lastSegment(tree.network) };
305
+ delete tree.network;
306
+ }
307
+ if (Array.isArray(tree.secondaryIpRanges)) {
308
+ tree.secondaryIpRange = tree.secondaryIpRanges;
309
+ delete tree.secondaryIpRanges;
310
+ }
311
+ const log = tree.logConfig as { enable?: unknown } | undefined;
312
+ if (log) {
313
+ if (log.enable === false) delete tree.logConfig;
314
+ else delete log.enable;
315
+ }
316
+ delete tree.gatewayAddress;
317
+ delete tree.fingerprint;
318
+ },
272
319
  ComputeForwardingRule(tree) {
273
320
  computeLocation(tree);
274
321
  if (tree.IPProtocol !== undefined) {
@@ -340,8 +387,9 @@ export async function observeResourcesDeepGcp(options: GcpDeepObserveOptions): P
340
387
 
341
388
  const endpoint = process.env.GCP_ENDPOINT_URL;
342
389
  const manifestProjects = manifestProjectAnnotations(options.buildOutput);
390
+ const targets = readTargets(options.entities);
343
391
 
344
- const reads = [...options.entities].map(async ([entityName, { entityType, props }]) => {
392
+ const reads = [...options.entities].map(async ([entityName, { entityType }]) => {
345
393
  const gvk = deriveGVK(entityType);
346
394
  if (!gvk || !mapperForKind(gvk.kind)) {
347
395
  unobserved[entityName] = {
@@ -354,16 +402,8 @@ export async function observeResourcesDeepGcp(options: GcpDeepObserveOptions): P
354
402
  return;
355
403
  }
356
404
 
357
- const metadata = props.metadata as { name?: string; annotations?: Record<string, string> } | undefined;
358
- const name = metadata?.name;
359
- if (!name) {
360
- unobserved[entityName] = {
361
- type: entityType,
362
- reason: "read-failed",
363
- detail: "declared entity has no metadata.name to query by",
364
- };
365
- return;
366
- }
405
+ const { name, props } = targets.get(entityName)!;
406
+ const metadata = props.metadata as { annotations?: Record<string, string> } | undefined;
367
407
 
368
408
  let client: GcpReadClientOptions;
369
409
  try {
@@ -80,16 +80,17 @@ 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::Network", props: { metadata: { name: "net" } } }]),
83
+ makeEntities([{ name: "e", entityType: "GCP::Compute::Firewall", props: { metadata: { name: "net" } } }]),
84
84
  );
85
85
  expect(out.unobserved?.e?.reason).toBe("unsupported-kind");
86
86
  expect(fetchMock).not.toHaveBeenCalled();
87
87
  });
88
88
 
89
- test("an entity with no metadata.name has nothing to query by", async () => {
90
- const out = await read(makeEntities([{ name: "e", entityType: "GCP::Storage::Bucket", props: {} }]));
91
- expect(out.unobserved?.e?.reason).toBe("read-failed");
92
- expect(fetchMock).not.toHaveBeenCalled();
89
+ test("an entity with no metadata.name is read by the name the build gives it, the kebab-cased logical id (#4251)", async () => {
90
+ fetchMock.mockResolvedValue(reply(200, { name: "web-assets" }));
91
+ const out = await read(makeEntities([{ name: "webAssets", entityType: "GCP::Storage::Bucket", props: {} }]));
92
+ expect(out.resources.webAssets).toBeDefined();
93
+ expect(String(fetchMock.mock.calls[0][0])).toContain("/b/web-assets");
93
94
  });
94
95
 
95
96
  test("kinds resolve through the serializer's GVK map, not naive recasing", async () => {
@@ -40,8 +40,8 @@ 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, projectRefProject } from "./op/activities/gcp-apply";
44
- import { resolveGVK } from "./serializer";
43
+ import { NO_PROJECT, parseManifest, projectRefProject } from "./op/activities/gcp-apply";
44
+ import { cnrmNameOf, resolveCnrmRefs, resolveGVK } from "./serializer";
45
45
 
46
46
  /**
47
47
  * chant's ownership marker as GCP labels.
@@ -119,11 +119,12 @@ export function manifestProjectAnnotations(buildOutput: string | undefined): Map
119
119
  }
120
120
 
121
121
  /**
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
125
- * manifest's merged annotation. Throws the applier's own error when nothing
126
- * resolves, so the `no-binding` detail names both knobs.
122
+ * The project a read should target, in apply's order (#4252): the entity's
123
+ * own literal `projectRef.external`, then the built manifest's project for it
124
+ * (its resolved projectRef, or the project-id annotation chant.config's
125
+ * `gcp.project` writes), then the entity's own annotation, then
126
+ * `GCP_PROJECT_ID`, then `GOOGLE_CLOUD_PROJECT`. Throws apply's own error when
127
+ * nothing resolves, so the `no-binding` detail names every knob.
127
128
  */
128
129
  export function resolveReadProject(
129
130
  kind: string,
@@ -131,14 +132,39 @@ export function resolveReadProject(
131
132
  metadata: { annotations?: Record<string, string> } | undefined,
132
133
  manifestProjects: Map<string, string>,
133
134
  props?: Record<string, unknown>,
135
+ env: NodeJS.ProcessEnv = process.env,
134
136
  ): string {
135
- try {
136
- return resolveGcpProject({ kind, metadata, ...(props ? { spec: props } : {}) });
137
- } catch (err) {
138
- const fromManifest = manifestProjects.get(`${kind}/${name}`);
139
- if (fromManifest) return fromManifest;
140
- throw err;
137
+ const project =
138
+ projectRefProject(props, {}) ||
139
+ manifestProjects.get(`${kind}/${name}`) ||
140
+ metadata?.annotations?.["cnrm.cloud.google.com/project-id"] ||
141
+ projectRefProject(props, env) ||
142
+ env.GCP_PROJECT_ID ||
143
+ env.GOOGLE_CLOUD_PROJECT;
144
+ if (!project) throw new Error(NO_PROJECT);
145
+ return project;
146
+ }
147
+
148
+ /**
149
+ * Each declared entity as a read sees it (#4251): the name the build writes
150
+ * (`metadata.name`, else the kebab-cased logical id) and its props with every
151
+ * reference to another resource resolved as the serializer resolves it, so a
152
+ * mapper building a read URL never meets an attribute reference.
153
+ */
154
+ export function readTargets(
155
+ entities: Map<string, { entityType: string; props: Record<string, unknown> }>,
156
+ ): Map<string, { name: string; props: Record<string, unknown> }> {
157
+ const logicalByProps = new Map<unknown, string>();
158
+ for (const [logical, { props }] of entities) logicalByProps.set(props, logical);
159
+ const logicalOf = (props: unknown) => logicalByProps.get(props);
160
+ const out = new Map<string, { name: string; props: Record<string, unknown> }>();
161
+ for (const [logical, { props }] of entities) {
162
+ out.set(logical, {
163
+ name: cnrmNameOf(logical, props),
164
+ props: resolveCnrmRefs(props, logicalOf) as Record<string, unknown>,
165
+ });
141
166
  }
167
+ return out;
142
168
  }
143
169
 
144
170
  function pruneUndefined<T extends Record<string, unknown>>(obj: T): Record<string, unknown> {
@@ -196,8 +222,9 @@ export async function describeResources(options: {
196
222
  let warnedOwnership = false;
197
223
 
198
224
  const manifestProjects = manifestProjectAnnotations(options.buildOutput);
225
+ const targets = readTargets(options.entities);
199
226
 
200
- const reads = [...options.entities].map(async ([entityName, { entityType, props }]) => {
227
+ const reads = [...options.entities].map(async ([entityName, { entityType }]) => {
201
228
  const gvk = deriveGVK(entityType);
202
229
  if (!gvk) {
203
230
  unobserved[entityName] = {
@@ -218,16 +245,8 @@ export async function describeResources(options: {
218
245
  return;
219
246
  }
220
247
 
221
- const metadata = props.metadata as { name?: string; annotations?: Record<string, string> } | undefined;
222
- const name = metadata?.name;
223
- if (!name) {
224
- unobserved[entityName] = {
225
- type: entityType,
226
- reason: "read-failed",
227
- detail: "declared entity has no metadata.name to query by",
228
- };
229
- return;
230
- }
248
+ const { name, props } = targets.get(entityName)!;
249
+ const metadata = props.metadata as { annotations?: Record<string, string> } | undefined;
231
250
 
232
251
  let client: GcpReadClientOptions;
233
252
  try {
package/src/health.ts CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  import type { HealthEntry, HealthReadOptions, HealthReadResult, UnobservedEntity } from "@intentius/chant/lexicon";
13
13
  import { getResource, GcpReadError, isNotFound, type GcpReadHttp } from "./api/read-client";
14
- import { deriveGVK, manifestProjectAnnotations, resolveReadProject } from "./describe-resources";
14
+ import { deriveGVK, manifestProjectAnnotations, readTargets, resolveReadProject } from "./describe-resources";
15
15
 
16
16
  export const GCP_HEALTH_TYPES = ["GCP::Run::Service"] as const;
17
17
 
@@ -75,18 +75,15 @@ export async function observeHealthGcp(options: GcpHealthOptions): Promise<Healt
75
75
  const entities: Record<string, HealthEntry> = {};
76
76
  const unobserved: Record<string, UnobservedEntity> = {};
77
77
  const absent: string[] = [];
78
+ const targets = readTargets(options.entities);
78
79
 
79
80
  await Promise.all(
80
- [...options.entities].map(async ([entityName, { entityType, props }]) => {
81
+ [...options.entities].map(async ([entityName, { entityType }]) => {
81
82
  const gvk = deriveGVK(entityType);
82
- const metadata = props.metadata as { name?: string; annotations?: Record<string, string> } | undefined;
83
- const name = metadata?.name;
84
- if (!gvk || !name) {
85
- unobserved[entityName] = {
86
- type: entityType,
87
- reason: "read-failed",
88
- detail: gvk ? "declared entity has no metadata.name to query by" : `cannot derive a GCP kind from ${entityType}`,
89
- };
83
+ const { name, props } = targets.get(entityName)!;
84
+ const metadata = props.metadata as { annotations?: Record<string, string> } | undefined;
85
+ if (!gvk) {
86
+ unobserved[entityName] = { type: entityType, reason: "read-failed", detail: `cannot derive a GCP kind from ${entityType}` };
90
87
  return;
91
88
  }
92
89
  let project: string;