@intentius/chant-lexicon-gcp 0.28.0 → 0.30.0
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/describe-resources.d.ts +6 -3
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +2 -2
- package/src/describe-resources.test.ts +31 -11
- package/src/describe-resources.ts +44 -10
- package/src/lifecycle-integration.test.ts +61 -14
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
* GCP::Storage::Bucket → kubectl get storagebucket.storage.cnrm.cloud.google.com
|
|
10
10
|
* GCP::Compute::Subnetwork → kubectl get computesubnetwork.compute.cnrm.cloud.google.com
|
|
11
11
|
*
|
|
12
|
-
* Resource-not-found is
|
|
12
|
+
* Resource-not-found is an absence — `state diff --live` reports it as missing.
|
|
13
|
+
* Everything else the kubectl call can fail with (auth, an unreachable API
|
|
14
|
+
* server, an entity type with no derivable GVK) is NOT-OBSERVED (#1089), so a
|
|
15
|
+
* read that never happened cannot become a proposed `create`.
|
|
13
16
|
*
|
|
14
17
|
* Since this reads Config Connector CRDs through the same kubectl path as
|
|
15
18
|
* the K8s lexicon, it resolves the same environment→cluster binding (chant
|
|
@@ -18,7 +21,7 @@
|
|
|
18
21
|
* `gcp.profiles` key, because it is fundamentally the same kubectl context a
|
|
19
22
|
* project's K8s entities would use against the same cluster.
|
|
20
23
|
*/
|
|
21
|
-
import type {
|
|
24
|
+
import type { ObservationResult } from "@intentius/chant/lexicon";
|
|
22
25
|
/**
|
|
23
26
|
* Mirror of `lexicons/gcp/src/serializer.ts:deriveGVKFromType` — keeping the
|
|
24
27
|
* derivation logic local so describeResources can compute the kubectl resource
|
|
@@ -37,5 +40,5 @@ export declare function describeResources(options: {
|
|
|
37
40
|
props: Record<string, unknown>;
|
|
38
41
|
}>;
|
|
39
42
|
owned?: boolean;
|
|
40
|
-
}): Promise<
|
|
43
|
+
}): Promise<ObservationResult>;
|
|
41
44
|
//# sourceMappingURL=describe-resources.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"describe-resources.d.ts","sourceRoot":"","sources":["../src/describe-resources.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"describe-resources.d.ts","sourceRoot":"","sources":["../src/describe-resources.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAIH,OAAO,KAAK,EAAE,iBAAiB,EAAsC,MAAM,0BAA0B,CAAC;AAuBtG;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,UAAU,EAAE,MAAM,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CASpF;AA4BD,wBAAsB,iBAAiB,CAAC,OAAO,EAAE;IAC/C,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IAC9E,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CA8E7B"}
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "934b2e465f4a0be499267e8b23f84e1df1353c29eb599046ccb50bc20b3c2bd8",
|
|
5
5
|
"meta.json": "475f10909854d059a2e2d4fb541c058803422e38b9c64bd4d6858c0623eedb8c",
|
|
6
6
|
"types/index.d.ts": "5433b54c04b57d6fda8f3d81ef92ed0aca87f7e8e5dd3ea92d8de6e62a3ccfab",
|
|
7
7
|
"rules/hardcoded-project.ts": "228631d3159e1ffcce2359c66073d4ae59bb0285f378e46e446f416aec50481c",
|
|
@@ -37,5 +37,5 @@
|
|
|
37
37
|
"skills/chant-gcp-patterns.md": "a7ef31c1eb2f7244d3f73952c300472ef94c1eb09bd7a1003281b89299b6b704",
|
|
38
38
|
"skills/chant-gcp-gke.md": "be277019da9a722c851e47cd2dfb9c9536668948c3535fb20db7697e934c4e2b"
|
|
39
39
|
},
|
|
40
|
-
"composite": "
|
|
40
|
+
"composite": "9b0f2abc33da4def1bad8cc078ec2d56039630e59fe397152d17d1a317f80b30"
|
|
41
41
|
}
|
package/dist/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-gcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
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,7 +76,7 @@
|
|
|
76
76
|
"typescript": "^5.9.3"
|
|
77
77
|
},
|
|
78
78
|
"peerDependencies": {
|
|
79
|
-
"@intentius/chant": "^0.
|
|
79
|
+
"@intentius/chant": "^0.30.0",
|
|
80
80
|
"typescript": "^5.9.3"
|
|
81
81
|
}
|
|
82
82
|
}
|
|
@@ -55,7 +55,7 @@ describe("gcp describeResources (Config Connector)", () => {
|
|
|
55
55
|
expect(receivedCmd).toContain("data-bucket");
|
|
56
56
|
expect(receivedCmd).toContain("-n config-control");
|
|
57
57
|
|
|
58
|
-
expect(result["dataBucket"]).toMatchObject({
|
|
58
|
+
expect(result.resources["dataBucket"]).toMatchObject({
|
|
59
59
|
type: "GCP::Storage::Bucket",
|
|
60
60
|
physicalId: "uid-1",
|
|
61
61
|
status: "READY",
|
|
@@ -99,7 +99,7 @@ describe("gcp describeResources (Config Connector)", () => {
|
|
|
99
99
|
|
|
100
100
|
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["x"], entities });
|
|
101
101
|
|
|
102
|
-
expect(result["x"].status).toBe("DependencyNotFound");
|
|
102
|
+
expect(result.resources["x"].status).toBe("DependencyNotFound");
|
|
103
103
|
});
|
|
104
104
|
|
|
105
105
|
test("missing Ready condition falls back to PRESENT", async () => {
|
|
@@ -117,10 +117,10 @@ describe("gcp describeResources (Config Connector)", () => {
|
|
|
117
117
|
|
|
118
118
|
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["x"], entities });
|
|
119
119
|
|
|
120
|
-
expect(result["x"].status).toBe("PRESENT");
|
|
120
|
+
expect(result.resources["x"].status).toBe("PRESENT");
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
-
test("kubectl-not-found leaves entity out of result", async () => {
|
|
123
|
+
test("kubectl-not-found leaves entity out of result (a confirmed absence)", async () => {
|
|
124
124
|
execMock.mockImplementation(() => { throw new Error('Error from server (NotFound): storagebucket "x" not found'); });
|
|
125
125
|
|
|
126
126
|
const entities = makeEntities([
|
|
@@ -129,28 +129,48 @@ describe("gcp describeResources (Config Connector)", () => {
|
|
|
129
129
|
|
|
130
130
|
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["x"], entities });
|
|
131
131
|
|
|
132
|
-
expect(result).toEqual({});
|
|
132
|
+
expect(result.resources).toEqual({});
|
|
133
|
+
expect(result.unobserved ?? {}).toEqual({});
|
|
133
134
|
});
|
|
134
135
|
|
|
135
|
-
test("
|
|
136
|
+
test("an unreachable cluster is unobserved, not absent (#1089)", async () => {
|
|
137
|
+
execMock.mockImplementation(() => {
|
|
138
|
+
throw Object.assign(new Error("kubectl failed"), {
|
|
139
|
+
stderr: "Unable to connect to the server: dial tcp: i/o timeout",
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const entities = makeEntities([
|
|
144
|
+
{ name: "x", entityType: "GCP::Storage::Bucket", props: { metadata: { name: "x" } } },
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["x"], entities });
|
|
148
|
+
|
|
149
|
+
expect(result.resources).toEqual({});
|
|
150
|
+
expect(result.unobserved?.x?.reason).toBe("no-binding");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("non-GCP entity types are unobserved — no GVK to query (#1089)", async () => {
|
|
136
154
|
const entities = makeEntities([
|
|
137
155
|
{ name: "x", entityType: "AWS::S3::Bucket", props: { metadata: { name: "x" } } },
|
|
138
156
|
]);
|
|
139
157
|
|
|
140
158
|
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["x"], entities });
|
|
141
159
|
|
|
142
|
-
expect(result).toEqual({});
|
|
160
|
+
expect(result.resources).toEqual({});
|
|
161
|
+
expect(result.unobserved?.x?.reason).toBe("unsupported-kind");
|
|
143
162
|
expect(execMock).not.toHaveBeenCalled();
|
|
144
163
|
});
|
|
145
164
|
|
|
146
|
-
test("entity without metadata.name is
|
|
165
|
+
test("entity without metadata.name is unobserved — nothing was queried", async () => {
|
|
147
166
|
const entities = makeEntities([
|
|
148
167
|
{ name: "broken", entityType: "GCP::Storage::Bucket", props: {} },
|
|
149
168
|
]);
|
|
150
169
|
|
|
151
170
|
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["broken"], entities });
|
|
152
171
|
|
|
153
|
-
expect(result).toEqual({});
|
|
172
|
+
expect(result.resources).toEqual({});
|
|
173
|
+
expect(result.unobserved?.broken?.reason).toBe("read-failed");
|
|
154
174
|
expect(execMock).not.toHaveBeenCalled();
|
|
155
175
|
});
|
|
156
176
|
|
|
@@ -182,7 +202,7 @@ describe("gcp describeResources (Config Connector)", () => {
|
|
|
182
202
|
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["dataBucket"], entities: bucketEntities() });
|
|
183
203
|
|
|
184
204
|
expect(receivedCmd).toContain("--context prod-cnrm");
|
|
185
|
-
expect(result["dataBucket"]).toMatchObject({ type: "GCP::Storage::Bucket", physicalId: "uid-1", status: "READY" });
|
|
205
|
+
expect(result.resources["dataBucket"]).toMatchObject({ type: "GCP::Storage::Bucket", physicalId: "uid-1", status: "READY" });
|
|
186
206
|
});
|
|
187
207
|
|
|
188
208
|
test("bound and ambient context mismatches: refuses loudly instead of observing the wrong cluster", async () => {
|
|
@@ -212,7 +232,7 @@ describe("gcp describeResources (Config Connector)", () => {
|
|
|
212
232
|
|
|
213
233
|
expect(receivedCmd).not.toContain("--context");
|
|
214
234
|
expect(receivedCmd).not.toContain("current-context");
|
|
215
|
-
expect(result["dataBucket"]).toMatchObject({ type: "GCP::Storage::Bucket", physicalId: "uid-1", status: "READY" });
|
|
235
|
+
expect(result.resources["dataBucket"]).toMatchObject({ type: "GCP::Storage::Bucket", physicalId: "uid-1", status: "READY" });
|
|
216
236
|
|
|
217
237
|
const bindingWarning = warnSpy.mock.calls.find((c) => String(c[0]).includes("no cluster binding"));
|
|
218
238
|
expect(bindingWarning?.[0]).toContain('environment "prod"');
|
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
* GCP::Storage::Bucket → kubectl get storagebucket.storage.cnrm.cloud.google.com
|
|
10
10
|
* GCP::Compute::Subnetwork → kubectl get computesubnetwork.compute.cnrm.cloud.google.com
|
|
11
11
|
*
|
|
12
|
-
* Resource-not-found is
|
|
12
|
+
* Resource-not-found is an absence — `state diff --live` reports it as missing.
|
|
13
|
+
* Everything else the kubectl call can fail with (auth, an unreachable API
|
|
14
|
+
* server, an entity type with no derivable GVK) is NOT-OBSERVED (#1089), so a
|
|
15
|
+
* read that never happened cannot become a proposed `create`.
|
|
13
16
|
*
|
|
14
17
|
* Since this reads Config Connector CRDs through the same kubectl path as
|
|
15
18
|
* the K8s lexicon, it resolves the same environment→cluster binding (chant
|
|
@@ -21,10 +24,11 @@
|
|
|
21
24
|
|
|
22
25
|
import { exec } from "node:child_process";
|
|
23
26
|
import { promisify } from "node:util";
|
|
24
|
-
import type { ResourceMetadata } from "@intentius/chant/lexicon";
|
|
27
|
+
import type { ObservationResult, ResourceMetadata, UnobservedEntity } from "@intentius/chant/lexicon";
|
|
28
|
+
import { observation } from "@intentius/chant/observation";
|
|
25
29
|
import { hasOwnershipMarker, classifyOwnership, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
|
|
26
30
|
import { loadChantConfig } from "@intentius/chant/config";
|
|
27
|
-
import { resolveClusterTarget } from "@intentius/chant/kubectl-context";
|
|
31
|
+
import { resolveClusterTarget, classifyKubectlFailure } from "@intentius/chant/kubectl-context";
|
|
28
32
|
|
|
29
33
|
const execAsync = promisify(exec);
|
|
30
34
|
|
|
@@ -91,8 +95,9 @@ export async function describeResources(options: {
|
|
|
91
95
|
entityNames: string[];
|
|
92
96
|
entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
93
97
|
owned?: boolean;
|
|
94
|
-
}): Promise<
|
|
98
|
+
}): Promise<ObservationResult> {
|
|
95
99
|
const result: Record<string, ResourceMetadata> = {};
|
|
100
|
+
const unobserved: Record<string, UnobservedEntity> = {};
|
|
96
101
|
|
|
97
102
|
// Resolve the cluster identity for this environment before touching any
|
|
98
103
|
// resource — a declared-but-mismatched binding throws here, aborting the
|
|
@@ -104,11 +109,27 @@ export async function describeResources(options: {
|
|
|
104
109
|
|
|
105
110
|
for (const [entityName, { entityType, props }] of options.entities) {
|
|
106
111
|
const gvk = deriveGVK(entityType);
|
|
107
|
-
if (!gvk)
|
|
112
|
+
if (!gvk) {
|
|
113
|
+
// Not a `GCP::Service::Kind` this lexicon can turn into a Config
|
|
114
|
+
// Connector GVK — nothing was queried, so nothing is known (#1089).
|
|
115
|
+
unobserved[entityName] = {
|
|
116
|
+
type: entityType,
|
|
117
|
+
reason: "unsupported-kind",
|
|
118
|
+
detail: `cannot derive a Config Connector GVK from ${entityType}`,
|
|
119
|
+
};
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
108
122
|
|
|
109
123
|
const metadata = props.metadata as { name?: string; namespace?: string } | undefined;
|
|
110
124
|
const name = metadata?.name;
|
|
111
|
-
if (!name)
|
|
125
|
+
if (!name) {
|
|
126
|
+
unobserved[entityName] = {
|
|
127
|
+
type: entityType,
|
|
128
|
+
reason: "read-failed",
|
|
129
|
+
detail: "declared entity has no metadata.name to query by",
|
|
130
|
+
};
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
112
133
|
|
|
113
134
|
const kubectlResource = `${gvk.kind.toLowerCase()}.${gvk.group}`;
|
|
114
135
|
const nsArg = metadata.namespace ? ["-n", metadata.namespace] : [];
|
|
@@ -117,8 +138,14 @@ export async function describeResources(options: {
|
|
|
117
138
|
try {
|
|
118
139
|
const { stdout } = await execAsync(cmd);
|
|
119
140
|
const obj: KubectlResponse = JSON.parse(stdout);
|
|
120
|
-
// owned filter:
|
|
141
|
+
// owned filter: withhold resources not carrying chant's marker label.
|
|
142
|
+
// Withheld is not absent (#1089) — the CR exists, it just isn't chant's.
|
|
121
143
|
if (options.owned && !hasOwnershipMarker(obj.metadata?.labels, LABEL_OWNERSHIP_KEYS)) {
|
|
144
|
+
unobserved[entityName] = {
|
|
145
|
+
type: entityType,
|
|
146
|
+
reason: "filtered",
|
|
147
|
+
detail: "live resource carries no chant ownership marker and --owned was requested",
|
|
148
|
+
};
|
|
122
149
|
continue;
|
|
123
150
|
}
|
|
124
151
|
result[entityName] = {
|
|
@@ -133,10 +160,17 @@ export async function describeResources(options: {
|
|
|
133
160
|
annotations: obj.metadata?.annotations,
|
|
134
161
|
}),
|
|
135
162
|
};
|
|
136
|
-
} catch {
|
|
137
|
-
//
|
|
163
|
+
} catch (err) {
|
|
164
|
+
// A NotFound is a real absence (the CR isn't there, or Config Connector
|
|
165
|
+
// doesn't serve that CRD, so no instance can be). Anything else — auth,
|
|
166
|
+
// an unreachable API server, a context that doesn't resolve — proves
|
|
167
|
+
// nothing and is reported as a hole rather than an absence (#1089).
|
|
168
|
+
const outcome = classifyKubectlFailure(err);
|
|
169
|
+
if (outcome.kind === "unobserved") {
|
|
170
|
+
unobserved[entityName] = { type: entityType, reason: outcome.reason, detail: outcome.detail };
|
|
171
|
+
}
|
|
138
172
|
}
|
|
139
173
|
}
|
|
140
174
|
|
|
141
|
-
return result;
|
|
175
|
+
return observation(result, unobserved);
|
|
142
176
|
}
|
|
@@ -31,6 +31,8 @@ vi.mock("node:child_process", async () => {
|
|
|
31
31
|
const { gcpPlugin } = await import("./plugin");
|
|
32
32
|
const { liveImportFromPlugins } = await import("@intentius/chant/cli/commands/import");
|
|
33
33
|
const { buildChangeSet } = await import("@intentius/chant/lifecycle/change-set");
|
|
34
|
+
const { normalizeObservation } = await import("@intentius/chant/observation");
|
|
35
|
+
const { describeObservationConformance } = await import("@intentius/chant-test-utils");
|
|
34
36
|
|
|
35
37
|
const liveBucket = {
|
|
36
38
|
apiVersion: "storage.cnrm.cloud.google.com/v1beta1",
|
|
@@ -84,20 +86,22 @@ describe("gcp lifecycle integration (#163)", () => {
|
|
|
84
86
|
: new Error("not found"),
|
|
85
87
|
);
|
|
86
88
|
|
|
87
|
-
const observedNow =
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
[
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
89
|
+
const { resources: observedNow } = normalizeObservation(
|
|
90
|
+
await gcpPlugin.describeResources!({
|
|
91
|
+
environment: "prod",
|
|
92
|
+
buildOutput: "",
|
|
93
|
+
entityNames: ["dataBucket"],
|
|
94
|
+
entities: new Map([
|
|
95
|
+
[
|
|
96
|
+
"dataBucket",
|
|
97
|
+
{
|
|
98
|
+
entityType: "GCP::Storage::Bucket",
|
|
99
|
+
props: { metadata: { name: "data-bucket", namespace: "config-control" } },
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
]),
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
101
105
|
expect(observedNow.dataBucket?.type).toBe("GCP::Storage::Bucket");
|
|
102
106
|
|
|
103
107
|
const cs = buildChangeSet("prod", {
|
|
@@ -117,3 +121,46 @@ describe("gcp lifecycle integration (#163)", () => {
|
|
|
117
121
|
expect(cs2.entries.find((e) => e.name === "dataBucket")!.action).toBe("noop");
|
|
118
122
|
});
|
|
119
123
|
});
|
|
124
|
+
|
|
125
|
+
// The shared conformance suite (#1089).
|
|
126
|
+
describeObservationConformance({
|
|
127
|
+
lexicon: "gcp",
|
|
128
|
+
scenarios: [
|
|
129
|
+
{
|
|
130
|
+
name: "an entity type with no derivable Config Connector GVK",
|
|
131
|
+
declared: ["notGcp", "gone"],
|
|
132
|
+
expectUnobserved: ["notGcp"],
|
|
133
|
+
expectAbsent: ["gone"],
|
|
134
|
+
run: () => {
|
|
135
|
+
execMock.mockImplementation(() => new Error('Error from server (NotFound): storagebucket "gone" not found'));
|
|
136
|
+
return gcpPlugin.describeResources!({
|
|
137
|
+
environment: "prod",
|
|
138
|
+
buildOutput: "",
|
|
139
|
+
entityNames: ["notGcp", "gone"],
|
|
140
|
+
entities: new Map([
|
|
141
|
+
["notGcp", { entityType: "AWS::S3::Bucket", props: { metadata: { name: "not-gcp" } } }],
|
|
142
|
+
["gone", { entityType: "GCP::Storage::Bucket", props: { metadata: { name: "gone" } } }],
|
|
143
|
+
]),
|
|
144
|
+
});
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: "an unreachable Config Connector cluster",
|
|
149
|
+
declared: ["dataBucket"],
|
|
150
|
+
expectUnobserved: ["dataBucket"],
|
|
151
|
+
run: () => {
|
|
152
|
+
execMock.mockImplementation(() =>
|
|
153
|
+
Object.assign(new Error("kubectl failed"), { stderr: "Unable to connect to the server: dial tcp: i/o timeout" }),
|
|
154
|
+
);
|
|
155
|
+
return gcpPlugin.describeResources!({
|
|
156
|
+
environment: "prod",
|
|
157
|
+
buildOutput: "",
|
|
158
|
+
entityNames: ["dataBucket"],
|
|
159
|
+
entities: new Map([
|
|
160
|
+
["dataBucket", { entityType: "GCP::Storage::Bucket", props: { metadata: { name: "data-bucket" } } }],
|
|
161
|
+
]),
|
|
162
|
+
});
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
});
|