@intentius/chant-lexicon-k8s 0.0.16 → 0.0.22
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.
- package/dist/integrity.json +3 -3
- package/dist/manifest.json +1 -1
- package/dist/skills/chant-k8s.md +1 -1
- package/package.json +20 -2
- package/src/codegen/docs.ts +10 -0
- package/src/composites/cockroachdb-cluster.ts +421 -0
- package/src/composites/composites.test.ts +167 -0
- package/src/composites/index.ts +2 -0
- package/src/index.ts +2 -2
- package/src/plugin.ts +92 -2
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "xxhash64",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "b91b5f6c197826b1",
|
|
5
5
|
"meta.json": "1ce194f36f9b5f90",
|
|
6
6
|
"types/index.d.ts": "beec4cc869064186",
|
|
7
7
|
"rules/missing-resource-limits.ts": "a6f776d2ff477948",
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
"rules/wk8105.ts": "8dbcfe399f23656a",
|
|
32
32
|
"rules/k8s-helpers.ts": "53a6d3bfbedb2852",
|
|
33
33
|
"rules/wk8207.ts": "6f2bc621d530afa2",
|
|
34
|
-
"skills/chant-k8s.md": "
|
|
34
|
+
"skills/chant-k8s.md": "177efa9794242096",
|
|
35
35
|
"skills/chant-k8s-patterns.md": "c5151ed799145c4b"
|
|
36
36
|
},
|
|
37
|
-
"composite": "
|
|
37
|
+
"composite": "c7b4bc8445140fdf"
|
|
38
38
|
}
|
package/dist/manifest.json
CHANGED
package/dist/skills/chant-k8s.md
CHANGED
|
@@ -8,7 +8,7 @@ user-invocable: true
|
|
|
8
8
|
|
|
9
9
|
## How chant and Kubernetes relate
|
|
10
10
|
|
|
11
|
-
chant is a **synthesis
|
|
11
|
+
chant is a **synthesis compiler** — it compiles TypeScript source files into Kubernetes YAML manifests. `chant build` does not call the Kubernetes API; synthesis is pure and deterministic. The optional `chant state snapshot` command queries the Kubernetes API to capture deployment metadata (pod names, status, UIDs) for observability. Your job as an agent is to bridge synthesis and deployment:
|
|
12
12
|
|
|
13
13
|
- Use **chant** for: build, lint, diff (local YAML comparison)
|
|
14
14
|
- Use **kubectl / k8s API** for: apply, rollback, monitoring, troubleshooting
|
package/package.json
CHANGED
|
@@ -1,7 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-k8s",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.22",
|
|
4
|
+
"description": "Kubernetes lexicon for chant — declarative IaC in TypeScript",
|
|
4
5
|
"license": "Apache-2.0",
|
|
6
|
+
"homepage": "https://intentius.io/chant",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/intentius/chant.git",
|
|
10
|
+
"directory": "lexicons/k8s"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/intentius/chant/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"infrastructure-as-code",
|
|
17
|
+
"iac",
|
|
18
|
+
"typescript",
|
|
19
|
+
"kubernetes",
|
|
20
|
+
"k8s",
|
|
21
|
+
"chant"
|
|
22
|
+
],
|
|
5
23
|
"type": "module",
|
|
6
24
|
"files": [
|
|
7
25
|
"src/",
|
|
@@ -25,7 +43,7 @@
|
|
|
25
43
|
"prepack": "bun run generate && bun run bundle && bun run validate"
|
|
26
44
|
},
|
|
27
45
|
"dependencies": {
|
|
28
|
-
"@intentius/chant": "0.0.
|
|
46
|
+
"@intentius/chant": "0.0.22"
|
|
29
47
|
},
|
|
30
48
|
"devDependencies": {
|
|
31
49
|
"typescript": "^5.9.3"
|
package/src/codegen/docs.ts
CHANGED
|
@@ -1134,6 +1134,16 @@ The lexicon also provides MCP (Model Context Protocol) tools and resources:
|
|
|
1134
1134
|
},
|
|
1135
1135
|
],
|
|
1136
1136
|
basePath: "/chant/lexicons/k8s/",
|
|
1137
|
+
sidebarExtra: [
|
|
1138
|
+
{
|
|
1139
|
+
label: "Vendor Composites",
|
|
1140
|
+
items: [
|
|
1141
|
+
{ label: "EKS Composites", slug: "eks-composites" },
|
|
1142
|
+
{ label: "AKS Composites", slug: "aks-composites" },
|
|
1143
|
+
{ label: "GKE Composites", slug: "gke-composites" },
|
|
1144
|
+
],
|
|
1145
|
+
},
|
|
1146
|
+
],
|
|
1137
1147
|
};
|
|
1138
1148
|
|
|
1139
1149
|
const result = await docsPipeline(config);
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CockroachDbCluster composite — StatefulSet + Services + RBAC + PDB + Jobs.
|
|
3
|
+
*
|
|
4
|
+
* Deploys a CockroachDB cluster on Kubernetes with TLS support via self-signed
|
|
5
|
+
* certificates. Produces all K8s resources needed for a single cloud's slice of
|
|
6
|
+
* a CockroachDB cluster (typically 3 nodes). Multi-cloud deployments use one
|
|
7
|
+
* CockroachDbCluster per cloud, sharing joinAddresses across clouds.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface CockroachDbClusterProps {
|
|
11
|
+
/** Cluster name — used in metadata, labels, and service names. */
|
|
12
|
+
name: string;
|
|
13
|
+
/** Namespace for all namespaced resources. */
|
|
14
|
+
namespace?: string;
|
|
15
|
+
/** Number of StatefulSet replicas (default: 3). */
|
|
16
|
+
replicas?: number;
|
|
17
|
+
/** CockroachDB container image (default: "cockroachdb/cockroach:v24.3.0"). */
|
|
18
|
+
image?: string;
|
|
19
|
+
/** PVC storage size per node (default: "100Gi"). */
|
|
20
|
+
storageSize?: string;
|
|
21
|
+
/** StorageClass name for PVCs. */
|
|
22
|
+
storageClassName?: string;
|
|
23
|
+
/** CPU limit per pod (default: "2"). */
|
|
24
|
+
cpuLimit?: string;
|
|
25
|
+
/** Memory limit per pod (default: "8Gi"). */
|
|
26
|
+
memoryLimit?: string;
|
|
27
|
+
/** Fraction of container memory for CockroachDB cache (default: ".25"). */
|
|
28
|
+
cachePercent?: string;
|
|
29
|
+
/** Fraction of container memory for SQL temp storage (default: ".25"). */
|
|
30
|
+
sqlMemoryPercent?: string;
|
|
31
|
+
/** CockroachDB locality flag (e.g., "cloud=aws,region=us-east-1"). */
|
|
32
|
+
locality?: string;
|
|
33
|
+
/** All node DNS names for --join (cross-cloud cluster membership). */
|
|
34
|
+
joinAddresses?: string[];
|
|
35
|
+
/** Enable TLS via self-signed CA certs (default: true). */
|
|
36
|
+
secure?: boolean;
|
|
37
|
+
/** Additional labels to apply to all resources. */
|
|
38
|
+
labels?: Record<string, string>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CockroachDbClusterResult {
|
|
42
|
+
serviceAccount: Record<string, unknown>;
|
|
43
|
+
role: Record<string, unknown>;
|
|
44
|
+
roleBinding: Record<string, unknown>;
|
|
45
|
+
clusterRole: Record<string, unknown>;
|
|
46
|
+
clusterRoleBinding: Record<string, unknown>;
|
|
47
|
+
/** Client-facing service (ClusterIP, ports 26257+8080). */
|
|
48
|
+
publicService: Record<string, unknown>;
|
|
49
|
+
/** Pod discovery service (headless, publishNotReadyAddresses). */
|
|
50
|
+
headlessService: Record<string, unknown>;
|
|
51
|
+
pdb: Record<string, unknown>;
|
|
52
|
+
statefulSet: Record<string, unknown>;
|
|
53
|
+
/** One-shot cockroach init job. */
|
|
54
|
+
initJob: Record<string, unknown>;
|
|
55
|
+
/** Generates self-signed CA + node certs, stores in Secrets. */
|
|
56
|
+
certGenJob: Record<string, unknown>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Create a CockroachDbCluster composite — returns prop objects for a full
|
|
61
|
+
* CockroachDB StatefulSet deployment including RBAC, Services, PDB, and Jobs.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* ```ts
|
|
65
|
+
* import { CockroachDbCluster } from "@intentius/chant-lexicon-k8s";
|
|
66
|
+
*
|
|
67
|
+
* const crdb = CockroachDbCluster({
|
|
68
|
+
* name: "cockroachdb",
|
|
69
|
+
* namespace: "crdb",
|
|
70
|
+
* replicas: 3,
|
|
71
|
+
* locality: "cloud=aws,region=us-east-1",
|
|
72
|
+
* joinAddresses: [
|
|
73
|
+
* "cockroachdb-0.cockroachdb.crdb.svc.cluster.local",
|
|
74
|
+
* "cockroachdb-1.cockroachdb.crdb.svc.cluster.local",
|
|
75
|
+
* "cockroachdb-2.cockroachdb.crdb.svc.cluster.local",
|
|
76
|
+
* ],
|
|
77
|
+
* });
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
export function CockroachDbCluster(props: CockroachDbClusterProps): CockroachDbClusterResult {
|
|
81
|
+
const {
|
|
82
|
+
name,
|
|
83
|
+
namespace,
|
|
84
|
+
replicas = 3,
|
|
85
|
+
image = "cockroachdb/cockroach:v24.3.0",
|
|
86
|
+
storageSize = "100Gi",
|
|
87
|
+
storageClassName,
|
|
88
|
+
cpuLimit = "2",
|
|
89
|
+
memoryLimit = "8Gi",
|
|
90
|
+
cachePercent = ".25",
|
|
91
|
+
sqlMemoryPercent = ".25",
|
|
92
|
+
locality,
|
|
93
|
+
joinAddresses = [],
|
|
94
|
+
secure = true,
|
|
95
|
+
labels: extraLabels = {},
|
|
96
|
+
} = props;
|
|
97
|
+
|
|
98
|
+
const saName = name;
|
|
99
|
+
const certsDir = "/cockroach/cockroach-certs";
|
|
100
|
+
const dataDir = "/cockroach/cockroach-data";
|
|
101
|
+
|
|
102
|
+
const commonLabels: Record<string, string> = {
|
|
103
|
+
"app.kubernetes.io/name": name,
|
|
104
|
+
"app.kubernetes.io/managed-by": "chant",
|
|
105
|
+
...extraLabels,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// ── RBAC ─────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
const serviceAccount: Record<string, unknown> = {
|
|
111
|
+
metadata: {
|
|
112
|
+
name: saName,
|
|
113
|
+
...(namespace && { namespace }),
|
|
114
|
+
labels: { ...commonLabels, "app.kubernetes.io/component": "database" },
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const role: Record<string, unknown> = {
|
|
119
|
+
metadata: {
|
|
120
|
+
name,
|
|
121
|
+
...(namespace && { namespace }),
|
|
122
|
+
labels: { ...commonLabels, "app.kubernetes.io/component": "rbac" },
|
|
123
|
+
},
|
|
124
|
+
rules: [
|
|
125
|
+
{ apiGroups: [""], resources: ["secrets"], verbs: ["get", "create", "patch"] },
|
|
126
|
+
],
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const roleBinding: Record<string, unknown> = {
|
|
130
|
+
metadata: {
|
|
131
|
+
name,
|
|
132
|
+
...(namespace && { namespace }),
|
|
133
|
+
labels: { ...commonLabels, "app.kubernetes.io/component": "rbac" },
|
|
134
|
+
},
|
|
135
|
+
roleRef: {
|
|
136
|
+
apiGroup: "rbac.authorization.k8s.io",
|
|
137
|
+
kind: "Role",
|
|
138
|
+
name,
|
|
139
|
+
},
|
|
140
|
+
subjects: [
|
|
141
|
+
{ kind: "ServiceAccount", name: saName, ...(namespace && { namespace }) },
|
|
142
|
+
],
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const clusterRole: Record<string, unknown> = {
|
|
146
|
+
metadata: {
|
|
147
|
+
name,
|
|
148
|
+
labels: { ...commonLabels, "app.kubernetes.io/component": "rbac" },
|
|
149
|
+
},
|
|
150
|
+
rules: [
|
|
151
|
+
{ apiGroups: ["certificates.k8s.io"], resources: ["certificatesigningrequests"], verbs: ["get", "create", "watch"] },
|
|
152
|
+
],
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const clusterRoleBinding: Record<string, unknown> = {
|
|
156
|
+
metadata: {
|
|
157
|
+
name,
|
|
158
|
+
labels: { ...commonLabels, "app.kubernetes.io/component": "rbac" },
|
|
159
|
+
},
|
|
160
|
+
roleRef: {
|
|
161
|
+
apiGroup: "rbac.authorization.k8s.io",
|
|
162
|
+
kind: "ClusterRole",
|
|
163
|
+
name,
|
|
164
|
+
},
|
|
165
|
+
subjects: [
|
|
166
|
+
{ kind: "ServiceAccount", name: saName, ...(namespace && { namespace }) },
|
|
167
|
+
],
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// ── Services ────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
const publicService: Record<string, unknown> = {
|
|
173
|
+
metadata: {
|
|
174
|
+
name: `${name}-public`,
|
|
175
|
+
...(namespace && { namespace }),
|
|
176
|
+
labels: { ...commonLabels, "app.kubernetes.io/component": "database" },
|
|
177
|
+
},
|
|
178
|
+
spec: {
|
|
179
|
+
selector: { "app.kubernetes.io/name": name },
|
|
180
|
+
ports: [
|
|
181
|
+
{ port: 26257, targetPort: 26257, protocol: "TCP", name: "grpc" },
|
|
182
|
+
{ port: 8080, targetPort: 8080, protocol: "TCP", name: "http" },
|
|
183
|
+
],
|
|
184
|
+
type: "ClusterIP",
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const headlessService: Record<string, unknown> = {
|
|
189
|
+
metadata: {
|
|
190
|
+
name,
|
|
191
|
+
...(namespace && { namespace }),
|
|
192
|
+
labels: { ...commonLabels, "app.kubernetes.io/component": "database" },
|
|
193
|
+
annotations: {
|
|
194
|
+
"service.alpha.kubernetes.io/tolerate-unready-endpoints": "true",
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
spec: {
|
|
198
|
+
selector: { "app.kubernetes.io/name": name },
|
|
199
|
+
ports: [
|
|
200
|
+
{ port: 26257, targetPort: 26257, protocol: "TCP", name: "grpc" },
|
|
201
|
+
{ port: 8080, targetPort: 8080, protocol: "TCP", name: "http" },
|
|
202
|
+
],
|
|
203
|
+
clusterIP: "None",
|
|
204
|
+
publishNotReadyAddresses: true,
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// ── PodDisruptionBudget ─────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
const pdb: Record<string, unknown> = {
|
|
211
|
+
metadata: {
|
|
212
|
+
name,
|
|
213
|
+
...(namespace && { namespace }),
|
|
214
|
+
labels: { ...commonLabels, "app.kubernetes.io/component": "disruption-budget" },
|
|
215
|
+
},
|
|
216
|
+
spec: {
|
|
217
|
+
maxUnavailable: 1,
|
|
218
|
+
selector: { matchLabels: { "app.kubernetes.io/name": name } },
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// ── StatefulSet ─────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
const cockroachArgs = [
|
|
225
|
+
"start",
|
|
226
|
+
`--logtostderr=WARNING`,
|
|
227
|
+
`--certs-dir=${secure ? certsDir : ""}`,
|
|
228
|
+
...(secure ? [] : ["--insecure"]),
|
|
229
|
+
`--advertise-host=$(hostname -f)`,
|
|
230
|
+
`--http-addr=0.0.0.0`,
|
|
231
|
+
`--cache=${cachePercent}`,
|
|
232
|
+
`--max-sql-memory=${sqlMemoryPercent}`,
|
|
233
|
+
...(joinAddresses.length > 0 ? [`--join=${joinAddresses.join(",")}`] : []),
|
|
234
|
+
...(locality ? [`--locality=${locality}`] : []),
|
|
235
|
+
];
|
|
236
|
+
|
|
237
|
+
const volumes: Record<string, unknown>[] = [];
|
|
238
|
+
const volumeMounts: Record<string, unknown>[] = [
|
|
239
|
+
{ name: "datadir", mountPath: dataDir },
|
|
240
|
+
];
|
|
241
|
+
|
|
242
|
+
if (secure) {
|
|
243
|
+
volumes.push({ name: "certs", secret: { secretName: `${name}-node-certs`, defaultMode: 0o400 } });
|
|
244
|
+
volumeMounts.push({ name: "certs", mountPath: certsDir });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const container: Record<string, unknown> = {
|
|
248
|
+
name,
|
|
249
|
+
image,
|
|
250
|
+
ports: [
|
|
251
|
+
{ containerPort: 26257, name: "grpc" },
|
|
252
|
+
{ containerPort: 8080, name: "http" },
|
|
253
|
+
],
|
|
254
|
+
command: ["/cockroach/cockroach"],
|
|
255
|
+
args: cockroachArgs,
|
|
256
|
+
resources: {
|
|
257
|
+
limits: { cpu: cpuLimit, memory: memoryLimit },
|
|
258
|
+
requests: { cpu: cpuLimit, memory: memoryLimit },
|
|
259
|
+
},
|
|
260
|
+
volumeMounts,
|
|
261
|
+
env: [
|
|
262
|
+
{ name: "COCKROACH_CHANNEL", value: "kubernetes-multiregion" },
|
|
263
|
+
],
|
|
264
|
+
readinessProbe: {
|
|
265
|
+
httpGet: { path: "/health?ready=1", port: 8080, ...(secure && { scheme: "HTTPS" }) },
|
|
266
|
+
initialDelaySeconds: 10,
|
|
267
|
+
periodSeconds: 5,
|
|
268
|
+
failureThreshold: 2,
|
|
269
|
+
},
|
|
270
|
+
livenessProbe: {
|
|
271
|
+
httpGet: { path: "/health", port: 8080, ...(secure && { scheme: "HTTPS" }) },
|
|
272
|
+
initialDelaySeconds: 30,
|
|
273
|
+
periodSeconds: 5,
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const statefulSet: Record<string, unknown> = {
|
|
278
|
+
metadata: {
|
|
279
|
+
name,
|
|
280
|
+
...(namespace && { namespace }),
|
|
281
|
+
labels: { ...commonLabels, "app.kubernetes.io/component": "database" },
|
|
282
|
+
},
|
|
283
|
+
spec: {
|
|
284
|
+
serviceName: name,
|
|
285
|
+
replicas,
|
|
286
|
+
podManagementPolicy: "Parallel",
|
|
287
|
+
selector: { matchLabels: { "app.kubernetes.io/name": name } },
|
|
288
|
+
template: {
|
|
289
|
+
metadata: { labels: { "app.kubernetes.io/name": name, ...extraLabels } },
|
|
290
|
+
spec: {
|
|
291
|
+
serviceAccountName: saName,
|
|
292
|
+
terminationGracePeriodSeconds: 60,
|
|
293
|
+
containers: [container],
|
|
294
|
+
...(volumes.length > 0 && { volumes }),
|
|
295
|
+
affinity: {
|
|
296
|
+
podAntiAffinity: {
|
|
297
|
+
preferredDuringSchedulingIgnoredDuringExecution: [
|
|
298
|
+
{
|
|
299
|
+
weight: 100,
|
|
300
|
+
podAffinityTerm: {
|
|
301
|
+
labelSelector: { matchLabels: { "app.kubernetes.io/name": name } },
|
|
302
|
+
topologyKey: "kubernetes.io/hostname",
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
],
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
volumeClaimTemplates: [
|
|
311
|
+
{
|
|
312
|
+
metadata: { name: "datadir" },
|
|
313
|
+
spec: {
|
|
314
|
+
accessModes: ["ReadWriteOnce"],
|
|
315
|
+
...(storageClassName && { storageClassName }),
|
|
316
|
+
resources: { requests: { storage: storageSize } },
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
],
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
// ── cert-gen Job ─────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
// Generates self-signed CA and node certs, stores them in K8s Secrets.
|
|
326
|
+
// Each node's cert includes the pod DNS names (pod-N.svc.namespace.svc.cluster.local).
|
|
327
|
+
const nodeNames = Array.from({ length: replicas }, (_, i) => `${name}-${i}.${name}`);
|
|
328
|
+
const nodeAddresses = namespace
|
|
329
|
+
? nodeNames.map((n) => `${n}.${namespace}.svc.cluster.local`)
|
|
330
|
+
: nodeNames.map((n) => `${n}.default.svc.cluster.local`);
|
|
331
|
+
|
|
332
|
+
const certGenScript = [
|
|
333
|
+
"set -ex",
|
|
334
|
+
"cd /cockroach",
|
|
335
|
+
"cockroach cert create-ca --certs-dir=certs --ca-key=certs/ca.key",
|
|
336
|
+
`cockroach cert create-node ${nodeAddresses.join(" ")} localhost 127.0.0.1 --certs-dir=certs --ca-key=certs/ca.key`,
|
|
337
|
+
"cockroach cert create-client root --certs-dir=certs --ca-key=certs/ca.key",
|
|
338
|
+
].join(" && ");
|
|
339
|
+
|
|
340
|
+
const certGenJob: Record<string, unknown> = {
|
|
341
|
+
metadata: {
|
|
342
|
+
name: `${name}-cert-gen`,
|
|
343
|
+
...(namespace && { namespace }),
|
|
344
|
+
labels: { ...commonLabels, "app.kubernetes.io/component": "cert-gen" },
|
|
345
|
+
},
|
|
346
|
+
spec: {
|
|
347
|
+
backoffLimit: 3,
|
|
348
|
+
ttlSecondsAfterFinished: 3600,
|
|
349
|
+
template: {
|
|
350
|
+
metadata: { labels: { "app.kubernetes.io/name": name, ...extraLabels } },
|
|
351
|
+
spec: {
|
|
352
|
+
serviceAccountName: saName,
|
|
353
|
+
restartPolicy: "OnFailure",
|
|
354
|
+
containers: [
|
|
355
|
+
{
|
|
356
|
+
name: "cert-gen",
|
|
357
|
+
image,
|
|
358
|
+
command: ["bash", "-c", certGenScript],
|
|
359
|
+
},
|
|
360
|
+
],
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
// ── init Job ────────────────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
const initArgs = secure
|
|
369
|
+
? [`--certs-dir=${certsDir}`, `--host=${name}-0.${name}`]
|
|
370
|
+
: ["--insecure", `--host=${name}-0.${name}`];
|
|
371
|
+
|
|
372
|
+
const initVolumes: Record<string, unknown>[] = [];
|
|
373
|
+
const initVolumeMounts: Record<string, unknown>[] = [];
|
|
374
|
+
if (secure) {
|
|
375
|
+
initVolumes.push({ name: "client-certs", secret: { secretName: `${name}-node-certs`, defaultMode: 0o400 } });
|
|
376
|
+
initVolumeMounts.push({ name: "client-certs", mountPath: certsDir });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const initJob: Record<string, unknown> = {
|
|
380
|
+
metadata: {
|
|
381
|
+
name: `${name}-init`,
|
|
382
|
+
...(namespace && { namespace }),
|
|
383
|
+
labels: { ...commonLabels, "app.kubernetes.io/component": "init" },
|
|
384
|
+
},
|
|
385
|
+
spec: {
|
|
386
|
+
backoffLimit: 6,
|
|
387
|
+
ttlSecondsAfterFinished: 3600,
|
|
388
|
+
template: {
|
|
389
|
+
metadata: { labels: { "app.kubernetes.io/name": name, ...extraLabels } },
|
|
390
|
+
spec: {
|
|
391
|
+
serviceAccountName: saName,
|
|
392
|
+
restartPolicy: "OnFailure",
|
|
393
|
+
containers: [
|
|
394
|
+
{
|
|
395
|
+
name: "cluster-init",
|
|
396
|
+
image,
|
|
397
|
+
command: ["/cockroach/cockroach"],
|
|
398
|
+
args: ["init", ...initArgs],
|
|
399
|
+
...(initVolumeMounts.length > 0 && { volumeMounts: initVolumeMounts }),
|
|
400
|
+
},
|
|
401
|
+
],
|
|
402
|
+
...(initVolumes.length > 0 && { volumes: initVolumes }),
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
serviceAccount,
|
|
410
|
+
role,
|
|
411
|
+
roleBinding,
|
|
412
|
+
clusterRole,
|
|
413
|
+
clusterRoleBinding,
|
|
414
|
+
publicService,
|
|
415
|
+
headlessService,
|
|
416
|
+
pdb,
|
|
417
|
+
statefulSet,
|
|
418
|
+
initJob,
|
|
419
|
+
certGenJob,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
@@ -3050,3 +3050,170 @@ describe("AksExternalDnsAgent", () => {
|
|
|
3050
3050
|
expect(container.securityContext.runAsUser).toBe(65534);
|
|
3051
3051
|
});
|
|
3052
3052
|
});
|
|
3053
|
+
|
|
3054
|
+
// ── CockroachDbCluster ──────────────────────────────────────────────
|
|
3055
|
+
|
|
3056
|
+
describe("CockroachDbCluster", () => {
|
|
3057
|
+
const { CockroachDbCluster } = require("./cockroachdb-cluster");
|
|
3058
|
+
|
|
3059
|
+
const minProps = { name: "cockroachdb" };
|
|
3060
|
+
|
|
3061
|
+
test("returns all expected resources", () => {
|
|
3062
|
+
const result = CockroachDbCluster(minProps);
|
|
3063
|
+
expect(result.serviceAccount).toBeDefined();
|
|
3064
|
+
expect(result.role).toBeDefined();
|
|
3065
|
+
expect(result.roleBinding).toBeDefined();
|
|
3066
|
+
expect(result.clusterRole).toBeDefined();
|
|
3067
|
+
expect(result.clusterRoleBinding).toBeDefined();
|
|
3068
|
+
expect(result.publicService).toBeDefined();
|
|
3069
|
+
expect(result.headlessService).toBeDefined();
|
|
3070
|
+
expect(result.pdb).toBeDefined();
|
|
3071
|
+
expect(result.statefulSet).toBeDefined();
|
|
3072
|
+
expect(result.initJob).toBeDefined();
|
|
3073
|
+
expect(result.certGenJob).toBeDefined();
|
|
3074
|
+
});
|
|
3075
|
+
|
|
3076
|
+
test("default replicas is 3", () => {
|
|
3077
|
+
const result = CockroachDbCluster(minProps);
|
|
3078
|
+
const spec = result.statefulSet.spec as any;
|
|
3079
|
+
expect(spec.replicas).toBe(3);
|
|
3080
|
+
});
|
|
3081
|
+
|
|
3082
|
+
test("default image is cockroachdb/cockroach:v24.3.0", () => {
|
|
3083
|
+
const result = CockroachDbCluster(minProps);
|
|
3084
|
+
const container = (result.statefulSet.spec as any).template.spec.containers[0];
|
|
3085
|
+
expect(container.image).toBe("cockroachdb/cockroach:v24.3.0");
|
|
3086
|
+
});
|
|
3087
|
+
|
|
3088
|
+
test("StatefulSet has correct ports (26257+8080)", () => {
|
|
3089
|
+
const result = CockroachDbCluster(minProps);
|
|
3090
|
+
const container = (result.statefulSet.spec as any).template.spec.containers[0];
|
|
3091
|
+
const ports = container.ports.map((p: any) => p.containerPort);
|
|
3092
|
+
expect(ports).toContain(26257);
|
|
3093
|
+
expect(ports).toContain(8080);
|
|
3094
|
+
});
|
|
3095
|
+
|
|
3096
|
+
test("StatefulSet has PVC with default 100Gi storage", () => {
|
|
3097
|
+
const result = CockroachDbCluster(minProps);
|
|
3098
|
+
const vct = (result.statefulSet.spec as any).volumeClaimTemplates[0];
|
|
3099
|
+
expect(vct.spec.resources.requests.storage).toBe("100Gi");
|
|
3100
|
+
expect(vct.spec.accessModes).toEqual(["ReadWriteOnce"]);
|
|
3101
|
+
});
|
|
3102
|
+
|
|
3103
|
+
test("headless service has clusterIP None and publishNotReadyAddresses", () => {
|
|
3104
|
+
const result = CockroachDbCluster(minProps);
|
|
3105
|
+
const spec = result.headlessService.spec as any;
|
|
3106
|
+
expect(spec.clusterIP).toBe("None");
|
|
3107
|
+
expect(spec.publishNotReadyAddresses).toBe(true);
|
|
3108
|
+
});
|
|
3109
|
+
|
|
3110
|
+
test("public service has ClusterIP type with both ports", () => {
|
|
3111
|
+
const result = CockroachDbCluster(minProps);
|
|
3112
|
+
const spec = result.publicService.spec as any;
|
|
3113
|
+
expect(spec.type).toBe("ClusterIP");
|
|
3114
|
+
const ports = spec.ports.map((p: any) => p.port);
|
|
3115
|
+
expect(ports).toContain(26257);
|
|
3116
|
+
expect(ports).toContain(8080);
|
|
3117
|
+
});
|
|
3118
|
+
|
|
3119
|
+
test("PDB has maxUnavailable 1", () => {
|
|
3120
|
+
const result = CockroachDbCluster(minProps);
|
|
3121
|
+
const spec = result.pdb.spec as any;
|
|
3122
|
+
expect(spec.maxUnavailable).toBe(1);
|
|
3123
|
+
});
|
|
3124
|
+
|
|
3125
|
+
test("StatefulSet has pod anti-affinity", () => {
|
|
3126
|
+
const result = CockroachDbCluster(minProps);
|
|
3127
|
+
const affinity = (result.statefulSet.spec as any).template.spec.affinity;
|
|
3128
|
+
expect(affinity.podAntiAffinity).toBeDefined();
|
|
3129
|
+
});
|
|
3130
|
+
|
|
3131
|
+
test("props flow through (replicas, image, storage)", () => {
|
|
3132
|
+
const result = CockroachDbCluster({
|
|
3133
|
+
name: "crdb",
|
|
3134
|
+
replicas: 5,
|
|
3135
|
+
image: "cockroachdb/cockroach:v23.2.0",
|
|
3136
|
+
storageSize: "200Gi",
|
|
3137
|
+
});
|
|
3138
|
+
const spec = result.statefulSet.spec as any;
|
|
3139
|
+
expect(spec.replicas).toBe(5);
|
|
3140
|
+
expect(spec.template.spec.containers[0].image).toBe("cockroachdb/cockroach:v23.2.0");
|
|
3141
|
+
expect(spec.volumeClaimTemplates[0].spec.resources.requests.storage).toBe("200Gi");
|
|
3142
|
+
});
|
|
3143
|
+
|
|
3144
|
+
test("joinAddresses appear in container args", () => {
|
|
3145
|
+
const joins = ["crdb-0.crdb.ns.svc.cluster.local", "crdb-1.crdb.ns.svc.cluster.local"];
|
|
3146
|
+
const result = CockroachDbCluster({ name: "crdb", joinAddresses: joins });
|
|
3147
|
+
const args = (result.statefulSet.spec as any).template.spec.containers[0].args as string[];
|
|
3148
|
+
const joinArg = args.find((a: string) => a.startsWith("--join="));
|
|
3149
|
+
expect(joinArg).toBeDefined();
|
|
3150
|
+
expect(joinArg).toContain("crdb-0.crdb.ns.svc.cluster.local");
|
|
3151
|
+
expect(joinArg).toContain("crdb-1.crdb.ns.svc.cluster.local");
|
|
3152
|
+
});
|
|
3153
|
+
|
|
3154
|
+
test("locality appears in container args when set", () => {
|
|
3155
|
+
const result = CockroachDbCluster({ name: "crdb", locality: "cloud=aws,region=us-east-1" });
|
|
3156
|
+
const args = (result.statefulSet.spec as any).template.spec.containers[0].args as string[];
|
|
3157
|
+
expect(args).toContain("--locality=cloud=aws,region=us-east-1");
|
|
3158
|
+
});
|
|
3159
|
+
|
|
3160
|
+
test("namespace is set on all namespaced resources", () => {
|
|
3161
|
+
const result = CockroachDbCluster({ name: "crdb", namespace: "crdb-eks" });
|
|
3162
|
+
for (const key of ["serviceAccount", "role", "roleBinding", "publicService", "headlessService", "pdb", "statefulSet", "initJob", "certGenJob"] as const) {
|
|
3163
|
+
expect((result[key].metadata as any).namespace).toBe("crdb-eks");
|
|
3164
|
+
}
|
|
3165
|
+
});
|
|
3166
|
+
|
|
3167
|
+
test("cluster-scoped resources do not have namespace", () => {
|
|
3168
|
+
const result = CockroachDbCluster({ name: "crdb", namespace: "crdb-eks" });
|
|
3169
|
+
expect((result.clusterRole.metadata as any).namespace).toBeUndefined();
|
|
3170
|
+
expect((result.clusterRoleBinding.metadata as any).namespace).toBeUndefined();
|
|
3171
|
+
});
|
|
3172
|
+
|
|
3173
|
+
test("includes common labels", () => {
|
|
3174
|
+
const result = CockroachDbCluster(minProps);
|
|
3175
|
+
const meta = result.statefulSet.metadata as any;
|
|
3176
|
+
expect(meta.labels["app.kubernetes.io/name"]).toBe("cockroachdb");
|
|
3177
|
+
expect(meta.labels["app.kubernetes.io/managed-by"]).toBe("chant");
|
|
3178
|
+
});
|
|
3179
|
+
|
|
3180
|
+
test("secure mode mounts certs volume", () => {
|
|
3181
|
+
const result = CockroachDbCluster({ name: "crdb", secure: true });
|
|
3182
|
+
const spec = (result.statefulSet.spec as any).template.spec;
|
|
3183
|
+
expect(spec.volumes).toBeDefined();
|
|
3184
|
+
const certsVol = spec.volumes.find((v: any) => v.name === "certs");
|
|
3185
|
+
expect(certsVol).toBeDefined();
|
|
3186
|
+
expect(certsVol.secret.secretName).toBe("crdb-node-certs");
|
|
3187
|
+
});
|
|
3188
|
+
|
|
3189
|
+
test("insecure mode omits certs volume", () => {
|
|
3190
|
+
const result = CockroachDbCluster({ name: "crdb", secure: false });
|
|
3191
|
+
const spec = (result.statefulSet.spec as any).template.spec;
|
|
3192
|
+
expect(spec.volumes).toBeUndefined();
|
|
3193
|
+
const args = spec.containers[0].args as string[];
|
|
3194
|
+
expect(args).toContain("--insecure");
|
|
3195
|
+
});
|
|
3196
|
+
|
|
3197
|
+
test("storageClassName is set when provided", () => {
|
|
3198
|
+
const result = CockroachDbCluster({ name: "crdb", storageClassName: "gp3-encrypted" });
|
|
3199
|
+
const vct = (result.statefulSet.spec as any).volumeClaimTemplates[0];
|
|
3200
|
+
expect(vct.spec.storageClassName).toBe("gp3-encrypted");
|
|
3201
|
+
});
|
|
3202
|
+
|
|
3203
|
+
test("init job references correct host", () => {
|
|
3204
|
+
const result = CockroachDbCluster({ name: "crdb" });
|
|
3205
|
+
const container = (result.initJob.spec as any).template.spec.containers[0];
|
|
3206
|
+
expect(container.args).toContain("--host=crdb-0.crdb");
|
|
3207
|
+
});
|
|
3208
|
+
|
|
3209
|
+
test("StatefulSet uses Parallel podManagementPolicy", () => {
|
|
3210
|
+
const result = CockroachDbCluster(minProps);
|
|
3211
|
+
expect((result.statefulSet.spec as any).podManagementPolicy).toBe("Parallel");
|
|
3212
|
+
});
|
|
3213
|
+
|
|
3214
|
+
test("cert-gen job uses same image as StatefulSet", () => {
|
|
3215
|
+
const result = CockroachDbCluster({ name: "crdb", image: "cockroachdb/cockroach:v23.2.0" });
|
|
3216
|
+
const container = (result.certGenJob.spec as any).template.spec.containers[0];
|
|
3217
|
+
expect(container.image).toBe("cockroachdb/cockroach:v23.2.0");
|
|
3218
|
+
});
|
|
3219
|
+
});
|
package/src/composites/index.ts
CHANGED
|
@@ -71,3 +71,5 @@ export { GkeExternalDnsAgent } from "./gke-external-dns-agent";
|
|
|
71
71
|
export type { GkeExternalDnsAgentProps, GkeExternalDnsAgentResult } from "./gke-external-dns-agent";
|
|
72
72
|
export { AksExternalDnsAgent } from "./aks-external-dns-agent";
|
|
73
73
|
export type { AksExternalDnsAgentProps, AksExternalDnsAgentResult } from "./aks-external-dns-agent";
|
|
74
|
+
export { CockroachDbCluster } from "./cockroachdb-cluster";
|
|
75
|
+
export type { CockroachDbClusterProps, CockroachDbClusterResult } from "./cockroachdb-cluster";
|
package/src/index.ts
CHANGED
|
@@ -21,7 +21,7 @@ export {
|
|
|
21
21
|
BatchJob, SecureIngress, ConfiguredApp, SidecarApp, MonitoredService, NetworkIsolatedApp,
|
|
22
22
|
IrsaServiceAccount, AlbIngress, EbsStorageClass, EfsStorageClass, FluentBitAgent, ExternalDnsAgent, AdotCollector,
|
|
23
23
|
MetricsServer, WorkloadIdentityServiceAccount, GcePdStorageClass, FilestoreStorageClass, GkeGateway, ConfigConnectorContext,
|
|
24
|
-
GceIngress,
|
|
24
|
+
GceIngress, CockroachDbCluster,
|
|
25
25
|
AgicIngress, AzureDiskStorageClass, AzureFileStorageClass, AzureMonitorCollector,
|
|
26
26
|
AksWorkloadIdentityServiceAccount,
|
|
27
27
|
GkeFluentBitAgent, GkeOtelCollector, GkeExternalDnsAgent, AksExternalDnsAgent,
|
|
@@ -43,7 +43,7 @@ export type {
|
|
|
43
43
|
FilestoreStorageClassProps, FilestoreStorageClassResult,
|
|
44
44
|
GkeGatewayProps, GkeGatewayResult,
|
|
45
45
|
ConfigConnectorContextProps, ConfigConnectorContextResult,
|
|
46
|
-
GceIngressProps, GceIngressResult,
|
|
46
|
+
GceIngressProps, GceIngressResult, CockroachDbClusterProps, CockroachDbClusterResult,
|
|
47
47
|
AgicIngressProps, AgicIngressResult,
|
|
48
48
|
AzureDiskStorageClassProps, AzureDiskStorageClassResult,
|
|
49
49
|
AzureFileStorageClassProps, AzureFileStorageClassResult,
|
package/src/plugin.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { createRequire } from "module";
|
|
9
|
-
import type { LexiconPlugin, SkillDefinition, InitTemplateSet } from "@intentius/chant/lexicon";
|
|
9
|
+
import type { LexiconPlugin, SkillDefinition, InitTemplateSet, ResourceMetadata } from "@intentius/chant/lexicon";
|
|
10
10
|
const require = createRequire(import.meta.url);
|
|
11
11
|
import type { LintRule } from "@intentius/chant/lint/rule";
|
|
12
12
|
import type { PostSynthCheck } from "@intentius/chant/lint/post-synth";
|
|
@@ -299,6 +299,96 @@ export const service = new Service({
|
|
|
299
299
|
console.error(`Packaged ${stats.resources} resources, ${stats.ruleCount} rules, ${stats.skillCount} skills`);
|
|
300
300
|
},
|
|
301
301
|
|
|
302
|
+
async describeResources(options: {
|
|
303
|
+
environment: string;
|
|
304
|
+
buildOutput: string;
|
|
305
|
+
entityNames: string[];
|
|
306
|
+
}): Promise<Record<string, ResourceMetadata>> {
|
|
307
|
+
const { getRuntime } = await import("@intentius/chant/runtime-adapter");
|
|
308
|
+
const rt = getRuntime();
|
|
309
|
+
const resources: Record<string, ResourceMetadata> = {};
|
|
310
|
+
|
|
311
|
+
// Resolve namespace from environment (convention: env name = namespace)
|
|
312
|
+
const namespace = options.environment;
|
|
313
|
+
|
|
314
|
+
// Parse build output to extract kind/name pairs for each entity
|
|
315
|
+
let manifests: Array<{ kind: string; metadata: { name: string; namespace?: string }; apiVersion: string }> = [];
|
|
316
|
+
try {
|
|
317
|
+
// K8s build output is YAML with --- separators
|
|
318
|
+
const docs = options.buildOutput.split(/^---$/m).filter((d) => d.trim());
|
|
319
|
+
for (const doc of docs) {
|
|
320
|
+
// Simple YAML parsing — look for kind: and metadata.name:
|
|
321
|
+
const kindMatch = doc.match(/^kind:\s*(.+)$/m);
|
|
322
|
+
const nameMatch = doc.match(/^\s+name:\s*(.+)$/m);
|
|
323
|
+
const apiVersionMatch = doc.match(/^apiVersion:\s*(.+)$/m);
|
|
324
|
+
if (kindMatch && nameMatch && apiVersionMatch) {
|
|
325
|
+
manifests.push({
|
|
326
|
+
kind: kindMatch[1].trim(),
|
|
327
|
+
metadata: { name: nameMatch[1].trim() },
|
|
328
|
+
apiVersion: apiVersionMatch[1].trim(),
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
} catch {
|
|
333
|
+
// If build output parsing fails, skip
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Query each resource
|
|
337
|
+
for (const entityName of options.entityNames) {
|
|
338
|
+
// Find the corresponding manifest
|
|
339
|
+
const manifest = manifests.find((m) => {
|
|
340
|
+
// Try matching by entity name convention
|
|
341
|
+
return m.metadata.name === entityName ||
|
|
342
|
+
entityName.toLowerCase().includes(m.metadata.name.toLowerCase());
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
if (!manifest) continue;
|
|
346
|
+
|
|
347
|
+
// Build kubectl resource type from apiVersion + kind
|
|
348
|
+
const resourceType = manifest.kind.toLowerCase();
|
|
349
|
+
const getResult = await rt.spawn([
|
|
350
|
+
"kubectl", "get", resourceType, manifest.metadata.name,
|
|
351
|
+
"-n", namespace,
|
|
352
|
+
"-o", "json",
|
|
353
|
+
]);
|
|
354
|
+
|
|
355
|
+
if (getResult.exitCode !== 0) continue;
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
const obj = JSON.parse(getResult.stdout) as {
|
|
359
|
+
metadata: { name: string; uid: string; creationTimestamp: string };
|
|
360
|
+
status?: { phase?: string; conditions?: Array<{ type: string; status: string }> };
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
// Derive status from conditions or phase
|
|
364
|
+
let status = "Unknown";
|
|
365
|
+
if (obj.status?.phase) {
|
|
366
|
+
status = obj.status.phase;
|
|
367
|
+
} else if (obj.status?.conditions) {
|
|
368
|
+
const ready = obj.status.conditions.find((c) => c.type === "Ready" || c.type === "Available");
|
|
369
|
+
status = ready?.status === "True" ? "Ready" : "NotReady";
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Build attributes, scrubbing sensitive data
|
|
373
|
+
const attributes: Record<string, unknown> = {
|
|
374
|
+
uid: obj.metadata.uid,
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
resources[entityName] = {
|
|
378
|
+
type: `${manifest.apiVersion}/${manifest.kind}`,
|
|
379
|
+
physicalId: obj.metadata.name,
|
|
380
|
+
status,
|
|
381
|
+
lastUpdated: obj.metadata.creationTimestamp,
|
|
382
|
+
attributes,
|
|
383
|
+
};
|
|
384
|
+
} catch {
|
|
385
|
+
// Skip parse failures
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return resources;
|
|
390
|
+
},
|
|
391
|
+
|
|
302
392
|
mcpTools() {
|
|
303
393
|
return [
|
|
304
394
|
{
|
|
@@ -400,7 +490,7 @@ user-invocable: true
|
|
|
400
490
|
|
|
401
491
|
## How chant and Kubernetes relate
|
|
402
492
|
|
|
403
|
-
chant is a **synthesis
|
|
493
|
+
chant is a **synthesis compiler** — it compiles TypeScript source files into Kubernetes YAML manifests. \`chant build\` does not call the Kubernetes API; synthesis is pure and deterministic. The optional \`chant state snapshot\` command queries the Kubernetes API to capture deployment metadata (pod names, status, UIDs) for observability. Your job as an agent is to bridge synthesis and deployment:
|
|
404
494
|
|
|
405
495
|
- Use **chant** for: build, lint, diff (local YAML comparison)
|
|
406
496
|
- Use **kubectl / k8s API** for: apply, rollback, monitoring, troubleshooting
|