@intentius/chant-lexicon-gcp 0.124.36 → 0.124.38
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 -1
- package/dist/api/read-client.d.ts.map +1 -1
- package/dist/api/read-client.js +1 -1
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/deep-observe.js +6 -11
- package/dist/describe-resources.d.ts +33 -6
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/describe-resources.js +58 -25
- package/dist/health.d.ts.map +1 -1
- package/dist/health.js +6 -9
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/op/activities/gcp-apply.d.ts +7 -1
- package/dist/op/activities/gcp-apply.d.ts.map +1 -1
- package/dist/op/activities/gcp-apply.js +12 -6
- package/dist/op/activities/gcp-iam.d.ts.map +1 -1
- package/dist/op/activities/gcp-iam.js +2 -0
- package/dist/serializer.d.ts +12 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/serializer.js +52 -8
- package/package.json +2 -2
- package/src/api/read-client.ts +7 -1
- package/src/deep-observe.ts +7 -12
- package/src/describe-resources.test.ts +5 -4
- package/src/describe-resources.ts +65 -24
- package/src/health.ts +7 -10
- package/src/op/activities/gcp-apply.test.ts +10 -3
- package/src/op/activities/gcp-apply.ts +13 -8
- package/src/op/activities/gcp-iam.ts +2 -0
- package/src/project-config.test.ts +166 -0
- package/src/read-refs.test.ts +195 -0
- package/src/serializer.ts +53 -8
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { describeResources } from "./describe-resources";
|
|
3
|
+
import { CloudRunServiceComposite } from "./composites/cloud-run-service";
|
|
4
|
+
import {
|
|
5
|
+
BackendService,
|
|
6
|
+
CloudRunService,
|
|
7
|
+
ComputeAddress,
|
|
8
|
+
ForwardingRule,
|
|
9
|
+
IAMPolicyMember,
|
|
10
|
+
RegionNetworkEndpointGroup,
|
|
11
|
+
Subnetwork,
|
|
12
|
+
TargetHTTPProxy,
|
|
13
|
+
URLMap,
|
|
14
|
+
VPCNetwork,
|
|
15
|
+
} from "./generated";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* #4251: behold's three-cloud gcp member, read back. Every resource here names
|
|
19
|
+
* another by `.name`, an attribute reference the serializer resolves to the
|
|
20
|
+
* target's metadata.name (#4245). The read path has to resolve it the same way
|
|
21
|
+
* before a mapper builds a URL from the declared props.
|
|
22
|
+
*/
|
|
23
|
+
const fetchMock = vi.fn();
|
|
24
|
+
|
|
25
|
+
function reply(status: number, body: unknown) {
|
|
26
|
+
return { status, text: async () => JSON.stringify(body) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function estate() {
|
|
30
|
+
const web = new CloudRunService({ metadata: { name: "three-cloud-site" }, location: "us-central1", template: {} } as never);
|
|
31
|
+
const vpc = new VPCNetwork({ metadata: { name: "three-cloud-vpc" }, autoCreateSubnetworks: false } as never);
|
|
32
|
+
const subnet = new Subnetwork({
|
|
33
|
+
metadata: { name: "three-cloud-subnet" },
|
|
34
|
+
region: "us-central1",
|
|
35
|
+
ipCidrRange: "10.8.0.0/24",
|
|
36
|
+
networkRef: { name: vpc.name },
|
|
37
|
+
} as never);
|
|
38
|
+
(web as unknown as { props: Record<string, unknown> }).props.template = {
|
|
39
|
+
containers: [{ image: "gcr.io/p/site:1" }],
|
|
40
|
+
vpcAccess: { egress: "ALL_TRAFFIC", networkInterfaces: [{ networkRef: { name: vpc.name }, subnetworkRef: { name: subnet.name } }] },
|
|
41
|
+
};
|
|
42
|
+
const neg = new RegionNetworkEndpointGroup({
|
|
43
|
+
metadata: { name: "three-cloud-neg" },
|
|
44
|
+
region: "us-central1",
|
|
45
|
+
networkEndpointType: "SERVERLESS",
|
|
46
|
+
cloudRun: { serviceRef: { name: web.name } },
|
|
47
|
+
} as never);
|
|
48
|
+
const backend = new BackendService({
|
|
49
|
+
metadata: { name: "three-cloud-backend" },
|
|
50
|
+
location: "global",
|
|
51
|
+
loadBalancingScheme: "EXTERNAL_MANAGED",
|
|
52
|
+
backend: [{ group: { networkEndpointGroupRef: { name: neg.name } } }],
|
|
53
|
+
} as never);
|
|
54
|
+
const urlMap = new URLMap({
|
|
55
|
+
metadata: { name: "three-cloud-urlmap" },
|
|
56
|
+
location: "global",
|
|
57
|
+
defaultService: { backendServiceRef: { name: backend.name } },
|
|
58
|
+
} as never);
|
|
59
|
+
const proxy = new TargetHTTPProxy({ metadata: { name: "three-cloud-proxy" }, location: "global", urlMapRef: { name: urlMap.name } } as never);
|
|
60
|
+
const address = new ComputeAddress({ metadata: { name: "three-cloud-ip" }, location: "global" } as never);
|
|
61
|
+
const rule = new ForwardingRule({
|
|
62
|
+
metadata: { name: "three-cloud-rule" },
|
|
63
|
+
location: "global",
|
|
64
|
+
loadBalancingScheme: "EXTERNAL_MANAGED",
|
|
65
|
+
portRange: "80",
|
|
66
|
+
target: { targetHTTPProxyRef: { name: proxy.name } },
|
|
67
|
+
ipAddress: { addressRef: { name: address.name } },
|
|
68
|
+
} as never);
|
|
69
|
+
const publicIam = new IAMPolicyMember({
|
|
70
|
+
metadata: { name: "three-cloud-site-public" },
|
|
71
|
+
member: "allUsers",
|
|
72
|
+
role: "roles/run.invoker",
|
|
73
|
+
resourceRef: { apiVersion: "run.cnrm.cloud.google.com/v1beta1", kind: "RunService", name: "three-cloud-site" },
|
|
74
|
+
} as never);
|
|
75
|
+
const all = { web, vpc, subnet, neg, backend, urlMap, proxy, address, rule, publicIam };
|
|
76
|
+
return new Map(
|
|
77
|
+
Object.entries(all).map(([logical, e]) => {
|
|
78
|
+
const entity = e as unknown as { entityType: string; props: Record<string, unknown> };
|
|
79
|
+
return [logical, { entityType: entity.entityType, props: entity.props }];
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe("gcp live read resolves .name references as the build does (#4251)", () => {
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
fetchMock.mockReset();
|
|
87
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
88
|
+
process.env.GCP_ENDPOINT_URL = "http://localhost:4588";
|
|
89
|
+
process.env.GOOGLE_CLOUD_PROJECT = "three-cloud";
|
|
90
|
+
fetchMock.mockImplementation(async (url: string) => {
|
|
91
|
+
if (url.includes(":getIamPolicy")) {
|
|
92
|
+
return reply(200, { etag: "e1", bindings: [{ role: "roles/run.invoker", members: ["allUsers"] }] });
|
|
93
|
+
}
|
|
94
|
+
return reply(200, { id: url, name: url.split("/").pop() });
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
afterEach(() => {
|
|
99
|
+
vi.unstubAllGlobals();
|
|
100
|
+
delete process.env.GCP_ENDPOINT_URL;
|
|
101
|
+
delete process.env.GOOGLE_CLOUD_PROJECT;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("every resource reads, by the names the build writes, and none fails on a reference", async () => {
|
|
105
|
+
const entities = estate();
|
|
106
|
+
const out = await describeResources({ environment: "local", buildOutput: "", entityNames: [...entities.keys()], entities });
|
|
107
|
+
|
|
108
|
+
expect(out.unobserved ?? {}).toEqual({});
|
|
109
|
+
for (const logical of entities.keys()) {
|
|
110
|
+
expect(out.resources[logical]?.physicalId, logical).toBeTruthy();
|
|
111
|
+
}
|
|
112
|
+
const urls = fetchMock.mock.calls.map((c) => String(c[0]));
|
|
113
|
+
for (const name of ["three-cloud-neg", "three-cloud-backend", "three-cloud-urlmap", "three-cloud-proxy", "three-cloud-rule", "three-cloud-subnet", "three-cloud-site"]) {
|
|
114
|
+
expect(urls.some((u) => u.includes(name)), name).toBe(true);
|
|
115
|
+
}
|
|
116
|
+
expect(urls.every((u) => !u.includes("[object"))).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("the public IAMPolicyMember reads as the binding in the service's policy, with a physical id", async () => {
|
|
120
|
+
const entities = estate();
|
|
121
|
+
const out = await describeResources({ environment: "local", buildOutput: "", entityNames: [...entities.keys()], entities });
|
|
122
|
+
expect(out.resources.publicIam?.physicalId).toBe(
|
|
123
|
+
"projects/three-cloud/locations/us-central1/services/three-cloud-site#roles/run.invoker#allUsers",
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("a binding missing from the policy reads as absent, not observed", async () => {
|
|
128
|
+
fetchMock.mockImplementation(async (url: string) =>
|
|
129
|
+
url.includes(":getIamPolicy") ? reply(200, { etag: "e1", bindings: [] }) : reply(200, { id: url }),
|
|
130
|
+
);
|
|
131
|
+
const entities = estate();
|
|
132
|
+
const out = await describeResources({ environment: "local", buildOutput: "", entityNames: [...entities.keys()], entities });
|
|
133
|
+
expect(out.resources.publicIam).toBeUndefined();
|
|
134
|
+
expect(out.unobserved?.publicIam).toBeUndefined();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* #4251, the IAM binding behold saw as pending: the public IAMPolicyMember of a
|
|
140
|
+
* CloudRunServiceComposite whose service runs outside us-central1. The binding
|
|
141
|
+
* names its service by `name`, so the read has to take the service's region from
|
|
142
|
+
* the declared service, as apply does, or it asks a region where the service
|
|
143
|
+
* isn't, gets a 404 and reports the binding absent.
|
|
144
|
+
*/
|
|
145
|
+
describe("the public binding of a service outside us-central1 reads as observed (#4251)", () => {
|
|
146
|
+
const policyUrl = "/v2/projects/three-cloud/locations/asia-northeast1/services/three-cloud-site:getIamPolicy";
|
|
147
|
+
|
|
148
|
+
function composite() {
|
|
149
|
+
const { service, publicIam } = CloudRunServiceComposite({
|
|
150
|
+
name: "three-cloud-site",
|
|
151
|
+
image: "gcr.io/p/site:1",
|
|
152
|
+
projectId: "three-cloud",
|
|
153
|
+
location: "asia-northeast1",
|
|
154
|
+
publicAccess: true,
|
|
155
|
+
}) as unknown as Record<string, { entityType: string; props: Record<string, unknown> }>;
|
|
156
|
+
return [
|
|
157
|
+
["webService", { entityType: service.entityType, props: service.props }],
|
|
158
|
+
["webPublicIam", { entityType: publicIam.entityType, props: publicIam.props }],
|
|
159
|
+
] as Array<[string, { entityType: string; props: Record<string, unknown> }]>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
beforeEach(() => {
|
|
163
|
+
fetchMock.mockReset();
|
|
164
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
165
|
+
process.env.GCP_ENDPOINT_URL = "http://localhost:4588";
|
|
166
|
+
process.env.GCP_PROJECT_ID = "three-cloud";
|
|
167
|
+
delete process.env.GCP_REGION;
|
|
168
|
+
fetchMock.mockImplementation(async (url: string) => {
|
|
169
|
+
if (url.includes(":getIamPolicy")) {
|
|
170
|
+
return url.includes(policyUrl)
|
|
171
|
+
? reply(200, { bindings: [{ role: "roles/run.invoker", members: ["allUsers"] }] })
|
|
172
|
+
: reply(404, { error: { code: 404 } });
|
|
173
|
+
}
|
|
174
|
+
return reply(200, { name: url.split("?")[0].split("/").slice(-6).join("/") });
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
afterEach(() => {
|
|
179
|
+
vi.unstubAllGlobals();
|
|
180
|
+
delete process.env.GCP_ENDPOINT_URL;
|
|
181
|
+
delete process.env.GCP_PROJECT_ID;
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
for (const order of ["service first", "binding first"]) {
|
|
185
|
+
test(`webPublicIam is observed with a physical id, ${order}`, async () => {
|
|
186
|
+
const pairs = composite();
|
|
187
|
+
const entities = new Map(order === "service first" ? pairs : [...pairs].reverse());
|
|
188
|
+
const out = await describeResources({ environment: "local", buildOutput: "", entityNames: [...entities.keys()], entities });
|
|
189
|
+
expect(out.unobserved?.webPublicIam).toBeUndefined();
|
|
190
|
+
expect(out.resources.webPublicIam?.physicalId).toBe(
|
|
191
|
+
"projects/three-cloud/locations/asia-northeast1/services/three-cloud-site#roles/run.invoker#allUsers",
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
});
|
package/src/serializer.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
import { createRequire } from "module";
|
|
9
9
|
import { moduleSourceUrl } from "@intentius/chant/module-source";
|
|
10
10
|
import type { Declarable } from "@intentius/chant/declarable";
|
|
11
|
-
import { isPropertyDeclarable, isResourceDeclarable } from "@intentius/chant/declarable";
|
|
11
|
+
import { isDeclarable, isPropertyDeclarable, isResourceDeclarable } from "@intentius/chant/declarable";
|
|
12
|
+
import { AttrRef } from "@intentius/chant/attrref";
|
|
12
13
|
import type { Serializer, SerializerResult, SerializeContext } from "@intentius/chant/serializer";
|
|
13
14
|
import { ownershipEntries, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
|
|
14
15
|
import type { LexiconOutput } from "@intentius/chant/lexicon-output";
|
|
@@ -113,11 +114,42 @@ function gcpVisitor(entityNames: Map<Declarable, string>): SerializerVisitor {
|
|
|
113
114
|
* props, else its logical id in kebab case.
|
|
114
115
|
*/
|
|
115
116
|
export function cnrmName(logical: string, entity: Declarable): string {
|
|
116
|
-
|
|
117
|
-
|
|
117
|
+
return cnrmNameOf(logical, isResourceDeclarable(entity) ? entity.props : undefined);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** {@link cnrmName} from a resource's props, for callers holding props rather than the declarable. */
|
|
121
|
+
export function cnrmNameOf(logical: string, props: unknown): string {
|
|
122
|
+
const declared = (props as { metadata?: { name?: unknown } } | undefined)?.metadata?.name;
|
|
118
123
|
return typeof declared === "string" && declared.length > 0 ? declared : kebabName(logical);
|
|
119
124
|
}
|
|
120
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Declared props with each reference to another resource replaced as the
|
|
128
|
+
* serializer writes it (#4251): an attribute reference (`neg.name`) becomes the
|
|
129
|
+
* target's metadata.name, and a resource placed whole becomes `{ name }`.
|
|
130
|
+
* `logicalOf` maps a target's props object to its logical id, for a reference
|
|
131
|
+
* that does not carry one. Other values, pseudo-parameters included, are kept.
|
|
132
|
+
*/
|
|
133
|
+
export function resolveCnrmRefs(value: unknown, logicalOf: (props: unknown) => string | undefined): unknown {
|
|
134
|
+
if (value instanceof AttrRef) {
|
|
135
|
+
const parent = value.parent.deref() as Declarable | undefined;
|
|
136
|
+
const props = parent && isResourceDeclarable(parent) ? parent.props : undefined;
|
|
137
|
+
const logical = value.getLogicalName() ?? logicalOf(props);
|
|
138
|
+
return logical === undefined ? value : cnrmNameOf(logical, props);
|
|
139
|
+
}
|
|
140
|
+
if (Array.isArray(value)) return value.map((v) => resolveCnrmRefs(v, logicalOf));
|
|
141
|
+
if (value === null || typeof value !== "object") return value;
|
|
142
|
+
if (isDeclarable(value)) {
|
|
143
|
+
const props = isResourceDeclarable(value) ? value.props : undefined;
|
|
144
|
+
const logical = logicalOf(props);
|
|
145
|
+
return logical === undefined ? value : { name: cnrmNameOf(logical, props) };
|
|
146
|
+
}
|
|
147
|
+
if (INTRINSIC_MARKER in (value as object)) return value;
|
|
148
|
+
const out: Record<string, unknown> = {};
|
|
149
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) out[k] = resolveCnrmRefs(v, logicalOf);
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
121
153
|
function kebabName(logical: string): string {
|
|
122
154
|
return logical.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
123
155
|
}
|
|
@@ -163,7 +195,11 @@ export const gcpSerializer: Serializer = {
|
|
|
163
195
|
let defaultLabelEntries: Record<string, unknown> = context?.ownership
|
|
164
196
|
? { ...ownershipEntries(LABEL_OWNERSHIP_KEYS, context.ownership) }
|
|
165
197
|
: {};
|
|
166
|
-
|
|
198
|
+
// #4252: a member's project, once, in chant.config's `gcp.project`. It
|
|
199
|
+
// resolves GCP.ProjectId and is written as each resource's project-id
|
|
200
|
+
// annotation, which apply and the readers take ahead of the env vars.
|
|
201
|
+
const configProject = gcpConfigProject(context?.config);
|
|
202
|
+
let defaultAnnotationEntries: Record<string, unknown> = configProject ? { [PROJECT_ID_ANNOTATION]: configProject } : {};
|
|
167
203
|
|
|
168
204
|
for (const [, entity] of entities) {
|
|
169
205
|
if (isDefaultLabels(entity)) {
|
|
@@ -189,6 +225,7 @@ export const gcpSerializer: Serializer = {
|
|
|
189
225
|
toYAMLValue(isResourceDeclarable(entity) ? entity.props : undefined, entityNames),
|
|
190
226
|
name,
|
|
191
227
|
unresolved,
|
|
228
|
+
configProject,
|
|
192
229
|
) as Record<string, unknown> | undefined;
|
|
193
230
|
|
|
194
231
|
if (!props) continue;
|
|
@@ -246,6 +283,14 @@ export const gcpSerializer: Serializer = {
|
|
|
246
283
|
},
|
|
247
284
|
};
|
|
248
285
|
|
|
286
|
+
const PROJECT_ID_ANNOTATION = "cnrm.cloud.google.com/project-id";
|
|
287
|
+
|
|
288
|
+
/** The `gcp.project` a chant.config declares, if any (#4252). */
|
|
289
|
+
export function gcpConfigProject(config: Record<string, unknown> | undefined): string | undefined {
|
|
290
|
+
const project = (config?.gcp as { project?: unknown } | undefined)?.project;
|
|
291
|
+
return typeof project === "string" && project.length > 0 ? project : undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
249
294
|
/**
|
|
250
295
|
* Pseudo-parameter → environment variable mapping.
|
|
251
296
|
* Config Connector annotations must be plain strings, so PseudoParameters
|
|
@@ -270,20 +315,20 @@ interface UnresolvedPseudo {
|
|
|
270
315
|
* the API as an object. A variable that isn't set is recorded, and the
|
|
271
316
|
* build refuses with every one named.
|
|
272
317
|
*/
|
|
273
|
-
function resolvePseudoParameters(value: unknown, entity: string, unresolved: UnresolvedPseudo[]): unknown {
|
|
274
|
-
if (Array.isArray(value)) return value.map((v) => resolvePseudoParameters(v, entity, unresolved));
|
|
318
|
+
function resolvePseudoParameters(value: unknown, entity: string, unresolved: UnresolvedPseudo[], project?: string): unknown {
|
|
319
|
+
if (Array.isArray(value)) return value.map((v) => resolvePseudoParameters(v, entity, unresolved, project));
|
|
275
320
|
if (value === null || typeof value !== "object") return value;
|
|
276
321
|
const obj = value as Record<string, unknown>;
|
|
277
322
|
const keys = Object.keys(obj);
|
|
278
323
|
if (keys.length === 1 && keys[0] === "Ref" && typeof obj.Ref === "string" && obj.Ref in PSEUDO_ENV_MAP) {
|
|
279
324
|
const { envVar } = PSEUDO_ENV_MAP[obj.Ref];
|
|
280
|
-
const resolved = process.env[envVar];
|
|
325
|
+
const resolved = (obj.Ref === "GCP::ProjectId" ? project : undefined) ?? process.env[envVar];
|
|
281
326
|
if (resolved) return resolved;
|
|
282
327
|
unresolved.push({ entity, ref: obj.Ref, envVar });
|
|
283
328
|
return value;
|
|
284
329
|
}
|
|
285
330
|
const out: Record<string, unknown> = {};
|
|
286
|
-
for (const [k, v] of Object.entries(obj)) out[k] = resolvePseudoParameters(v, entity, unresolved);
|
|
331
|
+
for (const [k, v] of Object.entries(obj)) out[k] = resolvePseudoParameters(v, entity, unresolved, project);
|
|
287
332
|
return out;
|
|
288
333
|
}
|
|
289
334
|
|