@nimbus-dev/connectors 0.9.0 → 0.11.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.
@@ -0,0 +1,250 @@
1
+ /**
2
+ * manifest-diff: the plan's diff, computed here from two `kubectl ... -o json` replies instead of
3
+ * by `kubectl diff` (which needs a `diff` program: Windows has none, and one named by the
4
+ * environment is not one to trust with the text an owner approves). Pure: no process, no file.
5
+ *
6
+ * - {@link unpackKubeJson} reads a kubectl reply (nothing, a bare object, or a List) into a map
7
+ * keyed `<resource prefix>/<metadata.name>`, failing closed on anything it cannot key.
8
+ * - {@link normaliseForDiff} drops the fields the API server changes on its own and renders one
9
+ * object as indented JSON, keys sorted, one value per line.
10
+ * - {@link planDiff} diffs the planned (server-side dry run) side against the live side, per
11
+ * object in code-unit order, with `shared/line-diff.ts`.
12
+ */
13
+
14
+ import { createHash } from "node:crypto";
15
+ import { LineDiffTooLargeError, unifiedLineDiff } from "../../../shared/line-diff.ts";
16
+
17
+ /** The `kubectl ... -o name` resource prefix of each allowlisted kind. */
18
+ export const RESOURCE_PREFIX: Readonly<Record<string, string>> = {
19
+ ConfigMap: "configmap",
20
+ Secret: "secret",
21
+ Service: "service",
22
+ Pod: "pod",
23
+ ServiceAccount: "serviceaccount",
24
+ PersistentVolumeClaim: "persistentvolumeclaim",
25
+ Deployment: "deployment.apps",
26
+ StatefulSet: "statefulset.apps",
27
+ DaemonSet: "daemonset.apps",
28
+ ReplicaSet: "replicaset.apps",
29
+ Job: "job.batch",
30
+ CronJob: "cronjob.batch",
31
+ Ingress: "ingress.networking.k8s.io",
32
+ NetworkPolicy: "networkpolicy.networking.k8s.io",
33
+ Role: "role.rbac.authorization.k8s.io",
34
+ RoleBinding: "rolebinding.rbac.authorization.k8s.io",
35
+ HorizontalPodAutoscaler: "horizontalpodautoscaler.autoscaling",
36
+ PodDisruptionBudget: "poddisruptionbudget.policy",
37
+ };
38
+
39
+ /** What a live object is rendered as when it does not exist yet. */
40
+ export const ABSENT_LIVE = "(absent: will be created)";
41
+
42
+ /** Domain separator of {@link diffSha256}. */
43
+ export const DIFF_HASH_DOMAIN = "nimbus-k8s-diff-v1";
44
+
45
+ /** sha256 (hex) of the domain, a newline and the diff text, as UTF-8. */
46
+ export function diffSha256(diff: string): string {
47
+ return createHash("sha256").update(`${DIFF_HASH_DOMAIN}\n${diff}`, "utf8").digest("hex");
48
+ }
49
+
50
+ type Obj = Record<string, unknown>;
51
+
52
+ function isObj(v: unknown): v is Obj {
53
+ return typeof v === "object" && v !== null && !Array.isArray(v);
54
+ }
55
+
56
+ /** Order by UTF-16 code unit, the same on every machine (not `localeCompare`: collation can tie). */
57
+ export function compareCodeUnits(a: string, b: string): number {
58
+ if (a < b) return -1;
59
+ return a > b ? 1 : 0;
60
+ }
61
+
62
+ /**
63
+ * The objects in a kubectl `-o json` reply, keyed `<resource prefix>/<metadata.name>`, in the
64
+ * order kubectl gave them. Nothing printed (`null` or an empty string) is no objects; a
65
+ * `{kind: "List", items}` is its items; any other object is itself. An item without a string kind
66
+ * and a string name, of a kind outside {@link RESOURCE_PREFIX}, or a second one with a key already
67
+ * seen, throws: a reply that cannot be keyed is never half-trusted.
68
+ */
69
+ export function unpackKubeJson(data: unknown): Map<string, Obj> {
70
+ const out = new Map<string, Obj>();
71
+ if (data === null || data === undefined || data === "") return out;
72
+ if (!isObj(data)) throw new Error("kubectl returned JSON that is not an object");
73
+ let items: unknown[];
74
+ if (data["kind"] === "List") {
75
+ if (!Array.isArray(data["items"])) throw new Error("kubectl returned a List without items");
76
+ items = data["items"] as unknown[];
77
+ } else {
78
+ items = [data];
79
+ }
80
+ for (const item of items) {
81
+ if (!isObj(item)) throw new Error("kubectl returned an item that is not an object");
82
+ const kind = item["kind"];
83
+ const meta = item["metadata"];
84
+ const name = isObj(meta) ? meta["name"] : undefined;
85
+ if (typeof kind !== "string" || kind === "" || typeof name !== "string" || name === "") {
86
+ throw new Error("kubectl returned an object without a kind and a name");
87
+ }
88
+ const prefix = Object.hasOwn(RESOURCE_PREFIX, kind) ? RESOURCE_PREFIX[kind] : undefined;
89
+ if (prefix === undefined) {
90
+ throw new Error(`kubectl returned a ${kind}, which is not on the allowlist`);
91
+ }
92
+ const key = `${prefix}/${name}`;
93
+ if (out.has(key)) throw new Error(`kubectl returned ${key} twice`);
94
+ out.set(key, item);
95
+ }
96
+ return out;
97
+ }
98
+
99
+ const DROPPED_METADATA = [
100
+ "managedFields",
101
+ "resourceVersion",
102
+ "uid",
103
+ "generation",
104
+ "creationTimestamp",
105
+ "selfLink",
106
+ ] as const;
107
+ const LAST_APPLIED = "kubectl.kubernetes.io/last-applied-configuration";
108
+
109
+ /**
110
+ * The name the ServiceAccount admission plugin gives the token volume it injects into a Pod
111
+ * (`kube-api-access-` and five random characters). A new Pod's dry run gets a fresh one each time,
112
+ * so it would change the diff hash between the plan and the apply's re-plan.
113
+ */
114
+ const GENERATED_TOKEN_VOLUME = /^kube-api-access-[a-z0-9]{5}$/;
115
+
116
+ /** True for a volume the ServiceAccount admission plugin generated: the name and a projected token. */
117
+ function isGeneratedTokenVolume(v: unknown): boolean {
118
+ if (!isObj(v) || typeof v["name"] !== "string" || !GENERATED_TOKEN_VOLUME.test(v["name"])) {
119
+ return false;
120
+ }
121
+ const projected = v["projected"];
122
+ const sources = isObj(projected) ? projected["sources"] : undefined;
123
+ return Array.isArray(sources) && sources.some((s) => isObj(s) && isObj(s["serviceAccountToken"]));
124
+ }
125
+
126
+ /** What a Job label value derived from the Job's own (generated) uid is rendered as. */
127
+ export const GENERATED_JOB_UID = "(generated by the cluster)";
128
+
129
+ /**
130
+ * In a Job, every value of `spec.selector.matchLabels` and `spec.template.metadata.labels` equal
131
+ * to the Job's own `metadata.uid` becomes {@link GENERATED_JOB_UID}. The API server writes the
132
+ * uid into the automatic selector and its template labels (`batch.kubernetes.io/controller-uid`,
133
+ * `controller-uid`), and a new Job's dry run gets a fresh uid each time, which would change the
134
+ * diff hash between the plan and the apply's re-plan. A value the user wrote cannot equal a uid
135
+ * the server had not generated yet, so it is kept.
136
+ */
137
+ function maskGeneratedJobUid(copy: Obj): void {
138
+ const meta = copy["metadata"];
139
+ const uid = isObj(meta) ? meta["uid"] : undefined;
140
+ const spec = copy["spec"];
141
+ if (typeof uid !== "string" || uid === "" || !isObj(spec)) return;
142
+ const selector = spec["selector"];
143
+ const template = spec["template"];
144
+ const templateMeta = isObj(template) ? template["metadata"] : undefined;
145
+ const maps = [
146
+ isObj(selector) ? selector["matchLabels"] : undefined,
147
+ isObj(templateMeta) ? templateMeta["labels"] : undefined,
148
+ ];
149
+ for (const labels of maps) {
150
+ if (!isObj(labels)) continue;
151
+ for (const key of Object.keys(labels)) {
152
+ if (labels[key] === uid) labels[key] = GENERATED_JOB_UID;
153
+ }
154
+ }
155
+ }
156
+
157
+ /** Drops, from a Pod's spec, the generated token volume and every container's mount of it. */
158
+ function dropGeneratedTokenVolume(spec: Obj): void {
159
+ const volumes = spec["volumes"];
160
+ if (!Array.isArray(volumes)) return;
161
+ const generated = new Set(
162
+ volumes.filter(isGeneratedTokenVolume).map((v) => (v as Obj)["name"] as string),
163
+ );
164
+ if (generated.size === 0) return;
165
+ const kept = volumes.filter((v) => !isGeneratedTokenVolume(v));
166
+ if (kept.length === 0) delete spec["volumes"];
167
+ else spec["volumes"] = kept;
168
+ for (const containers of [
169
+ spec["containers"],
170
+ spec["initContainers"],
171
+ spec["ephemeralContainers"],
172
+ ]) {
173
+ if (!Array.isArray(containers)) continue;
174
+ for (const c of containers) {
175
+ if (!isObj(c) || !Array.isArray(c["volumeMounts"])) continue;
176
+ const mounts = (c["volumeMounts"] as unknown[]).filter(
177
+ (m) => !(isObj(m) && typeof m["name"] === "string" && generated.has(m["name"])),
178
+ );
179
+ if (mounts.length === 0) delete c["volumeMounts"];
180
+ else c["volumeMounts"] = mounts;
181
+ }
182
+ }
183
+ }
184
+
185
+ /** `v` with every object's keys in code-unit order (arrays keep their order). */
186
+ function sortKeys(v: unknown): unknown {
187
+ if (Array.isArray(v)) return v.map(sortKeys);
188
+ if (!isObj(v)) return v;
189
+ return Object.fromEntries(
190
+ Object.keys(v)
191
+ .sort(compareCodeUnits)
192
+ .map((k) => [k, sortKeys(v[k])]),
193
+ );
194
+ }
195
+
196
+ /**
197
+ * One object as the lines that are diffed. Dropped, and nothing else: `metadata.managedFields`,
198
+ * `resourceVersion`, `uid`, `generation`, `creationTimestamp`, `selfLink`, the top-level `status`,
199
+ * and the annotation `kubectl.kubernetes.io/last-applied-configuration` (an `annotations` map that
200
+ * held only that one is then empty and is dropped with it), and, in a Pod, the token volume the
201
+ * ServiceAccount admission plugin generated (a `kube-api-access-xxxxx` projected
202
+ * `serviceAccountToken` volume) with every container's mount of it; in a Job, a selector or
203
+ * template label value equal to the Job's own uid is masked as {@link GENERATED_JOB_UID}. Keys sorted by code unit, rendered as
204
+ * 2-space-indented JSON, so a scalar is a line of its own. The input is not changed.
205
+ */
206
+ export function normaliseForDiff(obj: Obj): string[] {
207
+ const copy = structuredClone(obj);
208
+ delete copy["status"];
209
+ if (copy["kind"] === "Pod" && isObj(copy["spec"])) dropGeneratedTokenVolume(copy["spec"]);
210
+ if (copy["kind"] === "Job") maskGeneratedJobUid(copy);
211
+ const meta = copy["metadata"];
212
+ if (isObj(meta)) {
213
+ for (const key of DROPPED_METADATA) delete meta[key];
214
+ const ann = meta["annotations"];
215
+ if (isObj(ann) && Object.hasOwn(ann, LAST_APPLIED)) {
216
+ delete ann[LAST_APPLIED];
217
+ if (Object.keys(ann).length === 0) delete meta["annotations"];
218
+ }
219
+ }
220
+ return JSON.stringify(sortKeys(copy), null, 2).split("\n");
221
+ }
222
+
223
+ /**
224
+ * The plan's diff: per object, in code-unit order of `<prefix>/<name>`, a unified diff of the live
225
+ * object (or {@link ABSENT_LIVE}) against the planned one, headers `--- live <key>` and
226
+ * `+++ planned <key>`; an object that does not differ adds nothing. "" means no differences.
227
+ * Throws {@link LineDiffTooLargeError} when one object's edit script is past the diff bound.
228
+ */
229
+ export function planDiff(
230
+ planned: ReadonlyMap<string, Obj>,
231
+ live: ReadonlyMap<string, Obj>,
232
+ ): string {
233
+ let out = "";
234
+ for (const key of [...planned.keys()].sort(compareCodeUnits)) {
235
+ const plan = planned.get(key);
236
+ if (plan === undefined) continue;
237
+ const was = live.get(key);
238
+ out += unifiedLineDiff(
239
+ was === undefined ? [ABSENT_LIVE] : normaliseForDiff(was),
240
+ normaliseForDiff(plan),
241
+ {
242
+ fromLabel: `live ${key}`,
243
+ toLabel: `planned ${key}`,
244
+ },
245
+ );
246
+ }
247
+ return out;
248
+ }
249
+
250
+ export { LineDiffTooLargeError };