@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.
@@ -1,85 +1,75 @@
1
1
  /**
2
- * Live introspection of a GCP project via Config Connector CRDs.
2
+ * GCP thin observation (#1209) presence and scrubbed outputs, read over the
3
+ * applier's own REST transport.
3
4
  *
4
- * GCP entities in chant are emitted as Config Connector custom resources
5
- * (apiVersion <service>.cnrm.cloud.google.com/v1beta1, kind <Service><Kind>).
6
- * To observe them at runtime we shell out to kubectl against a Config
7
- * Connector-enabled cluster the same pattern as the K8s lexicon.
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
- * GCP::Storage::Bucket → kubectl get storagebucket.storage.cnrm.cloud.google.com
10
- * GCP::Compute::Subnetwork kubectl get computesubnetwork.compute.cnrm.cloud.google.com
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
- * 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
+ * ## What changed in the answers, not just the transport
16
17
  *
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.
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
- * `resolveGcpKubectlContext` and `execConfigConnectorGet` below are exported
25
- * so `./deep-observe.ts` (chant #1087) shares this exact cluster-binding
26
- * resolution and `kubectl get -o json` mechanics rather than restating them
27
- * the two readers differ only in how much of the response each one keeps.
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, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
35
- import { loadChantConfig } from "@intentius/chant/config";
36
- import { resolveClusterTarget, classifyKubectlFailure } from "@intentius/chant/kubectl-context";
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
- * Resolve this environment's cluster binding once and return the `--context`
42
- * argv fragment every subsequent kubectl call should append (empty when
43
- * unbound ambient context, unchanged behavior). Throws on a bound-but-
44
- * mismatched context (chant #1100), the same loud refusal both readers rely
45
- * on core to turn into NOT-OBSERVED for every declared entity.
46
- */
47
- export async function resolveGcpKubectlContext(environment: string): Promise<string[]> {
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
- export async function execConfigConnectorGet(
60
- gvk: { group: string; kind: string },
61
- name: string,
62
- namespace: string | undefined,
63
- ctxArg: readonly string[],
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
- interface KubectlResponse {
73
- metadata?: {
74
- name?: string;
75
- namespace?: string;
76
- uid?: string;
77
- creationTimestamp?: string;
78
- labels?: Record<string, string>;
79
- annotations?: Record<string, string>;
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; reason?: string; message?: 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
- * Config Connector encodes deployment state as a `Ready` condition on the
113
- * resource's status. Fall back to listing all condition types if `Ready`
114
- * isn't present.
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 statusFromCC(obj: KubectlResponse): string {
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
- // Resolve the cluster identity for this environment before touching any
140
- // resource a declared-but-mismatched binding throws here, aborting the
141
- // whole describe rather than letting the per-entity try/catch below
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
- for (const [entityName, { entityType, props }] of options.entities) {
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 Config Connector GVK from ${entityType}`,
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
- continue;
165
+ return;
156
166
  }
157
167
 
158
- const metadata = props.metadata as { name?: string; namespace?: string } | undefined;
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
- continue;
176
+ return;
167
177
  }
168
178
 
179
+ let client: GcpReadClientOptions;
169
180
  try {
170
- const obj = (await execConfigConnectorGet(gvk, name, metadata.namespace, ctxArg)) as KubectlResponse;
171
- // owned filter: withhold resources not carrying chant's marker label.
172
- // Withheld is not absent (#1089) — the CR exists, it just isn't chant's.
173
- if (options.owned && !hasOwnershipMarker(obj.metadata?.labels, LABEL_OWNERSHIP_KEYS)) {
174
- unobserved[entityName] = {
175
- type: entityType,
176
- reason: "filtered",
177
- detail: "live resource carries no chant ownership marker and --owned was requested",
178
- };
179
- continue;
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.metadata?.uid,
184
- status: statusFromCC(obj),
185
- lastUpdated: obj.metadata?.creationTimestamp,
186
- ownership: classifyOwnership(obj.metadata?.labels, LABEL_OWNERSHIP_KEYS),
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
- namespace: obj.metadata?.namespace,
189
- labels: obj.metadata?.labels,
190
- annotations: obj.metadata?.annotations,
225
+ labels: obj.labels ?? undefined,
226
+ selfLink: obj.selfLink,
191
227
  }),
192
228
  };
193
229
  } catch (err) {
194
- // A NotFound is a real absence (the CR isn't there, or Config Connector
195
- // doesn't serve that CRD, so no instance can be). Anything else — auth,
196
- // an unreachable API server, a context that doesn't resolve proves
197
- // nothing and is reported as a hole rather than an absence (#1089).
198
- const outcome = classifyKubectlFailure(err);
199
- if (outcome.kind === "unobserved") {
200
- unobserved[entityName] = { type: entityType, reason: outcome.reason, detail: outcome.detail };
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, with the `kubectl` (Config Connector) edge mocked.
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(() => execMock.mockReset());
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
- execMock.mockImplementation((cmd?: string) =>
78
- cmd?.includes("data-bucket")
79
- ? {
80
- stdout: JSON.stringify({
81
- metadata: { name: "data-bucket", namespace: "config-control", uid: "uid-1" },
82
- status: { conditions: [{ type: "Ready", status: "True" }] },
83
- }),
84
- stderr: "",
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 with no derivable Config Connector GVK",
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
- execMock.mockImplementation(() => new Error('Error from server (NotFound): storagebucket "gone" not found'));
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 Config Connector cluster",
164
+ name: "an unreachable GCP endpoint",
149
165
  declared: ["dataBucket"],
150
166
  expectUnobserved: ["dataBucket"],
151
167
  run: () => {
152
- execMock.mockImplementation(() =>
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:latest",
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:latest`. */
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
- const gcp = emulatorLifecycle({
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:latest",
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: {