@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,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
+ }