@intentius/chant-lexicon-gcp 0.124.37 → 0.124.39
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 +14 -1
- package/dist/api/read-client.d.ts.map +1 -1
- package/dist/api/read-client.js +25 -10
- package/dist/composites/catalog.d.ts.map +1 -1
- package/dist/composites/catalog.js +187 -0
- package/dist/composites/index.d.ts +4 -0
- package/dist/composites/index.d.ts.map +1 -1
- package/dist/composites/index.js +2 -0
- package/dist/composites/private-egress-network.d.ts +42 -0
- package/dist/composites/private-egress-network.d.ts.map +1 -0
- package/dist/composites/private-egress-network.js +48 -0
- package/dist/composites/public-cloud-run.d.ts +85 -0
- package/dist/composites/public-cloud-run.d.ts.map +1 -0
- package/dist/composites/public-cloud-run.js +144 -0
- package/dist/deep-observe.d.ts +5 -0
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/deep-observe.js +37 -3
- package/dist/describe-resources.d.ts +13 -0
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/describe-resources.js +19 -0
- package/dist/health.d.ts +26 -2
- package/dist/health.d.ts.map +1 -1
- package/dist/health.js +85 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/integrity.json +3 -3
- package/dist/manifest.json +1 -1
- package/dist/op/activities/gcp-apply.d.ts +19 -0
- package/dist/op/activities/gcp-apply.d.ts.map +1 -1
- package/dist/op/activities/gcp-apply.js +16 -0
- package/dist/op/activities/gcp-compute.d.ts +9 -0
- package/dist/op/activities/gcp-compute.d.ts.map +1 -1
- package/dist/op/activities/gcp-compute.js +107 -11
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +6 -2
- package/dist/skills/chant-gcp.md +2 -0
- package/package.json +2 -2
- package/src/api/read-client.ts +30 -9
- package/src/composites/catalog.ts +187 -0
- package/src/composites/cloud-run-lb.test.ts +93 -0
- package/src/composites/index.ts +4 -0
- package/src/composites/private-egress-network.ts +82 -0
- package/src/composites/public-cloud-run.ts +245 -0
- package/src/deep-observe.test.ts +46 -0
- package/src/deep-observe.ts +41 -3
- package/src/describe-resources.ts +23 -1
- package/src/health.test.ts +65 -1
- package/src/health.ts +88 -11
- package/src/index.ts +2 -1
- package/src/op/activities/gcp-apply.ts +31 -1
- package/src/op/activities/gcp-compute-reconcile.test.ts +150 -0
- package/src/op/activities/gcp-compute.test.ts +4 -2
- package/src/op/activities/gcp-compute.ts +129 -11
- package/src/plugin.ts +6 -2
- package/src/project-config.test.ts +50 -3
- package/src/read-refs.test.ts +60 -0
- package/src/skills/chant-gcp.md +2 -0
package/src/composites/index.ts
CHANGED
|
@@ -32,3 +32,7 @@ export { GkeCrdbRegion } from "./gke-crdb-region";
|
|
|
32
32
|
export type { GkeCrdbRegionConfig, GkeCrdbRegionNodeConfig } from "./gke-crdb-region";
|
|
33
33
|
export { GpuNodePool } from "./gpu-node-pool";
|
|
34
34
|
export type { GpuNodePoolProps, GpuNodePoolTaint } from "./gpu-node-pool";
|
|
35
|
+
export { PrivateEgressNetwork } from "./private-egress-network";
|
|
36
|
+
export type { PrivateEgressNetworkProps } from "./private-egress-network";
|
|
37
|
+
export { PublicCloudRun } from "./public-cloud-run";
|
|
38
|
+
export type { PublicCloudRunProps } from "./public-cloud-run";
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PrivateEgressNetwork composite: a custom-mode VPC with one subnet a service sends its egress into.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Composite, mergeDefaults } from "@intentius/chant";
|
|
6
|
+
import { VPCNetwork, Subnetwork } from "../generated";
|
|
7
|
+
|
|
8
|
+
export interface PrivateEgressNetworkProps {
|
|
9
|
+
/** Name prefix: the network is `<name>-vpc`, its subnet `<name>-apps`. */
|
|
10
|
+
name: string;
|
|
11
|
+
/** GCP region of the subnet. */
|
|
12
|
+
region: string;
|
|
13
|
+
/** IP CIDR range of the subnet. */
|
|
14
|
+
subnetCidr: string;
|
|
15
|
+
/** Enable private Google access on the subnet (default: true). */
|
|
16
|
+
privateIpGoogleAccess?: boolean;
|
|
17
|
+
/** Additional labels. */
|
|
18
|
+
labels?: Record<string, string>;
|
|
19
|
+
/** Namespace for all resources. */
|
|
20
|
+
namespace?: string;
|
|
21
|
+
/** Per-member defaults for customizing individual resources. */
|
|
22
|
+
defaults?: {
|
|
23
|
+
vpc?: Partial<ConstructorParameters<typeof VPCNetwork>[0]>;
|
|
24
|
+
subnet?: Partial<ConstructorParameters<typeof Subnetwork>[0]>;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Create a PrivateEgressNetwork composite.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```ts
|
|
33
|
+
* import { PrivateEgressNetwork } from "@intentius/chant-lexicon-gcp";
|
|
34
|
+
*
|
|
35
|
+
* const { vpc, subnet } = PrivateEgressNetwork({
|
|
36
|
+
* name: "web",
|
|
37
|
+
* region: "us-central1",
|
|
38
|
+
* subnetCidr: "10.10.0.0/24",
|
|
39
|
+
* });
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export const PrivateEgressNetwork = Composite((props: PrivateEgressNetworkProps) => {
|
|
43
|
+
const {
|
|
44
|
+
name,
|
|
45
|
+
region,
|
|
46
|
+
subnetCidr,
|
|
47
|
+
privateIpGoogleAccess = true,
|
|
48
|
+
labels: extraLabels = {},
|
|
49
|
+
namespace,
|
|
50
|
+
defaults: defs,
|
|
51
|
+
} = props;
|
|
52
|
+
|
|
53
|
+
const commonLabels: Record<string, string> = {
|
|
54
|
+
"app.kubernetes.io/name": name,
|
|
55
|
+
"app.kubernetes.io/managed-by": "chant",
|
|
56
|
+
...extraLabels,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const vpc = new VPCNetwork(mergeDefaults({
|
|
60
|
+
metadata: {
|
|
61
|
+
name: `${name}-vpc`,
|
|
62
|
+
...(namespace && { namespace }),
|
|
63
|
+
labels: { ...commonLabels, "app.kubernetes.io/component": "network" },
|
|
64
|
+
},
|
|
65
|
+
autoCreateSubnetworks: false,
|
|
66
|
+
routingMode: "REGIONAL",
|
|
67
|
+
} as Record<string, unknown>, defs?.vpc));
|
|
68
|
+
|
|
69
|
+
const subnet = new Subnetwork(mergeDefaults({
|
|
70
|
+
metadata: {
|
|
71
|
+
name: `${name}-apps`,
|
|
72
|
+
...(namespace && { namespace }),
|
|
73
|
+
labels: { ...commonLabels, "app.kubernetes.io/component": "subnet" },
|
|
74
|
+
},
|
|
75
|
+
region,
|
|
76
|
+
ipCidrRange: subnetCidr,
|
|
77
|
+
privateIpGoogleAccess,
|
|
78
|
+
networkRef: { name: vpc.name },
|
|
79
|
+
} as Record<string, unknown>, defs?.subnet));
|
|
80
|
+
|
|
81
|
+
return { vpc, subnet };
|
|
82
|
+
}, "PrivateEgressNetwork");
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PublicCloudRun composite: a Cloud Run service behind a global external
|
|
3
|
+
* Application Load Balancer, running as its own service account with Direct
|
|
4
|
+
* VPC egress into a subnet.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Composite, mergeDefaults } from "@intentius/chant";
|
|
8
|
+
import {
|
|
9
|
+
BackendService,
|
|
10
|
+
ComputeAddress,
|
|
11
|
+
ForwardingRule,
|
|
12
|
+
GCPServiceAccount,
|
|
13
|
+
ManagedSSLCertificate,
|
|
14
|
+
RegionNetworkEndpointGroup,
|
|
15
|
+
TargetHTTPProxy,
|
|
16
|
+
TargetHTTPSProxy,
|
|
17
|
+
URLMap,
|
|
18
|
+
} from "../generated";
|
|
19
|
+
import { GCP } from "../pseudo";
|
|
20
|
+
import { CloudRunServiceComposite } from "./cloud-run-service";
|
|
21
|
+
|
|
22
|
+
export interface PublicCloudRunProps {
|
|
23
|
+
/** Name prefix every resource is named with. */
|
|
24
|
+
name: string;
|
|
25
|
+
/** Container image. */
|
|
26
|
+
image: string;
|
|
27
|
+
/** GCP region of the service and its network endpoint group. */
|
|
28
|
+
region: string;
|
|
29
|
+
/** GCP project, as `projectRef.external` (default: GCP.ProjectId, resolved from GCP_PROJECT_ID at build). */
|
|
30
|
+
projectId?: string;
|
|
31
|
+
/** VPC the service's egress goes into, such as a PrivateEgressNetwork's `vpc`. */
|
|
32
|
+
vpc: { name: string };
|
|
33
|
+
/** Subnet the service's egress goes into, such as a PrivateEgressNetwork's `subnet`. */
|
|
34
|
+
subnet: { name: string };
|
|
35
|
+
/** Egress sent through the VPC: "PRIVATE_RANGES_ONLY" or "ALL_TRAFFIC" (default: "PRIVATE_RANGES_ONLY"). */
|
|
36
|
+
vpcEgress?: string;
|
|
37
|
+
/** Port the container listens on (default: 8080). */
|
|
38
|
+
port?: number;
|
|
39
|
+
/** Port the HTTP forwarding rule listens on (default: 80). */
|
|
40
|
+
frontendPort?: number;
|
|
41
|
+
/** Minimum instances (default: 0). */
|
|
42
|
+
minInstanceCount?: number;
|
|
43
|
+
/** Maximum instances (default: 100). */
|
|
44
|
+
maxInstanceCount?: number;
|
|
45
|
+
/** Environment variables. */
|
|
46
|
+
env?: Array<{ name: string; value: string }>;
|
|
47
|
+
/** An existing service account to run as: an email, or the account id of an IAMServiceAccount in the project. When unset, the composite creates `<name>-run`. */
|
|
48
|
+
serviceAccount?: string;
|
|
49
|
+
/** Domains for a Google-managed certificate. When set, the composite adds an HTTPS proxy and a rule on 443, and the HTTP rule redirects to HTTPS. */
|
|
50
|
+
domains?: string[];
|
|
51
|
+
/** Name of a ComputeSecurityPolicy (Cloud Armor) attached to the backend service. */
|
|
52
|
+
securityPolicy?: string;
|
|
53
|
+
/** Additional labels. */
|
|
54
|
+
labels?: Record<string, string>;
|
|
55
|
+
/** Namespace for all resources. */
|
|
56
|
+
namespace?: string;
|
|
57
|
+
/** Per-member defaults for customizing individual resources. */
|
|
58
|
+
defaults?: {
|
|
59
|
+
runtime?: Partial<ConstructorParameters<typeof GCPServiceAccount>[0]>;
|
|
60
|
+
service?: Record<string, unknown>;
|
|
61
|
+
publicAccess?: Record<string, unknown>;
|
|
62
|
+
neg?: Partial<ConstructorParameters<typeof RegionNetworkEndpointGroup>[0]>;
|
|
63
|
+
backend?: Partial<ConstructorParameters<typeof BackendService>[0]>;
|
|
64
|
+
urlMap?: Partial<ConstructorParameters<typeof URLMap>[0]>;
|
|
65
|
+
proxy?: Partial<ConstructorParameters<typeof TargetHTTPProxy>[0]>;
|
|
66
|
+
address?: Partial<ConstructorParameters<typeof ComputeAddress>[0]>;
|
|
67
|
+
rule?: Partial<ConstructorParameters<typeof ForwardingRule>[0]>;
|
|
68
|
+
certificate?: Partial<ConstructorParameters<typeof ManagedSSLCertificate>[0]>;
|
|
69
|
+
httpsProxy?: Partial<ConstructorParameters<typeof TargetHTTPSProxy>[0]>;
|
|
70
|
+
httpsRule?: Partial<ConstructorParameters<typeof ForwardingRule>[0]>;
|
|
71
|
+
redirectMap?: Partial<ConstructorParameters<typeof URLMap>[0]>;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Create a PublicCloudRun composite.
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* ```ts
|
|
80
|
+
* import { PrivateEgressNetwork, PublicCloudRun } from "@intentius/chant-lexicon-gcp";
|
|
81
|
+
*
|
|
82
|
+
* const network = PrivateEgressNetwork({ name: "web", region: "us-central1", subnetCidr: "10.10.0.0/24" });
|
|
83
|
+
* const web = PublicCloudRun({
|
|
84
|
+
* name: "web",
|
|
85
|
+
* projectId: "my-project",
|
|
86
|
+
* region: "us-central1",
|
|
87
|
+
* vpc: network.vpc,
|
|
88
|
+
* subnet: network.subnet,
|
|
89
|
+
* image: "us-docker.pkg.dev/my-project/web/site:1",
|
|
90
|
+
* });
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
export const PublicCloudRun = Composite((props: PublicCloudRunProps) => {
|
|
94
|
+
const {
|
|
95
|
+
name,
|
|
96
|
+
image,
|
|
97
|
+
region,
|
|
98
|
+
projectId,
|
|
99
|
+
vpc,
|
|
100
|
+
subnet,
|
|
101
|
+
vpcEgress = "PRIVATE_RANGES_ONLY",
|
|
102
|
+
port = 8080,
|
|
103
|
+
frontendPort = 80,
|
|
104
|
+
minInstanceCount = 0,
|
|
105
|
+
maxInstanceCount = 100,
|
|
106
|
+
env,
|
|
107
|
+
serviceAccount,
|
|
108
|
+
domains,
|
|
109
|
+
securityPolicy,
|
|
110
|
+
labels: extraLabels = {},
|
|
111
|
+
namespace,
|
|
112
|
+
defaults: defs,
|
|
113
|
+
} = props;
|
|
114
|
+
|
|
115
|
+
const commonLabels: Record<string, string> = {
|
|
116
|
+
"app.kubernetes.io/name": name,
|
|
117
|
+
"app.kubernetes.io/managed-by": "chant",
|
|
118
|
+
...extraLabels,
|
|
119
|
+
};
|
|
120
|
+
const meta = (suffix: string, component: string) => ({
|
|
121
|
+
name: `${name}-${suffix}`,
|
|
122
|
+
...(namespace && { namespace }),
|
|
123
|
+
labels: { ...commonLabels, "app.kubernetes.io/component": component },
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const result: Record<string, any> = {};
|
|
127
|
+
|
|
128
|
+
if (!serviceAccount) {
|
|
129
|
+
result.runtime = new GCPServiceAccount(mergeDefaults({
|
|
130
|
+
metadata: meta("run", "service-account"),
|
|
131
|
+
displayName: `${name} Cloud Run runtime`,
|
|
132
|
+
} as Record<string, unknown>, defs?.runtime));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const run = CloudRunServiceComposite({
|
|
136
|
+
name: `${name}-site`,
|
|
137
|
+
image,
|
|
138
|
+
projectId,
|
|
139
|
+
location: region,
|
|
140
|
+
port,
|
|
141
|
+
minInstanceCount,
|
|
142
|
+
maxInstanceCount,
|
|
143
|
+
env,
|
|
144
|
+
publicAccess: true,
|
|
145
|
+
serviceAccount: serviceAccount ?? `${name}-run`,
|
|
146
|
+
labels: extraLabels,
|
|
147
|
+
namespace,
|
|
148
|
+
defaults: {
|
|
149
|
+
service: mergeDefaults({
|
|
150
|
+
template: {
|
|
151
|
+
vpcAccess: {
|
|
152
|
+
egress: vpcEgress,
|
|
153
|
+
networkInterfaces: [{ networkRef: { name: vpc.name }, subnetworkRef: { name: subnet.name } }],
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
} as Record<string, unknown>, defs?.service) as any,
|
|
157
|
+
publicIam: defs?.publicAccess as any,
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
result.service = run.service;
|
|
161
|
+
result.publicAccess = run.publicIam;
|
|
162
|
+
|
|
163
|
+
const neg = new RegionNetworkEndpointGroup(mergeDefaults({
|
|
164
|
+
metadata: meta("neg", "neg"),
|
|
165
|
+
region,
|
|
166
|
+
networkEndpointType: "SERVERLESS",
|
|
167
|
+
cloudRun: { serviceRef: { name: run.service.name } },
|
|
168
|
+
} as Record<string, unknown>, defs?.neg));
|
|
169
|
+
|
|
170
|
+
// A backend service over a serverless NEG takes no health check: Cloud Run
|
|
171
|
+
// reports its own readiness.
|
|
172
|
+
const backend = new BackendService(mergeDefaults({
|
|
173
|
+
metadata: meta("backend", "backend"),
|
|
174
|
+
location: "global",
|
|
175
|
+
loadBalancingScheme: "EXTERNAL_MANAGED",
|
|
176
|
+
protocol: "HTTP",
|
|
177
|
+
backend: [{ group: { networkEndpointGroupRef: { name: neg.name } } }],
|
|
178
|
+
...(securityPolicy && { securityPolicyRef: { name: securityPolicy } }),
|
|
179
|
+
} as Record<string, unknown>, defs?.backend));
|
|
180
|
+
|
|
181
|
+
const urlMap = new URLMap(mergeDefaults({
|
|
182
|
+
metadata: meta("map", "url-map"),
|
|
183
|
+
location: "global",
|
|
184
|
+
defaultService: { backendServiceRef: { name: backend.name } },
|
|
185
|
+
} as Record<string, unknown>, defs?.urlMap));
|
|
186
|
+
|
|
187
|
+
const address = new ComputeAddress(mergeDefaults({
|
|
188
|
+
metadata: meta("ip", "address"),
|
|
189
|
+
location: "global",
|
|
190
|
+
} as Record<string, unknown>, defs?.address));
|
|
191
|
+
|
|
192
|
+
Object.assign(result, { neg, backend, urlMap, address });
|
|
193
|
+
|
|
194
|
+
// With domains, HTTPS serves the URL map and HTTP serves a redirect to it.
|
|
195
|
+
let httpMapName: string = urlMap.name;
|
|
196
|
+
if (domains && domains.length > 0) {
|
|
197
|
+
const certificate = new ManagedSSLCertificate(mergeDefaults({
|
|
198
|
+
metadata: meta("cert", "certificate"),
|
|
199
|
+
projectRef: { external: projectId ?? GCP.ProjectId },
|
|
200
|
+
managed: { domains },
|
|
201
|
+
} as Record<string, unknown>, defs?.certificate));
|
|
202
|
+
|
|
203
|
+
const httpsProxy = new TargetHTTPSProxy(mergeDefaults({
|
|
204
|
+
metadata: meta("https-proxy", "proxy"),
|
|
205
|
+
location: "global",
|
|
206
|
+
urlMapRef: { name: urlMap.name },
|
|
207
|
+
sslCertificates: [{ name: certificate.name }],
|
|
208
|
+
} as Record<string, unknown>, defs?.httpsProxy));
|
|
209
|
+
|
|
210
|
+
const httpsRule = new ForwardingRule(mergeDefaults({
|
|
211
|
+
metadata: meta("https-rule", "forwarding-rule"),
|
|
212
|
+
location: "global",
|
|
213
|
+
loadBalancingScheme: "EXTERNAL_MANAGED",
|
|
214
|
+
portRange: "443",
|
|
215
|
+
ipAddress: { addressRef: { name: address.name } },
|
|
216
|
+
target: { targetHTTPSProxyRef: { name: httpsProxy.name } },
|
|
217
|
+
} as Record<string, unknown>, defs?.httpsRule));
|
|
218
|
+
|
|
219
|
+
const redirectMap = new URLMap(mergeDefaults({
|
|
220
|
+
metadata: meta("redirect", "url-map"),
|
|
221
|
+
location: "global",
|
|
222
|
+
defaultUrlRedirect: { httpsRedirect: true, stripQuery: false },
|
|
223
|
+
} as Record<string, unknown>, defs?.redirectMap));
|
|
224
|
+
httpMapName = redirectMap.name;
|
|
225
|
+
|
|
226
|
+
Object.assign(result, { certificate, httpsProxy, httpsRule, redirectMap });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
result.proxy = new TargetHTTPProxy(mergeDefaults({
|
|
230
|
+
metadata: meta("proxy", "proxy"),
|
|
231
|
+
location: "global",
|
|
232
|
+
urlMapRef: { name: httpMapName },
|
|
233
|
+
} as Record<string, unknown>, defs?.proxy));
|
|
234
|
+
|
|
235
|
+
result.rule = new ForwardingRule(mergeDefaults({
|
|
236
|
+
metadata: meta("rule", "forwarding-rule"),
|
|
237
|
+
location: "global",
|
|
238
|
+
loadBalancingScheme: "EXTERNAL_MANAGED",
|
|
239
|
+
portRange: String(frontendPort),
|
|
240
|
+
ipAddress: { addressRef: { name: address.name } },
|
|
241
|
+
target: { targetHTTPProxyRef: { name: result.proxy.name } },
|
|
242
|
+
} as Record<string, unknown>, defs?.rule));
|
|
243
|
+
|
|
244
|
+
return result;
|
|
245
|
+
}, "PublicCloudRun");
|
package/src/deep-observe.test.ts
CHANGED
|
@@ -352,3 +352,49 @@ describe("observeResourcesDeepGcp — over REST (#1209)", () => {
|
|
|
352
352
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
353
353
|
});
|
|
354
354
|
});
|
|
355
|
+
|
|
356
|
+
describe("a forwarding rule's IP read back as its declared addressRef (#4238)", () => {
|
|
357
|
+
beforeEach(() => {
|
|
358
|
+
fetchMock.mockReset();
|
|
359
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
360
|
+
process.env.GOOGLE_CLOUD_PROJECT = "my-project";
|
|
361
|
+
process.env.GCP_ENDPOINT_URL = "http://localhost:4588";
|
|
362
|
+
});
|
|
363
|
+
afterEach(() => {
|
|
364
|
+
vi.unstubAllGlobals();
|
|
365
|
+
delete process.env.GCP_ENDPOINT_URL;
|
|
366
|
+
delete process.env.GOOGLE_CLOUD_PROJECT;
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("toCnrmTree: the declared address's IP becomes the addressRef; any other IP stays an ip", () => {
|
|
370
|
+
const ref = { name: "web-ip" };
|
|
371
|
+
expect(toCnrmTree("ComputeForwardingRule", { name: "fr", IPAddress: "34.1.2.3" }, { project: "p", address: { ip: "34.1.2.3", ref } })).toMatchObject({
|
|
372
|
+
ipAddress: { addressRef: ref },
|
|
373
|
+
});
|
|
374
|
+
const other = toCnrmTree("ComputeForwardingRule", { name: "fr", IPAddress: "10.0.0.9" }, { project: "p", address: { ip: "34.1.2.3", ref } });
|
|
375
|
+
expect(other).toMatchObject({ ipAddress: { ip: "10.0.0.9" } });
|
|
376
|
+
expect(other).not.toHaveProperty("IPAddress");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("observe reads the declared address and reports the rule's ipAddress as declared", async () => {
|
|
380
|
+
fetchMock.mockImplementation(async (url: string) => {
|
|
381
|
+
if (url.endsWith("/global/forwardingRules/web-fr")) {
|
|
382
|
+
return reply(200, { name: "web-fr", IPAddress: "34.1.2.3", portRange: "80", target: "projects/my-project/global/targetHttpProxies/web-proxy" });
|
|
383
|
+
}
|
|
384
|
+
if (url.endsWith("/global/addresses/web-ip")) return reply(200, { name: "web-ip", address: "34.1.2.3" });
|
|
385
|
+
return reply(404, {});
|
|
386
|
+
});
|
|
387
|
+
const result = await observe(
|
|
388
|
+
entities([
|
|
389
|
+
[
|
|
390
|
+
"rule",
|
|
391
|
+
"GCP::Compute::ForwardingRule",
|
|
392
|
+
{ metadata: { name: "web-fr" }, location: "global", portRange: "80", ipAddress: { addressRef: { name: "web-ip" } }, target: { targetHTTPProxyRef: { name: "web-proxy" } } },
|
|
393
|
+
],
|
|
394
|
+
["ip", "GCP::Compute::Address", { metadata: { name: "web-ip" }, location: "global" }],
|
|
395
|
+
]),
|
|
396
|
+
);
|
|
397
|
+
expect(result.resources.rule?.properties).toMatchObject({ ipAddress: { addressRef: { name: "web-ip" } } });
|
|
398
|
+
expect(result.resources.rule?.properties).not.toHaveProperty("IPAddress");
|
|
399
|
+
});
|
|
400
|
+
});
|
package/src/deep-observe.ts
CHANGED
|
@@ -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, readTargets, resolveReadProject } from "./describe-resources";
|
|
64
|
+
import { deriveGVK, manifestProjectAnnotations, readManifest, 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
|
|
|
@@ -122,6 +122,8 @@ function propertiesTreeOf(obj: Record<string, unknown>): Record<string, unknown>
|
|
|
122
122
|
interface RefineContext {
|
|
123
123
|
project: string;
|
|
124
124
|
restName?: string;
|
|
125
|
+
/** A forwarding rule's declared `ipAddress.addressRef` and that address's live IP (#4238). */
|
|
126
|
+
address?: { ip: string; ref: unknown };
|
|
125
127
|
}
|
|
126
128
|
|
|
127
129
|
type CnrmTree = Record<string, unknown> & { metadata?: Record<string, unknown> };
|
|
@@ -316,8 +318,14 @@ const CNRM_REFINERS: Record<string, (tree: CnrmTree, ctx: RefineContext) => void
|
|
|
316
318
|
delete tree.gatewayAddress;
|
|
317
319
|
delete tree.fingerprint;
|
|
318
320
|
},
|
|
319
|
-
ComputeForwardingRule(tree) {
|
|
321
|
+
ComputeForwardingRule(tree, ctx) {
|
|
320
322
|
computeLocation(tree);
|
|
323
|
+
// Compute returns the IP; CNRM writes `ipAddress: { addressRef }` or `{ ip }`.
|
|
324
|
+
// The IP is the declared address's own when the read found it so (#4238).
|
|
325
|
+
if (typeof tree.IPAddress === "string") {
|
|
326
|
+
tree.ipAddress = ctx.address && ctx.address.ip === tree.IPAddress ? { addressRef: ctx.address.ref } : { ip: tree.IPAddress };
|
|
327
|
+
delete tree.IPAddress;
|
|
328
|
+
}
|
|
321
329
|
if (tree.IPProtocol !== undefined) {
|
|
322
330
|
tree.ipProtocol = tree.IPProtocol;
|
|
323
331
|
delete tree.IPProtocol;
|
|
@@ -374,6 +382,33 @@ export function toCnrmTree(kind: string, body: Record<string, unknown>, ctx: Ref
|
|
|
374
382
|
return tree as Record<string, unknown>;
|
|
375
383
|
}
|
|
376
384
|
|
|
385
|
+
/**
|
|
386
|
+
* The live IP of the address a forwarding rule declares in
|
|
387
|
+
* `ipAddress.addressRef`, read with the address's own declaration when the
|
|
388
|
+
* manifest holds it, so the rule's `IPAddress` can be matched to the ref
|
|
389
|
+
* rather than read as drift (#4238). Undefined when there is no ref or the
|
|
390
|
+
* read fails; the rule then shows the IP as it is.
|
|
391
|
+
*/
|
|
392
|
+
async function declaredAddressIp(
|
|
393
|
+
client: GcpReadClientOptions,
|
|
394
|
+
props: Record<string, unknown>,
|
|
395
|
+
manifest: readonly { kind?: string; metadata?: { name?: string }; spec?: Record<string, unknown> }[],
|
|
396
|
+
): Promise<{ ip: string; ref: unknown } | undefined> {
|
|
397
|
+
const spec = (props.spec as Record<string, unknown> | undefined) ?? props;
|
|
398
|
+
const ref = (spec.ipAddress as { addressRef?: { name?: unknown; external?: unknown } } | undefined)?.addressRef;
|
|
399
|
+
if (!ref) return undefined;
|
|
400
|
+
const raw = typeof ref.name === "string" ? ref.name : typeof ref.external === "string" ? ref.external : undefined;
|
|
401
|
+
if (!raw) return undefined;
|
|
402
|
+
const name = raw.slice(raw.lastIndexOf("/") + 1);
|
|
403
|
+
const declared = manifest.find((r) => r.kind === "ComputeAddress" && r.metadata?.name === name);
|
|
404
|
+
try {
|
|
405
|
+
const body = await getResource(client, "ComputeAddress", name, declared?.spec ?? { location: spec.location });
|
|
406
|
+
return typeof body.address === "string" ? { ip: body.address, ref } : undefined;
|
|
407
|
+
} catch {
|
|
408
|
+
return undefined;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
377
412
|
/**
|
|
378
413
|
* Read the live property tree for each declared entity over the applier's
|
|
379
414
|
* REST transport, normalized into the declared CNRM shape (see the module
|
|
@@ -388,6 +423,7 @@ export async function observeResourcesDeepGcp(options: GcpDeepObserveOptions): P
|
|
|
388
423
|
const endpoint = process.env.GCP_ENDPOINT_URL;
|
|
389
424
|
const manifestProjects = manifestProjectAnnotations(options.buildOutput);
|
|
390
425
|
const targets = readTargets(options.entities);
|
|
426
|
+
const manifest = readManifest(options.entities, targets);
|
|
391
427
|
|
|
392
428
|
const reads = [...options.entities].map(async ([entityName, { entityType }]) => {
|
|
393
429
|
const gvk = deriveGVK(entityType);
|
|
@@ -410,6 +446,7 @@ export async function observeResourcesDeepGcp(options: GcpDeepObserveOptions): P
|
|
|
410
446
|
client = {
|
|
411
447
|
project: resolveReadProject(gvk.kind, name, metadata, manifestProjects, props),
|
|
412
448
|
...(endpoint ? { endpoint } : {}),
|
|
449
|
+
manifest,
|
|
413
450
|
};
|
|
414
451
|
} catch (err) {
|
|
415
452
|
unobserved[entityName] = {
|
|
@@ -423,6 +460,7 @@ export async function observeResourcesDeepGcp(options: GcpDeepObserveOptions): P
|
|
|
423
460
|
try {
|
|
424
461
|
const obj = await getResource(client, gvk.kind, name, props);
|
|
425
462
|
const labels = obj.labels as Record<string, string> | null | undefined;
|
|
463
|
+
const address = gvk.kind === "ComputeForwardingRule" ? await declaredAddressIp(client, props, manifest) : undefined;
|
|
426
464
|
|
|
427
465
|
// owned filter: withhold what does not carry chant's marker. Withheld is
|
|
428
466
|
// not absent (#1089). Where the payload has no labels at all there is
|
|
@@ -441,7 +479,7 @@ export async function observeResourcesDeepGcp(options: GcpDeepObserveOptions): P
|
|
|
441
479
|
type: entityType,
|
|
442
480
|
physicalId:
|
|
443
481
|
(obj.id as string | undefined) ?? (obj.selfLink as string | undefined) ?? (obj.name as string | undefined),
|
|
444
|
-
properties: normalizeDeepProperties(toCnrmTree(gvk.kind, obj, { project: client.project }), {
|
|
482
|
+
properties: normalizeDeepProperties(toCnrmTree(gvk.kind, obj, { project: client.project, ...(address ? { address } : {}) }), {
|
|
445
483
|
entityType,
|
|
446
484
|
side: "live",
|
|
447
485
|
// The static table is the whole prune here — there is no per-resource
|
|
@@ -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 { NO_PROJECT, parseManifest, projectRefProject } from "./op/activities/gcp-apply";
|
|
43
|
+
import { NO_PROJECT, parseManifest, projectRefProject, type GcpResource } from "./op/activities/gcp-apply";
|
|
44
44
|
import { cnrmNameOf, resolveCnrmRefs, resolveGVK } from "./serializer";
|
|
45
45
|
|
|
46
46
|
/**
|
|
@@ -167,6 +167,26 @@ export function readTargets(
|
|
|
167
167
|
return out;
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
/**
|
|
171
|
+
* The declared resources as the applier's manifest holds them (kind, CNRM
|
|
172
|
+
* name, spec), for a read that needs another declared resource: an
|
|
173
|
+
* IAMPolicyMember on a RunService takes the service's region from it (#4251).
|
|
174
|
+
*/
|
|
175
|
+
export function readManifest(
|
|
176
|
+
entities: Map<string, { entityType: string; props: Record<string, unknown> }>,
|
|
177
|
+
targets: Map<string, { name: string; props: Record<string, unknown> }>,
|
|
178
|
+
): GcpResource[] {
|
|
179
|
+
const out: GcpResource[] = [];
|
|
180
|
+
for (const [logical, { entityType }] of entities) {
|
|
181
|
+
const gvk = deriveGVK(entityType);
|
|
182
|
+
const target = targets.get(logical);
|
|
183
|
+
if (!gvk || !target) continue;
|
|
184
|
+
const spec = (target.props.spec as Record<string, unknown> | undefined) ?? target.props;
|
|
185
|
+
out.push({ kind: gvk.kind, metadata: { name: target.name }, spec });
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
170
190
|
function pruneUndefined<T extends Record<string, unknown>>(obj: T): Record<string, unknown> {
|
|
171
191
|
const out: Record<string, unknown> = {};
|
|
172
192
|
for (const [k, v] of Object.entries(obj)) {
|
|
@@ -223,6 +243,7 @@ export async function describeResources(options: {
|
|
|
223
243
|
|
|
224
244
|
const manifestProjects = manifestProjectAnnotations(options.buildOutput);
|
|
225
245
|
const targets = readTargets(options.entities);
|
|
246
|
+
const manifest = readManifest(options.entities, targets);
|
|
226
247
|
|
|
227
248
|
const reads = [...options.entities].map(async ([entityName, { entityType }]) => {
|
|
228
249
|
const gvk = deriveGVK(entityType);
|
|
@@ -253,6 +274,7 @@ export async function describeResources(options: {
|
|
|
253
274
|
client = {
|
|
254
275
|
project: resolveReadProject(gvk.kind, name, metadata, manifestProjects, props),
|
|
255
276
|
...(endpoint ? { endpoint } : {}),
|
|
277
|
+
manifest,
|
|
256
278
|
};
|
|
257
279
|
} catch (err) {
|
|
258
280
|
// No project resolvable — nothing was queried, so nothing is known.
|
package/src/health.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
2
|
-
import { cloudRunEntry, observeHealthGcp } from "./health";
|
|
2
|
+
import { backendServiceEntry, cloudRunEntry, GCP_HEALTH_TYPES, observeHealthGcp } from "./health";
|
|
3
|
+
import { gcpPlugin } from "./plugin";
|
|
3
4
|
import type { GcpReadHttp } from "./api/read-client";
|
|
4
5
|
|
|
5
6
|
const AT = "2026-10-20T21:00:00.000Z";
|
|
@@ -56,4 +57,67 @@ describe("gcp health (#4177)", () => {
|
|
|
56
57
|
const r = await observeHealthGcp({ environment: "dev", buildOutput: "", entities, http: denied });
|
|
57
58
|
expect(r.unobserved.api?.reason).toBe("no-credentials");
|
|
58
59
|
});
|
|
60
|
+
|
|
61
|
+
test("backend service: any unhealthy endpoint is unhealthy, all healthy is healthy, no status is unknown (#4239)", () => {
|
|
62
|
+
const g = (healthState: string) => ({ group: "g", healthStatus: [{ healthState }] });
|
|
63
|
+
expect(backendServiceEntry([g("HEALTHY"), g("HEALTHY")], AT)).toMatchObject({ state: "healthy", detail: "2 of 2 backends healthy" });
|
|
64
|
+
expect(backendServiceEntry([g("HEALTHY"), g("UNHEALTHY")], AT)).toMatchObject({ state: "unhealthy", detail: "1 of 2 backends healthy, 1 unhealthy" });
|
|
65
|
+
// A serverless NEG reports no health status; a failed call counts the same.
|
|
66
|
+
expect(backendServiceEntry([{ group: "neg", healthStatus: [] }], AT)).toMatchObject({ state: "unknown", detail: "0 of 1 backend healthy, 1 with no health status" });
|
|
67
|
+
expect(backendServiceEntry([{ group: "g", error: "getHealth 400" }], AT).state).toBe("unknown");
|
|
68
|
+
expect(backendServiceEntry([], AT)).toMatchObject({ state: "unknown", detail: "no backends" });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("a backend service is read, then getHealth is called for each backend group at its URL (#4239)", async () => {
|
|
72
|
+
const calls: Array<{ method: string; url: string; body?: unknown }> = [];
|
|
73
|
+
const group = "https://compute.googleapis.com/compute/v1/projects/demo/zones/us-central1-a/instanceGroups/ig";
|
|
74
|
+
const http: GcpReadHttp = async (method, url, body) => {
|
|
75
|
+
calls.push({ method, url, ...(body === undefined ? {} : { body }) });
|
|
76
|
+
if (method === "GET") return { status: 200, text: JSON.stringify({ name: "web", backends: [{ group }] }) };
|
|
77
|
+
return { status: 200, text: JSON.stringify({ healthStatus: [{ healthState: "HEALTHY" }, { healthState: "HEALTHY" }] }) };
|
|
78
|
+
};
|
|
79
|
+
const lb = new Map([
|
|
80
|
+
[
|
|
81
|
+
"backend",
|
|
82
|
+
{
|
|
83
|
+
entityType: "GCP::Compute::BackendService",
|
|
84
|
+
props: { metadata: { name: "web", annotations: { "cnrm.cloud.google.com/project-id": "demo" } }, location: "global" },
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
]);
|
|
88
|
+
const r = await observeHealthGcp({ environment: "dev", buildOutput: "", entities: lb, http, now });
|
|
89
|
+
expect(r.entities.backend).toMatchObject({ type: "GCP::Compute::BackendService", state: "healthy" });
|
|
90
|
+
expect(calls[1]).toEqual({ method: "POST", url: "http://localhost:4588/compute/v1/projects/demo/global/backendServices/web/getHealth", body: { group } });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("an IAM policy member in the target's policy is healthy; one missing from it is absent (#4239)", async () => {
|
|
94
|
+
const iam = new Map<string, { entityType: string; props: Record<string, unknown> }>([
|
|
95
|
+
[
|
|
96
|
+
"publicIam",
|
|
97
|
+
{
|
|
98
|
+
entityType: "GCP::Iam::PolicyMember",
|
|
99
|
+
props: {
|
|
100
|
+
metadata: { name: "api-public", annotations: { "cnrm.cloud.google.com/project-id": "demo" } },
|
|
101
|
+
member: "allUsers",
|
|
102
|
+
role: "roles/run.invoker",
|
|
103
|
+
resourceRef: { kind: "RunService", name: "api" },
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
...entities,
|
|
108
|
+
]);
|
|
109
|
+
const policy = (members: string[]): GcpReadHttp => async (_m, url) =>
|
|
110
|
+
url.includes(":getIamPolicy")
|
|
111
|
+
? { status: 200, text: JSON.stringify({ bindings: [{ role: "roles/run.invoker", members }] }) }
|
|
112
|
+
: { status: 200, text: JSON.stringify({ terminalCondition: { type: "Ready", state: "CONDITION_SUCCEEDED" } }) };
|
|
113
|
+
const present = await observeHealthGcp({ environment: "dev", buildOutput: "", entities: iam, http: policy(["allUsers"]), now });
|
|
114
|
+
expect(present.entities.publicIam).toMatchObject({ type: "GCP::Iam::PolicyMember", state: "healthy" });
|
|
115
|
+
expect(present.entities.publicIam?.detail).toMatch(/^allUsers holds roles\/run\.invoker on /);
|
|
116
|
+
const missing = await observeHealthGcp({ environment: "dev", buildOutput: "", entities: iam, http: policy(["user:x@y.z"]), now });
|
|
117
|
+
expect(missing.absent).toContain("publicIam");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("the plugin's healthTypes match the reader's", () => {
|
|
121
|
+
expect([...(gcpPlugin.healthTypes ?? [])]).toEqual([...GCP_HEALTH_TYPES]);
|
|
122
|
+
});
|
|
59
123
|
});
|