@nimbus-dev/connectors 0.7.0 → 0.9.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.
- package/connectors/apple/src/adapters.ts +56 -1
- package/connectors/apple/src/caldav-core.ts +7 -0
- package/connectors/apple/src/calendar-tools.ts +34 -0
- package/connectors/apple/src/tools.ts +2 -1
- package/connectors/aws/src/tools.ts +110 -0
- package/connectors/azure/src/tools.ts +70 -0
- package/connectors/circleci/src/tools.ts +63 -1
- package/connectors/gcp/src/tools.ts +74 -16
- package/connectors/github/src/tools.ts +298 -6
- package/connectors/gitlab/src/gitlab-files.ts +213 -0
- package/connectors/gitlab/src/tools.ts +226 -0
- package/connectors/iac/nimbus.extension.json +1 -1
- package/connectors/iac/src/tools.ts +808 -105
- package/connectors/kubernetes/nimbus.extension.json +1 -1
- package/connectors/kubernetes/src/manifest-scan.ts +602 -0
- package/connectors/kubernetes/src/tools.ts +366 -17
- package/connectors/snowflake/src/server.ts +69 -2
- package/package.json +1 -1
- package/shared/audit-chain.ts +5 -2
- package/shared/commit-files.ts +138 -0
- package/shared/consent-kit.ts +156 -31
- package/shared/github-files.ts +195 -0
- package/shared/github-rest.ts +12 -0
- package/shared/k8s-deployment-view.ts +68 -0
- package/shared/prepare-tool-ids.ts +30 -0
- package/shared/private-kubeconfig.ts +111 -0
- package/shared/private-scratch.ts +68 -0
- package/shared/run-cli-json.ts +17 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "com.nimbus.kubernetes",
|
|
3
3
|
"displayName": "Kubernetes",
|
|
4
4
|
"version": "0.1.0",
|
|
5
|
-
"description": "kubectl CLI connector (pods, deployments, services). API server hostname lives inside kubeconfig YAML; parsing it at spawn time to extend permissions.network is a known Task 14 follow-up. Mutations require Gateway HITL.",
|
|
5
|
+
"description": "kubectl CLI connector (pods, deployments, services, plan-bound manifest apply). API server hostname lives inside kubeconfig YAML; parsing it at spawn time to extend permissions.network is a known Task 14 follow-up. Mutations require Gateway HITL.",
|
|
6
6
|
"author": "Nimbus",
|
|
7
7
|
"entrypoint": "dist/server.js",
|
|
8
8
|
"runtime": "bun",
|
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* manifest-scan — a linear, fail-closed reader of the few fields of a Kubernetes manifest that
|
|
3
|
+
* decide WHERE an apply lands: each document's `apiVersion`, `kind`, `metadata.namespace` and
|
|
4
|
+
* `metadata.name`.
|
|
5
|
+
*
|
|
6
|
+
* Why it exists: `k8s_manifest_apply` runs `kubectl apply -n <namespace> -f <file>`. `-n` makes
|
|
7
|
+
* kubectl refuse a namespaced object whose `metadata.namespace` differs (the exact error text is
|
|
8
|
+
* not on the pages read, README bound U) but does nothing against a CLUSTER-scoped kind
|
|
9
|
+
* (ClusterRole, Namespace, a CRD), which `-n` simply ignores. The scope the owner approved is
|
|
10
|
+
* `namespace:<ns>`, so the connector itself must prove every document is a namespaced kind in that
|
|
11
|
+
* namespace, before any prompt and again before any spawn.
|
|
12
|
+
*
|
|
13
|
+
* Why it is not a YAML parser: no YAML library is a dependency of this package, and a hand-written
|
|
14
|
+
* parser would be a second YAML implementation to disagree with kubectl's. kubectl reads the text
|
|
15
|
+
* with go-yaml (YAML 1.1 flavour, sigs.k8s.io/yaml). The only way a SECOND reader stays safe next to
|
|
16
|
+
* the first is to accept a deliberately tiny, boring subset of YAML on which every plausible reader
|
|
17
|
+
* agrees, and to REFUSE everything else. The rule for every line is therefore "if I cannot read it
|
|
18
|
+
* with certainty, throw": a refused manifest costs the caller a rewrite in plain block style, a
|
|
19
|
+
* misread one costs a cluster-scoped write.
|
|
20
|
+
*
|
|
21
|
+
* What is accepted is documented, rule by rule, in `connectors/kubernetes/README.md`. In short:
|
|
22
|
+
*
|
|
23
|
+
* - printable text only (no tabs, no C0/C1 controls, no BOM, no NEL/LS/PS: go-yaml treats NEL, LS
|
|
24
|
+
* and PS as line breaks, so a `kind:` hidden after one would be a top-level key to kubectl and
|
|
25
|
+
* part of a scalar to a reader that only splits on `\n`);
|
|
26
|
+
* - documents split only on a line that is exactly `---` (an optional ` # comment` is allowed);
|
|
27
|
+
* - every line at column 0 is `plainKey:` or `plainKey: value`; the three keys read at the top
|
|
28
|
+
* level (`apiVersion`, `kind`, `metadata`) appear at most once;
|
|
29
|
+
* - no anchors, aliases, tags, merge keys, `? ` complex keys, flow collections or JSON;
|
|
30
|
+
* - the four values it interprets are plain or simply quoted scalars with no escapes, and a plain
|
|
31
|
+
* one that YAML 1.1 would not read as a string (`yes`, `null`, `123`, `1e3`) is refused.
|
|
32
|
+
*
|
|
33
|
+
* Linear: one pass over the lines, no backtracking, no nested data structure.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** One object the manifest declares, as read. */
|
|
37
|
+
export interface ScannedObject {
|
|
38
|
+
readonly apiVersion: string;
|
|
39
|
+
readonly kind: string;
|
|
40
|
+
readonly name: string;
|
|
41
|
+
readonly namespace?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The kinds an apply may carry, each with the apiVersion(s) that name it. Every kind here is
|
|
46
|
+
* NAMESPACED. Pairing the group is what defeats a cluster-scoped CRD named like a core kind: a
|
|
47
|
+
* CustomResourceDefinition may declare `kind: Service` in the group `example.com`, which is a
|
|
48
|
+
* different resource that the bare name would wrongly allow.
|
|
49
|
+
*/
|
|
50
|
+
const ALLOWED: Readonly<Record<string, readonly string[]>> = {
|
|
51
|
+
Deployment: ["apps/v1"],
|
|
52
|
+
StatefulSet: ["apps/v1"],
|
|
53
|
+
DaemonSet: ["apps/v1"],
|
|
54
|
+
ReplicaSet: ["apps/v1"],
|
|
55
|
+
Job: ["batch/v1"],
|
|
56
|
+
CronJob: ["batch/v1"],
|
|
57
|
+
Pod: ["v1"],
|
|
58
|
+
Service: ["v1"],
|
|
59
|
+
ConfigMap: ["v1"],
|
|
60
|
+
Secret: ["v1"],
|
|
61
|
+
PersistentVolumeClaim: ["v1"],
|
|
62
|
+
ServiceAccount: ["v1"],
|
|
63
|
+
Ingress: ["networking.k8s.io/v1"],
|
|
64
|
+
NetworkPolicy: ["networking.k8s.io/v1"],
|
|
65
|
+
Role: ["rbac.authorization.k8s.io/v1"],
|
|
66
|
+
RoleBinding: ["rbac.authorization.k8s.io/v1"],
|
|
67
|
+
HorizontalPodAutoscaler: ["autoscaling/v2", "autoscaling/v1"],
|
|
68
|
+
PodDisruptionBudget: ["policy/v1"],
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const PLAIN_KEY = /^[A-Za-z][A-Za-z0-9_./-]*$/;
|
|
72
|
+
/** A `kind`/`apiVersion` value; the allowlist is the real check, this keeps the echo harmless. */
|
|
73
|
+
const TYPE_TEXT = /^[A-Za-z][A-Za-z0-9./-]*$/;
|
|
74
|
+
/** A `metadata.name`/`metadata.namespace` value. */
|
|
75
|
+
const NAME_TEXT = /^[A-Za-z0-9][A-Za-z0-9.-]*$/;
|
|
76
|
+
/** Words (any case) that YAML 1.1 reads as a boolean or null rather than a string. */
|
|
77
|
+
const YAML11_WORDS = new Set(["y", "n", "yes", "no", "true", "false", "on", "off", "null"]);
|
|
78
|
+
/** The start of a block scalar header: `|` or `>` with the indentation and chomping indicators. */
|
|
79
|
+
const BLOCK_INDICATORS = /^[|>](?:[1-9][+-]?|[+-][1-9]?)?/;
|
|
80
|
+
/** What may follow the indicators: nothing, or spaces and then an optional comment. */
|
|
81
|
+
const BLOCK_TRAILER = /^(?: +(?:#.*)?)?$/;
|
|
82
|
+
/** A document separator: `---`, optionally followed by spaces and a comment. */
|
|
83
|
+
const DOC_SEPARATOR = /^---(?: +(?:#.*)?)?$/;
|
|
84
|
+
|
|
85
|
+
/** Whether `v` is a whole block scalar header (indicators, then an optional comment). */
|
|
86
|
+
function isBlockHeader(v: string): boolean {
|
|
87
|
+
const indicators = BLOCK_INDICATORS.exec(v);
|
|
88
|
+
return indicators !== null && BLOCK_TRAILER.test(v.slice(indicators[0].length));
|
|
89
|
+
}
|
|
90
|
+
/** A value may not START with these: anchor, alias, tag, flow collection, reserved, directive. */
|
|
91
|
+
const FORBIDDEN_START = "&*!{[]},?@`%";
|
|
92
|
+
/** A `metadata.name`: a DNS subdomain (lower-case alphanumerics, `-` and `.`, at most 253). */
|
|
93
|
+
const DNS_SUBDOMAIN = /^[a-z0-9](?:[-a-z0-9.]{0,251}[a-z0-9])?$/;
|
|
94
|
+
const KEY_ECHO_LENGTH = 40;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A fixed-message refusal: the rule, the document and the line, never manifest text (a Secret value
|
|
98
|
+
* that starts with `@` was once echoed). The only text that may follow is a KEY NAME, passed by the
|
|
99
|
+
* two callers that have just checked it is a plain key at column 0 or directly under metadata.
|
|
100
|
+
*/
|
|
101
|
+
function refuse(why: string, where: Where, key?: string): never {
|
|
102
|
+
const at = `document ${String(where.doc)}, line ${String(where.line)}`;
|
|
103
|
+
const named =
|
|
104
|
+
key !== undefined && key.length <= KEY_ECHO_LENGTH && PLAIN_KEY.test(key)
|
|
105
|
+
? ` (key ${key})`
|
|
106
|
+
: "";
|
|
107
|
+
throw new Error(`manifest refused (${at}): ${why}${named}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface Where {
|
|
111
|
+
readonly doc: number;
|
|
112
|
+
readonly line: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The BMP ranges go-yaml reads as plain printable content (inclusive), with the line and paragraph
|
|
117
|
+
* separators U+2028/U+2029 (line breaks to go-yaml) and the BOM U+FEFF left out, and everything
|
|
118
|
+
* from U+0080 to U+009F (C1 controls, NEL among them) and U+FFFE/U+FFFF.
|
|
119
|
+
*/
|
|
120
|
+
const PRINTABLE_RANGES: readonly (readonly [number, number])[] = [
|
|
121
|
+
[0x0a, 0x0a],
|
|
122
|
+
[0x20, 0x7e],
|
|
123
|
+
[0xa0, 0x2027],
|
|
124
|
+
[0x202a, 0xd7ff],
|
|
125
|
+
[0xe000, 0xfefe],
|
|
126
|
+
[0xff00, 0xfffd],
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* How many UTF-16 units the character at `text[i]` takes if it is allowed, or 0 if it is not.
|
|
131
|
+
* `codePointAt` returns the whole code point at a high surrogate that has its low half (an astral
|
|
132
|
+
* character: allowed, 2 units) and the bare surrogate value otherwise, which no range holds, so a
|
|
133
|
+
* lone surrogate of either kind is refused.
|
|
134
|
+
*/
|
|
135
|
+
function allowedUnitsAt(text: string, i: number): number {
|
|
136
|
+
const cp = text.codePointAt(i) ?? 0;
|
|
137
|
+
if (cp > 0xffff) return 2;
|
|
138
|
+
if (PRINTABLE_RANGES.some(([from, to]) => cp >= from && cp <= to)) return 1;
|
|
139
|
+
// CRLF is a line break; a lone CR is one to go-yaml and not to a reader that splits on LF.
|
|
140
|
+
return cp === 0x0d && text.codePointAt(i + 1) === 0x0a ? 1 : 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The first character of `text` that go-yaml would not read as plain printable content. */
|
|
144
|
+
function firstBadCharacter(text: string): { index: number; code: number } | undefined {
|
|
145
|
+
let i = 0;
|
|
146
|
+
while (i < text.length) {
|
|
147
|
+
const units = allowedUnitsAt(text, i);
|
|
148
|
+
if (units === 0) return { index: i, code: text.charCodeAt(i) };
|
|
149
|
+
i += units;
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
interface DocLine {
|
|
155
|
+
readonly no: number;
|
|
156
|
+
readonly text: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Split into documents on lines that are exactly `---`; refuse the other document-level markers. */
|
|
160
|
+
function splitDocuments(text: string): DocLine[][] {
|
|
161
|
+
const bad = firstBadCharacter(text);
|
|
162
|
+
if (bad !== undefined) {
|
|
163
|
+
const line = text.slice(0, bad.index).split("\n").length;
|
|
164
|
+
throw new Error(
|
|
165
|
+
`manifest refused (line ${String(line)}): character U+${bad.code
|
|
166
|
+
.toString(16)
|
|
167
|
+
.toUpperCase()
|
|
168
|
+
.padStart(4, "0")} is not allowed (tabs, control characters, a BOM and Unicode line ` +
|
|
169
|
+
"separators are refused)",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
const docs: DocLine[][] = [[]];
|
|
173
|
+
const lines = text.split("\n");
|
|
174
|
+
for (let i = 0; i < lines.length; i++) {
|
|
175
|
+
const raw = lines[i] ?? "";
|
|
176
|
+
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
|
177
|
+
const where = { doc: docs.length, line: i + 1 };
|
|
178
|
+
if (line.startsWith("---")) {
|
|
179
|
+
if (!DOC_SEPARATOR.test(line)) {
|
|
180
|
+
refuse("a line starting with --- must be exactly ---", where);
|
|
181
|
+
}
|
|
182
|
+
docs.push([]);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (line.startsWith("...")) refuse("a document end marker (...) is not accepted", where);
|
|
186
|
+
if (line.startsWith("%")) refuse("a directive (%) is not accepted", where);
|
|
187
|
+
docs.at(-1)?.push({ no: i + 1, text: line });
|
|
188
|
+
}
|
|
189
|
+
return docs;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** A scalar as it was written. */
|
|
193
|
+
type Scalar = { readonly t: "plain" | "quoted"; readonly text: string };
|
|
194
|
+
|
|
195
|
+
/** What follows a `key:` (or a `- `): nothing, a scalar, or a block scalar header. */
|
|
196
|
+
type Value = { readonly t: "empty" } | { readonly t: "block" } | Scalar;
|
|
197
|
+
|
|
198
|
+
interface Lexed {
|
|
199
|
+
readonly dashes: number;
|
|
200
|
+
/** Column of the key (or of the last dash for a keyless `- |`). */
|
|
201
|
+
readonly column: number;
|
|
202
|
+
readonly key?: string;
|
|
203
|
+
readonly keyQuoted: boolean;
|
|
204
|
+
readonly value: Value;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Read a quoted scalar that starts at `s[0]`. Returns its text and the index just past the closing
|
|
209
|
+
* quote. Throws if it does not close on this line. Escapes are not interpreted: a double-quoted
|
|
210
|
+
* scalar with a backslash keeps the backslash in `text`, which the four interpreted values then
|
|
211
|
+
* refuse (an escape could spell a different kind or namespace than the one this reader sees).
|
|
212
|
+
*/
|
|
213
|
+
function readQuoted(s: string, where: Where): { text: string; end: number } {
|
|
214
|
+
const quote = s[0];
|
|
215
|
+
for (let i = 1; i < s.length; i++) {
|
|
216
|
+
const c = s[i];
|
|
217
|
+
if (quote === '"' && c === "\\") {
|
|
218
|
+
i++;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (c === quote) {
|
|
222
|
+
if (quote === "'" && s[i + 1] === "'") {
|
|
223
|
+
i++;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
return { text: s.slice(1, i), end: i + 1 };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return refuse("a quoted string must close on the line it opens on", where);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function skipSpaces(s: string, from: number): number {
|
|
233
|
+
let i = from;
|
|
234
|
+
while (s[i] === " ") i++;
|
|
235
|
+
return i;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Whether `s[i]` is a `:` that is a mapping indicator (followed by a space or the line end). */
|
|
239
|
+
function isIndicatorAt(s: string, i: number): boolean {
|
|
240
|
+
return s[i] === ":" && (i + 1 === s.length || s[i + 1] === " ");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* `s` without its trailing spaces, in one backwards pass. Not `.replace(/ +$/, "")`: that regex
|
|
245
|
+
* retries from every space of an interior run, so a value holding a quarter-million spaces before
|
|
246
|
+
* a non-space took twelve seconds to scan (a manifest under the size cap could stall the connector).
|
|
247
|
+
*/
|
|
248
|
+
export function trimTrailingSpaces(s: string): string {
|
|
249
|
+
let end = s.length;
|
|
250
|
+
while (end > 0 && s.codePointAt(end - 1) === 0x20) end -= 1;
|
|
251
|
+
return s.slice(0, end);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The text after a `key:` or a `- `: refuse what cannot be read with certainty, strip a trailing
|
|
256
|
+
* comment, and classify it. Applied to EVERY line at EVERY depth.
|
|
257
|
+
*/
|
|
258
|
+
function lexValue(raw: string, where: Where): Value {
|
|
259
|
+
const v = raw.slice(skipSpaces(raw, 0));
|
|
260
|
+
if (v === "" || v.startsWith("#")) return { t: "empty" };
|
|
261
|
+
const first = v[0] ?? "";
|
|
262
|
+
if (FORBIDDEN_START.includes(first)) {
|
|
263
|
+
refuse(
|
|
264
|
+
"anchors, aliases, tags, flow collections ({ } [ ]), JSON and reserved indicators are not accepted",
|
|
265
|
+
where,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
if (v.startsWith("<<")) refuse("a merge key (<<) is not accepted", where);
|
|
269
|
+
if (first === "|" || first === ">") {
|
|
270
|
+
if (!isBlockHeader(v)) refuse("unreadable block scalar header", where);
|
|
271
|
+
return { t: "block" };
|
|
272
|
+
}
|
|
273
|
+
if (first === '"' || first === "'") {
|
|
274
|
+
const { text, end } = readQuoted(v, where);
|
|
275
|
+
const after = v.slice(end);
|
|
276
|
+
const trimmed = after.slice(skipSpaces(after, 0));
|
|
277
|
+
// After the closing quote only spaces and a comment (separated by a space) may follow.
|
|
278
|
+
if (trimmed !== "" && !(after.startsWith(" ") && trimmed.startsWith("#"))) {
|
|
279
|
+
refuse("unexpected text after a quoted string", where);
|
|
280
|
+
}
|
|
281
|
+
return { t: "quoted", text };
|
|
282
|
+
}
|
|
283
|
+
if ((first === "-" || first === ":") && (v.length === 1 || v[1] === " ")) {
|
|
284
|
+
refuse("a nested sequence or empty-key indicator on one line is not accepted", where);
|
|
285
|
+
}
|
|
286
|
+
const hash = v.indexOf(" #");
|
|
287
|
+
const text = trimTrailingSpaces(hash === -1 ? v : v.slice(0, hash));
|
|
288
|
+
if (text.endsWith(":") || text.includes(": ")) {
|
|
289
|
+
refuse("a mapping indicator inside a value is not accepted", where);
|
|
290
|
+
}
|
|
291
|
+
return { t: "plain", text };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Index of the `:` that ends a plain key in `body`, or -1 when the line has no key. */
|
|
295
|
+
function plainKeyEnd(body: string): number {
|
|
296
|
+
for (let i = 0; i < body.length; i++) {
|
|
297
|
+
if (body[i] === "#" && (i === 0 || body[i - 1] === " ")) return -1;
|
|
298
|
+
if (isIndicatorAt(body, i)) return i;
|
|
299
|
+
}
|
|
300
|
+
return -1;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Read one non-blank, non-comment line: dashes, key, value. */
|
|
304
|
+
function lexLine(rest: string, indent: number, where: Where): Lexed {
|
|
305
|
+
let body = rest;
|
|
306
|
+
let column = indent;
|
|
307
|
+
let dashes = 0;
|
|
308
|
+
let dashColumn = indent;
|
|
309
|
+
while (body === "-" || body.startsWith("- ")) {
|
|
310
|
+
dashes++;
|
|
311
|
+
dashColumn = column;
|
|
312
|
+
const next = skipSpaces(body, 1);
|
|
313
|
+
column += next;
|
|
314
|
+
body = body.slice(next);
|
|
315
|
+
}
|
|
316
|
+
if (body === "") return { dashes, column: dashColumn, keyQuoted: false, value: { t: "empty" } };
|
|
317
|
+
const first = body[0] ?? "";
|
|
318
|
+
if (FORBIDDEN_START.includes(first)) {
|
|
319
|
+
refuse(
|
|
320
|
+
"anchors, aliases, tags, flow collections ({ } [ ]), JSON and reserved indicators are not accepted",
|
|
321
|
+
where,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
if (body.startsWith("<<")) refuse("a merge key (<<) is not accepted", where);
|
|
325
|
+
if (isIndicatorAt(body, 0)) refuse("an empty key is not accepted", where);
|
|
326
|
+
if (first === "|" || first === ">") {
|
|
327
|
+
if (dashes === 0) refuse("a block scalar needs a key or a list entry", where);
|
|
328
|
+
// A bare `- |` is opened by the last dash: its content must be indented past that dash.
|
|
329
|
+
return { dashes, column: dashColumn, keyQuoted: false, value: lexValue(body, where) };
|
|
330
|
+
}
|
|
331
|
+
if (first === '"' || first === "'") {
|
|
332
|
+
const { text, end } = readQuoted(body, where);
|
|
333
|
+
const afterQuote = skipSpaces(body, end);
|
|
334
|
+
if (isIndicatorAt(body, afterQuote)) {
|
|
335
|
+
return {
|
|
336
|
+
dashes,
|
|
337
|
+
column,
|
|
338
|
+
key: text,
|
|
339
|
+
keyQuoted: true,
|
|
340
|
+
value: lexValue(body.slice(afterQuote + 1), where),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
return { dashes, column, keyQuoted: false, value: lexValue(body, where) };
|
|
344
|
+
}
|
|
345
|
+
const end = plainKeyEnd(body);
|
|
346
|
+
if (end === -1) return { dashes, column, keyQuoted: false, value: lexValue(body, where) };
|
|
347
|
+
return {
|
|
348
|
+
dashes,
|
|
349
|
+
column,
|
|
350
|
+
key: body.slice(0, end),
|
|
351
|
+
keyQuoted: false,
|
|
352
|
+
value: lexValue(body.slice(end + 1), where),
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** The text of a scalar one of the four interpreted values holds, or a refusal. */
|
|
357
|
+
function interpreted(value: Value, field: string, shape: RegExp, where: Where): string {
|
|
358
|
+
if (value.t !== "plain" && value.t !== "quoted") {
|
|
359
|
+
refuse(`${field} must be a plain or simply quoted scalar`, where);
|
|
360
|
+
}
|
|
361
|
+
const { text } = value;
|
|
362
|
+
if (!shape.test(text)) {
|
|
363
|
+
refuse(`${field} has a value this scanner cannot read with certainty`, where);
|
|
364
|
+
}
|
|
365
|
+
if (value.t === "plain" && field.startsWith("metadata.")) {
|
|
366
|
+
// YAML 1.1 reads these plain scalars as booleans, null or numbers; a quoted one is a string.
|
|
367
|
+
if (YAML11_WORDS.has(text.toLowerCase()) || /^\d/.test(text)) {
|
|
368
|
+
refuse(
|
|
369
|
+
`${field}: a plain value YAML may read as a boolean or a number must be quoted`,
|
|
370
|
+
where,
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return text;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Everything one document's scan remembers; the phase steps below all read and write this one record. */
|
|
378
|
+
interface DocState {
|
|
379
|
+
apiVersion: string | undefined;
|
|
380
|
+
kind: string | undefined;
|
|
381
|
+
sawMetadata: boolean;
|
|
382
|
+
name: string | undefined;
|
|
383
|
+
namespace: string | undefined;
|
|
384
|
+
hasContent: boolean;
|
|
385
|
+
/** Inside a block scalar opened by a key at this column. */
|
|
386
|
+
blockColumn: number | undefined;
|
|
387
|
+
inMetadata: boolean;
|
|
388
|
+
/** Indentation of metadata's direct children. */
|
|
389
|
+
childIndent: number | undefined;
|
|
390
|
+
/** An interpreted value must not continue on a line indented past this column. */
|
|
391
|
+
pendingColumn: number | undefined;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Phase 1: is this line skipped outright? A block scalar's content is never interpreted (every
|
|
396
|
+
* blank or more-indented line is skipped, and the first line that is not ends it); blank lines and
|
|
397
|
+
* comment lines are skipped.
|
|
398
|
+
*/
|
|
399
|
+
function isSkipped(st: DocState, indent: number, rest: string): boolean {
|
|
400
|
+
if (st.blockColumn !== undefined) {
|
|
401
|
+
if (rest === "" || indent > st.blockColumn) return true;
|
|
402
|
+
st.blockColumn = undefined;
|
|
403
|
+
}
|
|
404
|
+
return rest === "" || rest.startsWith("#");
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Phase 2: the first content line must be at column 0. */
|
|
408
|
+
function noteContent(st: DocState, indent: number, where: Where): void {
|
|
409
|
+
if (!st.hasContent && indent !== 0) {
|
|
410
|
+
refuse("the first line of a document must start at column 0", where);
|
|
411
|
+
}
|
|
412
|
+
st.hasContent = true;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Phase 3: a plain multi-line value continues on more-indented lines. For the four interpreted
|
|
417
|
+
* values that would make this reader and kubectl read different text, so it is refused.
|
|
418
|
+
*/
|
|
419
|
+
function checkNoContinuation(st: DocState, indent: number, where: Where): void {
|
|
420
|
+
if (st.pendingColumn === undefined) return;
|
|
421
|
+
if (indent > st.pendingColumn) {
|
|
422
|
+
refuse("kind, apiVersion, metadata.name and metadata.namespace must fit on one line", where);
|
|
423
|
+
}
|
|
424
|
+
st.pendingColumn = undefined;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Phase 4a: a column-0 line must be `plainKey:` or `plainKey: value`; returns the key. */
|
|
428
|
+
function requireTopLevelKey(lexed: Lexed, where: Where): string {
|
|
429
|
+
if (lexed.dashes > 0) {
|
|
430
|
+
refuse("a list at column 0 is not accepted; indent a list under its key", where);
|
|
431
|
+
}
|
|
432
|
+
if (lexed.key === undefined) {
|
|
433
|
+
refuse("a line at column 0 must be `key:` or `key: value`", where);
|
|
434
|
+
}
|
|
435
|
+
if (lexed.keyQuoted || !PLAIN_KEY.test(lexed.key)) {
|
|
436
|
+
refuse("a key at column 0 must be a plain key (letters, digits, _ . / -)", where);
|
|
437
|
+
}
|
|
438
|
+
if (lexed.key === "items") {
|
|
439
|
+
// kubectl's builder flattens ANY object whose top-level `items` is an array (it asks
|
|
440
|
+
// Unstructured.IsList()), applies the items and drops the wrapper: a ConfigMap with an
|
|
441
|
+
// `items:` list of ClusterRoles would apply the ClusterRoles. Nested `items` (volume
|
|
442
|
+
// projections) is not top-level and stays allowed.
|
|
443
|
+
refuse(
|
|
444
|
+
"a top-level items key is not accepted (kubectl would read the document as a List)",
|
|
445
|
+
where,
|
|
446
|
+
lexed.key,
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
return lexed.key;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Phase 4b: record the three top-level keys the scan interprets, each at most once. */
|
|
453
|
+
function readTopLevelKey(st: DocState, key: string, lexed: Lexed, where: Where): void {
|
|
454
|
+
if (key === "apiVersion" || key === "kind") {
|
|
455
|
+
if (st[key] !== undefined) refuse(`duplicate top-level ${key}`, where);
|
|
456
|
+
st[key] = interpreted(lexed.value, key, TYPE_TEXT, where);
|
|
457
|
+
st.pendingColumn = 0;
|
|
458
|
+
} else if (key === "metadata") {
|
|
459
|
+
if (st.sawMetadata) refuse("duplicate top-level metadata", where);
|
|
460
|
+
st.sawMetadata = true;
|
|
461
|
+
if (lexed.value.t !== "empty") refuse("metadata must be a block mapping", where);
|
|
462
|
+
st.inMetadata = true;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Phase 5a: a line directly under metadata must be `plainKey:` or `plainKey: value`; returns the key. */
|
|
467
|
+
function requireMetadataKey(lexed: Lexed, where: Where): string {
|
|
468
|
+
if (lexed.key === undefined) {
|
|
469
|
+
refuse("a line under metadata must be `key:` or `key: value`", where);
|
|
470
|
+
}
|
|
471
|
+
if (lexed.keyQuoted || !PLAIN_KEY.test(lexed.key)) {
|
|
472
|
+
refuse("a key directly under metadata must be a plain key", where);
|
|
473
|
+
}
|
|
474
|
+
if (lexed.key === "generateName") {
|
|
475
|
+
refuse("metadata.generateName is not accepted; give metadata.name", where, lexed.key);
|
|
476
|
+
}
|
|
477
|
+
return lexed.key;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Phase 5b: record metadata.name and metadata.namespace, each at most once. */
|
|
481
|
+
function readMetadataValue(
|
|
482
|
+
st: DocState,
|
|
483
|
+
key: "name" | "namespace",
|
|
484
|
+
lexed: Lexed,
|
|
485
|
+
where: Where,
|
|
486
|
+
): void {
|
|
487
|
+
if (st[key] !== undefined) refuse(`duplicate metadata.${key}`, where);
|
|
488
|
+
st[key] = interpreted(
|
|
489
|
+
lexed.value,
|
|
490
|
+
`metadata.${key}`,
|
|
491
|
+
key === "name" ? DNS_SUBDOMAIN : NAME_TEXT,
|
|
492
|
+
where,
|
|
493
|
+
);
|
|
494
|
+
st.pendingColumn = st.childIndent;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Phase 5: inside metadata, the direct children are the lines at the first child's indentation. */
|
|
498
|
+
function readMetadataLine(st: DocState, lexed: Lexed, indent: number, where: Where): void {
|
|
499
|
+
if (st.childIndent === undefined) {
|
|
500
|
+
st.childIndent = indent;
|
|
501
|
+
if (lexed.dashes > 0 || lexed.key === undefined) {
|
|
502
|
+
refuse("metadata must be a block mapping", where);
|
|
503
|
+
}
|
|
504
|
+
} else if (indent < st.childIndent) {
|
|
505
|
+
refuse("inconsistent indentation under metadata", where);
|
|
506
|
+
}
|
|
507
|
+
// Deeper lines, and list entries of the previous child, are not children.
|
|
508
|
+
if (indent !== st.childIndent || lexed.dashes > 0) return;
|
|
509
|
+
const key = requireMetadataKey(lexed, where);
|
|
510
|
+
if (key === "name" || key === "namespace") readMetadataValue(st, key, lexed, where);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Phase 6: once every line is read, the document must be one allowlisted object in the namespace. */
|
|
514
|
+
function finishDocument(st: DocState, where: Where, ns: string): ScannedObject {
|
|
515
|
+
if (st.kind === undefined || st.apiVersion === undefined) {
|
|
516
|
+
refuse("every document needs a top-level apiVersion and kind", where);
|
|
517
|
+
}
|
|
518
|
+
if (!st.sawMetadata) refuse("every document needs a top-level metadata block", where);
|
|
519
|
+
if (st.name === undefined) refuse("every document needs metadata.name", where);
|
|
520
|
+
const versions = Object.hasOwn(ALLOWED, st.kind) ? ALLOWED[st.kind] : undefined;
|
|
521
|
+
if (versions === undefined) {
|
|
522
|
+
refuse("this kind is not on the namespaced allowlist", where);
|
|
523
|
+
}
|
|
524
|
+
if (!versions.includes(st.apiVersion)) {
|
|
525
|
+
refuse(
|
|
526
|
+
`apiVersion is not the one paired with this kind (expected ${versions.join(" or ")})`,
|
|
527
|
+
where,
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
if (st.namespace !== undefined && st.namespace !== ns) {
|
|
531
|
+
refuse("metadata.namespace differs from the namespace argument", where);
|
|
532
|
+
}
|
|
533
|
+
return {
|
|
534
|
+
apiVersion: st.apiVersion,
|
|
535
|
+
kind: st.kind,
|
|
536
|
+
name: st.name,
|
|
537
|
+
...(st.namespace === undefined ? {} : { namespace: st.namespace }),
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Scan one document; returns its object, or `undefined` when it holds no content at all. The phases
|
|
543
|
+
* run in this order for every line, and then once for the document.
|
|
544
|
+
*/
|
|
545
|
+
function scanDocument(
|
|
546
|
+
lines: readonly DocLine[],
|
|
547
|
+
docNo: number,
|
|
548
|
+
ns: string,
|
|
549
|
+
): ScannedObject | undefined {
|
|
550
|
+
const st: DocState = {
|
|
551
|
+
apiVersion: undefined,
|
|
552
|
+
kind: undefined,
|
|
553
|
+
sawMetadata: false,
|
|
554
|
+
name: undefined,
|
|
555
|
+
namespace: undefined,
|
|
556
|
+
hasContent: false,
|
|
557
|
+
blockColumn: undefined,
|
|
558
|
+
inMetadata: false,
|
|
559
|
+
childIndent: undefined,
|
|
560
|
+
pendingColumn: undefined,
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
for (const { no, text } of lines) {
|
|
564
|
+
const where: Where = { doc: docNo, line: no };
|
|
565
|
+
const indent = skipSpaces(text, 0);
|
|
566
|
+
const rest = text.slice(indent);
|
|
567
|
+
|
|
568
|
+
if (isSkipped(st, indent, rest)) continue; // 1. block scalar content, blanks, comments
|
|
569
|
+
noteContent(st, indent, where); // 2. the first line is at column 0
|
|
570
|
+
checkNoContinuation(st, indent, where); // 3. interpreted values stay on one line
|
|
571
|
+
|
|
572
|
+
const lexed = lexLine(rest, indent, where);
|
|
573
|
+
if (lexed.value.t === "block") st.blockColumn = lexed.column;
|
|
574
|
+
|
|
575
|
+
if (indent === 0) {
|
|
576
|
+
st.inMetadata = false;
|
|
577
|
+
st.childIndent = undefined;
|
|
578
|
+
readTopLevelKey(st, requireTopLevelKey(lexed, where), lexed, where); // 4. column 0
|
|
579
|
+
} else if (st.inMetadata) {
|
|
580
|
+
readMetadataLine(st, lexed, indent, where); // 5. a line under metadata
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (!st.hasContent) return undefined;
|
|
585
|
+
return finishDocument(st, { doc: docNo, line: lines.at(-1)?.no ?? 1 }, ns); // 6. the document
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Read `text` as a multi-document Kubernetes manifest and return one entry per object, or throw a
|
|
590
|
+
* fixed-message error naming the rule and the place. Never returns for a manifest it could not read
|
|
591
|
+
* with certainty, and never for one with an object outside `namespace` or off the allowlist.
|
|
592
|
+
*/
|
|
593
|
+
export function scanNamespacedManifest(text: string, namespace: string): ScannedObject[] {
|
|
594
|
+
const objects: ScannedObject[] = [];
|
|
595
|
+
const docs = splitDocuments(text);
|
|
596
|
+
for (let i = 0; i < docs.length; i++) {
|
|
597
|
+
const object = scanDocument(docs[i] ?? [], i + 1, namespace);
|
|
598
|
+
if (object !== undefined) objects.push(object);
|
|
599
|
+
}
|
|
600
|
+
if (objects.length === 0) throw new Error("manifest refused: it declares no objects");
|
|
601
|
+
return objects;
|
|
602
|
+
}
|