@intentius/chant-lexicon-gcp 0.37.2 → 0.39.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.
Files changed (57) hide show
  1. package/dist/api/read-client.d.ts +71 -0
  2. package/dist/api/read-client.d.ts.map +1 -0
  3. package/dist/composites/cloud-function.d.ts +1 -1
  4. package/dist/composites/cloud-function.d.ts.map +1 -1
  5. package/dist/composites/cloud-run-service.d.ts +1 -1
  6. package/dist/composites/cloud-run-service.d.ts.map +1 -1
  7. package/dist/composites/cloud-sql-instance.d.ts +5 -1
  8. package/dist/composites/cloud-sql-instance.d.ts.map +1 -1
  9. package/dist/composites/gcs-bucket.d.ts +3 -1
  10. package/dist/composites/gcs-bucket.d.ts.map +1 -1
  11. package/dist/composites/gke-cluster.d.ts +5 -1
  12. package/dist/composites/gke-cluster.d.ts.map +1 -1
  13. package/dist/composites/managed-certificate.d.ts +1 -1
  14. package/dist/composites/managed-certificate.d.ts.map +1 -1
  15. package/dist/composites/memorystore-redis.d.ts +3 -1
  16. package/dist/composites/memorystore-redis.d.ts.map +1 -1
  17. package/dist/composites/private-service.d.ts +1 -1
  18. package/dist/composites/private-service.d.ts.map +1 -1
  19. package/dist/composites/pubsub-pipeline.d.ts +1 -1
  20. package/dist/composites/pubsub-pipeline.d.ts.map +1 -1
  21. package/dist/composites/secure-project.d.ts +1 -1
  22. package/dist/composites/secure-project.d.ts.map +1 -1
  23. package/dist/composites/vpc-network.d.ts +1 -1
  24. package/dist/composites/vpc-network.d.ts.map +1 -1
  25. package/dist/deep-observe-hooks.d.ts +41 -30
  26. package/dist/deep-observe-hooks.d.ts.map +1 -1
  27. package/dist/deep-observe.d.ts +24 -0
  28. package/dist/deep-observe.d.ts.map +1 -1
  29. package/dist/describe-resources.d.ts +65 -39
  30. package/dist/describe-resources.d.ts.map +1 -1
  31. package/dist/integrity.json +2 -2
  32. package/dist/manifest.json +1 -1
  33. package/dist/op/activities/floci-gcp.d.ts +27 -1
  34. package/dist/op/activities/floci-gcp.d.ts.map +1 -1
  35. package/dist/plugin.d.ts.map +1 -1
  36. package/package.json +2 -2
  37. package/src/api/read-client.ts +129 -0
  38. package/src/composites/cloud-function.ts +1 -1
  39. package/src/composites/cloud-run-service.ts +1 -1
  40. package/src/composites/cloud-sql-instance.ts +1 -1
  41. package/src/composites/gcs-bucket.ts +1 -1
  42. package/src/composites/gke-cluster.ts +1 -1
  43. package/src/composites/managed-certificate.ts +1 -1
  44. package/src/composites/memorystore-redis.ts +1 -1
  45. package/src/composites/private-service.ts +1 -1
  46. package/src/composites/pubsub-pipeline.ts +1 -1
  47. package/src/composites/secure-project.ts +1 -1
  48. package/src/composites/vpc-network.ts +1 -1
  49. package/src/deep-observe-hooks.ts +112 -60
  50. package/src/deep-observe.test.ts +124 -478
  51. package/src/deep-observe.ts +94 -74
  52. package/src/describe-resources.test.ts +119 -206
  53. package/src/describe-resources.ts +153 -111
  54. package/src/lifecycle-integration.test.ts +32 -18
  55. package/src/op/activities/floci-gcp.test.ts +26 -1
  56. package/src/op/activities/floci-gcp.ts +37 -5
  57. package/src/plugin.ts +4 -0
@@ -1,9 +1,10 @@
1
+ import { type EmulatorCapability, type EmulatorSpec } from "@intentius/chant/op";
1
2
  export interface FlociGcpUpArgs {
2
3
  /** Container name. Default: `chant-floci-gcp`. */
3
4
  name?: string;
4
5
  /** Host port mapped to the emulator's `:4588`. Default: `4588`. */
5
6
  port?: number;
6
- /** Image. Default: `floci/floci-gcp:latest`. */
7
+ /** Image. Default: the pinned `floci/floci-gcp` tag. */
7
8
  image?: string;
8
9
  /** Readiness timeout in ms. Default: `60000`. */
9
10
  timeoutMs?: number;
@@ -14,6 +15,31 @@ export interface FlociGcpDownArgs {
14
15
  /** Container name to remove. Default: `chant-floci-gcp`. */
15
16
  name?: string;
16
17
  }
18
+ export declare const FLOCI_GCP_SPEC: EmulatorSpec;
19
+ /**
20
+ * The gcp plugin's emulator capability (#1345, corrected in #1431).
21
+ *
22
+ * The apply and read paths reach the emulator differently, and the capability
23
+ * exists for the one that cannot be told where to look:
24
+ *
25
+ * - **apply** — `gcpApply` takes an explicit `endpoint` argument. Nothing to
26
+ * inject; the caller already holds it.
27
+ * - **read** — `describeResources` and `observeResourcesDeep` take it from
28
+ * `GCP_ENDPOINT_URL` and nothing else (`../../describe-resources.ts`,
29
+ * `../../deep-observe.ts`). A reader given no variable talks to real GCP.
30
+ *
31
+ * `env` was empty because when #1345 declared this capability the second bullet
32
+ * was not yet true: GCP was observed through Config Connector over kubectl, so
33
+ * there genuinely was no variable to inject. #1209 moved observation onto the
34
+ * applier's own direct-REST transport about two hours later, and nothing
35
+ * re-read this comment. The result was an emulator that booted, reported its
36
+ * endpoint, and left every `--live` read pointed at production.
37
+ *
38
+ * Injecting it makes `chant emulator up --lexicon gcp` mean the same thing for
39
+ * gcp that it already means for aws and azure: the estate's reads land on the
40
+ * emulator you just started.
41
+ */
42
+ export declare const FLOCI_GCP_EMULATOR: EmulatorCapability;
17
43
  export declare const flociGcpExistsCommand: (name: string) => string;
18
44
  export declare const flociGcpRmCommand: (name: string) => string;
19
45
  export declare const flociGcpHealthUrl: (port: number) => string;
@@ -1 +1 @@
1
- {"version":3,"file":"floci-gcp.d.ts","sourceRoot":"","sources":["../../../src/op/activities/floci-gcp.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,cAAc;IAC7B,kDAAkD;IAClD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,mEAAmE;IACnE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,gDAAgD;IAChD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iDAAiD;IACjD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mDAAmD;IACnD,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAC/B,4DAA4D;IAC5D,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAWD,eAAO,MAAM,qBAAqB,0BAAoB,CAAC;AACvD,eAAO,MAAM,iBAAiB,0BAAgB,CAAC;AAC/C,eAAO,MAAM,iBAAiB,0BAAgB,CAAC;AAC/C,2EAA2E;AAC3E,eAAO,MAAM,gBAAgB,0BAAe,CAAC;AAC7C,eAAO,MAAM,kBAAkB,GAAI,OAAM,cAAmB,KAAG,MAA8B,CAAC;AAE9F,+EAA+E;AAC/E,eAAO,MAAM,UAAU,GAAI,OAAM,cAAmB,EAAE,SAAS,WAAW,KAAG,OAAO,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAA;CAAE,CACnF,CAAC;AAEvB,6EAA6E;AAC7E,eAAO,MAAM,YAAY,GAAI,OAAM,gBAAqB,EAAE,SAAS,WAAW,KAAG,OAAO,CAAC,IAAI,CACrE,CAAC"}
1
+ {"version":3,"file":"floci-gcp.d.ts","sourceRoot":"","sources":["../../../src/op/activities/floci-gcp.ts"],"names":[],"mappings":"AAAA,OAAO,EAAqB,KAAK,kBAAkB,EAAE,KAAK,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAEpG,MAAM,WAAW,cAAc;IAC7B,kDAAkD;IAClD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,mEAAmE;IACnE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,wDAAwD;IACxD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iDAAiD;IACjD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mDAAmD;IACnD,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAC/B,4DAA4D;IAC5D,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAKD,eAAO,MAAM,cAAc,EAAE,YAM5B,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,kBAAkB,EAAE,kBAGhC,CAAC;AAIF,eAAO,MAAM,qBAAqB,0BAAoB,CAAC;AACvD,eAAO,MAAM,iBAAiB,0BAAgB,CAAC;AAC/C,eAAO,MAAM,iBAAiB,0BAAgB,CAAC;AAC/C,2EAA2E;AAC3E,eAAO,MAAM,gBAAgB,0BAAe,CAAC;AAC7C,eAAO,MAAM,kBAAkB,GAAI,OAAM,cAAmB,KAAG,MAA8B,CAAC;AAE9F,+EAA+E;AAC/E,eAAO,MAAM,UAAU,GAAI,OAAM,cAAmB,EAAE,SAAS,WAAW,KAAG,OAAO,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAA;CAAE,CACnF,CAAC;AAEvB,6EAA6E;AAC7E,eAAO,MAAM,YAAY,GAAI,OAAM,gBAAqB,EAAE,SAAS,WAAW,KAAG,OAAO,CAAC,IAAI,CACrE,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAqC,MAAM,0BAA0B,CAAC;AAqBjG,eAAO,MAAM,SAAS,EAAE,aAuXvB,CAAC"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAqC,MAAM,0BAA0B,CAAC;AAuBjG,eAAO,MAAM,SAAS,EAAE,aAyXvB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-gcp",
3
- "version": "0.37.2",
3
+ "version": "0.39.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.37.2",
79
+ "@intentius/chant": "^0.39.0",
80
80
  "typescript": "^5.9.3"
81
81
  }
82
82
  }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * GCP read transport (#1209) — the applier's own REST client, pointed at the
3
+ * read side.
4
+ *
5
+ * GCP applies cluster-free over direct REST (#706) and, until this, observed
6
+ * through a Config Connector cluster: `kubectl get <cnrm-gvk> -o json` per
7
+ * entity, on both the thin and the deep path. That split is what #1085's
8
+ * principle forbids — a lexicon should be observed on the transport it is
9
+ * applied with, or the two can disagree about what a resource even is — and it
10
+ * is what made GCP the heaviest of the three O slots.
11
+ *
12
+ * ## The URL comes from the applier, not from here
13
+ *
14
+ * Every GET is built by the same `ResourceMapper.plan()` the applier uses, and
15
+ * this module reads `plan().getUrl` rather than composing its own URL. That is
16
+ * deliberate: a reader that builds its own paths is a second, silently
17
+ * divergent opinion about where a resource lives, and the first symptom is a
18
+ * read that reports absent for something the applier just wrote. Reusing the
19
+ * mapper makes the two structurally incapable of disagreeing.
20
+ *
21
+ * ## What that bounds
22
+ *
23
+ * Coverage is exactly the applier's dispatch table (`MAPPERS`) — StorageBucket,
24
+ * PubSubTopic, PubSubSubscription, SecretManagerSecret, IAMServiceAccount,
25
+ * RunService. The kubectl path could fetch any CNRM kind the cluster knew
26
+ * about, so this is narrower on paper. It is not narrower in practice for
27
+ * anything chant can act on: a kind with no mapper cannot be applied either, so
28
+ * observing it produced a live tree nothing could ever reconcile against.
29
+ *
30
+ * A kind outside the table reports NOT-OBSERVED with `unsupported-kind` rather
31
+ * than being dropped, so the estate says "chant did not look at this" instead of
32
+ * quietly implying it is not there. Widening the table is #1209's stated
33
+ * non-goal and belongs with the applier.
34
+ */
35
+ import { MAPPERS, type GcpResource, type ResourceMapper } from "../op/activities/gcp-apply";
36
+
37
+ /** Where to read, and as whom. */
38
+ export interface GcpReadClientOptions {
39
+ /** Endpoint override for every kind (floci-gcp `http://localhost:4588`). Omit for real GCP. */
40
+ endpoint?: string;
41
+ /** GCP project the resources live in. */
42
+ project: string;
43
+ /** Injectable HTTP, so tests never touch the network. */
44
+ http?: GcpReadHttp;
45
+ }
46
+
47
+ /** Injectable HTTP client — mirrors the applier's `GcpHttp`. */
48
+ export type GcpReadHttp = (method: string, url: string) => Promise<{ status: number; text: string }>;
49
+
50
+ const defaultHttp: GcpReadHttp = async (method, url) => {
51
+ const res = await fetch(url, { method });
52
+ return { status: res.status, text: await res.text() };
53
+ };
54
+
55
+ /**
56
+ * A read that failed for a reason the caller must classify — carries the HTTP
57
+ * status so a 404 (absent) is told apart from a 401/403 (no credentials) and a
58
+ * 5xx/network error (could not look). Mirrors `AzureReadError`.
59
+ */
60
+ export class GcpReadError extends Error {
61
+ constructor(
62
+ message: string,
63
+ readonly status?: number,
64
+ ) {
65
+ super(message);
66
+ this.name = "GcpReadError";
67
+ }
68
+ }
69
+
70
+ /** A 404 — the resource is genuinely absent, which is an answer, not a failure. */
71
+ export function isNotFound(err: unknown): boolean {
72
+ return err instanceof GcpReadError && err.status === 404;
73
+ }
74
+
75
+ /** The mapper for a CNRM kind, or undefined when chant cannot act on it. */
76
+ export function mapperForKind(kind: string): ResourceMapper | undefined {
77
+ return MAPPERS[kind];
78
+ }
79
+
80
+ /**
81
+ * GET one resource on the applier's transport.
82
+ *
83
+ * `declared` is the entity's own declared spec, which is what `plan()` needs to
84
+ * build a URL — the same input the applier hands it. Returns the parsed body,
85
+ * or throws {@link GcpReadError} carrying the status.
86
+ */
87
+ export async function getResource(
88
+ client: GcpReadClientOptions,
89
+ kind: string,
90
+ name: string,
91
+ declared?: Record<string, unknown>,
92
+ ): Promise<Record<string, unknown>> {
93
+ const mapper = mapperForKind(kind);
94
+ if (!mapper) throw new GcpReadError(`no REST mapper for kind ${kind}`);
95
+
96
+ const base = (client.endpoint ?? mapper.defaultHost).replace(/\/$/, "");
97
+ const resource: GcpResource = {
98
+ kind,
99
+ metadata: { name },
100
+ spec: (declared?.spec as Record<string, unknown> | undefined) ?? declared ?? {},
101
+ };
102
+
103
+ let getUrl: string;
104
+ try {
105
+ getUrl = mapper.plan(resource, { base, project: client.project }).getUrl;
106
+ } catch (err) {
107
+ // A mapper that cannot plan from the declared spec cannot produce a URL, so
108
+ // there is nothing to read — a hole, never an absence.
109
+ throw new GcpReadError(`could not build a read URL for ${kind}/${name}: ${err instanceof Error ? err.message : String(err)}`);
110
+ }
111
+
112
+ const http = client.http ?? defaultHttp;
113
+ let res: { status: number; text: string };
114
+ try {
115
+ res = await http("GET", getUrl);
116
+ } catch (err) {
117
+ throw new GcpReadError(err instanceof Error ? err.message : String(err));
118
+ }
119
+
120
+ if (res.status === 404) throw new GcpReadError(`${kind}/${name} not found`, 404);
121
+ if (res.status < 200 || res.status >= 300) {
122
+ throw new GcpReadError(`GET ${kind}/${name} failed with ${res.status}: ${res.text.slice(0, 200)}`, res.status);
123
+ }
124
+ try {
125
+ return JSON.parse(res.text) as Record<string, unknown>;
126
+ } catch {
127
+ throw new GcpReadError(`GET ${kind}/${name} returned a non-JSON body`, res.status);
128
+ }
129
+ }
@@ -42,7 +42,7 @@ export interface CloudFunctionWithTriggerProps {
42
42
  };
43
43
  }
44
44
 
45
- export const CloudFunctionWithTrigger = Composite<CloudFunctionWithTriggerProps>((props) => {
45
+ export const CloudFunctionWithTrigger = Composite((props: CloudFunctionWithTriggerProps) => {
46
46
  const {
47
47
  name,
48
48
  runtime,
@@ -56,7 +56,7 @@ export interface CloudRunServiceProps {
56
56
  * });
57
57
  * ```
58
58
  */
59
- export const CloudRunServiceComposite = Composite<CloudRunServiceProps>((props) => {
59
+ export const CloudRunServiceComposite = Composite((props: CloudRunServiceProps) => {
60
60
  const {
61
61
  name,
62
62
  image,
@@ -52,7 +52,7 @@ export interface CloudSqlInstanceProps {
52
52
  * });
53
53
  * ```
54
54
  */
55
- export const CloudSqlInstance = Composite<CloudSqlInstanceProps>((props) => {
55
+ export const CloudSqlInstance = Composite((props: CloudSqlInstanceProps) => {
56
56
  const {
57
57
  name,
58
58
  databaseVersion = "POSTGRES_15",
@@ -47,7 +47,7 @@ export interface GcsBucketProps {
47
47
  * });
48
48
  * ```
49
49
  */
50
- export const GcsBucket = Composite<GcsBucketProps>((props) => {
50
+ export const GcsBucket = Composite((props: GcsBucketProps) => {
51
51
  const {
52
52
  name,
53
53
  location = "US",
@@ -80,7 +80,7 @@ export interface GkeClusterProps {
80
80
  * });
81
81
  * ```
82
82
  */
83
- export const GkeCluster = Composite<GkeClusterProps>((props) => {
83
+ export const GkeCluster = Composite((props: GkeClusterProps) => {
84
84
  const {
85
85
  name,
86
86
  projectId: rawProjectId,
@@ -26,7 +26,7 @@ export interface ManagedCertificateProps {
26
26
  };
27
27
  }
28
28
 
29
- export const ManagedCertificate = Composite<ManagedCertificateProps>((props) => {
29
+ export const ManagedCertificate = Composite((props: ManagedCertificateProps) => {
30
30
  const {
31
31
  name,
32
32
  domains,
@@ -53,7 +53,7 @@ export interface MemorystoreRedisProps {
53
53
  * });
54
54
  * ```
55
55
  */
56
- export const MemorystoreRedis = Composite<MemorystoreRedisProps>((props) => {
56
+ export const MemorystoreRedis = Composite((props: MemorystoreRedisProps) => {
57
57
  const {
58
58
  name,
59
59
  purpose,
@@ -38,7 +38,7 @@ export interface PrivateServiceProps {
38
38
  };
39
39
  }
40
40
 
41
- export const PrivateService = Composite<PrivateServiceProps>((props) => {
41
+ export const PrivateService = Composite((props: PrivateServiceProps) => {
42
42
  const {
43
43
  name,
44
44
  networkName,
@@ -31,7 +31,7 @@ export interface PubSubPipelineProps {
31
31
  };
32
32
  }
33
33
 
34
- export const PubSubPipeline = Composite<PubSubPipelineProps>((props) => {
34
+ export const PubSubPipeline = Composite((props: PubSubPipelineProps) => {
35
35
  const {
36
36
  name,
37
37
  ackDeadlineSeconds = 10,
@@ -41,7 +41,7 @@ export interface SecureProjectProps {
41
41
  };
42
42
  }
43
43
 
44
- export const SecureProject = Composite<SecureProjectProps>((props) => {
44
+ export const SecureProject = Composite((props: SecureProjectProps) => {
45
45
  const {
46
46
  name,
47
47
  orgId,
@@ -68,7 +68,7 @@ export interface VpcNetworkProps {
68
68
  * });
69
69
  * ```
70
70
  */
71
- export const VpcNetwork = Composite<VpcNetworkProps>((props) => {
71
+ export const VpcNetwork = Composite((props: VpcNetworkProps) => {
72
72
  const {
73
73
  name,
74
74
  autoCreateSubnetworks = false,
@@ -1,79 +1,131 @@
1
1
  /**
2
- * The gcp lexicon's *static* deep-observation noise rules (#1087, epic #1073).
2
+ * GCP deep-observation noise rules (#1209) — for REST payloads, not Config
3
+ * Connector objects.
3
4
  *
4
- * Split out from `./deep-observe.ts` for the same reason chant #1076 split
5
- * the k8s lexicon's rules into `lexicons/k8s/src/deep-observe-hooks.ts`: this
6
- * file must be safe to import from `plugin.ts` at module load time, because
7
- * `LexiconPlugin.deepNormalizationHooks` is plain data core reads to
8
- * normalize the *declared* tree — the half of the contract that runs whether
9
- * or not a cluster is ever touched (`lifecycle diff` without `--live`,
10
- * `chant build`, tests that only exercise normalization). `./deep-observe.ts`
11
- * imports `./describe-resources.ts` for the live kubectl transport, which
12
- * pulls in `node:child_process`; nothing this file exports may pull that in,
13
- * directly or transitively, so `chant build` never resolves a process-
14
- * spawning module just to normalize a declared tree.
5
+ * ## What this replaced, and why it had to
15
6
  *
16
- * What lives here is entirely reused from `@intentius/chant/managed-fields`
17
- * (chant #1087's module doc explains why that lives in core rather than the
18
- * k8s lexicon) plus GCP/Config-Connector-specific annotation noise. Nothing
19
- * here needs a live object in hand — the per-resource managed-fields prune
20
- * (which does) is computed in `./deep-observe.ts` and layered on top.
7
+ * Until #1209 the deep reader read Config Connector custom resources through
8
+ * kubectl, and suppressed noise two ways: this module's static rules (the k8s
9
+ * object envelope plus CNRM's own observed-state annotations) and, in
10
+ * `./deep-observe.ts`, a per-resource prune driven by `metadata.managedFields`
11
+ * — Kubernetes records which manager owns which field, so everything the CNRM
12
+ * controller owned was subtracted before the diff ran.
13
+ *
14
+ * Reading GCP's own REST APIs removes both. **A GCP payload never says who
15
+ * wrote a field**, so there is no managed-fields attribution to prune by, and
16
+ * it is not a Kubernetes object, so the k8s envelope and CNRM annotation rules
17
+ * match nothing in it. Audited every rule that was here: all of them were
18
+ * CNRM-shaped, none applies.
19
+ *
20
+ * So the noise story is re-answered rather than re-pointed, in the shape the
21
+ * other two cloud lexicons already use — a static table naming what the
22
+ * provider populates. It is hand-maintained, which is the honest cost: a
23
+ * server-set field nobody has listed reads as drift until it is. That is
24
+ * visible and fixable, where an over-broad rule silently hides real drift.
21
25
  */
22
-
23
- import type { DeepNode, DeepNormalizationHooks } from "@intentius/chant/lexicon";
24
- import { K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS, k8sListMapOrderKey } from "@intentius/chant/managed-fields";
26
+ import type { DeepNode, DeepNormalizationHooks } from "@intentius/chant/deep-observation";
25
27
 
26
28
  /**
27
- * Config Connector's own observed-state annotations written by
28
- * `cnrm-controller-manager` after reconciling, mirroring GCP-side state the
29
- * CRD schema has no field for (e.g. secret version pins). Never authored by
30
- * a chant manifest, so pruning them unconditionally cannot hide declared
31
- * config the way blindly pruning every `cnrm.cloud.google.com/*` annotation
32
- * would — several of that family (`state-into-spec`, `deletion-policy`,
33
- * `management-conflict-prevention-policy`) are themselves user-authored
34
- * configuration, not observed state, and pruning those would hide genuine
35
- * drift on them.
29
+ * Fields GCP populates on essentially anything, wherever they appear.
36
30
  *
37
- * Sparse and evidence-based (Config Connector's own docs), the same posture
38
- * every other lexicon's noise table takes. Widening it is additive and needs
39
- * no contract change.
40
- *
41
- * Most CNRM-controller noise doesn't need this list at all — it's already
42
- * pruned by the reused managed-fields ownership rule in `./deep-observe.ts`,
43
- * since `cnrm-controller-manager` is a foreign, undeclared owner of whatever
44
- * it touches. This list only covers the narrow, evidence-based case worth
45
- * pruning even were managedFields attribution ever missing for it.
31
+ * Pruned on BOTH sides and regardless of whether source declared them: these
32
+ * are server-assigned identity and bookkeeping, and a user who writes one is
33
+ * writing something the API will overwrite anyway.
46
34
  */
47
- const CNRM_OBSERVED_STATE_ANNOTATION_KEYS: ReadonlySet<string> = new Set([
48
- "cnrm.cloud.google.com/observed-secret-versions",
35
+ export const GCP_READ_ONLY_NAMES: ReadonlySet<string> = new Set([
36
+ "etag",
37
+ "selfLink",
38
+ "id",
39
+ "projectNumber",
40
+ "timeCreated",
41
+ "updated",
42
+ "createTime",
43
+ "updateTime",
44
+ "generation",
45
+ "metageneration",
46
+ "uid",
47
+ "state",
48
+ "reconciling",
49
+ "observedGeneration",
49
50
  ]);
50
51
 
51
- const ANNOTATIONS_PREFIX = "metadata.annotations.";
52
+ /**
53
+ * Per-kind values GCP fills in when the request omits them.
54
+ *
55
+ * Unlike {@link GCP_READ_ONLY_NAMES} these are only noise where **source never
56
+ * declared the property** — a default somebody explicitly wrote is a fact worth
57
+ * diffing, and pruning it would hide a real change away from it. The
58
+ * `counterpart === "absent"` gate below is what enforces that, and it is the
59
+ * same rule the AWS and Azure hooks apply.
60
+ */
61
+ export const GCP_SERVICE_DEFAULTS: Readonly<Record<string, Readonly<Record<string, unknown>>>> = {
62
+ StorageBucket: {
63
+ storageClass: "STANDARD",
64
+ locationType: "multi-region",
65
+ "iamConfiguration.publicAccessPrevention": "inherited",
66
+ "iamConfiguration.uniformBucketLevelAccess.enabled": false,
67
+ "softDeletePolicy.retentionDurationSeconds": "604800",
68
+ kind: "storage#bucket",
69
+ },
70
+ PubSubTopic: {
71
+ "messageStoragePolicy.enforceInTransit": false,
72
+ },
73
+ PubSubSubscription: {
74
+ ackDeadlineSeconds: 10,
75
+ "expirationPolicy.ttl": "2678400s",
76
+ enableMessageOrdering: false,
77
+ "retryPolicy.minimumBackoff": "10s",
78
+ },
79
+ SecretManagerSecret: {
80
+ etag: undefined, // covered by READ_ONLY; listed for readers looking here first
81
+ },
82
+ RunService: {
83
+ ingress: "INGRESS_TRAFFIC_ALL",
84
+ launchStage: "GA",
85
+ },
86
+ IAMServiceAccount: {
87
+ disabled: false,
88
+ },
89
+ };
90
+
91
+ /** chant's own ownership labels, as the applier stamps them. */
92
+ const CHANT_OWNERSHIP_LABEL_PATTERNS: ReadonlySet<string> = new Set([
93
+ "metadata.labels.managed-by",
94
+ "metadata.labels.chant-stack",
95
+ "metadata.labels.chant-env",
96
+ ]);
52
97
 
53
- function isCnrmObservedStateAnnotation(node: DeepNode): boolean {
54
- if (!node.pattern.startsWith(ANNOTATIONS_PREFIX)) return false;
55
- return CNRM_OBSERVED_STATE_ANNOTATION_KEYS.has(node.pattern.slice(ANNOTATIONS_PREFIX.length));
98
+ /** Last dotted segment of a path pattern — `spec.foo.etag` -> `etag`. */
99
+ function lastSegment(pattern: string): string {
100
+ const i = pattern.lastIndexOf(".");
101
+ return i === -1 ? pattern : pattern.slice(i + 1);
56
102
  }
57
103
 
58
104
  /**
59
- * GCP's static deep-observation noise rules the entity-agnostic half,
60
- * applied to the *declared* tree by core (`gcpPlugin.deepNormalizationHooks`)
61
- * exactly like the other three rows, and layered under the per-resource
62
- * managed-fields prune in `./deep-observe.ts` for the live tree.
63
- *
64
- * A Config Connector custom resource *is* a Kubernetes object (see
65
- * `./deep-observe.ts`'s module doc), so the generic envelope
66
- * (`K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS`) and Kubernetes' own list-map-key
67
- * ordering conventions (`k8sListMapOrderKey` — needed because some CNRM kinds
68
- * embed genuinely k8s-shaped substructures, e.g. Cloud Run's `RunService`)
69
- * are reused verbatim from `@intentius/chant/managed-fields` rather than
70
- * restated. The one GCP-specific rule is CNRM's own observed-state
71
- * annotation noise.
105
+ * GCP's static deep-observation noise rules, applied by core to the declared
106
+ * tree and the live tree alike so the two are compared in the same shape.
72
107
  */
73
108
  export const gcpDeepNormalizationHooks: DeepNormalizationHooks = {
74
109
  prune(node: DeepNode): boolean {
75
- if (K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS.has(node.pattern)) return true;
76
- return isCnrmObservedStateAnnotation(node);
110
+ // Server-assigned, wherever it appears, on either side.
111
+ if (GCP_READ_ONLY_NAMES.has(lastSegment(node.pattern))) return true;
112
+
113
+ // chant's own ownership marker is not drift. The applier stamps these, so
114
+ // reporting them back is chant showing its own signature to itself — the
115
+ // same correction #1301 made for AWS after the emulator began applying tags.
116
+ if (CHANT_OWNERSHIP_LABEL_PATTERNS.has(node.pattern)) return true;
117
+
118
+ // A provider default is noise only where source never declared the
119
+ // property. `counterpart` is a tri-state; only `absent` licenses this.
120
+ if (node.side !== "live" || node.counterpart !== "absent") return false;
121
+ const kindDefaults = GCP_SERVICE_DEFAULTS[shortKind(node.entityType)];
122
+ if (!kindDefaults) return false;
123
+ return Object.prototype.hasOwnProperty.call(kindDefaults, node.pattern) && kindDefaults[node.pattern] === node.value;
77
124
  },
78
- orderKey: k8sListMapOrderKey,
79
125
  };
126
+
127
+ /** `GCP::Storage::Bucket` -> `StorageBucket`, the key `GCP_SERVICE_DEFAULTS` uses. */
128
+ function shortKind(entityType: string): string {
129
+ const parts = entityType.split("::");
130
+ return parts.length === 3 ? `${parts[1]}${parts[2]}` : entityType;
131
+ }