@intentius/chant-lexicon-gcp 0.1.14 → 0.1.16

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,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "7c2f49cff06e49b16310682353b1e912b0bab74df0a90c3431f610b77d482529",
4
+ "manifest.json": "c7239e90d2c98771e463e46fa1caf8e5cdf5ac1a5e177c169feb5dcae951ed50",
5
5
  "meta.json": "2bc3713e9e01e90832d18dedaf4bf544dd4d8fe32f1363f7e95dc711d3d76e9d",
6
6
  "types/index.d.ts": "0554f7e883c6216ba735cbe79a8534ccad762bea28cc4d4c4884f8760a2f1dd3",
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": "f1fb5bb2d78ed635a1522a87ec8b8339dd75ad601f6cbee347711ec560e7ff33"
40
+ "composite": "91a1efdba5fac572f59c43ae7203dfe7e3d8697adc20b0957c4d3293c11e8690"
41
41
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gcp",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "GCP",
6
6
  "intrinsics": [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-gcp",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
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",
@@ -15,6 +15,7 @@
15
15
  import { exec } from "node:child_process";
16
16
  import { promisify } from "node:util";
17
17
  import type { ResourceMetadata } from "@intentius/chant/lexicon";
18
+ import { hasOwnershipMarker, classifyOwnership } from "@intentius/chant/ownership";
18
19
 
19
20
  const execAsync = promisify(exec);
20
21
 
@@ -38,7 +39,7 @@ interface KubectlResponse {
38
39
  * derivation logic local so describeResources can compute the kubectl resource
39
40
  * name without importing serializer internals.
40
41
  */
41
- function deriveGVK(entityType: string): { group: string; kind: string } | null {
42
+ export function deriveGVK(entityType: string): { group: string; kind: string } | null {
42
43
  const parts = entityType.split("::");
43
44
  if (parts.length !== 3 || parts[0] !== "GCP") return null;
44
45
  const service = parts[1].toLowerCase();
@@ -80,6 +81,7 @@ export async function describeResources(options: {
80
81
  buildOutput: string;
81
82
  entityNames: string[];
82
83
  entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
84
+ owned?: boolean;
83
85
  }): Promise<Record<string, ResourceMetadata>> {
84
86
  const result: Record<string, ResourceMetadata> = {};
85
87
 
@@ -98,11 +100,16 @@ export async function describeResources(options: {
98
100
  try {
99
101
  const { stdout } = await execAsync(cmd);
100
102
  const obj: KubectlResponse = JSON.parse(stdout);
103
+ // owned filter: skip resources not carrying chant's marker label.
104
+ if (options.owned && !hasOwnershipMarker(obj.metadata?.labels, "label")) {
105
+ continue;
106
+ }
101
107
  result[entityName] = {
102
108
  type: entityType,
103
109
  physicalId: obj.metadata?.uid,
104
110
  status: statusFromCC(obj),
105
111
  lastUpdated: obj.metadata?.creationTimestamp,
112
+ ownership: classifyOwnership(obj.metadata?.labels, "label"),
106
113
  attributes: pruneUndefined({
107
114
  namespace: obj.metadata?.namespace,
108
115
  labels: obj.metadata?.labels,
@@ -0,0 +1,81 @@
1
+ import { describe, test, expect, vi, beforeEach } from "vitest";
2
+
3
+ // Deliver exec results synchronously (value, or Error for the failure path) so
4
+ // a skipped kind can't leave a dangling unhandled rejection.
5
+ const execMock = vi.fn();
6
+ vi.mock("node:child_process", async () => {
7
+ const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
8
+ return {
9
+ ...actual,
10
+ exec: (
11
+ cmd: string,
12
+ cb: (err: Error | null, out: { stdout: string; stderr: string }) => void,
13
+ ) => {
14
+ const r = execMock(cmd);
15
+ queueMicrotask(() =>
16
+ r instanceof Error
17
+ ? cb(r, { stdout: "", stderr: "" })
18
+ : cb(null, r as { stdout: string; stderr: string }),
19
+ );
20
+ },
21
+ };
22
+ });
23
+
24
+ const { exportResources } = await import("./export-resources");
25
+
26
+ const liveBucket = {
27
+ apiVersion: "storage.cnrm.cloud.google.com/v1beta1",
28
+ kind: "StorageBucket",
29
+ metadata: { name: "my-bucket", namespace: "default", uid: "u-1" },
30
+ spec: { location: "US", storageClass: "STANDARD" },
31
+ };
32
+
33
+ describe("gcp exportResources I/O glue (#160)", () => {
34
+ beforeEach(() => execMock.mockReset());
35
+
36
+ test("discovers cnrm kinds, sweeps each with `kubectl get`, maps to IR", async () => {
37
+ execMock.mockImplementation((cmd?: string) => {
38
+ if (cmd?.includes("api-resources")) {
39
+ return {
40
+ stdout:
41
+ "storagebuckets.storage.cnrm.cloud.google.com\n" +
42
+ "pubsubtopics.pubsub.cnrm.cloud.google.com\n" +
43
+ "pods\n",
44
+ stderr: "",
45
+ };
46
+ }
47
+ if (cmd?.includes("storagebuckets.storage.cnrm")) {
48
+ return { stdout: JSON.stringify({ items: [liveBucket] }), stderr: "" };
49
+ }
50
+ return { stdout: JSON.stringify({ items: [] }), stderr: "" };
51
+ });
52
+
53
+ const ir = await exportResources({ environment: "prod" });
54
+ const cmds = execMock.mock.calls.map((c) => c[0] as string);
55
+ expect(cmds.some((c) => c === "kubectl api-resources -o name")).toBe(true);
56
+ expect(
57
+ cmds.some((c) => c === "kubectl get storagebuckets.storage.cnrm.cloud.google.com -A -o json"),
58
+ ).toBe(true);
59
+ // The non-cnrm "pods" line must be filtered out of the sweep.
60
+ expect(cmds.some((c) => c.includes("kubectl get pods"))).toBe(false);
61
+ expect(ir.resources.map((r) => r.logicalId)).toContain("my-bucket");
62
+ });
63
+
64
+ test("a kind that errors (absent / RBAC) is skipped; export still succeeds", async () => {
65
+ execMock.mockImplementation((cmd?: string) => {
66
+ if (cmd?.includes("api-resources")) {
67
+ return { stdout: "computenetworks.compute.cnrm.cloud.google.com\n", stderr: "" };
68
+ }
69
+ return new Error("Error from server (Forbidden)");
70
+ });
71
+ const ir = await exportResources({ environment: "prod" });
72
+ expect(ir.resources).toEqual([]);
73
+ });
74
+
75
+ test("no cnrm kinds discovered → empty export, no get calls", async () => {
76
+ execMock.mockImplementation(() => ({ stdout: "pods\nservices\n", stderr: "" }));
77
+ const ir = await exportResources({ environment: "prod" });
78
+ expect(ir.resources).toEqual([]);
79
+ expect(execMock.mock.calls.filter((c) => (c[0] as string).includes("get")).length).toBe(0);
80
+ });
81
+ });
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Live export for the GCP Config Connector lexicon — implements
3
+ * LexiconPlugin.exportResources() so `chant import --from <gcp-env>`
4
+ * regenerates GCP resources as chant TypeScript.
5
+ *
6
+ * Config Connector encodes each GCP resource as a Kubernetes CRD on a
7
+ * management cluster, so export reads live CC objects with kubectl and maps
8
+ * them to the import IR. A type selector targets one kind; otherwise every
9
+ * `*.cnrm.cloud.google.com` resource kind is discovered and swept. All I/O
10
+ * lives here; cleaning and IR-building is pure in `./import/live-export`.
11
+ */
12
+ import { exec } from "node:child_process";
13
+ import { promisify } from "node:util";
14
+ import type { ExportedTemplate, ResourceSelector } from "@intentius/chant/lexicon";
15
+ import { deriveGVK } from "./describe-resources";
16
+ import { buildExportFromObjects } from "./import/live-export";
17
+
18
+ const execAsync = promisify(exec);
19
+
20
+ interface KubectlList {
21
+ items?: unknown[];
22
+ }
23
+
24
+ /** Discover all Config Connector resource kinds present in the cluster. */
25
+ async function discoverCnrmResources(): Promise<string[]> {
26
+ try {
27
+ const { stdout } = await execAsync("kubectl api-resources -o name");
28
+ return stdout
29
+ .split("\n")
30
+ .map((l) => l.trim())
31
+ .filter((l) => l.endsWith(".cnrm.cloud.google.com"));
32
+ } catch {
33
+ return [];
34
+ }
35
+ }
36
+
37
+ export async function exportResources(options: {
38
+ environment: string;
39
+ selector?: ResourceSelector;
40
+ owned?: boolean;
41
+ verbatim?: boolean;
42
+ }): Promise<ExportedTemplate> {
43
+ let resources: string[];
44
+ if (options.selector?.type) {
45
+ const gvk = deriveGVK(options.selector.type);
46
+ resources = gvk ? [`${gvk.kind.toLowerCase()}.${gvk.group}`] : [];
47
+ } else {
48
+ resources = await discoverCnrmResources();
49
+ }
50
+
51
+ if (resources.length === 0) {
52
+ return { resources: [], parameters: [] };
53
+ }
54
+
55
+ const objects: unknown[] = [];
56
+ for (const resource of resources) {
57
+ try {
58
+ const { stdout } = await execAsync(
59
+ ["kubectl", "get", resource, "-A", "-o", "json"].join(" "),
60
+ );
61
+ const list = JSON.parse(stdout) as KubectlList;
62
+ if (Array.isArray(list.items)) objects.push(...list.items);
63
+ } catch {
64
+ // Kind absent / RBAC denied — skip, don't fail the whole export.
65
+ }
66
+ }
67
+
68
+ return buildExportFromObjects(objects, {
69
+ verbatim: options.verbatim,
70
+ selector: options.selector,
71
+ owned: options.owned,
72
+ });
73
+ }
@@ -0,0 +1,105 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { stripServerFields, buildExportFromObjects } from "./live-export";
3
+ import { GcpGenerator } from "./generator";
4
+
5
+ // A live Config Connector object as `kubectl get -o json` returns it.
6
+ const liveBucket = () => ({
7
+ apiVersion: "storage.cnrm.cloud.google.com/v1beta1",
8
+ kind: "StorageBucket",
9
+ metadata: {
10
+ name: "my-bucket",
11
+ namespace: "default",
12
+ uid: "u-1",
13
+ resourceVersion: "55",
14
+ generation: 2,
15
+ creationTimestamp: "2026-01-01T00:00:00Z",
16
+ managedFields: [{ manager: "cnrm" }],
17
+ annotations: {
18
+ "cnrm.cloud.google.com/management-conflict-prevention-policy": "resource",
19
+ "cnrm.cloud.google.com/project-id": "my-project",
20
+ "team": "data",
21
+ },
22
+ labels: { env: "prod" },
23
+ },
24
+ spec: { location: "US", storageClass: "STANDARD" },
25
+ status: { conditions: [{ type: "Ready", status: "True" }] },
26
+ });
27
+
28
+ const livePubsub = () => ({
29
+ apiVersion: "pubsub.cnrm.cloud.google.com/v1beta1",
30
+ kind: "PubSubTopic",
31
+ metadata: { name: "events", namespace: "default", uid: "u-2" },
32
+ spec: { messageRetentionDuration: "86400s" },
33
+ status: { conditions: [] },
34
+ });
35
+
36
+ describe("GCP stripServerFields (#117)", () => {
37
+ test("removes status, managedFields, and server metadata", () => {
38
+ const cleaned = stripServerFields(liveBucket());
39
+ expect(cleaned.status).toBeUndefined();
40
+ const md = cleaned.metadata as Record<string, unknown>;
41
+ expect(md.managedFields).toBeUndefined();
42
+ expect(md.uid).toBeUndefined();
43
+ expect(md.generation).toBeUndefined();
44
+ });
45
+
46
+ test("drops cnrm bookkeeping annotations but keeps authored ones", () => {
47
+ const cleaned = stripServerFields(liveBucket());
48
+ const annotations = (cleaned.metadata as Record<string, unknown>).annotations as Record<string, string>;
49
+ expect(annotations["cnrm.cloud.google.com/management-conflict-prevention-policy"]).toBeUndefined();
50
+ expect(annotations["cnrm.cloud.google.com/project-id"]).toBeUndefined();
51
+ expect(annotations.team).toBe("data");
52
+ });
53
+
54
+ test("keeps authored spec and labels; does not mutate input", () => {
55
+ const input = liveBucket();
56
+ const cleaned = stripServerFields(input);
57
+ expect(cleaned.spec).toEqual({ location: "US", storageClass: "STANDARD" });
58
+ expect(input.status).toBeDefined();
59
+ });
60
+ });
61
+
62
+ describe("GCP buildExportFromObjects (#117)", () => {
63
+ test("maps live CC objects to export IR, stripped by default", () => {
64
+ const ir = buildExportFromObjects([liveBucket(), livePubsub()]);
65
+ expect(ir.resources).toHaveLength(2);
66
+ const bucket = ir.resources.find((r) => r.logicalId === "my-bucket")!;
67
+ expect(bucket.type).toBe("GCP::Storage::Bucket");
68
+ expect((bucket.properties.metadata as Record<string, unknown>).uid).toBeUndefined();
69
+ expect(bucket.properties.location).toBe("US");
70
+ });
71
+
72
+ test("verbatim keeps server fields", () => {
73
+ const ir = buildExportFromObjects([liveBucket()], { verbatim: true });
74
+ const bucket = ir.resources[0];
75
+ expect((bucket.properties.metadata as Record<string, unknown>).uid).toBe("u-1");
76
+ });
77
+
78
+ test("selector by type narrows the export", () => {
79
+ const ir = buildExportFromObjects([liveBucket(), livePubsub()], {
80
+ selector: { type: "GCP::Pubsub::Topic" },
81
+ });
82
+ expect(ir.resources.map((r) => r.logicalId)).toEqual(["events"]);
83
+ });
84
+
85
+ test("selector by name narrows the export", () => {
86
+ const ir = buildExportFromObjects([liveBucket(), livePubsub()], {
87
+ selector: { name: "my-bucket" },
88
+ });
89
+ expect(ir.resources.map((r) => r.logicalId)).toEqual(["my-bucket"]);
90
+ });
91
+
92
+ test("owned filter keeps only objects carrying the chant marker label (#120)", () => {
93
+ const mine = liveBucket();
94
+ (mine.metadata as any).labels = { "app.kubernetes.io/managed-by": "chant", "chant.intentius.io/stack": "billing" };
95
+ const theirs = livePubsub(); // no chant label
96
+ const ir = buildExportFromObjects([mine, theirs], { owned: true });
97
+ expect(ir.resources.map((r) => r.logicalId)).toEqual(["my-bucket"]);
98
+ });
99
+
100
+ test("export IR feeds GcpGenerator (templateGenerator) unchanged", () => {
101
+ const ir = buildExportFromObjects([liveBucket()]);
102
+ const files = new GcpGenerator().generate(ir);
103
+ expect(files.length).toBeGreaterThan(0);
104
+ });
105
+ });
@@ -0,0 +1,87 @@
1
+ import type { ExportedTemplate, ResourceSelector } from "@intentius/chant/lexicon";
2
+ import { hasOwnershipMarker } from "@intentius/chant/ownership";
3
+ import { GcpParser } from "./parser";
4
+
5
+ /** Server-written metadata fields, stripped unless `verbatim`. */
6
+ const SERVER_METADATA_FIELDS = [
7
+ "managedFields",
8
+ "uid",
9
+ "resourceVersion",
10
+ "generation",
11
+ "creationTimestamp",
12
+ "selfLink",
13
+ "ownerReferences",
14
+ "finalizers",
15
+ ];
16
+
17
+ function isRecord(v: unknown): v is Record<string, unknown> {
18
+ return typeof v === "object" && v !== null && !Array.isArray(v);
19
+ }
20
+
21
+ /**
22
+ * Strip `status` and server-written metadata to reach the declared shape of a
23
+ * Config Connector object. Config Connector writes back several
24
+ * `cnrm.cloud.google.com/*` annotations that are not authored config; those are
25
+ * dropped too. Returns a new object; the input is not mutated.
26
+ */
27
+ export function stripServerFields(obj: Record<string, unknown>): Record<string, unknown> {
28
+ const clone = JSON.parse(JSON.stringify(obj)) as Record<string, unknown>;
29
+ delete clone.status;
30
+
31
+ if (isRecord(clone.metadata)) {
32
+ const md = clone.metadata;
33
+ for (const f of SERVER_METADATA_FIELDS) delete md[f];
34
+ if (isRecord(md.annotations)) {
35
+ for (const key of Object.keys(md.annotations)) {
36
+ // Config Connector's own bookkeeping annotations, plus the apply
37
+ // round-trip annotation — none are authored config.
38
+ if (
39
+ key.startsWith("cnrm.cloud.google.com/") ||
40
+ key === "kubectl.kubernetes.io/last-applied-configuration"
41
+ ) {
42
+ delete md.annotations[key];
43
+ }
44
+ }
45
+ if (Object.keys(md.annotations).length === 0) delete md.annotations;
46
+ }
47
+ }
48
+ return clone;
49
+ }
50
+
51
+ /**
52
+ * Build full-fidelity export IR from a list of live Config Connector objects
53
+ * (each live object is already its manifest). Reuses the import GcpParser by
54
+ * feeding cleaned objects as JSON documents. Pure: all I/O stays in the caller.
55
+ */
56
+ export function buildExportFromObjects(
57
+ objects: unknown[],
58
+ opts: { verbatim?: boolean; selector?: ResourceSelector; owned?: boolean } = {},
59
+ ): ExportedTemplate {
60
+ const owning = opts.owned
61
+ ? objects.filter((o) => {
62
+ if (!isRecord(o)) return false;
63
+ const labels = isRecord(o.metadata) ? (o.metadata.labels as Record<string, unknown>) : undefined;
64
+ return hasOwnershipMarker(labels, "label");
65
+ })
66
+ : objects;
67
+
68
+ const cleaned = owning
69
+ .filter(isRecord)
70
+ .map((o) => (opts.verbatim ? o : stripServerFields(o)));
71
+
72
+ const content = cleaned.map((o) => JSON.stringify(o, null, 2)).join("\n---\n");
73
+ const ir = new GcpParser().parse(content);
74
+
75
+ const selector = opts.selector;
76
+ if (!selector || (selector.type === undefined && selector.name === undefined)) {
77
+ return ir;
78
+ }
79
+ return {
80
+ ...ir,
81
+ resources: ir.resources.filter(
82
+ (r) =>
83
+ (selector.type === undefined || r.type === selector.type) &&
84
+ (selector.name === undefined || r.logicalId === selector.name),
85
+ ),
86
+ };
87
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Cross-lexicon lifecycle integration (#163) — GCP row.
3
+ *
4
+ * Drives the REAL gcpPlugin through core's live-import driver and the changeset
5
+ * path, with the `kubectl` (Config Connector) edge mocked.
6
+ */
7
+ import { describe, test, expect, vi, beforeEach } from "vitest";
8
+ import { mkdtempSync, rmSync, readdirSync, readFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ const execMock = vi.fn();
13
+ vi.mock("node:child_process", async () => {
14
+ const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
15
+ return {
16
+ ...actual,
17
+ exec: (
18
+ cmd: string,
19
+ cb: (err: Error | null, out: { stdout: string; stderr: string }) => void,
20
+ ) => {
21
+ const r = execMock(cmd);
22
+ queueMicrotask(() =>
23
+ r instanceof Error
24
+ ? cb(r, { stdout: "", stderr: "" })
25
+ : cb(null, r as { stdout: string; stderr: string }),
26
+ );
27
+ },
28
+ };
29
+ });
30
+
31
+ const { gcpPlugin } = await import("./plugin");
32
+ const { liveImportFromPlugins } = await import("@intentius/chant/cli/commands/import");
33
+ const { buildChangeSet } = await import("@intentius/chant/lifecycle/change-set");
34
+
35
+ const liveBucket = {
36
+ apiVersion: "storage.cnrm.cloud.google.com/v1beta1",
37
+ kind: "StorageBucket",
38
+ metadata: { name: "my-bucket", namespace: "default", uid: "u-1" },
39
+ spec: { location: "US", storageClass: "STANDARD" },
40
+ };
41
+
42
+ describe("gcp lifecycle integration (#163)", () => {
43
+ beforeEach(() => execMock.mockReset());
44
+
45
+ test("live-import driver: real exportResources → IR → generated source", async () => {
46
+ execMock.mockImplementation((cmd?: string) => {
47
+ if (cmd?.includes("api-resources")) {
48
+ return { stdout: "storagebuckets.storage.cnrm.cloud.google.com\n", stderr: "" };
49
+ }
50
+ if (cmd?.includes("storagebuckets.storage.cnrm")) {
51
+ return { stdout: JSON.stringify({ items: [liveBucket] }), stderr: "" };
52
+ }
53
+ return { stdout: JSON.stringify({ items: [] }), stderr: "" };
54
+ });
55
+ const output = mkdtempSync(join(tmpdir(), "chant-gcp-li-"));
56
+ try {
57
+ const result = await liveImportFromPlugins([gcpPlugin], {
58
+ environment: "prod",
59
+ output,
60
+ force: true,
61
+ });
62
+ expect(result.success).toBe(true);
63
+ expect(result.generatedFiles.length).toBeGreaterThan(0);
64
+ const all = readdirSync(output)
65
+ .map((f) => readFileSync(join(output, f), "utf-8"))
66
+ .join("\n")
67
+ .toLowerCase();
68
+ expect(all).toContain("bucket");
69
+ } finally {
70
+ rmSync(output, { recursive: true, force: true });
71
+ }
72
+ });
73
+
74
+ test("changeset path: real describeResources → buildChangeSet verdicts", async () => {
75
+ execMock.mockImplementation((cmd?: string) =>
76
+ cmd?.includes("data-bucket")
77
+ ? {
78
+ stdout: JSON.stringify({
79
+ metadata: { name: "data-bucket", namespace: "config-control", uid: "uid-1" },
80
+ status: { conditions: [{ type: "Ready", status: "True" }] },
81
+ }),
82
+ stderr: "",
83
+ }
84
+ : new Error("not found"),
85
+ );
86
+
87
+ const observedNow = await gcpPlugin.describeResources!({
88
+ environment: "prod",
89
+ buildOutput: "",
90
+ entityNames: ["dataBucket"],
91
+ entities: new Map([
92
+ [
93
+ "dataBucket",
94
+ {
95
+ entityType: "GCP::Storage::Bucket",
96
+ props: { metadata: { name: "data-bucket", namespace: "config-control" } },
97
+ },
98
+ ],
99
+ ]),
100
+ });
101
+ expect(observedNow.dataBucket?.type).toBe("GCP::Storage::Bucket");
102
+
103
+ const cs = buildChangeSet("prod", {
104
+ declared: new Set(["pubsubTopic"]),
105
+ observedNow,
106
+ observedThen: undefined,
107
+ });
108
+ const byName = Object.fromEntries(cs.entries.map((e) => [e.name, e.action]));
109
+ expect(byName.pubsubTopic).toBe("create");
110
+ expect(byName.dataBucket).toBe("adopt");
111
+
112
+ const cs2 = buildChangeSet("prod", {
113
+ declared: new Set(["dataBucket"]),
114
+ observedNow,
115
+ observedThen: undefined,
116
+ });
117
+ expect(cs2.entries.find((e) => e.name === "dataBucket")!.action).toBe("noop");
118
+ });
119
+ });
package/src/plugin.ts CHANGED
@@ -385,4 +385,9 @@ export const bucket = new StorageBucket({
385
385
  const { describeResources } = await import("./describe-resources");
386
386
  return describeResources(options);
387
387
  },
388
+
389
+ async exportResources(options) {
390
+ const { exportResources } = await import("./export-resources");
391
+ return exportResources(options);
392
+ },
388
393
  };
@@ -0,0 +1,28 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { gcpSerializer } from "./serializer";
3
+ import { DECLARABLE_MARKER } from "@intentius/chant/declarable";
4
+
5
+ function mockResource(entityType: string, props: Record<string, unknown>): any {
6
+ return { [DECLARABLE_MARKER]: true, lexicon: "gcp", entityType, kind: "resource", props };
7
+ }
8
+
9
+ describe("gcpSerializer ownership stamping (#119)", () => {
10
+ test("stamps the ownership marker as labels when context.ownership is set", () => {
11
+ const entities = new Map<string, any>([
12
+ ["myBucket", mockResource("GCP::Storage::Bucket", { metadata: { name: "my-bucket" }, location: "US" })],
13
+ ]);
14
+ const yaml = gcpSerializer.serialize(entities, [], { ownership: { stack: "billing", env: "prod" } });
15
+ expect(yaml).toContain("app.kubernetes.io/managed-by: chant");
16
+ expect(yaml).toContain("chant.intentius.io/stack: billing");
17
+ expect(yaml).toContain("chant.intentius.io/env: prod");
18
+ });
19
+
20
+ test("no ownership context → no chant labels", () => {
21
+ const entities = new Map<string, any>([
22
+ ["myBucket", mockResource("GCP::Storage::Bucket", { metadata: { name: "my-bucket" }, location: "US" })],
23
+ ]);
24
+ const yaml = gcpSerializer.serialize(entities, []);
25
+ expect(yaml).not.toContain("managed-by: chant");
26
+ expect(yaml).not.toContain("chant.intentius.io/stack");
27
+ });
28
+ });
package/src/serializer.ts CHANGED
@@ -8,7 +8,8 @@
8
8
  import { createRequire } from "module";
9
9
  import type { Declarable } from "@intentius/chant/declarable";
10
10
  import { isPropertyDeclarable } from "@intentius/chant/declarable";
11
- import type { Serializer, SerializerResult } from "@intentius/chant/serializer";
11
+ import type { Serializer, SerializerResult, SerializeContext } from "@intentius/chant/serializer";
12
+ import { ownershipEntries } from "@intentius/chant/ownership";
12
13
  import type { LexiconOutput } from "@intentius/chant/lexicon-output";
13
14
  import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
14
15
  import { emitYAML } from "@intentius/chant/yaml";
@@ -116,15 +117,18 @@ export const gcpSerializer: Serializer = {
116
117
  name: "gcp",
117
118
  rulePrefix: "WGC",
118
119
 
119
- serialize(entities: Map<string, Declarable>, _outputs?: LexiconOutput[]): string {
120
+ serialize(entities: Map<string, Declarable>, _outputs?: LexiconOutput[], context?: SerializeContext): string {
120
121
  // Build reverse map: entity → name
121
122
  const entityNames = new Map<Declarable, string>();
122
123
  for (const [name, entity] of entities) {
123
124
  entityNames.set(entity, name);
124
125
  }
125
126
 
126
- // Collect default labels and annotations
127
- let defaultLabelEntries: Record<string, unknown> = {};
127
+ // Collect default labels and annotations. Ownership markers are stamped as
128
+ // labels (Config Connector resources carry them in metadata.labels).
129
+ let defaultLabelEntries: Record<string, unknown> = context?.ownership
130
+ ? { ...ownershipEntries("label", context.ownership) }
131
+ : {};
128
132
  let defaultAnnotationEntries: Record<string, unknown> = {};
129
133
 
130
134
  for (const [, entity] of entities) {