@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.
- package/connectors/bitrise/nimbus.extension.json +2 -2
- package/connectors/bitrise/src/server.ts +12 -4
- package/connectors/bitrise/src/tools.ts +138 -7
- package/connectors/codemagic/nimbus.extension.json +2 -2
- package/connectors/codemagic/src/server.ts +12 -4
- package/connectors/codemagic/src/tools.ts +130 -6
- package/connectors/firebase/nimbus.extension.json +2 -2
- package/connectors/firebase/src/server.ts +12 -4
- package/connectors/firebase/src/tools.ts +155 -9
- package/connectors/kubernetes/src/manifest-diff.ts +250 -0
- package/connectors/kubernetes/src/tools.ts +259 -179
- package/connectors/netlify/nimbus.extension.json +2 -2
- package/connectors/netlify/src/server.ts +12 -4
- package/connectors/netlify/src/tools.ts +158 -8
- package/connectors/testflight/nimbus.extension.json +2 -2
- package/connectors/testflight/src/server.ts +12 -4
- package/connectors/testflight/src/tools.ts +185 -6
- package/connectors/vercel/nimbus.extension.json +2 -2
- package/connectors/vercel/src/server.ts +12 -4
- package/connectors/vercel/src/tools.ts +161 -7
- package/package.json +1 -1
- package/shared/cap-text.ts +15 -0
- package/shared/env-json-api.ts +24 -6
- package/shared/line-diff.ts +158 -0
|
@@ -17,28 +17,66 @@ import {
|
|
|
17
17
|
import { withPrivateKubeconfig } from "../../../shared/private-kubeconfig.ts";
|
|
18
18
|
import { withScratchDir } from "../../../shared/private-scratch.ts";
|
|
19
19
|
import { runCliCode, runCliJsonThrowing, runCliOkThrowing } from "../../../shared/run-cli-json.ts";
|
|
20
|
+
import {
|
|
21
|
+
compareCodeUnits,
|
|
22
|
+
diffSha256,
|
|
23
|
+
planDiff,
|
|
24
|
+
RESOURCE_PREFIX,
|
|
25
|
+
unpackKubeJson,
|
|
26
|
+
} from "./manifest-diff.ts";
|
|
20
27
|
import { type ScannedObject, scanNamespacedManifest } from "./manifest-scan.ts";
|
|
21
28
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
29
|
+
/** `kubectl --context <ctx>`: every call names its context, none relies on the kubeconfig's current one. */
|
|
30
|
+
function kubectlBase(context: string): string[] {
|
|
31
|
+
return ["kubectl", "--context", context];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const CONTEXT_MISMATCH = "context does not match the configured context";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The context every call of this process works against: `KUBE_CONTEXT` if the owner set it, else
|
|
38
|
+
* what `kubectl config current-context` prints for the call's private kubeconfig (never a
|
|
39
|
+
* hand-parse of the file). Fails closed: a kubectl error, no output, or a value that is not a plain
|
|
40
|
+
* argument is an error, so no call ever falls back to some other context.
|
|
41
|
+
*/
|
|
42
|
+
async function effectiveContext(kube: { readonly KUBECONFIG: string }): Promise<string> {
|
|
43
|
+
let ctx = process.env["KUBE_CONTEXT"]?.trim();
|
|
44
|
+
if (ctx === undefined || ctx === "") {
|
|
45
|
+
const r = await runCliCode(["kubectl", "config", "current-context"], kube);
|
|
46
|
+
if (r.code !== 0) {
|
|
47
|
+
throw new Error(`could not determine the kubectl context: kubectl exited ${String(r.code)}`);
|
|
48
|
+
}
|
|
49
|
+
ctx = r.stdout.trim();
|
|
50
|
+
if (ctx === "") {
|
|
51
|
+
throw new Error("could not determine the kubectl context: kubectl printed none");
|
|
52
|
+
}
|
|
27
53
|
}
|
|
28
|
-
|
|
54
|
+
if (!cliArg.safeParse(ctx).success) {
|
|
55
|
+
throw new Error("the configured kubectl context is not a plain value");
|
|
56
|
+
}
|
|
57
|
+
return ctx;
|
|
29
58
|
}
|
|
30
59
|
|
|
31
60
|
/**
|
|
32
|
-
* Run `body`
|
|
33
|
-
*
|
|
61
|
+
* Run `body` on a private kubeconfig copy (shared/private-kubeconfig.ts: one tool call, one copy, so
|
|
62
|
+
* a call can neither change the owner's file nor race another) with the effective context. A
|
|
63
|
+
* `given` context that is not that context throws before `body` runs.
|
|
34
64
|
*/
|
|
35
|
-
function withKube<T>(
|
|
36
|
-
|
|
65
|
+
function withKube<T>(
|
|
66
|
+
given: string | undefined,
|
|
67
|
+
body: (kube: { readonly KUBECONFIG: string }, context: string) => Promise<T>,
|
|
68
|
+
): Promise<T> {
|
|
69
|
+
return withPrivateKubeconfig({ source: process.env["KUBECONFIG"] ?? "" }, async (kube) => {
|
|
70
|
+
const context = await effectiveContext(kube);
|
|
71
|
+
if (given !== undefined && given !== context) throw new Error(CONTEXT_MISMATCH);
|
|
72
|
+
return body(kube, context);
|
|
73
|
+
});
|
|
37
74
|
}
|
|
38
75
|
|
|
39
|
-
function kubectlJson(rest: string[]): Promise<unknown> {
|
|
40
|
-
|
|
41
|
-
|
|
76
|
+
function kubectlJson(rest: string[], given: string | undefined): Promise<unknown> {
|
|
77
|
+
return withKube(given, (env, context) =>
|
|
78
|
+
runCliJsonThrowing([...kubectlBase(context), ...rest, "-o", "json"], env),
|
|
79
|
+
);
|
|
42
80
|
}
|
|
43
81
|
|
|
44
82
|
/** A manifest may be at most this many UTF-8 bytes (the schema's character cap is a floor of it). */
|
|
@@ -47,9 +85,10 @@ const MANIFEST_MAX_BYTES = 262_144;
|
|
|
47
85
|
const DIFF_MAX_BYTES = 65_536;
|
|
48
86
|
/** The whole plan reply, which the gateway refuses above this size. */
|
|
49
87
|
const PLAN_REPLY_MAX_BYTES = 262_144;
|
|
50
|
-
/** Domain separator of the plan hash
|
|
51
|
-
const PLAN_HASH_DOMAIN = "nimbus-k8s-manifest-
|
|
88
|
+
/** Domain separator of the plan hash (see {@link planHash}). */
|
|
89
|
+
const PLAN_HASH_DOMAIN = "nimbus-k8s-manifest-v2";
|
|
52
90
|
const MISMATCH = "manifest does not match the approved hash";
|
|
91
|
+
const CLUSTER_CHANGED = "the cluster changed since the plan was approved";
|
|
53
92
|
|
|
54
93
|
interface ManifestInput {
|
|
55
94
|
readonly namespace: string;
|
|
@@ -58,37 +97,39 @@ interface ManifestInput {
|
|
|
58
97
|
|
|
59
98
|
/**
|
|
60
99
|
* The checks every manifest tool makes before it touches a file or a process, in this order: size
|
|
61
|
-
* in UTF-8 bytes, the namespaced-subset scan (manifest-scan.ts)
|
|
62
|
-
* kubectl will be handed. Throws a fixed message.
|
|
100
|
+
* in UTF-8 bytes, then the namespaced-subset scan (manifest-scan.ts). Throws a fixed message.
|
|
63
101
|
*/
|
|
64
|
-
function checkedManifest(p: ManifestInput): {
|
|
65
|
-
bytes: Buffer;
|
|
66
|
-
sha256: string;
|
|
67
|
-
scanned: ScannedObject[];
|
|
68
|
-
} {
|
|
102
|
+
function checkedManifest(p: ManifestInput): { bytes: Buffer; scanned: ScannedObject[] } {
|
|
69
103
|
const bytes = Buffer.from(p.manifest, "utf8");
|
|
70
104
|
if (bytes.length > MANIFEST_MAX_BYTES) {
|
|
71
105
|
throw new Error(`manifest is larger than ${String(MANIFEST_MAX_BYTES)} bytes`);
|
|
72
106
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
107
|
+
return { bytes, scanned: scanNamespacedManifest(p.manifest, p.namespace) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The plan hash (v2): sha256 of the domain, the EFFECTIVE context, the namespace and the exact
|
|
112
|
+
* manifest text, newline-separated. The context and namespace are in it, so an approved plan
|
|
113
|
+
* cannot be replayed on another cluster or in another namespace. v1 (no context) is retired.
|
|
114
|
+
*/
|
|
115
|
+
export function planHash(context: string, namespace: string, manifest: string): string {
|
|
116
|
+
return createHash("sha256")
|
|
117
|
+
.update([PLAN_HASH_DOMAIN, context, namespace, manifest].join("\n"), "utf8")
|
|
118
|
+
.digest("hex");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Constant-time equality of two lowercase-hex digests (equal length is checked first). */
|
|
122
|
+
function sameHex(a: string, b: string): boolean {
|
|
123
|
+
const want = Buffer.from(a, "hex");
|
|
124
|
+
const have = Buffer.from(b, "hex");
|
|
125
|
+
return want.length === have.length && timingSafeEqual(want, have);
|
|
80
126
|
}
|
|
81
127
|
|
|
82
|
-
/**
|
|
83
|
-
function
|
|
84
|
-
|
|
85
|
-
const want = Buffer.from(p.planSha256, "hex");
|
|
86
|
-
const have = Buffer.from(checked.sha256, "hex");
|
|
87
|
-
// Equal length is checked first: timingSafeEqual throws on unequal buffers. The schema pins 64 hex.
|
|
88
|
-
if (want.length !== have.length || !timingSafeEqual(want, have)) {
|
|
128
|
+
/** The apply's own check: the manifest must hash, under this context, to what was approved. */
|
|
129
|
+
function assertApprovedHash(context: string, p: ManifestInput & { readonly planSha256: string }) {
|
|
130
|
+
if (!sameHex(p.planSha256, planHash(context, p.namespace, p.manifest))) {
|
|
89
131
|
throw new Error(MISMATCH);
|
|
90
132
|
}
|
|
91
|
-
return checked;
|
|
92
133
|
}
|
|
93
134
|
|
|
94
135
|
function refusalOf(check: () => unknown): string | undefined {
|
|
@@ -119,37 +160,6 @@ function kubectlFailure(
|
|
|
119
160
|
/** What a plan's `diff` holds in place of kubectl's when the manifest holds a Secret. */
|
|
120
161
|
export const SECRET_DIFF_WITHHELD = "(diff withheld: the manifest holds a Secret)";
|
|
121
162
|
|
|
122
|
-
/** The `kubectl ... -o name` resource prefix of each allowlisted kind. */
|
|
123
|
-
const RESOURCE_PREFIX: Readonly<Record<string, string>> = {
|
|
124
|
-
ConfigMap: "configmap",
|
|
125
|
-
Secret: "secret",
|
|
126
|
-
Service: "service",
|
|
127
|
-
Pod: "pod",
|
|
128
|
-
ServiceAccount: "serviceaccount",
|
|
129
|
-
PersistentVolumeClaim: "persistentvolumeclaim",
|
|
130
|
-
Deployment: "deployment.apps",
|
|
131
|
-
StatefulSet: "statefulset.apps",
|
|
132
|
-
DaemonSet: "daemonset.apps",
|
|
133
|
-
ReplicaSet: "replicaset.apps",
|
|
134
|
-
Job: "job.batch",
|
|
135
|
-
CronJob: "cronjob.batch",
|
|
136
|
-
Ingress: "ingress.networking.k8s.io",
|
|
137
|
-
NetworkPolicy: "networkpolicy.networking.k8s.io",
|
|
138
|
-
Role: "role.rbac.authorization.k8s.io",
|
|
139
|
-
RoleBinding: "rolebinding.rbac.authorization.k8s.io",
|
|
140
|
-
HorizontalPodAutoscaler: "horizontalpodautoscaler.autoscaling",
|
|
141
|
-
PodDisruptionBudget: "poddisruptionbudget.policy",
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Order by UTF-16 code unit, the same on every machine. Not `localeCompare`: collation can tie two
|
|
146
|
-
* distinct strings, and these lists are compared element by element.
|
|
147
|
-
*/
|
|
148
|
-
function compareCodeUnits(a: string, b: string): number {
|
|
149
|
-
if (a < b) return -1;
|
|
150
|
-
return a > b ? 1 : 0;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
163
|
/** The sorted `<prefix>/<name>` list the scan expects kubectl to report. */
|
|
154
164
|
function expectedNames(scanned: readonly ScannedObject[]): string[] {
|
|
155
165
|
return scanned
|
|
@@ -184,16 +194,42 @@ async function writeManifestFile(dir: string, bytes: Buffer): Promise<string> {
|
|
|
184
194
|
return file;
|
|
185
195
|
}
|
|
186
196
|
|
|
187
|
-
/**
|
|
188
|
-
|
|
197
|
+
/** Parse a kubectl `-o json` reply; an unparsable one is an error that never echoes the text (it may hold a Secret). */
|
|
198
|
+
function parseKubeReply(stdout: string, what: string): unknown {
|
|
199
|
+
const trimmed = stdout.trim();
|
|
200
|
+
if (trimmed === "") return null;
|
|
201
|
+
try {
|
|
202
|
+
return JSON.parse(trimmed) as unknown;
|
|
203
|
+
} catch {
|
|
204
|
+
throw new Error(`kubectl ${what} returned invalid JSON`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** What the plan computes, and what the apply recomputes to compare. */
|
|
209
|
+
interface Computed {
|
|
210
|
+
readonly objects: string[];
|
|
211
|
+
/** The diff as the reply shows it (withheld when the manifest holds a Secret). */
|
|
212
|
+
readonly diff: string;
|
|
213
|
+
readonly hasDifferences: boolean;
|
|
214
|
+
readonly diffSha256: string;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The plan: a server-side dry run (`-o json`, the objects as the API server would admit them,
|
|
219
|
+
* cross-checked against the scan), one `kubectl get` of the same objects as they are now
|
|
220
|
+
* (`--ignore-not-found`), and the built-in diff of the two (manifest-diff.ts). Changes nothing.
|
|
221
|
+
*/
|
|
222
|
+
async function computePlan(
|
|
189
223
|
kube: { readonly KUBECONFIG: string },
|
|
224
|
+
context: string,
|
|
190
225
|
ns: string,
|
|
191
226
|
file: string,
|
|
192
227
|
scanned: readonly ScannedObject[],
|
|
193
|
-
): Promise<
|
|
228
|
+
): Promise<Computed> {
|
|
229
|
+
const base = kubectlBase(context);
|
|
194
230
|
const dry = await runCliCode(
|
|
195
231
|
[
|
|
196
|
-
...
|
|
232
|
+
...base,
|
|
197
233
|
"apply",
|
|
198
234
|
"--server-side",
|
|
199
235
|
"--dry-run=server",
|
|
@@ -203,14 +239,43 @@ async function serverDryRun(
|
|
|
203
239
|
"-f",
|
|
204
240
|
file,
|
|
205
241
|
"-o",
|
|
206
|
-
"
|
|
242
|
+
"json",
|
|
207
243
|
],
|
|
208
244
|
kube,
|
|
209
245
|
);
|
|
210
246
|
if (dry.code !== 0) throw kubectlFailure("kubectl", dry.code, dry.stderr, scanned);
|
|
211
|
-
const
|
|
247
|
+
const planned = unpackKubeJson(parseKubeReply(dry.stdout, "dry run"));
|
|
248
|
+
const objects = [...planned.keys()];
|
|
212
249
|
assertSameObjects(objects, scanned, "dry run");
|
|
213
|
-
|
|
250
|
+
|
|
251
|
+
const names = [...new Set(expectedNames(scanned))];
|
|
252
|
+
const get = await runCliCode(
|
|
253
|
+
[...base, "get", ...names, "-n", ns, "-o", "json", "--ignore-not-found"],
|
|
254
|
+
kube,
|
|
255
|
+
);
|
|
256
|
+
if (get.code !== 0) throw kubectlFailure("kubectl get", get.code, get.stderr, scanned);
|
|
257
|
+
const live = unpackKubeJson(parseKubeReply(get.stdout, "get"));
|
|
258
|
+
|
|
259
|
+
const full = planDiff(planned, live);
|
|
260
|
+
if (Buffer.byteLength(full, "utf8") > DIFF_MAX_BYTES) {
|
|
261
|
+
throw new Error(`diff too large (over ${String(DIFF_MAX_BYTES)} bytes)`);
|
|
262
|
+
}
|
|
263
|
+
// A Secret's plaintext can sit in either side of the diff, so with a Secret in the manifest its
|
|
264
|
+
// part of the diff is withheld: the reply's diff is a fixed marker line, then the diff of every
|
|
265
|
+
// object that is NOT a Secret. The hash covers that text, so the apply's re-diff still sees a
|
|
266
|
+
// change to any other object (stated bound: it cannot see a change to a Secret).
|
|
267
|
+
const holdsSecret = scanned.some((o) => o.kind === "Secret");
|
|
268
|
+
const diff = holdsSecret
|
|
269
|
+
? `${SECRET_DIFF_WITHHELD}
|
|
270
|
+
${planDiff(withoutSecrets(planned), withoutSecrets(live))}`
|
|
271
|
+
: full;
|
|
272
|
+
return { objects, diff, hasDifferences: full !== "", diffSha256: diffSha256(diff) };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** `objects` without its Secrets (keyed `<RESOURCE_PREFIX.Secret>/<name>`). */
|
|
276
|
+
function withoutSecrets<V>(objects: ReadonlyMap<string, V>): Map<string, V> {
|
|
277
|
+
const secret = `${RESOURCE_PREFIX["Secret"] ?? "secret"}/`;
|
|
278
|
+
return new Map([...objects].filter(([key]) => !key.startsWith(secret)));
|
|
214
279
|
}
|
|
215
280
|
|
|
216
281
|
function objectLines(stdout: string): string[] {
|
|
@@ -220,11 +285,14 @@ function objectLines(stdout: string): string[] {
|
|
|
220
285
|
.filter((l) => l !== "");
|
|
221
286
|
}
|
|
222
287
|
|
|
223
|
-
|
|
288
|
+
/** Reads: `context` is optional; when given it must be the effective one. */
|
|
289
|
+
const readSchema = z.object({ context: cliArg.optional(), namespace: cliArg.optional() });
|
|
224
290
|
|
|
225
|
-
async function k8sListNamespacedResource(
|
|
226
|
-
|
|
227
|
-
|
|
291
|
+
async function k8sListNamespacedResource(
|
|
292
|
+
p: { context?: string | undefined; namespace?: string | undefined },
|
|
293
|
+
resource: string,
|
|
294
|
+
) {
|
|
295
|
+
const data = await kubectlJson(["get", resource, "-n", p.namespace ?? "default"], p.context);
|
|
228
296
|
return jsonResult(data ?? {});
|
|
229
297
|
}
|
|
230
298
|
|
|
@@ -269,21 +337,22 @@ export function registerKubernetesTools(
|
|
|
269
337
|
|
|
270
338
|
reg(
|
|
271
339
|
"k8s_pod_list",
|
|
272
|
-
"List pods in a namespace (default namespace: default).",
|
|
273
|
-
|
|
340
|
+
"List pods in a namespace (default namespace: default). `context` is optional; when given it must be the configured context (`KUBE_CONTEXT`, else the kubeconfig's current one).",
|
|
341
|
+
readSchema,
|
|
274
342
|
(p) => k8sListNamespacedResource(p, "pods"),
|
|
275
343
|
);
|
|
276
344
|
|
|
277
345
|
reg(
|
|
278
346
|
"k8s_deployment_get",
|
|
279
|
-
"Get one deployment's state: {name, namespace, replicas, readyReplicas, generation, observedGeneration, restartedAt, images}. The before-state of a restart or a scale.",
|
|
280
|
-
z.object({ namespace: cliArg.optional(), name: cliArg }),
|
|
347
|
+
"Get one deployment's state: {name, namespace, replicas, readyReplicas, generation, observedGeneration, restartedAt, images}. The before-state of a restart or a scale. `context` is optional; when given it must be the configured context.",
|
|
348
|
+
z.object({ context: cliArg.optional(), namespace: cliArg.optional(), name: cliArg }),
|
|
281
349
|
async (p) => {
|
|
282
350
|
let data: unknown;
|
|
283
351
|
try {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
352
|
+
data = await kubectlJson(
|
|
353
|
+
["get", "deployment", p.name, "-n", p.namespace ?? "default"],
|
|
354
|
+
p.context,
|
|
355
|
+
);
|
|
287
356
|
} catch (e) {
|
|
288
357
|
// Exit code of a missing object is not documented, so NotFound is read from the message.
|
|
289
358
|
if (e instanceof Error && isKubectlNotFound(e.message)) {
|
|
@@ -295,12 +364,18 @@ export function registerKubernetesTools(
|
|
|
295
364
|
},
|
|
296
365
|
);
|
|
297
366
|
|
|
298
|
-
reg(
|
|
299
|
-
|
|
367
|
+
reg(
|
|
368
|
+
"k8s_deployment_list",
|
|
369
|
+
"List deployments in a namespace. `context` is optional; when given it must be the configured context.",
|
|
370
|
+
readSchema,
|
|
371
|
+
(p) => k8sListNamespacedResource(p, "deployments"),
|
|
300
372
|
);
|
|
301
373
|
|
|
302
|
-
reg(
|
|
303
|
-
|
|
374
|
+
reg(
|
|
375
|
+
"k8s_event_list",
|
|
376
|
+
"List events in a namespace. `context` is optional; when given it must be the configured context.",
|
|
377
|
+
readSchema,
|
|
378
|
+
(p) => k8sListNamespacedResource(p, "events"),
|
|
304
379
|
);
|
|
305
380
|
|
|
306
381
|
registerWriteTool(
|
|
@@ -308,18 +383,30 @@ export function registerKubernetesTools(
|
|
|
308
383
|
{
|
|
309
384
|
mutates: "kubernetes.rollout.restart",
|
|
310
385
|
recoverable: true,
|
|
311
|
-
scopeTargetOf: (p) => ({ kind: "namespace", value: p.namespace
|
|
386
|
+
scopeTargetOf: (p) => ({ kind: "namespace", value: p.namespace }),
|
|
312
387
|
},
|
|
313
|
-
"Restart a rollout (e.g. deployment).",
|
|
388
|
+
"Restart a rollout (e.g. deployment). `context` and `namespace` are required; `context` must be the configured context (`KUBE_CONTEXT`, else the kubeconfig's current one).",
|
|
314
389
|
z.object({
|
|
315
|
-
|
|
390
|
+
context: cliArg,
|
|
391
|
+
namespace: cliArg,
|
|
316
392
|
resourceType: cliArg,
|
|
317
393
|
name: cliArg,
|
|
318
394
|
}),
|
|
319
395
|
async (p) => {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
396
|
+
await withKube(p.context, (env, context) =>
|
|
397
|
+
runCliOkThrowing(
|
|
398
|
+
[
|
|
399
|
+
...kubectlBase(context),
|
|
400
|
+
"rollout",
|
|
401
|
+
"restart",
|
|
402
|
+
p.resourceType,
|
|
403
|
+
p.name,
|
|
404
|
+
"-n",
|
|
405
|
+
p.namespace,
|
|
406
|
+
],
|
|
407
|
+
env,
|
|
408
|
+
),
|
|
409
|
+
);
|
|
323
410
|
return jsonResult({ ok: true });
|
|
324
411
|
},
|
|
325
412
|
);
|
|
@@ -328,22 +415,25 @@ export function registerKubernetesTools(
|
|
|
328
415
|
"k8s_pod_delete",
|
|
329
416
|
{
|
|
330
417
|
mutates: "kubernetes.pod.delete",
|
|
331
|
-
// A deleted pod is not restorable; its identity is all that remains to record
|
|
332
|
-
// the namespace the delete defaulted to, without which the record cannot say where it was.
|
|
418
|
+
// A deleted pod is not restorable; its identity is all that remains to record.
|
|
333
419
|
recoverable: false,
|
|
334
420
|
capturePreState: (p) =>
|
|
335
|
-
Promise.resolve({ namespace: p.namespace
|
|
336
|
-
scopeTargetOf: (p) => ({ kind: "namespace", value: p.namespace
|
|
421
|
+
Promise.resolve({ context: p.context, namespace: p.namespace, podName: p.podName }),
|
|
422
|
+
scopeTargetOf: (p) => ({ kind: "namespace", value: p.namespace }),
|
|
337
423
|
},
|
|
338
|
-
"Delete a pod.",
|
|
424
|
+
"Delete a pod. `context` and `namespace` are required; `context` must be the configured context.",
|
|
339
425
|
z.object({
|
|
340
|
-
|
|
426
|
+
context: cliArg,
|
|
427
|
+
namespace: cliArg,
|
|
341
428
|
podName: cliArg,
|
|
342
429
|
}),
|
|
343
430
|
async (p) => {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
431
|
+
await withKube(p.context, (env, context) =>
|
|
432
|
+
runCliOkThrowing(
|
|
433
|
+
[...kubectlBase(context), "delete", "pod", p.podName, "-n", p.namespace],
|
|
434
|
+
env,
|
|
435
|
+
),
|
|
436
|
+
);
|
|
347
437
|
return jsonResult({ ok: true });
|
|
348
438
|
},
|
|
349
439
|
);
|
|
@@ -353,26 +443,30 @@ export function registerKubernetesTools(
|
|
|
353
443
|
{
|
|
354
444
|
mutates: "kubernetes.deployment.scale",
|
|
355
445
|
recoverable: true,
|
|
356
|
-
scopeTargetOf: (p) => ({ kind: "namespace", value: p.namespace
|
|
446
|
+
scopeTargetOf: (p) => ({ kind: "namespace", value: p.namespace }),
|
|
357
447
|
},
|
|
358
|
-
"Scale a deployment.",
|
|
448
|
+
"Scale a deployment. `context` and `namespace` are required; `context` must be the configured context.",
|
|
359
449
|
z.object({
|
|
360
|
-
|
|
450
|
+
context: cliArg,
|
|
451
|
+
namespace: cliArg,
|
|
361
452
|
deploymentName: cliArg,
|
|
362
453
|
replicas: z.number().int().min(0),
|
|
363
454
|
}),
|
|
364
455
|
async (p) => {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
456
|
+
await withKube(p.context, (env, context) =>
|
|
457
|
+
runCliOkThrowing(
|
|
458
|
+
[
|
|
459
|
+
...kubectlBase(context),
|
|
460
|
+
"scale",
|
|
461
|
+
"deployment",
|
|
462
|
+
p.deploymentName,
|
|
463
|
+
"-n",
|
|
464
|
+
p.namespace,
|
|
465
|
+
`--replicas=${String(p.replicas)}`,
|
|
466
|
+
],
|
|
467
|
+
env,
|
|
468
|
+
),
|
|
469
|
+
);
|
|
376
470
|
return jsonResult({ ok: true });
|
|
377
471
|
},
|
|
378
472
|
);
|
|
@@ -380,60 +474,26 @@ export function registerKubernetesTools(
|
|
|
380
474
|
registerPrepareTool(
|
|
381
475
|
"k8s_manifest_plan",
|
|
382
476
|
{ scopeTargetOf: (p) => ({ kind: "namespace", value: p.namespace }) },
|
|
383
|
-
"Plan a k8s_manifest_apply: scan the manifest (namespaced kinds from an allowlist only, in the given namespace),
|
|
384
|
-
z.object({
|
|
477
|
+
"Plan a k8s_manifest_apply: scan the manifest (namespaced kinds from an allowlist only, in the given namespace), ask the API server for a server-side dry run, read the same objects as they are now, and diff the two in this connector (no `diff` program). Returns {planSha256, diffSha256, namespace, objects, scanned, diff, hasDifferences}; the apply must be given the same context, namespace, manifest, planSha256 and diffSha256. `context` must be the configured context (`KUBE_CONTEXT`, else the kubeconfig's current one) and is part of planSha256. Changes nothing.",
|
|
478
|
+
z.object({
|
|
479
|
+
context: cliArg,
|
|
480
|
+
namespace: cliArg,
|
|
481
|
+
manifest: z.string().min(1).max(MANIFEST_MAX_BYTES),
|
|
482
|
+
}),
|
|
385
483
|
async (p) => {
|
|
386
|
-
const { bytes,
|
|
387
|
-
const ns = p.namespace;
|
|
484
|
+
const { bytes, scanned } = checkedManifest(p);
|
|
388
485
|
const reply = await withScratchDir("nimbus-k8s-", async (dir) => {
|
|
389
486
|
const file = await writeManifestFile(dir, bytes);
|
|
390
|
-
return withKube(async (kube) => {
|
|
391
|
-
|
|
392
|
-
// and the names must be the ones the scan read (see assertSameObjects).
|
|
393
|
-
const objects = await serverDryRun(kube, ns, file, scanned);
|
|
394
|
-
|
|
395
|
-
// kubectl diff runs the program KUBECTL_EXTERNAL_DIFF names (default `diff -u -N`). The
|
|
396
|
-
// variable is removed from the child whatever the owner's shell sets, so the diff is
|
|
397
|
-
// never produced by an arbitrary program. TMPDIR/TMP/TEMP point kubectl's own temp files
|
|
398
|
-
// (Go reads TMP and TEMP on Windows) into this call's private directory. Exit codes:
|
|
399
|
-
// https://kubernetes.io/docs/reference/kubectl/generated/kubectl_diff/ (0 no
|
|
400
|
-
// differences, 1 differences, >1 error). --force-conflicts is never passed.
|
|
401
|
-
const diff = await runCliCode(
|
|
402
|
-
[
|
|
403
|
-
...kubectlBase(),
|
|
404
|
-
"diff",
|
|
405
|
-
"--server-side",
|
|
406
|
-
"--field-manager=nimbus",
|
|
407
|
-
"-n",
|
|
408
|
-
ns,
|
|
409
|
-
"-f",
|
|
410
|
-
file,
|
|
411
|
-
],
|
|
412
|
-
{
|
|
413
|
-
KUBECONFIG: kube.KUBECONFIG,
|
|
414
|
-
KUBECTL_EXTERNAL_DIFF: undefined,
|
|
415
|
-
TMPDIR: dir,
|
|
416
|
-
TMP: dir,
|
|
417
|
-
TEMP: dir,
|
|
418
|
-
},
|
|
419
|
-
);
|
|
420
|
-
if (diff.code !== 0 && diff.code !== 1) {
|
|
421
|
-
throw kubectlFailure("kubectl diff", diff.code, diff.stderr, scanned);
|
|
422
|
-
}
|
|
423
|
-
if (Buffer.byteLength(diff.stdout, "utf8") > DIFF_MAX_BYTES) {
|
|
424
|
-
throw new Error(`diff too large (over ${String(DIFF_MAX_BYTES)} bytes)`);
|
|
425
|
-
}
|
|
426
|
-
// kubectl masks a Secret's `data`, but the diff's context lines can still carry its
|
|
427
|
-
// plaintext (a `last-applied-configuration` annotation, `stringData`), so with a Secret
|
|
428
|
-
// in the manifest only the fact of a difference is returned, never the body.
|
|
429
|
-
const holdsSecret = scanned.some((o) => o.kind === "Secret");
|
|
487
|
+
return withKube(p.context, async (kube, context) => {
|
|
488
|
+
const plan = await computePlan(kube, context, p.namespace, file, scanned);
|
|
430
489
|
return {
|
|
431
|
-
planSha256:
|
|
432
|
-
|
|
433
|
-
|
|
490
|
+
planSha256: planHash(context, p.namespace, p.manifest),
|
|
491
|
+
diffSha256: plan.diffSha256,
|
|
492
|
+
namespace: p.namespace,
|
|
493
|
+
objects: plan.objects,
|
|
434
494
|
scanned,
|
|
435
|
-
diff:
|
|
436
|
-
hasDifferences:
|
|
495
|
+
diff: plan.diff,
|
|
496
|
+
hasDifferences: plan.hasDifferences,
|
|
437
497
|
};
|
|
438
498
|
});
|
|
439
499
|
});
|
|
@@ -452,37 +512,57 @@ export function registerKubernetesTools(
|
|
|
452
512
|
recoverable: false,
|
|
453
513
|
capturePreState: (p) =>
|
|
454
514
|
Promise.resolve({
|
|
515
|
+
context: p.context,
|
|
455
516
|
namespace: p.namespace,
|
|
456
517
|
planSha256: p.planSha256,
|
|
518
|
+
diffSha256: p.diffSha256,
|
|
457
519
|
objects: checkedManifest(p).scanned,
|
|
458
520
|
}),
|
|
459
521
|
scopeTargetOf: (p) => ({ kind: "namespace", value: p.namespace }),
|
|
460
522
|
// Standalone only: a manifest no consent could make safe is refused before any prompt. The
|
|
461
|
-
// handler repeats the same checks, because gateway mode registers the raw handler.
|
|
462
|
-
|
|
523
|
+
// handler repeats the same checks, because gateway mode registers the raw handler. The hash
|
|
524
|
+
// needs the effective context, which may take a kubectl call, so it is checked here only when
|
|
525
|
+
// KUBE_CONTEXT names it; the handler always checks it.
|
|
526
|
+
refuseBefore: (p) =>
|
|
527
|
+
refusalOf(() => {
|
|
528
|
+
checkedManifest(p);
|
|
529
|
+
const configured = process.env["KUBE_CONTEXT"]?.trim();
|
|
530
|
+
if (configured === undefined || configured === "") return;
|
|
531
|
+
if (p.context !== configured) throw new Error(CONTEXT_MISMATCH);
|
|
532
|
+
assertApprovedHash(configured, p);
|
|
533
|
+
}),
|
|
463
534
|
},
|
|
464
|
-
"Apply a manifest with a server-side apply, in the given namespace. The namespace and manifest must be the ones k8s_manifest_plan planned (planSha256) and
|
|
535
|
+
"Apply a manifest with a server-side apply, in the given context and namespace. The context, namespace and manifest must be the ones k8s_manifest_plan planned (planSha256), and diffSha256 must be the plan's: the apply first recomputes the plan against the cluster and applies only if the diff is the one that was approved, else it throws 'the cluster changed since the plan was approved' and applies nothing. The manifest must hold namespaced objects of allowlisted kinds only. `context` must be the configured context.",
|
|
465
536
|
z.object({
|
|
537
|
+
context: cliArg,
|
|
466
538
|
namespace: cliArg,
|
|
467
539
|
manifest: z.string().min(1).max(MANIFEST_MAX_BYTES),
|
|
468
540
|
planSha256: z.string().regex(/^[0-9a-f]{64}$/),
|
|
541
|
+
diffSha256: z.string().regex(/^[0-9a-f]{64}$/),
|
|
469
542
|
}),
|
|
470
543
|
async (p) => {
|
|
471
|
-
const { bytes, scanned } =
|
|
544
|
+
const { bytes, scanned } = checkedManifest(p);
|
|
472
545
|
return withScratchDir("nimbus-k8s-", async (dir) => {
|
|
473
546
|
const file = await writeManifestFile(dir, bytes);
|
|
474
|
-
return withKube(async (kube) => {
|
|
475
|
-
|
|
476
|
-
//
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
547
|
+
return withKube(p.context, async (kube, context) => {
|
|
548
|
+
assertApprovedHash(context, p);
|
|
549
|
+
// The re-diff: exactly the plan's dry run, get and diff, again, now. Any failure of it, or
|
|
550
|
+
// a diff that is not the approved one, means the cluster is not what the owner saw.
|
|
551
|
+
let again: Computed;
|
|
552
|
+
try {
|
|
553
|
+
again = await computePlan(kube, context, p.namespace, file, scanned);
|
|
554
|
+
} catch (e) {
|
|
555
|
+
throw new Error(CLUSTER_CHANGED, { cause: e });
|
|
556
|
+
}
|
|
557
|
+
if (!sameHex(again.diffSha256, p.diffSha256)) throw new Error(CLUSTER_CHANGED);
|
|
558
|
+
// The file the real apply reads must still hold the approved bytes: the re-diff ran
|
|
559
|
+
// child processes on the same file, and on Windows the 0600 mode is not enforced.
|
|
480
560
|
if (!bytes.equals(await readFile(file))) {
|
|
481
561
|
throw new Error("the manifest file changed before the apply");
|
|
482
562
|
}
|
|
483
563
|
const applied = await runCliCode(
|
|
484
564
|
[
|
|
485
|
-
...kubectlBase(),
|
|
565
|
+
...kubectlBase(context),
|
|
486
566
|
"apply",
|
|
487
567
|
"--server-side",
|
|
488
568
|
"--field-manager=nimbus",
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
"id": "com.nimbus.netlify",
|
|
3
3
|
"displayName": "Netlify",
|
|
4
4
|
"version": "0.1.0",
|
|
5
|
-
"description": "Netlify site connector
|
|
5
|
+
"description": "Netlify site connector. Surfaces site id, name, the production URL + SSL URL, the admin dashboard URL, the linked git repo + branch, and the embedded published-deploy status (state / branch / commit ref / commit URL / deploy preview URL) into the local index as `netlify:site` items — useful for answering \"is the latest deploy live?\" and \"which site shipped this commit?\". Bearer-token (PAT) auth. The SaaS host (api.netlify.com) is fixed. Write tool (needs owner approval): `netlify.build.trigger`; a build with no branch is a PRODUCTION deploy. Forms, functions, env vars, and DNS are deferred follow-ups.",
|
|
6
6
|
"author": "Nimbus",
|
|
7
7
|
"entrypoint": "dist/server.js",
|
|
8
8
|
"runtime": "bun",
|
|
9
9
|
"permissions": {
|
|
10
10
|
"network": ["api.netlify.com"]
|
|
11
11
|
},
|
|
12
|
-
"hitlRequired": [],
|
|
12
|
+
"hitlRequired": ["write"],
|
|
13
13
|
"syncInterval": 600,
|
|
14
14
|
"minNimbusVersion": "0.2.0"
|
|
15
15
|
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { CONSENT_SERVER_CAPABILITIES } from "../../../shared/consent-kit.ts";
|
|
2
4
|
import { registerNetlifyTools } from "./tools.ts";
|
|
3
5
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
6
|
+
const server = new McpServer(
|
|
7
|
+
{ name: "nimbus-netlify", version: "0.1.0" },
|
|
8
|
+
{ capabilities: CONSENT_SERVER_CAPABILITIES },
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
registerNetlifyTools(server);
|
|
12
|
+
|
|
13
|
+
const transport = new StdioServerTransport();
|
|
14
|
+
await server.connect(transport);
|