@intentius/chant-lexicon-gcp 0.27.0 → 0.29.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 +13 -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 +105 -9
- package/src/describe-resources.ts +61 -10
- package/src/lifecycle-integration.test.ts +61 -14
|
@@ -9,9 +9,19 @@
|
|
|
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`.
|
|
16
|
+
*
|
|
17
|
+
* Since this reads Config Connector CRDs through the same kubectl path as
|
|
18
|
+
* the K8s lexicon, it resolves the same environment→cluster binding (chant
|
|
19
|
+
* #1100) via `resolveClusterTarget` — `k8s.profiles.<env>.context` in
|
|
20
|
+
* `chant.config.ts` (see `lexicons/k8s/src/config.ts`), not a separate
|
|
21
|
+
* `gcp.profiles` key, because it is fundamentally the same kubectl context a
|
|
22
|
+
* project's K8s entities would use against the same cluster.
|
|
13
23
|
*/
|
|
14
|
-
import type {
|
|
24
|
+
import type { ObservationResult } from "@intentius/chant/lexicon";
|
|
15
25
|
/**
|
|
16
26
|
* Mirror of `lexicons/gcp/src/serializer.ts:deriveGVKFromType` — keeping the
|
|
17
27
|
* derivation logic local so describeResources can compute the kubectl resource
|
|
@@ -30,5 +40,5 @@ export declare function describeResources(options: {
|
|
|
30
40
|
props: Record<string, unknown>;
|
|
31
41
|
}>;
|
|
32
42
|
owned?: boolean;
|
|
33
|
-
}): Promise<
|
|
43
|
+
}): Promise<ObservationResult>;
|
|
34
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": "fa47fcfe6d8f3de4d1844dce0ccf6c7d35bfb0596dd700a11ea870986b29e8e6",
|
|
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": "83f8a5fc48ff3282c804d14768e9c904873d2fdc1b168276400780da729a4ba8"
|
|
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.29.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.29.0",
|
|
80
80
|
"typescript": "^5.9.3"
|
|
81
81
|
}
|
|
82
82
|
}
|
|
@@ -11,6 +11,11 @@ vi.mock("node:child_process", async () => {
|
|
|
11
11
|
} };
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
+
const loadChantConfigMock = vi.fn();
|
|
15
|
+
vi.mock("@intentius/chant/config", () => ({
|
|
16
|
+
loadChantConfig: (...args: unknown[]) => loadChantConfigMock(...args),
|
|
17
|
+
}));
|
|
18
|
+
|
|
14
19
|
const { describeResources } = await import("./describe-resources");
|
|
15
20
|
|
|
16
21
|
function makeEntities(records: Array<{ name: string; entityType: string; props: Record<string, unknown> }>) {
|
|
@@ -20,6 +25,10 @@ function makeEntities(records: Array<{ name: string; entityType: string; props:
|
|
|
20
25
|
describe("gcp describeResources (Config Connector)", () => {
|
|
21
26
|
beforeEach(() => {
|
|
22
27
|
execMock.mockReset();
|
|
28
|
+
loadChantConfigMock.mockReset();
|
|
29
|
+
// No binding declared by default — matches every test below except the
|
|
30
|
+
// dedicated cluster-binding tests, which override this per case.
|
|
31
|
+
loadChantConfigMock.mockResolvedValue({ config: {} });
|
|
23
32
|
});
|
|
24
33
|
|
|
25
34
|
test("queries kubectl with the derived CC GVK and maps the response", async () => {
|
|
@@ -46,7 +55,7 @@ describe("gcp describeResources (Config Connector)", () => {
|
|
|
46
55
|
expect(receivedCmd).toContain("data-bucket");
|
|
47
56
|
expect(receivedCmd).toContain("-n config-control");
|
|
48
57
|
|
|
49
|
-
expect(result["dataBucket"]).toMatchObject({
|
|
58
|
+
expect(result.resources["dataBucket"]).toMatchObject({
|
|
50
59
|
type: "GCP::Storage::Bucket",
|
|
51
60
|
physicalId: "uid-1",
|
|
52
61
|
status: "READY",
|
|
@@ -90,7 +99,7 @@ describe("gcp describeResources (Config Connector)", () => {
|
|
|
90
99
|
|
|
91
100
|
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["x"], entities });
|
|
92
101
|
|
|
93
|
-
expect(result["x"].status).toBe("DependencyNotFound");
|
|
102
|
+
expect(result.resources["x"].status).toBe("DependencyNotFound");
|
|
94
103
|
});
|
|
95
104
|
|
|
96
105
|
test("missing Ready condition falls back to PRESENT", async () => {
|
|
@@ -108,10 +117,10 @@ describe("gcp describeResources (Config Connector)", () => {
|
|
|
108
117
|
|
|
109
118
|
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["x"], entities });
|
|
110
119
|
|
|
111
|
-
expect(result["x"].status).toBe("PRESENT");
|
|
120
|
+
expect(result.resources["x"].status).toBe("PRESENT");
|
|
112
121
|
});
|
|
113
122
|
|
|
114
|
-
test("kubectl-not-found leaves entity out of result", async () => {
|
|
123
|
+
test("kubectl-not-found leaves entity out of result (a confirmed absence)", async () => {
|
|
115
124
|
execMock.mockImplementation(() => { throw new Error('Error from server (NotFound): storagebucket "x" not found'); });
|
|
116
125
|
|
|
117
126
|
const entities = makeEntities([
|
|
@@ -120,28 +129,115 @@ describe("gcp describeResources (Config Connector)", () => {
|
|
|
120
129
|
|
|
121
130
|
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["x"], entities });
|
|
122
131
|
|
|
123
|
-
expect(result).toEqual({});
|
|
132
|
+
expect(result.resources).toEqual({});
|
|
133
|
+
expect(result.unobserved ?? {}).toEqual({});
|
|
134
|
+
});
|
|
135
|
+
|
|
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");
|
|
124
151
|
});
|
|
125
152
|
|
|
126
|
-
test("non-GCP entity types are
|
|
153
|
+
test("non-GCP entity types are unobserved — no GVK to query (#1089)", async () => {
|
|
127
154
|
const entities = makeEntities([
|
|
128
155
|
{ name: "x", entityType: "AWS::S3::Bucket", props: { metadata: { name: "x" } } },
|
|
129
156
|
]);
|
|
130
157
|
|
|
131
158
|
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["x"], entities });
|
|
132
159
|
|
|
133
|
-
expect(result).toEqual({});
|
|
160
|
+
expect(result.resources).toEqual({});
|
|
161
|
+
expect(result.unobserved?.x?.reason).toBe("unsupported-kind");
|
|
134
162
|
expect(execMock).not.toHaveBeenCalled();
|
|
135
163
|
});
|
|
136
164
|
|
|
137
|
-
test("entity without metadata.name is
|
|
165
|
+
test("entity without metadata.name is unobserved — nothing was queried", async () => {
|
|
138
166
|
const entities = makeEntities([
|
|
139
167
|
{ name: "broken", entityType: "GCP::Storage::Bucket", props: {} },
|
|
140
168
|
]);
|
|
141
169
|
|
|
142
170
|
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["broken"], entities });
|
|
143
171
|
|
|
144
|
-
expect(result).toEqual({});
|
|
172
|
+
expect(result.resources).toEqual({});
|
|
173
|
+
expect(result.unobserved?.broken?.reason).toBe("read-failed");
|
|
145
174
|
expect(execMock).not.toHaveBeenCalled();
|
|
146
175
|
});
|
|
176
|
+
|
|
177
|
+
// chant #1100 — GCP-via-CNRM resolves the same environment→cluster binding
|
|
178
|
+
// as the K8s lexicon (bound-and-matching, bound-and-mismatched loud
|
|
179
|
+
// refusal, unbound unchanged), since it observes through the same kubectl
|
|
180
|
+
// path against the same cluster.
|
|
181
|
+
describe("cluster binding (chant #1100)", () => {
|
|
182
|
+
function bucketEntities() {
|
|
183
|
+
return makeEntities([
|
|
184
|
+
{ name: "dataBucket", entityType: "GCP::Storage::Bucket", props: { metadata: { name: "data-bucket" } } },
|
|
185
|
+
]);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const bucketStdout = JSON.stringify({
|
|
189
|
+
metadata: { name: "data-bucket", uid: "uid-1", creationTimestamp: "2026-05-01T00:00:00Z" },
|
|
190
|
+
status: { conditions: [{ type: "Ready", status: "True" }] },
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("bound and ambient context matches: observes explicitly via --context", async () => {
|
|
194
|
+
loadChantConfigMock.mockResolvedValue({ config: { k8s: { profiles: { prod: { context: "prod-cnrm" } } } } });
|
|
195
|
+
let receivedCmd = "";
|
|
196
|
+
execMock.mockImplementation((cmd: string) => {
|
|
197
|
+
if (cmd.includes("current-context")) return { stdout: "prod-cnrm\n", stderr: "" };
|
|
198
|
+
receivedCmd = cmd;
|
|
199
|
+
return { stdout: bucketStdout, stderr: "" };
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["dataBucket"], entities: bucketEntities() });
|
|
203
|
+
|
|
204
|
+
expect(receivedCmd).toContain("--context prod-cnrm");
|
|
205
|
+
expect(result.resources["dataBucket"]).toMatchObject({ type: "GCP::Storage::Bucket", physicalId: "uid-1", status: "READY" });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("bound and ambient context mismatches: refuses loudly instead of observing the wrong cluster", async () => {
|
|
209
|
+
loadChantConfigMock.mockResolvedValue({ config: { k8s: { profiles: { prod: { context: "prod-cnrm" } } } } });
|
|
210
|
+
execMock.mockImplementation((cmd: string) => {
|
|
211
|
+
if (cmd.includes("current-context")) return { stdout: "staging-cnrm\n", stderr: "" };
|
|
212
|
+
throw new Error(`unexpected cmd (should have refused before any kubectl get): ${cmd}`);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
await expect(
|
|
216
|
+
describeResources({ environment: "prod", buildOutput: "", entityNames: ["dataBucket"], entities: bucketEntities() }),
|
|
217
|
+
).rejects.toThrow(/environment "prod".*"prod-cnrm".*"staging-cnrm"/s);
|
|
218
|
+
|
|
219
|
+
expect(execMock).toHaveBeenCalledTimes(1);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("unbound: ambient context is used unchanged, but the fallback is visible (not silent)", async () => {
|
|
223
|
+
loadChantConfigMock.mockResolvedValue({ config: {} });
|
|
224
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
225
|
+
let receivedCmd = "";
|
|
226
|
+
execMock.mockImplementation((cmd: string) => {
|
|
227
|
+
receivedCmd = cmd;
|
|
228
|
+
return { stdout: bucketStdout, stderr: "" };
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["dataBucket"], entities: bucketEntities() });
|
|
232
|
+
|
|
233
|
+
expect(receivedCmd).not.toContain("--context");
|
|
234
|
+
expect(receivedCmd).not.toContain("current-context");
|
|
235
|
+
expect(result.resources["dataBucket"]).toMatchObject({ type: "GCP::Storage::Bucket", physicalId: "uid-1", status: "READY" });
|
|
236
|
+
|
|
237
|
+
const bindingWarning = warnSpy.mock.calls.find((c) => String(c[0]).includes("no cluster binding"));
|
|
238
|
+
expect(bindingWarning?.[0]).toContain('environment "prod"');
|
|
239
|
+
expect(bindingWarning?.[0]).toContain("k8s.profiles.prod.context");
|
|
240
|
+
warnSpy.mockRestore();
|
|
241
|
+
});
|
|
242
|
+
});
|
|
147
243
|
});
|
|
@@ -9,13 +9,26 @@
|
|
|
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`.
|
|
16
|
+
*
|
|
17
|
+
* Since this reads Config Connector CRDs through the same kubectl path as
|
|
18
|
+
* the K8s lexicon, it resolves the same environment→cluster binding (chant
|
|
19
|
+
* #1100) via `resolveClusterTarget` — `k8s.profiles.<env>.context` in
|
|
20
|
+
* `chant.config.ts` (see `lexicons/k8s/src/config.ts`), not a separate
|
|
21
|
+
* `gcp.profiles` key, because it is fundamentally the same kubectl context a
|
|
22
|
+
* project's K8s entities would use against the same cluster.
|
|
13
23
|
*/
|
|
14
24
|
|
|
15
25
|
import { exec } from "node:child_process";
|
|
16
26
|
import { promisify } from "node:util";
|
|
17
|
-
import type { ResourceMetadata } from "@intentius/chant/lexicon";
|
|
27
|
+
import type { ObservationResult, ResourceMetadata, UnobservedEntity } from "@intentius/chant/lexicon";
|
|
28
|
+
import { observation } from "@intentius/chant/observation";
|
|
18
29
|
import { hasOwnershipMarker, classifyOwnership, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
|
|
30
|
+
import { loadChantConfig } from "@intentius/chant/config";
|
|
31
|
+
import { resolveClusterTarget, classifyKubectlFailure } from "@intentius/chant/kubectl-context";
|
|
19
32
|
|
|
20
33
|
const execAsync = promisify(exec);
|
|
21
34
|
|
|
@@ -82,26 +95,57 @@ export async function describeResources(options: {
|
|
|
82
95
|
entityNames: string[];
|
|
83
96
|
entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
84
97
|
owned?: boolean;
|
|
85
|
-
}): Promise<
|
|
98
|
+
}): Promise<ObservationResult> {
|
|
86
99
|
const result: Record<string, ResourceMetadata> = {};
|
|
100
|
+
const unobserved: Record<string, UnobservedEntity> = {};
|
|
101
|
+
|
|
102
|
+
// Resolve the cluster identity for this environment before touching any
|
|
103
|
+
// resource — a declared-but-mismatched binding throws here, aborting the
|
|
104
|
+
// whole describe rather than letting the per-entity try/catch below
|
|
105
|
+
// absorb it as an ordinary "not found".
|
|
106
|
+
const { config } = await loadChantConfig(process.cwd());
|
|
107
|
+
const target = await resolveClusterTarget(config as Record<string, unknown>, options.environment, "gcp");
|
|
108
|
+
const ctxArg = target.context ? ["--context", target.context] : [];
|
|
87
109
|
|
|
88
110
|
for (const [entityName, { entityType, props }] of options.entities) {
|
|
89
111
|
const gvk = deriveGVK(entityType);
|
|
90
|
-
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
|
+
}
|
|
91
122
|
|
|
92
123
|
const metadata = props.metadata as { name?: string; namespace?: string } | undefined;
|
|
93
124
|
const name = metadata?.name;
|
|
94
|
-
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
|
+
}
|
|
95
133
|
|
|
96
134
|
const kubectlResource = `${gvk.kind.toLowerCase()}.${gvk.group}`;
|
|
97
135
|
const nsArg = metadata.namespace ? ["-n", metadata.namespace] : [];
|
|
98
|
-
const cmd = ["kubectl", "get", kubectlResource, name, ...nsArg, "-o", "json"].join(" ");
|
|
136
|
+
const cmd = ["kubectl", "get", kubectlResource, name, ...nsArg, ...ctxArg, "-o", "json"].join(" ");
|
|
99
137
|
|
|
100
138
|
try {
|
|
101
139
|
const { stdout } = await execAsync(cmd);
|
|
102
140
|
const obj: KubectlResponse = JSON.parse(stdout);
|
|
103
|
-
// 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.
|
|
104
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
|
+
};
|
|
105
149
|
continue;
|
|
106
150
|
}
|
|
107
151
|
result[entityName] = {
|
|
@@ -116,10 +160,17 @@ export async function describeResources(options: {
|
|
|
116
160
|
annotations: obj.metadata?.annotations,
|
|
117
161
|
}),
|
|
118
162
|
};
|
|
119
|
-
} catch {
|
|
120
|
-
//
|
|
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
|
+
}
|
|
121
172
|
}
|
|
122
173
|
}
|
|
123
174
|
|
|
124
|
-
return result;
|
|
175
|
+
return observation(result, unobserved);
|
|
125
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
|
+
});
|