@intentius/chant-lexicon-k8s 0.37.3 → 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 (170) hide show
  1. package/dist/codegen/generate-list-map-keys.d.ts +53 -0
  2. package/dist/codegen/generate-list-map-keys.d.ts.map +1 -0
  3. package/dist/codegen/generate.d.ts.map +1 -1
  4. package/dist/composites/adot-collector.d.ts +7 -1
  5. package/dist/composites/adot-collector.d.ts.map +1 -1
  6. package/dist/composites/agic-ingress.d.ts +3 -1
  7. package/dist/composites/agic-ingress.d.ts.map +1 -1
  8. package/dist/composites/aks-external-dns-agent.d.ts +6 -1
  9. package/dist/composites/aks-external-dns-agent.d.ts.map +1 -1
  10. package/dist/composites/alb-ingress.d.ts +3 -1
  11. package/dist/composites/alb-ingress.d.ts.map +1 -1
  12. package/dist/composites/autoscaled-service.d.ts +6 -1
  13. package/dist/composites/autoscaled-service.d.ts.map +1 -1
  14. package/dist/composites/azure-disk-storage-class.d.ts +3 -1
  15. package/dist/composites/azure-disk-storage-class.d.ts.map +1 -1
  16. package/dist/composites/azure-file-storage-class.d.ts +3 -1
  17. package/dist/composites/azure-file-storage-class.d.ts.map +1 -1
  18. package/dist/composites/azure-monitor-collector.d.ts +7 -1
  19. package/dist/composites/azure-monitor-collector.d.ts.map +1 -1
  20. package/dist/composites/batch-job.d.ts +1 -1
  21. package/dist/composites/batch-job.d.ts.map +1 -1
  22. package/dist/composites/cockroachdb-cluster.d.ts +13 -1
  23. package/dist/composites/cockroachdb-cluster.d.ts.map +1 -1
  24. package/dist/composites/config-connector-context.d.ts +3 -1
  25. package/dist/composites/config-connector-context.d.ts.map +1 -1
  26. package/dist/composites/configured-app.d.ts +1 -1
  27. package/dist/composites/configured-app.d.ts.map +1 -1
  28. package/dist/composites/cron-workload.d.ts +6 -1
  29. package/dist/composites/cron-workload.d.ts.map +1 -1
  30. package/dist/composites/ebs-storage-class.d.ts +3 -1
  31. package/dist/composites/ebs-storage-class.d.ts.map +1 -1
  32. package/dist/composites/efs-storage-class.d.ts +3 -1
  33. package/dist/composites/efs-storage-class.d.ts.map +1 -1
  34. package/dist/composites/external-dns-agent.d.ts +6 -1
  35. package/dist/composites/external-dns-agent.d.ts.map +1 -1
  36. package/dist/composites/filestore-storage-class.d.ts +3 -1
  37. package/dist/composites/filestore-storage-class.d.ts.map +1 -1
  38. package/dist/composites/fluent-bit-agent.d.ts +7 -1
  39. package/dist/composites/fluent-bit-agent.d.ts.map +1 -1
  40. package/dist/composites/gce-ingress.d.ts +3 -1
  41. package/dist/composites/gce-ingress.d.ts.map +1 -1
  42. package/dist/composites/gce-pd-storage-class.d.ts +3 -1
  43. package/dist/composites/gce-pd-storage-class.d.ts.map +1 -1
  44. package/dist/composites/gke-external-dns-agent.d.ts +6 -1
  45. package/dist/composites/gke-external-dns-agent.d.ts.map +1 -1
  46. package/dist/composites/gke-fluent-bit-agent.d.ts +7 -1
  47. package/dist/composites/gke-fluent-bit-agent.d.ts.map +1 -1
  48. package/dist/composites/gke-gateway.d.ts +4 -1
  49. package/dist/composites/gke-gateway.d.ts.map +1 -1
  50. package/dist/composites/gke-otel-collector.d.ts +7 -1
  51. package/dist/composites/gke-otel-collector.d.ts.map +1 -1
  52. package/dist/composites/irsa-service-account.d.ts +1 -1
  53. package/dist/composites/irsa-service-account.d.ts.map +1 -1
  54. package/dist/composites/metrics-server.d.ts +10 -1
  55. package/dist/composites/metrics-server.d.ts.map +1 -1
  56. package/dist/composites/monitored-service.d.ts +1 -1
  57. package/dist/composites/monitored-service.d.ts.map +1 -1
  58. package/dist/composites/namespace-env.d.ts +1 -1
  59. package/dist/composites/namespace-env.d.ts.map +1 -1
  60. package/dist/composites/network-isolated-app.d.ts +5 -1
  61. package/dist/composites/network-isolated-app.d.ts.map +1 -1
  62. package/dist/composites/node-agent.d.ts +1 -1
  63. package/dist/composites/node-agent.d.ts.map +1 -1
  64. package/dist/composites/ray-cluster.d.ts +10 -1
  65. package/dist/composites/ray-cluster.d.ts.map +1 -1
  66. package/dist/composites/ray-job.d.ts +8 -1
  67. package/dist/composites/ray-job.d.ts.map +1 -1
  68. package/dist/composites/ray-service.d.ts +10 -1
  69. package/dist/composites/ray-service.d.ts.map +1 -1
  70. package/dist/composites/secure-ingress.d.ts +1 -1
  71. package/dist/composites/secure-ingress.d.ts.map +1 -1
  72. package/dist/composites/sidecar-app.d.ts +4 -1
  73. package/dist/composites/sidecar-app.d.ts.map +1 -1
  74. package/dist/composites/stateful-app.d.ts +1 -1
  75. package/dist/composites/stateful-app.d.ts.map +1 -1
  76. package/dist/composites/web-app.d.ts +1 -1
  77. package/dist/composites/web-app.d.ts.map +1 -1
  78. package/dist/composites/worker-pool.d.ts +1 -1
  79. package/dist/composites/worker-pool.d.ts.map +1 -1
  80. package/dist/composites/workload-identity-sa.d.ts +1 -1
  81. package/dist/composites/workload-identity-sa.d.ts.map +1 -1
  82. package/dist/composites/workload-identity-service-account.d.ts +1 -1
  83. package/dist/composites/workload-identity-service-account.d.ts.map +1 -1
  84. package/dist/config-schema.d.ts +40 -0
  85. package/dist/config-schema.d.ts.map +1 -0
  86. package/dist/config.d.ts +22 -1
  87. package/dist/config.d.ts.map +1 -1
  88. package/dist/crd/crd-sources.d.ts.map +1 -1
  89. package/dist/crd/parser.d.ts.map +1 -1
  90. package/dist/deep-observe-hooks.d.ts +15 -18
  91. package/dist/deep-observe-hooks.d.ts.map +1 -1
  92. package/dist/deep-observe.d.ts.map +1 -1
  93. package/dist/describe-resources.d.ts +1 -0
  94. package/dist/describe-resources.d.ts.map +1 -1
  95. package/dist/generated/index.d.ts +134 -0
  96. package/dist/generated/index.d.ts.map +1 -1
  97. package/dist/integrity.json +4 -4
  98. package/dist/manifest.json +1 -1
  99. package/dist/meta.json +1086 -45
  100. package/dist/plugin.d.ts.map +1 -1
  101. package/dist/serializer.d.ts.map +1 -1
  102. package/dist/spec/parse.d.ts +17 -0
  103. package/dist/spec/parse.d.ts.map +1 -1
  104. package/dist/types/index.d.ts +229 -0
  105. package/package.json +3 -2
  106. package/src/codegen/generate-list-map-keys.test.ts +112 -0
  107. package/src/codegen/generate-list-map-keys.ts +99 -0
  108. package/src/codegen/generate.ts +9 -1
  109. package/src/composites/adot-collector.ts +1 -1
  110. package/src/composites/agic-ingress.ts +1 -1
  111. package/src/composites/aks-external-dns-agent.ts +1 -1
  112. package/src/composites/alb-ingress.ts +1 -1
  113. package/src/composites/autoscaled-service.ts +1 -1
  114. package/src/composites/azure-disk-storage-class.ts +1 -1
  115. package/src/composites/azure-file-storage-class.ts +1 -1
  116. package/src/composites/azure-monitor-collector.ts +1 -1
  117. package/src/composites/batch-job.ts +1 -1
  118. package/src/composites/cockroachdb-cluster.ts +1 -1
  119. package/src/composites/config-connector-context.ts +1 -1
  120. package/src/composites/configured-app.ts +1 -1
  121. package/src/composites/cron-workload.ts +1 -1
  122. package/src/composites/ebs-storage-class.ts +1 -1
  123. package/src/composites/efs-storage-class.ts +1 -1
  124. package/src/composites/external-dns-agent.ts +1 -1
  125. package/src/composites/filestore-storage-class.ts +1 -1
  126. package/src/composites/fluent-bit-agent.ts +1 -1
  127. package/src/composites/gce-ingress.ts +1 -1
  128. package/src/composites/gce-pd-storage-class.ts +1 -1
  129. package/src/composites/gke-external-dns-agent.ts +1 -1
  130. package/src/composites/gke-fluent-bit-agent.ts +1 -1
  131. package/src/composites/gke-gateway.ts +1 -1
  132. package/src/composites/gke-otel-collector.ts +1 -1
  133. package/src/composites/irsa-service-account.ts +1 -1
  134. package/src/composites/metrics-server.ts +1 -1
  135. package/src/composites/monitored-service.ts +1 -1
  136. package/src/composites/namespace-env.ts +1 -1
  137. package/src/composites/network-isolated-app.ts +1 -1
  138. package/src/composites/node-agent.ts +1 -1
  139. package/src/composites/ray-cluster.ts +1 -1
  140. package/src/composites/ray-job.ts +1 -1
  141. package/src/composites/ray-service.ts +1 -1
  142. package/src/composites/secure-ingress.ts +1 -1
  143. package/src/composites/sidecar-app.ts +1 -1
  144. package/src/composites/stateful-app.ts +1 -1
  145. package/src/composites/web-app.ts +1 -1
  146. package/src/composites/worker-pool.ts +1 -1
  147. package/src/composites/workload-identity-sa.ts +1 -1
  148. package/src/composites/workload-identity-service-account.ts +1 -1
  149. package/src/config-schema.ts +46 -0
  150. package/src/config.ts +25 -1
  151. package/src/crd/cnpg.test.ts +186 -0
  152. package/src/crd/crd-sources.ts +123 -0
  153. package/src/crd/infisical.test.ts +100 -0
  154. package/src/crd/parser.ts +8 -0
  155. package/src/crd/traefik.test.ts +108 -0
  156. package/src/deep-observe-hooks.ts +90 -10
  157. package/src/deep-observe.ts +7 -0
  158. package/src/describe-resources.test.ts +138 -1
  159. package/src/describe-resources.ts +98 -0
  160. package/src/generated/index.d.ts +229 -0
  161. package/src/generated/index.ts +134 -0
  162. package/src/generated/lexicon-k8s.json +1086 -45
  163. package/src/generated/list-map-keys.json +166 -0
  164. package/src/generated/operations.json +288 -0
  165. package/src/lifecycle-integration.test.ts +1 -0
  166. package/src/list-map-order-key.test.ts +119 -0
  167. package/src/plugin.ts +4 -0
  168. package/src/serializer.ts +4 -0
  169. package/src/spec/parse.test.ts +42 -0
  170. package/src/spec/parse.ts +51 -2
@@ -93,6 +93,111 @@ const COCKROACH_OPERATOR_CRD_BASE = `https://raw.githubusercontent.com/cockroach
93
93
  const CERT_MANAGER_VERSION = "v1.16.2";
94
94
  const CERT_MANAGER_CRD_BUNDLE = `https://github.com/cert-manager/cert-manager/releases/download/${CERT_MANAGER_VERSION}/cert-manager.crds.yaml`;
95
95
 
96
+ /**
97
+ * CloudNativePG CRDs — postgresql.cnpg.io/v1
98
+ *
99
+ * The Postgres operator. Produces, under the `Cnpg` namespace (both groups are
100
+ * mapped there explicitly in parser.ts; the first-segment rule would otherwise
101
+ * split them into `Postgresql` and `Barmancloud`):
102
+ * K8s::Cnpg::Cluster → postgresql.cnpg.io/v1
103
+ * K8s::Cnpg::ScheduledBackup → postgresql.cnpg.io/v1
104
+ * K8s::Cnpg::Backup → postgresql.cnpg.io/v1
105
+ * K8s::Cnpg::Pooler → postgresql.cnpg.io/v1
106
+ *
107
+ * Pinned to 1.29.1 rather than latest (1.30.0) because that is the operator a
108
+ * real consumer runs — BinaryBourbon/fountain's k8s overlay.
109
+ *
110
+ * Note on ScheduledBackup.schedule: it is a **six**-field cron with leading
111
+ * seconds, not the five-field form Kubernetes CronJob takes. The schema types
112
+ * it as a plain string, so a five-field value passes every check here and
113
+ * silently means something else on the cluster.
114
+ *
115
+ * Database, Publication and Subscription are deliberately left out — no known
116
+ * consumer needs them, and pulling a whole provider is what the note at the top
117
+ * of this file warns against.
118
+ *
119
+ * Operator install: kubectl apply --server-side -f
120
+ * https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.29/releases/cnpg-1.29.1.yaml
121
+ */
122
+ const CNPG_VERSION = "v1.29.1";
123
+ const CNPG_CRD_BASE = `https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/${CNPG_VERSION}/config/crd/bases`;
124
+
125
+ /**
126
+ * barman-cloud plugin CRD — barmancloud.cnpg.io/v1
127
+ *
128
+ * CNPG's WAL archiving / object-storage backend, shipped as a separate plugin
129
+ * with its own release train. Produces:
130
+ * K8s::Cnpg::ObjectStore → barmancloud.cnpg.io/v1
131
+ *
132
+ * A CNPG Cluster reaches it through `spec.plugins[]` — `name:
133
+ * barman-cloud.cloudnative-pg.io`, `isWALArchiver: true`,
134
+ * `parameters.barmanObjectName` naming the ObjectStore — so the two are only
135
+ * ever useful together.
136
+ *
137
+ * Pinned to 0.14.0 (latest). Its ObjectStore schema is a superset of 0.7.0's:
138
+ * same four spec properties, more detail inside them.
139
+ *
140
+ * Plugin install: kubectl apply -f
141
+ * https://github.com/cloudnative-pg/plugin-barman-cloud/releases/download/v0.14.0/manifest.yaml
142
+ */
143
+ const BARMAN_PLUGIN_VERSION = "v0.14.0";
144
+ const BARMAN_PLUGIN_CRD_BASE = `https://raw.githubusercontent.com/cloudnative-pg/plugin-barman-cloud/${BARMAN_PLUGIN_VERSION}/config/crd/bases`;
145
+ /**
146
+ * Traefik proxy CRDs — traefik.io/v1alpha1
147
+ *
148
+ * Traefik's own routing surface. An `IngressRoute` is not a
149
+ * `networking.k8s.io` Ingress with annotations — it is a distinct CRD with its
150
+ * own matcher grammar, so an estate fronted by Traefik has no expressible edge
151
+ * without these. Produces, under the `Traefik` namespace (first-segment rule,
152
+ * no override needed):
153
+ * K8s::Traefik::IngressRoute K8s::Traefik::Middleware
154
+ * K8s::Traefik::IngressRouteTCP K8s::Traefik::MiddlewareTCP
155
+ * K8s::Traefik::IngressRouteUDP K8s::Traefik::ServersTransport
156
+ * K8s::Traefik::TLSOption K8s::Traefik::ServersTransportTCP
157
+ * K8s::Traefik::TLSStore K8s::Traefik::TraefikService
158
+ *
159
+ * Only the `traefik.io` group. The chart's crds/ directory also ships
160
+ * `hub.traefik.io_*` (Traefik Hub, a different commercial product, ~12 kinds,
161
+ * no known consumer) and a vendored copy of the Gateway API, which is already
162
+ * generated here from its upstream repo and would collide.
163
+ *
164
+ * Version footgun: the pin below is the **chart** version, and the chart
165
+ * version is not the Traefik version. Chart v41.1.0 ships Traefik v3.7.9.
166
+ * When bumping, record both.
167
+ *
168
+ * Controller install: helm repo add traefik https://traefik.github.io/charts
169
+ * && helm install traefik traefik/traefik --version 41.1.0
170
+ */
171
+ const TRAEFIK_CHART_VERSION = "v41.1.0";
172
+ const TRAEFIK_CRD_BASE = `https://raw.githubusercontent.com/traefik/traefik-helm-chart/${TRAEFIK_CHART_VERSION}/traefik/crds`;
173
+ /**
174
+ * Infisical operator CRDs — secrets.infisical.com/v1alpha1
175
+ *
176
+ * The declarative form of chant's own secrets boundary. An `InfisicalSecret`
177
+ * says where a secret comes from and who may fetch it; it never carries a
178
+ * value. Without it an estate can declare the Deployment that consumes a
179
+ * Secret but not the thing that makes the Secret exist. Produces, under the
180
+ * `Infisical` namespace:
181
+ * K8s::Infisical::InfisicalSecret
182
+ * K8s::Infisical::InfisicalPushSecret
183
+ * K8s::Infisical::InfisicalDynamicSecret
184
+ *
185
+ * The group is overridden to `Infisical` in parser.ts. The first-segment rule
186
+ * would give `K8s::Secrets::InfisicalSecret`, which reads like a core Secret
187
+ * next to the `K8s::Core::Secret` that already exists.
188
+ *
189
+ * Two things about the URL, both easy to lose an hour to:
190
+ * - The operator lives in its own repo, `Infisical/kubernetes-operator`.
191
+ * The path under the `Infisical/infisical` monorepo 404s.
192
+ * - The tag is path-shaped — `infisical-k8-operator/v0.11.7`. Not a typo,
193
+ * and not a directory.
194
+ *
195
+ * Operator install: helm repo add infisical https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/
196
+ * && helm install infisical-secrets-operator infisical/secrets-operator
197
+ */
198
+ const INFISICAL_OPERATOR_VERSION = "infisical-k8-operator/v0.11.7";
199
+ const INFISICAL_CRD_BASE = `https://raw.githubusercontent.com/Infisical/kubernetes-operator/${INFISICAL_OPERATOR_VERSION}/config/crd/bases`;
200
+
96
201
  /**
97
202
  * Prometheus Operator CRDs — monitoring.coreos.com/v1
98
203
  *
@@ -188,6 +293,24 @@ export const CRD_SOURCES: CRDSource[] = [
188
293
  { type: "url", url: CERT_MANAGER_CRD_BUNDLE },
189
294
  { type: "url", url: `${PROM_OPERATOR_CRD_BASE}/monitoring.coreos.com_servicemonitors.yaml` },
190
295
  { type: "url", url: `${PROM_OPERATOR_CRD_BASE}/monitoring.coreos.com_prometheusrules.yaml` },
296
+ { type: "url", url: `${CNPG_CRD_BASE}/postgresql.cnpg.io_clusters.yaml` },
297
+ { type: "url", url: `${CNPG_CRD_BASE}/postgresql.cnpg.io_scheduledbackups.yaml` },
298
+ { type: "url", url: `${CNPG_CRD_BASE}/postgresql.cnpg.io_backups.yaml` },
299
+ { type: "url", url: `${CNPG_CRD_BASE}/postgresql.cnpg.io_poolers.yaml` },
300
+ { type: "url", url: `${BARMAN_PLUGIN_CRD_BASE}/barmancloud.cnpg.io_objectstores.yaml` },
301
+ { type: "url", url: `${TRAEFIK_CRD_BASE}/traefik.io_ingressroutes.yaml` },
302
+ { type: "url", url: `${TRAEFIK_CRD_BASE}/traefik.io_ingressroutetcps.yaml` },
303
+ { type: "url", url: `${TRAEFIK_CRD_BASE}/traefik.io_ingressrouteudps.yaml` },
304
+ { type: "url", url: `${TRAEFIK_CRD_BASE}/traefik.io_middlewares.yaml` },
305
+ { type: "url", url: `${TRAEFIK_CRD_BASE}/traefik.io_middlewaretcps.yaml` },
306
+ { type: "url", url: `${TRAEFIK_CRD_BASE}/traefik.io_serverstransports.yaml` },
307
+ { type: "url", url: `${TRAEFIK_CRD_BASE}/traefik.io_serverstransporttcps.yaml` },
308
+ { type: "url", url: `${TRAEFIK_CRD_BASE}/traefik.io_tlsoptions.yaml` },
309
+ { type: "url", url: `${TRAEFIK_CRD_BASE}/traefik.io_tlsstores.yaml` },
310
+ { type: "url", url: `${TRAEFIK_CRD_BASE}/traefik.io_traefikservices.yaml` },
311
+ { type: "url", url: `${INFISICAL_CRD_BASE}/secrets.infisical.com_infisicalsecrets.yaml` },
312
+ { type: "url", url: `${INFISICAL_CRD_BASE}/secrets.infisical.com_infisicalpushsecrets.yaml` },
313
+ { type: "url", url: `${INFISICAL_CRD_BASE}/secrets.infisical.com_infisicaldynamicsecrets.yaml` },
191
314
  {
192
315
  type: "url",
193
316
  url: FLUX_TOOLKIT_INSTALL,
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Infisical acceptance test.
3
+ *
4
+ * Transcribed from fountain's `k8s/infisicalsecret.yaml`. Every field in that
5
+ * manifest is policy — where the secret comes from, who may fetch it, what
6
+ * happens to the materialized Secret when the CR goes away. None of it is a
7
+ * value, which is why this kind is expressible in chant at all.
8
+ */
9
+
10
+ import { describe, test, expect } from "vitest";
11
+ import { InfisicalSecret } from "../generated";
12
+ import { k8sSerializer } from "../serializer";
13
+ import { parseYAML } from "@intentius/chant/yaml";
14
+
15
+ function synth(logicalName: string, resource: unknown): any {
16
+ const yaml = k8sSerializer.serialize(new Map([[logicalName, resource as never]]));
17
+ return parseYAML(yaml);
18
+ }
19
+
20
+ const fountainSecrets = new InfisicalSecret({
21
+ metadata: { name: "fountain-secrets", namespace: "fountain", labels: { app: "fountain" } },
22
+ spec: {
23
+ hostAPI: "http://infisical.infisical.svc.cluster.local:8080",
24
+ resyncInterval: 60,
25
+ authentication: {
26
+ kubernetesAuth: {
27
+ identityId: "6ccf748c-7fe1-4584-a206-b0a56d84530c",
28
+ autoCreateServiceAccountToken: true,
29
+ serviceAccountRef: { name: "fountain-infisical", namespace: "fountain" },
30
+ secretsScope: { projectSlug: "fountain-q-cwt", envSlug: "prod", secretsPath: "/" },
31
+ },
32
+ },
33
+ managedSecretReference: {
34
+ secretName: "fountain-secrets",
35
+ secretNamespace: "fountain",
36
+ creationPolicy: "Orphan",
37
+ },
38
+ },
39
+ });
40
+
41
+ describe("InfisicalSecret", () => {
42
+ test("carries the Infisical apiVersion and kind", () => {
43
+ const doc = synth("fountainSecrets", fountainSecrets);
44
+ expect(doc.apiVersion).toBe("secrets.infisical.com/v1alpha1");
45
+ expect(doc.kind).toBe("InfisicalSecret");
46
+ expect(doc.metadata.name).toBe("fountain-secrets");
47
+ });
48
+
49
+ test("reproduces fountain's infisicalsecret.yaml", () => {
50
+ const { spec } = synth("fountainSecrets", fountainSecrets);
51
+ expect(spec.hostAPI).toBe("http://infisical.infisical.svc.cluster.local:8080");
52
+ expect(spec.resyncInterval).toBe(60);
53
+ expect(spec.managedSecretReference).toEqual({
54
+ secretName: "fountain-secrets",
55
+ secretNamespace: "fountain",
56
+ creationPolicy: "Orphan",
57
+ });
58
+ });
59
+
60
+ test("serializes the whole kubernetesAuth block", () => {
61
+ const { spec } = synth("fountainSecrets", fountainSecrets);
62
+ // Kubernetes-native auth: the point is that no credential is stored
63
+ // anywhere. The operator presents a ServiceAccount token instead, so
64
+ // dropping any part of this block is what turns the deployment back into
65
+ // one that needs a secret to fetch its secrets.
66
+ expect(spec.authentication.kubernetesAuth).toEqual({
67
+ identityId: "6ccf748c-7fe1-4584-a206-b0a56d84530c",
68
+ autoCreateServiceAccountToken: true,
69
+ serviceAccountRef: { name: "fountain-infisical", namespace: "fountain" },
70
+ secretsScope: { projectSlug: "fountain-q-cwt", envSlug: "prod", secretsPath: "/" },
71
+ });
72
+ });
73
+
74
+ test("declares a source and never a value", () => {
75
+ // The reason this CRD fits chant at all: synthesis resolves nothing here.
76
+ // If a secret value could reach the manifest, the manifest would stop
77
+ // being safe to commit -- so assert on the serialized text, not the props.
78
+ const yaml = k8sSerializer.serialize(
79
+ new Map([["fountainSecrets", fountainSecrets as never]]),
80
+ );
81
+ expect(yaml).not.toMatch(/\bvalue\s*:/);
82
+ expect(yaml).not.toMatch(/\bdata\s*:/);
83
+ expect(yaml).not.toMatch(/\bstringData\s*:/);
84
+ });
85
+
86
+ test("keeps resyncInterval a number", () => {
87
+ const { spec } = synth("fountainSecrets", fountainSecrets);
88
+ // Quoted, the operator rejects the CR rather than defaulting -- worth
89
+ // pinning because seconds-as-a-string is a habit from other tools.
90
+ expect(typeof spec.resyncInterval).toBe("number");
91
+ });
92
+
93
+ test("creationPolicy Orphan survives, since the two policies differ on deletion", () => {
94
+ const { spec } = synth("fountainSecrets", fountainSecrets);
95
+ // Orphan leaves the materialized Secret behind when the CR is deleted;
96
+ // Owner takes it with it. Silently flipping to the default would make CR
97
+ // deletion destructive.
98
+ expect(spec.managedSecretReference.creationPolicy).toBe("Orphan");
99
+ });
100
+ });
package/src/crd/parser.ts CHANGED
@@ -44,6 +44,14 @@ const GROUP_NAMESPACE_OVERRIDES: Record<string, string> = {
44
44
  "image.toolkit.fluxcd.io": "Flux",
45
45
  "fluxcd.controlplane.io": "Flux",
46
46
  "lambda.aws.amazon.com": "KubeMicroVM",
47
+ // CNPG and its barman-cloud plugin ship under two groups but are one thing to
48
+ // an author: a Cluster's `plugins[]` names an ObjectStore. The first-segment
49
+ // rule would scatter them into `Postgresql` and `Barmancloud`.
50
+ "postgresql.cnpg.io": "Cnpg",
51
+ "barmancloud.cnpg.io": "Cnpg",
52
+ // Not `Secrets`: `K8s::Secrets::InfisicalSecret` reads like a core Secret,
53
+ // and `K8s::Core::Secret` is right there to be confused with.
54
+ "secrets.infisical.com": "Infisical",
47
55
  };
48
56
 
49
57
  /**
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Traefik acceptance test.
3
+ *
4
+ * Transcribed from fountain's `k8s/ingressroute.yaml` — the websecure route
5
+ * with `tls.secretName`, and the web route whose middleware lives in another
6
+ * namespace. The cross-namespace reference is the reason this file exists: it
7
+ * is one extra key, it is easy to leave off, and leaving it off produces a
8
+ * route that applies cleanly and drops traffic.
9
+ */
10
+
11
+ import { describe, test, expect } from "vitest";
12
+ import { IngressRoute, Middleware } from "../generated";
13
+ import { k8sSerializer } from "../serializer";
14
+ import { parseYAML } from "@intentius/chant/yaml";
15
+
16
+ function synth(logicalName: string, resource: unknown): any {
17
+ const yaml = k8sSerializer.serialize(new Map([[logicalName, resource as never]]));
18
+ return parseYAML(yaml);
19
+ }
20
+
21
+ describe("Traefik IngressRoute", () => {
22
+ const secure = new IngressRoute({
23
+ metadata: { name: "fountain", namespace: "fountain", labels: { app: "fountain" } },
24
+ spec: {
25
+ entryPoints: ["websecure"],
26
+ routes: [
27
+ {
28
+ match: "Host(`fountain.inevitable.fyi`)",
29
+ kind: "Rule",
30
+ services: [{ name: "fountain", port: 80 }],
31
+ },
32
+ ],
33
+ tls: { secretName: "fountain-tls" },
34
+ },
35
+ });
36
+
37
+ const plain = new IngressRoute({
38
+ metadata: { name: "fountain-http", namespace: "fountain", labels: { app: "fountain" } },
39
+ spec: {
40
+ entryPoints: ["web"],
41
+ routes: [
42
+ {
43
+ match: "Host(`fountain.inevitable.fyi`)",
44
+ kind: "Rule",
45
+ middlewares: [{ name: "redirect-https", namespace: "default" }],
46
+ services: [{ name: "fountain", port: 80 }],
47
+ },
48
+ ],
49
+ },
50
+ });
51
+
52
+ test("carries the Traefik apiVersion and kind", () => {
53
+ const doc = synth("fountain", secure);
54
+ expect(doc.apiVersion).toBe("traefik.io/v1alpha1");
55
+ expect(doc.kind).toBe("IngressRoute");
56
+ });
57
+
58
+ test("reproduces the websecure route", () => {
59
+ const { spec } = synth("fountain", secure);
60
+ expect(spec.entryPoints).toEqual(["websecure"]);
61
+ expect(spec.tls).toEqual({ secretName: "fountain-tls" });
62
+ expect(spec.routes).toHaveLength(1);
63
+ expect(spec.routes[0].kind).toBe("Rule");
64
+ expect(spec.routes[0].services).toEqual([{ name: "fountain", port: 80 }]);
65
+ });
66
+
67
+ test("preserves the matcher grammar verbatim", () => {
68
+ const { spec } = synth("fountain", secure);
69
+ // Traefik's matcher is its own language, and the backticks around the host
70
+ // are part of it -- not YAML quoting. Anything that strips or re-quotes
71
+ // them produces a rule that parses and matches nothing.
72
+ expect(spec.routes[0].match).toBe("Host(`fountain.inevitable.fyi`)");
73
+ expect(spec.routes[0].match).toContain("`fountain.inevitable.fyi`");
74
+ });
75
+
76
+ test("keeps the namespace on a cross-namespace middleware ref", () => {
77
+ const { spec } = synth("fountainHttp", plain);
78
+ // `redirect-https` lives in `default`, the route in `fountain`. Without the
79
+ // qualifier Traefik looks in the route's own namespace, finds nothing, and
80
+ // serves the route with no redirect -- so plain HTTP quietly keeps working
81
+ // instead of upgrading.
82
+ expect(spec.routes[0].middlewares).toEqual([
83
+ { name: "redirect-https", namespace: "default" },
84
+ ]);
85
+ });
86
+
87
+ test("a route with no TLS omits the key rather than emptying it", () => {
88
+ const { spec } = synth("fountainHttp", plain);
89
+ expect(spec.tls).toBeUndefined();
90
+ expect(spec.entryPoints).toEqual(["web"]);
91
+ });
92
+ });
93
+
94
+ describe("Traefik Middleware", () => {
95
+ test("serializes the redirect the routes point at", () => {
96
+ const doc = synth(
97
+ "redirectHttps",
98
+ new Middleware({
99
+ metadata: { name: "redirect-https", namespace: "default" },
100
+ spec: { redirectScheme: { scheme: "https", permanent: true } },
101
+ }),
102
+ );
103
+ expect(doc.apiVersion).toBe("traefik.io/v1alpha1");
104
+ expect(doc.kind).toBe("Middleware");
105
+ expect(doc.metadata.namespace).toBe("default");
106
+ expect(doc.spec.redirectScheme).toEqual({ scheme: "https", permanent: true });
107
+ });
108
+ });
@@ -11,7 +11,9 @@
11
11
  * #1074 made that package's reachability from the build path a structural
12
12
  * property (`examples/k8s-client-boundary.test.ts`) rather than a lint rule —
13
13
  * so nothing this file exports may pull that package in, directly or
14
- * transitively. It imports nothing but the core contract's own types.
14
+ * transitively. It imports the core contract's own types, and one generated
15
+ * JSON data file (`./generated/list-map-keys.json`, chant #1441) — data, not a
16
+ * package, so the boundary property is unchanged.
15
17
  *
16
18
  * What lives here is deliberately the *entityType-keyed, resource-agnostic*
17
19
  * half of the rules: which fields the API server always populates regardless
@@ -39,7 +41,7 @@
39
41
  * keyed by chant's own k8s entityType catalog.
40
42
  */
41
43
 
42
- import type { DeepNode, DeepNormalizationHooks } from "@intentius/chant/lexicon";
44
+ import type { DeepArrayElement, DeepNode, DeepNormalizationHooks } from "@intentius/chant/lexicon";
43
45
  import { K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS, k8sListMapOrderKey } from "@intentius/chant/managed-fields";
44
46
 
45
47
  /**
@@ -96,13 +98,17 @@ function canonicalJson(value: unknown): string {
96
98
  * The k8s lexicon's static noise rules: the generic Kubernetes object
97
99
  * envelope (unconditional, by pattern, `@intentius/chant/managed-fields`) and
98
100
  * Kubernetes-defaulted fields (gated on `counterpart === "absent"`), plus the
99
- * array orderings the acceptance criteria name — `x-kubernetes-patch-merge-
100
- * key`/`list-map-keys` conventions the generated surface does not currently
101
- * carry (see the module doc), so these are the "else named-by-name
102
- * conventions" the issue calls for: containers by `name`, `env` by `name`,
103
- * `volumes` by `name`, container/service `ports` by `containerPort`/`port` +
104
- * `protocol` — the same conventions `@intentius/chant/managed-fields`'s
105
- * `k8sListMapOrderKey` implements, reused verbatim.
101
+ * array orderings.
102
+ *
103
+ * The orderings are now taken from the spec itself (chant #1441): codegen
104
+ * carries `x-kubernetes-list-type`/`x-kubernetes-list-map-keys` through into
105
+ * `./generated/list-map-keys.json`, and {@link schemaListMapOrderKey} reads
106
+ * it. The hand-written conventions in
107
+ * `@intentius/chant/managed-fields`'s `k8sListMapOrderKey` — containers by
108
+ * `name`, `env` by `name`, `volumes` by `name`, container/service `ports` by
109
+ * `containerPort`/`port` + `protocol` — remain as the fallback for lists the
110
+ * spec does not annotate, and for readers that have no generated table at all
111
+ * (GCP's Config Connector hooks).
106
112
  *
107
113
  * This is the object `k8sPlugin.deepNormalizationHooks` is. It is also what
108
114
  * `./deep-observe.ts` layers its per-resource managed-fields prune on top of,
@@ -111,6 +117,80 @@ function canonicalJson(value: unknown): string {
111
117
  * — see `packages/core/src/lifecycle/deep-observe.ts`) apply the identical
112
118
  * entityType-keyed rules either way.
113
119
  */
120
+ /** Property name → candidate key-field sets, generated from the spec (chant #1441). */
121
+ type ListMapKeyTable = Record<string, string[][]>;
122
+
123
+ let cachedListMapKeys: ListMapKeyTable | undefined;
124
+
125
+ function listMapKeyTable(): ListMapKeyTable {
126
+ cachedListMapKeys ??= require("./generated/list-map-keys.json") as ListMapKeyTable;
127
+ return cachedListMapKeys;
128
+ }
129
+
130
+ /** Last dotted segment of an index-erased path: `spec.template.spec.containers` → `containers`. */
131
+ function lastSegment(pattern: string): string {
132
+ const at = pattern.lastIndexOf(".");
133
+ return at === -1 ? pattern : pattern.slice(at + 1);
134
+ }
135
+
136
+ /**
137
+ * Render one key field's value into a sort key fragment. Numbers are
138
+ * zero-padded so the key orders numerically rather than lexicographically —
139
+ * `"443"` would otherwise sort before `"80"`. Cosmetic, since either order
140
+ * canonicalizes the two sides identically, but a human-sensible order is free
141
+ * to have here (the same reasoning `k8sListMapOrderKey` applies to ports).
142
+ */
143
+ function keyFragment(value: unknown): string {
144
+ if (typeof value === "number") return String(value).padStart(5, "0");
145
+ return typeof value === "string" ? value : canonicalJson(value);
146
+ }
147
+
148
+ /**
149
+ * Join a key set's values the way `k8sListMapOrderKey` already does: the bare
150
+ * values, `/`-separated, with no field names.
151
+ *
152
+ * This is not only a sort key. Core renders it as the element's ADDRESS in
153
+ * drift output — `spec.template.spec.containers[#app].image` — so the format
154
+ * is user-visible and load-bearing. Emitting `name=app` here would rewrite
155
+ * every existing drift path. Bare values keep the six properties that were
156
+ * hardcoded byte-identical, and extend to the rest for free: a container port
157
+ * reads `[#08080/TCP]` exactly as before, a condition reads `[#Ready]`.
158
+ */
159
+ function joinKeyValues(el: Record<string, unknown>, keys: string[]): string {
160
+ return keys.map((k) => keyFragment(el[k])).join("/");
161
+ }
162
+
163
+ /**
164
+ * Order an associative list's element by the identity the SPEC declares for it
165
+ * (chant #1441), falling back to `@intentius/chant/managed-fields`'s hand-written
166
+ * conventions for anything the generated table does not cover.
167
+ *
168
+ * The fallback is not vestigial. The table is built from the properties this
169
+ * lexicon's codegen parsed, so it covers core Kubernetes and any CRD that
170
+ * declares the extensions — but a CRD that declares an associative list
171
+ * WITHOUT `x-kubernetes-list-map-keys`, or a Config Connector resource read
172
+ * through GCP's hooks, still lands on the by-name conventions. Keeping both
173
+ * means widening the spec-derived table can never narrow what was already
174
+ * identified.
175
+ */
176
+ export function schemaListMapOrderKey(element: DeepArrayElement): string | undefined {
177
+ const candidates = listMapKeyTable()[lastSegment(element.pattern)];
178
+ const el = element.element;
179
+
180
+ if (candidates && isRecordLike(el)) {
181
+ for (const keys of candidates) {
182
+ if (!keys.every((k) => el[k] !== undefined)) continue;
183
+ return joinKeyValues(el, keys);
184
+ }
185
+ }
186
+
187
+ return k8sListMapOrderKey(element);
188
+ }
189
+
190
+ function isRecordLike(value: unknown): value is Record<string, unknown> {
191
+ return typeof value === "object" && value !== null && !Array.isArray(value);
192
+ }
193
+
114
194
  export const k8sDeepNormalizationHooks: DeepNormalizationHooks = {
115
195
  prune(node: DeepNode): boolean {
116
196
  if (K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS.has(node.pattern)) return true;
@@ -121,5 +201,5 @@ export const k8sDeepNormalizationHooks: DeepNormalizationHooks = {
121
201
  return canonicalJson(defaults[node.pattern]) === canonicalJson(node.value);
122
202
  },
123
203
 
124
- orderKey: k8sListMapOrderKey,
204
+ orderKey: schemaListMapOrderKey,
125
205
  };
@@ -284,6 +284,13 @@ export async function observeResourcesDeepK8s(
284
284
  side: "live",
285
285
  hooks: perResourceHooks(sets),
286
286
  }),
287
+ // Who owns each surviving path (#1189). The prune above already
288
+ // dropped confidently-foreign undeclared noise, so what reaches the
289
+ // diff is chant's own fields and contested ones — and for a contested
290
+ // field, naming the manager is the whole question: an operator needs to
291
+ // tell `hpa-controller` doing its job from somebody running
292
+ // `kubectl edit`.
293
+ ...(sets.owners.size > 0 ? { fieldOwners: Object.fromEntries(sets.owners) } : {}),
287
294
  };
288
295
  } catch (err) {
289
296
  const outcome = classifyApiFailure(err);
@@ -15,7 +15,7 @@ vi.mock("@intentius/chant/config", () => ({
15
15
  loadChantConfig: (...args: unknown[]) => loadChantConfigMock(...args),
16
16
  }));
17
17
 
18
- const { describeResources } = await import("./describe-resources");
18
+ const { describeResources, statusFromObject, unhappyConditions } = await import("./describe-resources");
19
19
  const { fakeCluster, objectKey, ownedObject } = await import("./api/fake-cluster");
20
20
  const { defaultK8sConnector } = await import("./api/connect");
21
21
  const { fakeKubeconfig, statusBody } = await import("@intentius/chant-k8s-client/testing");
@@ -725,3 +725,140 @@ describe("k8s describeResources", () => {
725
725
  });
726
726
  });
727
727
  });
728
+
729
+ // #1397 — a Pod's phase is `Running` from the moment the kubelet admits it, so
730
+ // the phase alone reported a crashlooping Pod as `Running`, which every
731
+ // consumer classifying that word read as healthy.
732
+ describe("statusFromObject — the most specific failing signal (#1397)", () => {
733
+ test("a crashlooping Pod reports the reason, not the phase", () => {
734
+ expect(
735
+ statusFromObject({
736
+ status: {
737
+ phase: "Running",
738
+ containerStatuses: [{ state: { waiting: { reason: "CrashLoopBackOff", message: "back-off 5m0s" } } }],
739
+ },
740
+ } as never),
741
+ ).toBe("CrashLoopBackOff");
742
+ });
743
+
744
+ test("an image-pull failure reports the reason", () => {
745
+ expect(
746
+ statusFromObject({
747
+ status: { phase: "Pending", containerStatuses: [{ state: { waiting: { reason: "ImagePullBackOff" } } }] },
748
+ } as never),
749
+ ).toBe("ImagePullBackOff");
750
+ });
751
+
752
+ test("an unschedulable Pod reports Unschedulable, not Pending", () => {
753
+ expect(
754
+ statusFromObject({
755
+ status: {
756
+ phase: "Pending",
757
+ conditions: [{ type: "PodScheduled", status: "False", reason: "Unschedulable" }],
758
+ },
759
+ } as never),
760
+ ).toBe("Unschedulable");
761
+ });
762
+
763
+ test("ordinary startup is not promoted over the phase", () => {
764
+ // ContainerCreating and PodInitializing are not failure; surfacing them
765
+ // would be noise, and the phase already classifies as progressing.
766
+ for (const reason of ["ContainerCreating", "PodInitializing"]) {
767
+ expect(
768
+ statusFromObject({
769
+ status: { phase: "Pending", containerStatuses: [{ state: { waiting: { reason } } }] },
770
+ } as never),
771
+ ).toBe("Pending");
772
+ }
773
+ });
774
+
775
+ test("a terminated container does not outrank the phase — a Job container exits cleanly", () => {
776
+ expect(
777
+ statusFromObject({
778
+ status: { phase: "Succeeded", containerStatuses: [{ state: { terminated: { reason: "Completed", exitCode: 0 } } }] },
779
+ } as never),
780
+ ).toBe("Succeeded");
781
+ });
782
+
783
+ test("a healthy Pod and a ready Deployment are unchanged", () => {
784
+ expect(statusFromObject({ status: { phase: "Running", containerStatuses: [{ state: { running: {} } }] } } as never)).toBe("Running");
785
+ expect(statusFromObject({ status: { readyReplicas: 3, replicas: 3 } } as never)).toBe("READY");
786
+ expect(statusFromObject({ status: { readyReplicas: 1, replicas: 3 } } as never)).toBe("PROGRESSING(1/3)");
787
+ expect(statusFromObject({} as never)).toBe("PRESENT");
788
+ });
789
+ });
790
+
791
+ // #1401 — #1397 made the status WORD honest; this carries the part that says
792
+ // what to do about it. `Unschedulable` is the reason; "0/3 nodes are available"
793
+ // is the answer.
794
+ describe("unhappyConditions (#1401)", () => {
795
+ test("reports an unschedulable Pod's message, not just its reason", () => {
796
+ expect(
797
+ unhappyConditions({
798
+ status: {
799
+ conditions: [
800
+ {
801
+ type: "PodScheduled",
802
+ status: "False",
803
+ reason: "Unschedulable",
804
+ message: "0/3 nodes are available: 1 node(s) had untolerated taint.",
805
+ },
806
+ ],
807
+ },
808
+ } as never),
809
+ ).toEqual(["PodScheduled=Unschedulable: 0/3 nodes are available: 1 node(s) had untolerated taint."]);
810
+ });
811
+
812
+ test("is absent when every condition is happy — presence means the object has something to say", () => {
813
+ expect(
814
+ unhappyConditions({
815
+ status: { conditions: [{ type: "Ready", status: "True" }, { type: "PodScheduled", status: "True" }] },
816
+ } as never),
817
+ ).toBeUndefined();
818
+ });
819
+
820
+ test("knows the happy polarity is per condition type", () => {
821
+ // Ready/Available/PodScheduled are good when True; the *Pressure and
822
+ // NetworkUnavailable node conditions are good when False. Treating them all
823
+ // the same would report every healthy node as unhappy, or hide a node
824
+ // genuinely under disk pressure.
825
+ const healthyNode = {
826
+ status: {
827
+ conditions: [
828
+ { type: "Ready", status: "True" },
829
+ { type: "MemoryPressure", status: "False" },
830
+ { type: "DiskPressure", status: "False" },
831
+ ],
832
+ },
833
+ };
834
+ expect(unhappyConditions(healthyNode as never)).toBeUndefined();
835
+
836
+ const pressured = {
837
+ status: {
838
+ conditions: [
839
+ { type: "Ready", status: "True" },
840
+ { type: "DiskPressure", status: "True", reason: "KubeletHasDiskPressure" },
841
+ ],
842
+ },
843
+ };
844
+ expect(unhappyConditions(pressured as never)).toEqual(["DiskPressure=KubeletHasDiskPressure"]);
845
+ });
846
+
847
+ test("reports every unhappy condition, since a stuck Deployment has two", () => {
848
+ expect(
849
+ unhappyConditions({
850
+ status: {
851
+ conditions: [
852
+ { type: "Available", status: "False", reason: "MinimumReplicasUnavailable" },
853
+ { type: "Progressing", status: "False", reason: "ProgressDeadlineExceeded", message: "ReplicaSet has timed out." },
854
+ ],
855
+ },
856
+ } as never),
857
+ ).toHaveLength(2);
858
+ });
859
+
860
+ test("is absent for an object with no conditions at all", () => {
861
+ expect(unhappyConditions({ status: {} } as never)).toBeUndefined();
862
+ expect(unhappyConditions({} as never)).toBeUndefined();
863
+ });
864
+ });