@intentius/chant-lexicon-gcp 0.37.2 → 0.38.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/api/read-client.d.ts +71 -0
- package/dist/api/read-client.d.ts.map +1 -0
- package/dist/deep-observe-hooks.d.ts +41 -30
- package/dist/deep-observe-hooks.d.ts.map +1 -1
- package/dist/deep-observe.d.ts +24 -0
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/describe-resources.d.ts +65 -39
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/op/activities/floci-gcp.d.ts +12 -1
- package/dist/op/activities/floci-gcp.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/api/read-client.ts +129 -0
- package/src/deep-observe-hooks.ts +112 -60
- package/src/deep-observe.test.ts +124 -478
- package/src/deep-observe.ts +94 -74
- package/src/describe-resources.test.ts +119 -206
- package/src/describe-resources.ts +153 -111
- package/src/lifecycle-integration.test.ts +32 -18
- package/src/op/activities/floci-gcp.test.ts +1 -1
- package/src/op/activities/floci-gcp.ts +22 -5
- package/src/plugin.ts +4 -0
|
@@ -1,85 +1,75 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* GCP thin observation (#1209) — presence and scrubbed outputs, read over the
|
|
3
|
+
* applier's own REST transport.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* chant applies GCP cluster-free over direct REST (#706). Until this, it
|
|
6
|
+
* observed through a Config Connector cluster — `kubectl get <cnrm-gvk> -o
|
|
7
|
+
* json` per entity — which is the split #1085's principle forbids: a lexicon
|
|
8
|
+
* observed on a different transport than it is applied with can disagree with
|
|
9
|
+
* itself about what a resource even is, and needs a GKE cluster to answer a
|
|
10
|
+
* question about a bucket.
|
|
8
11
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
12
|
+
* Every GET here is built by the same `ResourceMapper` the applier uses (see
|
|
13
|
+
* ./api/read-client.ts), so reader and applier cannot diverge about where a
|
|
14
|
+
* resource lives.
|
|
11
15
|
*
|
|
12
|
-
*
|
|
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
|
+
* ## What changed in the answers, not just the transport
|
|
16
17
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* project's K8s entities would use against the same cluster.
|
|
18
|
+
* **Status.** Config Connector encodes state as a `Ready` condition, which a
|
|
19
|
+
* GCP REST payload has no equivalent of — a bucket simply exists. So a
|
|
20
|
+
* successful GET is `PRESENT` unless the body carries a recognisable state of
|
|
21
|
+
* its own (Cloud Run's `status.conditions`, an explicit `state`). `PRESENT` is
|
|
22
|
+
* the same sentinel the Azure reader emits for the same reason.
|
|
23
23
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
24
|
+
* **Ownership.** CNRM carried chant's marker as a k8s label. The REST payloads
|
|
25
|
+
* carry `labels` only on kinds that have them (a bucket does; a Pub/Sub topic
|
|
26
|
+
* does not), so ownership is `unknown` where there is nothing to read — and
|
|
27
|
+
* `--owned` degrades to detect-only rather than withholding everything it
|
|
28
|
+
* cannot prove, the same posture the AWS thin path takes when
|
|
29
|
+
* `describe-stack-resources` returns no tags.
|
|
30
|
+
*
|
|
31
|
+
* **Coverage.** kubectl could fetch any CNRM kind the cluster knew about; REST
|
|
32
|
+
* reaches the kinds the applier has a mapper for. That is narrower on paper and
|
|
33
|
+
* not in practice: a kind with no mapper cannot be applied either, so observing
|
|
34
|
+
* it produced a live tree nothing could reconcile against. Anything outside the
|
|
35
|
+
* table reports NOT-OBSERVED with `unsupported-kind` (#1089) rather than being
|
|
36
|
+
* dropped.
|
|
28
37
|
*/
|
|
29
38
|
|
|
30
|
-
import { exec } from "node:child_process";
|
|
31
|
-
import { promisify } from "node:util";
|
|
32
39
|
import type { ObservationResult, ResourceMetadata, UnobservedEntity } from "@intentius/chant/lexicon";
|
|
33
40
|
import { observation } from "@intentius/chant/observation";
|
|
34
|
-
import { hasOwnershipMarker, classifyOwnership,
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
37
|
-
|
|
38
|
-
const execAsync = promisify(exec);
|
|
41
|
+
import { hasOwnershipMarker, classifyOwnership, type ChannelKeys } from "@intentius/chant/ownership";
|
|
42
|
+
import { getResource, mapperForKind, GcpReadError, isNotFound, type GcpReadClientOptions } from "./api/read-client";
|
|
43
|
+
import { resolveGcpProject } from "./op/activities/gcp-apply";
|
|
39
44
|
|
|
40
45
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const { config } = await loadChantConfig(process.cwd());
|
|
49
|
-
const target = await resolveClusterTarget(config as Record<string, unknown>, environment, "gcp");
|
|
50
|
-
return target.context ? ["--context", target.context] : [];
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* `kubectl get <kind>.<group> <name> [-n <namespace>] [--context <ctx>] -o
|
|
55
|
-
* json`, parsed. Throws on any kubectl failure — callers classify it with
|
|
56
|
-
* `classifyKubectlFailure` exactly like today, so a NotFound and an
|
|
57
|
-
* auth/connectivity failure are told apart at the call site, not here.
|
|
46
|
+
* chant's ownership marker as GCP labels.
|
|
47
|
+
*
|
|
48
|
+
* The applier stamps `managed-by: chant` (gcp-apply.ts) because GCP label keys
|
|
49
|
+
* cannot hold the k8s `app.kubernetes.io/managed-by` slash/dot form that
|
|
50
|
+
* `LABEL_OWNERSHIP_KEYS` uses — so the reader has to look for what the applier
|
|
51
|
+
* actually wrote, not for the CNRM label the kubectl path read. The stack/env
|
|
52
|
+
* keys follow the same flattening.
|
|
58
53
|
*/
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
): Promise<Record<string, unknown>> {
|
|
65
|
-
const kubectlResource = `${gvk.kind.toLowerCase()}.${gvk.group}`;
|
|
66
|
-
const nsArg = namespace ? ["-n", namespace] : [];
|
|
67
|
-
const cmd = ["kubectl", "get", kubectlResource, name, ...nsArg, ...ctxArg, "-o", "json"].join(" ");
|
|
68
|
-
const { stdout } = await execAsync(cmd);
|
|
69
|
-
return JSON.parse(stdout) as Record<string, unknown>;
|
|
70
|
-
}
|
|
54
|
+
const GCP_OWNERSHIP_LABEL_KEYS: ChannelKeys = {
|
|
55
|
+
managedBy: "managed-by",
|
|
56
|
+
stack: "chant-stack",
|
|
57
|
+
env: "chant-env",
|
|
58
|
+
};
|
|
71
59
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
60
|
+
/** The parts of a GCP REST payload this reader looks at. Every field is
|
|
61
|
+
* optional because they vary by kind — a bucket has `id` and `labels`, a
|
|
62
|
+
* Pub/Sub topic has neither. */
|
|
63
|
+
interface GcpRestResponse {
|
|
64
|
+
id?: string;
|
|
65
|
+
name?: string;
|
|
66
|
+
selfLink?: string;
|
|
67
|
+
labels?: Record<string, string> | null;
|
|
68
|
+
updated?: string;
|
|
69
|
+
timeCreated?: string;
|
|
70
|
+
state?: string;
|
|
81
71
|
status?: {
|
|
82
|
-
conditions?: Array<{ type?: string; status?: string;
|
|
72
|
+
conditions?: Array<{ type?: string; status?: string; state?: string; reason?: string }>;
|
|
83
73
|
[k: string]: unknown;
|
|
84
74
|
};
|
|
85
75
|
}
|
|
@@ -109,19 +99,27 @@ function pruneUndefined<T extends Record<string, unknown>>(obj: T): Record<strin
|
|
|
109
99
|
}
|
|
110
100
|
|
|
111
101
|
/**
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
102
|
+
* Status from a REST payload.
|
|
103
|
+
*
|
|
104
|
+
* Most GCP resources have no status at all — a bucket that answers a GET simply
|
|
105
|
+
* exists — so `PRESENT` is the honest answer and the common one. Where a kind
|
|
106
|
+
* does carry state (Cloud Run's `status.conditions`, a `state` enum), that is
|
|
107
|
+
* reported instead.
|
|
108
|
+
*
|
|
109
|
+
* `PRESENT` is deliberately the same sentinel the Azure reader emits for a
|
|
110
|
+
* resource with no `provisioningState`: one word, meaning "read it back, it is
|
|
111
|
+
* there, there is nothing richer to say".
|
|
115
112
|
*/
|
|
116
|
-
function
|
|
113
|
+
export function statusFromRest(obj: GcpRestResponse): string {
|
|
114
|
+
if (typeof obj.state === "string" && obj.state.length > 0) return obj.state;
|
|
117
115
|
const conditions = obj.status?.conditions ?? [];
|
|
118
116
|
const ready = conditions.find((c) => c.type === "Ready");
|
|
119
117
|
if (ready) {
|
|
120
|
-
if (ready.status === "True") return "READY";
|
|
118
|
+
if (ready.status === "True" || ready.state === "CONDITION_SUCCEEDED") return "READY";
|
|
121
119
|
return ready.reason ?? "NOT_READY";
|
|
122
120
|
}
|
|
123
121
|
if (conditions.length > 0) {
|
|
124
|
-
return conditions.map((c) => `${c.type}=${c.status}`).join(",");
|
|
122
|
+
return conditions.map((c) => `${c.type}=${c.status ?? c.state}`).join(",");
|
|
125
123
|
}
|
|
126
124
|
return "PRESENT";
|
|
127
125
|
}
|
|
@@ -136,26 +134,38 @@ export async function describeResources(options: {
|
|
|
136
134
|
const result: Record<string, ResourceMetadata> = {};
|
|
137
135
|
const unobserved: Record<string, UnobservedEntity> = {};
|
|
138
136
|
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
// absorb it as an ordinary "not found".
|
|
143
|
-
const ctxArg = await resolveGcpKubectlContext(options.environment);
|
|
137
|
+
// The endpoint override is the emulator tell (floci-gcp :4588); unset means
|
|
138
|
+
// real GCP, exactly as it does for the applier.
|
|
139
|
+
const endpoint = process.env.GCP_ENDPOINT_URL;
|
|
144
140
|
|
|
145
|
-
|
|
141
|
+
// `--owned` needs labels to filter on, and only some kinds carry them. Rather
|
|
142
|
+
// than withhold everything it cannot prove, this warns once and degrades to
|
|
143
|
+
// detect-only — the same posture the AWS thin path takes when
|
|
144
|
+
// `describe-stack-resources` returns no tags.
|
|
145
|
+
let warnedOwnership = false;
|
|
146
|
+
|
|
147
|
+
const reads = [...options.entities].map(async ([entityName, { entityType, props }]) => {
|
|
146
148
|
const gvk = deriveGVK(entityType);
|
|
147
149
|
if (!gvk) {
|
|
148
|
-
// Not a `GCP::Service::Kind` this lexicon can turn into a Config
|
|
149
|
-
// Connector GVK — nothing was queried, so nothing is known (#1089).
|
|
150
150
|
unobserved[entityName] = {
|
|
151
151
|
type: entityType,
|
|
152
152
|
reason: "unsupported-kind",
|
|
153
|
-
detail: `cannot derive a
|
|
153
|
+
detail: `cannot derive a GCP kind from ${entityType}`,
|
|
154
|
+
};
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (!mapperForKind(gvk.kind)) {
|
|
158
|
+
// Outside the applier's dispatch table: chant cannot write this kind, so
|
|
159
|
+
// it does not claim to have read it either (#1089).
|
|
160
|
+
unobserved[entityName] = {
|
|
161
|
+
type: entityType,
|
|
162
|
+
reason: "unsupported-kind",
|
|
163
|
+
detail: `no REST mapper for ${gvk.kind} — chant cannot apply this kind either`,
|
|
154
164
|
};
|
|
155
|
-
|
|
165
|
+
return;
|
|
156
166
|
}
|
|
157
167
|
|
|
158
|
-
const metadata = props.metadata as { name?: string;
|
|
168
|
+
const metadata = props.metadata as { name?: string; annotations?: Record<string, string> } | undefined;
|
|
159
169
|
const name = metadata?.name;
|
|
160
170
|
if (!name) {
|
|
161
171
|
unobserved[entityName] = {
|
|
@@ -163,44 +173,76 @@ export async function describeResources(options: {
|
|
|
163
173
|
reason: "read-failed",
|
|
164
174
|
detail: "declared entity has no metadata.name to query by",
|
|
165
175
|
};
|
|
166
|
-
|
|
176
|
+
return;
|
|
167
177
|
}
|
|
168
178
|
|
|
179
|
+
let client: GcpReadClientOptions;
|
|
169
180
|
try {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
181
|
+
client = {
|
|
182
|
+
project: resolveGcpProject({ kind: gvk.kind, metadata }),
|
|
183
|
+
...(endpoint ? { endpoint } : {}),
|
|
184
|
+
};
|
|
185
|
+
} catch (err) {
|
|
186
|
+
// No project resolvable — nothing was queried, so nothing is known.
|
|
187
|
+
unobserved[entityName] = {
|
|
188
|
+
type: entityType,
|
|
189
|
+
reason: "no-binding",
|
|
190
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
191
|
+
};
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const obj = (await getResource(client, gvk.kind, name, props)) as GcpRestResponse;
|
|
197
|
+
|
|
198
|
+
if (options.owned) {
|
|
199
|
+
if (obj.labels == null) {
|
|
200
|
+
if (!warnedOwnership) {
|
|
201
|
+
// eslint-disable-next-line no-console
|
|
202
|
+
console.warn(
|
|
203
|
+
`[gcp] ownership filter unavailable for ${gvk.kind} (the REST payload carries no labels) — returning it with an \`unknown\` verdict rather than withholding it`,
|
|
204
|
+
);
|
|
205
|
+
warnedOwnership = true;
|
|
206
|
+
}
|
|
207
|
+
} else if (!hasOwnershipMarker(obj.labels, GCP_OWNERSHIP_LABEL_KEYS)) {
|
|
208
|
+
// Withheld is not absent (#1089) — the resource exists, it just isn't chant's.
|
|
209
|
+
unobserved[entityName] = {
|
|
210
|
+
type: entityType,
|
|
211
|
+
reason: "filtered",
|
|
212
|
+
detail: "live resource carries no chant ownership marker and --owned was requested",
|
|
213
|
+
};
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
180
216
|
}
|
|
217
|
+
|
|
181
218
|
result[entityName] = {
|
|
182
219
|
type: entityType,
|
|
183
|
-
physicalId: obj.
|
|
184
|
-
status:
|
|
185
|
-
lastUpdated: obj.
|
|
186
|
-
ownership: classifyOwnership(obj.
|
|
220
|
+
physicalId: obj.id ?? obj.selfLink ?? obj.name,
|
|
221
|
+
status: statusFromRest(obj),
|
|
222
|
+
lastUpdated: obj.updated ?? obj.timeCreated,
|
|
223
|
+
ownership: obj.labels == null ? "unknown" : classifyOwnership(obj.labels, GCP_OWNERSHIP_LABEL_KEYS),
|
|
187
224
|
attributes: pruneUndefined({
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
annotations: obj.metadata?.annotations,
|
|
225
|
+
labels: obj.labels ?? undefined,
|
|
226
|
+
selfLink: obj.selfLink,
|
|
191
227
|
}),
|
|
192
228
|
};
|
|
193
229
|
} catch (err) {
|
|
194
|
-
// A
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
230
|
+
// A 404 is a real absence. Anything else — no credentials, an
|
|
231
|
+
// unreachable endpoint, a body that will not parse — proves nothing and
|
|
232
|
+
// is a hole rather than an absence (#1089).
|
|
233
|
+
if (isNotFound(err)) return;
|
|
234
|
+
const status = err instanceof GcpReadError ? err.status : undefined;
|
|
235
|
+
const noCreds = status === 401 || status === 403;
|
|
236
|
+
unobserved[entityName] = {
|
|
237
|
+
type: entityType,
|
|
238
|
+
reason: noCreds ? "no-credentials" : "read-failed",
|
|
239
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
240
|
+
};
|
|
202
241
|
}
|
|
203
|
-
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Concurrent, where the kubectl path was one spawn after another (#1201/#1209).
|
|
245
|
+
await Promise.all(reads);
|
|
204
246
|
|
|
205
247
|
return observation(result, unobserved);
|
|
206
248
|
}
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
* Cross-lexicon lifecycle integration (#163) — GCP row.
|
|
3
3
|
*
|
|
4
4
|
* Drives the REAL gcpPlugin through core's live-import driver and the changeset
|
|
5
|
-
* path,
|
|
5
|
+
* path. Two edges are mocked, because since #1209 the plugin has two
|
|
6
|
+
* transports: `describeResources` reads GCP REST (stubbed `fetch`), while
|
|
7
|
+
* `exportResources` and the deep reader still go through Config Connector
|
|
8
|
+
* (stubbed `exec`).
|
|
6
9
|
*/
|
|
7
10
|
import { describe, test, expect, vi, beforeEach } from "vitest";
|
|
8
11
|
import { mkdtempSync, rmSync, readdirSync, readFileSync } from "node:fs";
|
|
@@ -28,6 +31,17 @@ vi.mock("node:child_process", async () => {
|
|
|
28
31
|
};
|
|
29
32
|
});
|
|
30
33
|
|
|
34
|
+
const fetchMock = vi.fn();
|
|
35
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
36
|
+
// The REST reader resolves a project before it can build a URL; without one it
|
|
37
|
+
// correctly reports a hole rather than reading anything.
|
|
38
|
+
process.env.GOOGLE_CLOUD_PROJECT = "test-project";
|
|
39
|
+
|
|
40
|
+
/** A GCP REST reply, as the read client consumes it. */
|
|
41
|
+
function restReply(status: number, body: unknown) {
|
|
42
|
+
return { status, text: async () => JSON.stringify(body) };
|
|
43
|
+
}
|
|
44
|
+
|
|
31
45
|
const { gcpPlugin } = await import("./plugin");
|
|
32
46
|
const { liveImportFromPlugins } = await import("@intentius/chant/cli/commands/import");
|
|
33
47
|
const { buildChangeSet } = await import("@intentius/chant/lifecycle/change-set");
|
|
@@ -42,7 +56,10 @@ const liveBucket = {
|
|
|
42
56
|
};
|
|
43
57
|
|
|
44
58
|
describe("gcp lifecycle integration (#163)", () => {
|
|
45
|
-
beforeEach(() =>
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
execMock.mockReset();
|
|
61
|
+
fetchMock.mockReset();
|
|
62
|
+
});
|
|
46
63
|
|
|
47
64
|
test("live-import driver: real exportResources → IR → generated source", async () => {
|
|
48
65
|
execMock.mockImplementation((cmd?: string) => {
|
|
@@ -74,16 +91,14 @@ describe("gcp lifecycle integration (#163)", () => {
|
|
|
74
91
|
});
|
|
75
92
|
|
|
76
93
|
test("changeset path: real describeResources → buildChangeSet verdicts", async () => {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
: new Error("not found"),
|
|
94
|
+
fetchMock.mockImplementation((url: string) =>
|
|
95
|
+
Promise.resolve(
|
|
96
|
+
String(url).includes("data-bucket")
|
|
97
|
+
// No chant marker: live but not chant's, which is what makes the
|
|
98
|
+
// changeset propose `adopt` rather than `delete`.
|
|
99
|
+
? restReply(200, { id: "b/data-bucket", labels: { team: "data" } })
|
|
100
|
+
: restReply(404, { error: "not found" }),
|
|
101
|
+
),
|
|
87
102
|
);
|
|
88
103
|
|
|
89
104
|
const { resources: observedNow } = normalizeObservation(
|
|
@@ -125,14 +140,15 @@ describe("gcp lifecycle integration (#163)", () => {
|
|
|
125
140
|
// The shared conformance suite (#1089).
|
|
126
141
|
describeObservationConformance({
|
|
127
142
|
lexicon: "gcp",
|
|
143
|
+
ownershipChannel: gcpPlugin.ownershipChannel,
|
|
128
144
|
scenarios: [
|
|
129
145
|
{
|
|
130
|
-
name: "an entity type
|
|
146
|
+
name: "an entity type this lexicon cannot map to a GCP kind",
|
|
131
147
|
declared: ["notGcp", "gone"],
|
|
132
148
|
expectUnobserved: ["notGcp"],
|
|
133
149
|
expectAbsent: ["gone"],
|
|
134
150
|
run: () => {
|
|
135
|
-
|
|
151
|
+
fetchMock.mockResolvedValue(restReply(404, { error: "not found" }));
|
|
136
152
|
return gcpPlugin.describeResources!({
|
|
137
153
|
environment: "prod",
|
|
138
154
|
buildOutput: "",
|
|
@@ -145,13 +161,11 @@ describeObservationConformance({
|
|
|
145
161
|
},
|
|
146
162
|
},
|
|
147
163
|
{
|
|
148
|
-
name: "an unreachable
|
|
164
|
+
name: "an unreachable GCP endpoint",
|
|
149
165
|
declared: ["dataBucket"],
|
|
150
166
|
expectUnobserved: ["dataBucket"],
|
|
151
167
|
run: () => {
|
|
152
|
-
|
|
153
|
-
Object.assign(new Error("kubectl failed"), { stderr: "Unable to connect to the server: dial tcp: i/o timeout" }),
|
|
154
|
-
);
|
|
168
|
+
fetchMock.mockRejectedValue(new Error("dial tcp: i/o timeout"));
|
|
155
169
|
return gcpPlugin.describeResources!({
|
|
156
170
|
environment: "prod",
|
|
157
171
|
buildOutput: "",
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
describe("floci-gcp lifecycle commands (typed emulator, not shell)", () => {
|
|
11
11
|
test("run command uses defaults and maps the port", () => {
|
|
12
12
|
expect(flociGcpRunCommand({})).toBe(
|
|
13
|
-
"docker run -d --rm --name chant-floci-gcp -p 4588:4588 floci/floci-gcp:
|
|
13
|
+
"docker run -d --rm --name chant-floci-gcp -p 4588:4588 floci/floci-gcp:0.5.0",
|
|
14
14
|
);
|
|
15
15
|
});
|
|
16
16
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { emulatorLifecycle } from "@intentius/chant/op";
|
|
1
|
+
import { emulatorLifecycle, type EmulatorCapability, type EmulatorSpec } from "@intentius/chant/op";
|
|
2
2
|
|
|
3
3
|
export interface FlociGcpUpArgs {
|
|
4
4
|
/** Container name. Default: `chant-floci-gcp`. */
|
|
5
5
|
name?: string;
|
|
6
6
|
/** Host port mapped to the emulator's `:4588`. Default: `4588`. */
|
|
7
7
|
port?: number;
|
|
8
|
-
/** Image. Default: `floci/floci-gcp
|
|
8
|
+
/** Image. Default: the pinned `floci/floci-gcp` tag. */
|
|
9
9
|
image?: string;
|
|
10
10
|
/** Readiness timeout in ms. Default: `60000`. */
|
|
11
11
|
timeoutMs?: number;
|
|
@@ -20,12 +20,29 @@ export interface FlociGcpDownArgs {
|
|
|
20
20
|
|
|
21
21
|
// floci-gcp is a bespoke GCP-REST fake (not LocalStack) — a plain 200 on its
|
|
22
22
|
// health endpoint means ready. Shared lifecycle: emulatorLifecycle (#746).
|
|
23
|
-
|
|
23
|
+
// Pinned rather than `:latest` (#1345), same reasoning as the other emulators.
|
|
24
|
+
export const FLOCI_GCP_SPEC: EmulatorSpec = {
|
|
24
25
|
name: "chant-floci-gcp",
|
|
25
|
-
image: "floci/floci-gcp:
|
|
26
|
+
image: "floci/floci-gcp:0.5.0",
|
|
26
27
|
containerPort: 4588,
|
|
27
28
|
healthPath: "/_floci-gcp/health",
|
|
28
|
-
}
|
|
29
|
+
upstream: { repo: "floci-io/floci-gcp" },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The gcp plugin's emulator capability (#1345).
|
|
34
|
+
*
|
|
35
|
+
* `env` is deliberately empty: `gcpApply` reaches the emulator through an
|
|
36
|
+
* explicit `endpoint` argument rather than an ambient variable, so there is no
|
|
37
|
+
* var to inject and claiming one would be worse than claiming none. The
|
|
38
|
+
* endpoint is still reported by `chant emulator up --json`.
|
|
39
|
+
*/
|
|
40
|
+
export const FLOCI_GCP_EMULATOR: EmulatorCapability = {
|
|
41
|
+
spec: FLOCI_GCP_SPEC,
|
|
42
|
+
env: () => ({}),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const gcp = emulatorLifecycle(FLOCI_GCP_SPEC);
|
|
29
46
|
|
|
30
47
|
export const flociGcpExistsCommand = gcp.existsCommand;
|
|
31
48
|
export const flociGcpRmCommand = gcp.rmCommand;
|
package/src/plugin.ts
CHANGED
|
@@ -7,12 +7,14 @@
|
|
|
7
7
|
|
|
8
8
|
import type { LexiconPlugin, InitTemplateSet, ResourceMetadata } from "@intentius/chant/lexicon";
|
|
9
9
|
import { detectTemplate } from "./detect";
|
|
10
|
+
import { LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
|
|
10
11
|
import type { LintRule } from "@intentius/chant/lint/rule";
|
|
11
12
|
import type { TemplateParser } from "@intentius/chant/import/parser";
|
|
12
13
|
import type { TypeScriptGenerator } from "@intentius/chant/import/generator";
|
|
13
14
|
import type { CompletionContext, CompletionItem, HoverContext, HoverInfo } from "@intentius/chant/lsp/types";
|
|
14
15
|
import { postSynthChecks as postSynthCheckList } from "./lint/post-synth";
|
|
15
16
|
import { gcpAuditCatalog } from "./lint/audit-catalog";
|
|
17
|
+
import { FLOCI_GCP_EMULATOR } from "./op/activities/floci-gcp";
|
|
16
18
|
import { createSkillsLoader, createDiffTool, createCatalogResource } from "@intentius/chant/lexicon-plugin-helpers";
|
|
17
19
|
import { join, dirname } from "path";
|
|
18
20
|
import { fileURLToPath } from "url";
|
|
@@ -28,6 +30,8 @@ import { gcpDeepNormalizationHooks } from "./deep-observe-hooks";
|
|
|
28
30
|
|
|
29
31
|
export const gcpPlugin: LexiconPlugin = {
|
|
30
32
|
name: "gcp",
|
|
33
|
+
ownershipChannel: { keys: LABEL_OWNERSHIP_KEYS, reads: ["describeResources", "observeResourcesDeep", "exportResources"] },
|
|
34
|
+
emulator: FLOCI_GCP_EMULATOR,
|
|
31
35
|
auditCatalog: () => gcpAuditCatalog,
|
|
32
36
|
// Self-upgrade: where the pinned Config Connector (KCC) version lives + its upstream (#685).
|
|
33
37
|
upstreamPin: {
|