@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.
@@ -0,0 +1,111 @@
1
+ /**
2
+ * private-kubeconfig — one kubeconfig file per tool call, never the owner's own.
3
+ *
4
+ * `kubectl` and `gcloud container clusters get-credentials` both read AND write a kubeconfig:
5
+ * gcloud writes `$HOME/.kube/config` unless `KUBECONFIG` is set (first path if it is a list), and
6
+ * kubectl takes the file `KUBECONFIG` names, or merges a list of them. Handed the owner's real
7
+ * file, a tool call could change it, and two concurrent calls would share and race on it. So each
8
+ * call works on a private copy in its own 0700 directory, removed afterwards, success or failure.
9
+ *
10
+ * Sources: https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/
11
+ * (`--kubeconfig` set: only that file; else `KUBECONFIG` is a list, `:` on Linux and macOS, `;` on
12
+ * Windows) and https://docs.cloud.google.com/sdk/gcloud/reference/container/clusters/get-credentials
13
+ * (writes `HOME/.kube/config` unless `KUBECONFIG` is set).
14
+ *
15
+ * The copy is a plain file copy, not `kubectl config view --flatten`. One consequence is stated in
16
+ * the connector READMEs: a kubeconfig whose certificate paths are RELATIVE resolves them against the
17
+ * file's own directory, so a copy of such a file does not find them. Use absolute paths or
18
+ * embedded certificate data.
19
+ */
20
+
21
+ import { constants } from "node:fs";
22
+ import { lstat, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
23
+ import { tmpdir } from "node:os";
24
+ import { delimiter, join } from "node:path";
25
+ import { configuredScratchBase } from "./private-scratch.ts";
26
+
27
+ /** Names the directory private kubeconfigs are made in; an absolute path, else `os.tmpdir()`. */
28
+ export { SCRATCH_DIR_ENV } from "./private-scratch.ts";
29
+
30
+ /**
31
+ * The one path `raw` (a `KUBECONFIG` value) names. A list of paths throws: a tool that cannot say
32
+ * which file it works on cannot copy it. `separator` is the platform's `path.delimiter`, so a
33
+ * Windows drive path (`C:\Users\me\config`, whose `:` is not a separator there) is one path, and
34
+ * a `;` list is refused there; elsewhere a `:` list is.
35
+ */
36
+ export function singleKubeconfigPath(
37
+ raw: string | undefined,
38
+ separator: string = delimiter,
39
+ ): string {
40
+ const p = raw?.trim();
41
+ if (p === undefined || p === "") {
42
+ throw new Error("KUBECONFIG env must be set to a kubeconfig file path");
43
+ }
44
+ const pieces = p
45
+ .split(separator)
46
+ .map((piece) => piece.trim())
47
+ .filter((piece) => piece !== "");
48
+ if (pieces.length > 1) {
49
+ throw new Error(
50
+ `KUBECONFIG names more than one file (separator "${separator}"); set it to ONE kubeconfig path`,
51
+ );
52
+ }
53
+ // A value that is only separators names no file at all.
54
+ const only = pieces[0];
55
+ if (only === undefined) throw new Error("KUBECONFIG env must be set to a kubeconfig file path");
56
+ // The one piece that remains, not the raw value: `/a/config:` names `/a/config`, and the copy is
57
+ // made from that path, never from a string with a separator in it.
58
+ return only;
59
+ }
60
+
61
+ /**
62
+ * The base directory private kubeconfigs are made under: the configured scratch base, else
63
+ * `os.tmpdir()` on every platform. Unlike `withScratchDir` (private-scratch.ts) this does NOT
64
+ * refuse on Windows under the gateway: the before-state reads shipped in 0.8.0 run there today
65
+ * without the env var, and the gateway grants it with the Kubernetes write rows (D3).
66
+ */
67
+ function scratchBase(): string {
68
+ return configuredScratchBase() ?? tmpdir();
69
+ }
70
+
71
+ /** Refuse a source that is not a plain file: a symlink, a directory, a device. */
72
+ async function assertRegularFile(path: string): Promise<void> {
73
+ const st = await lstat(path).catch(() => undefined);
74
+ if (st === undefined) throw new Error("KUBECONFIG names a file that does not exist");
75
+ if (st.isSymbolicLink() || !st.isFile()) {
76
+ throw new Error("KUBECONFIG must name a regular file (not a symlink or a directory)");
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Run `body` with a private kubeconfig.
82
+ *
83
+ * With `source` (a `KUBECONFIG` value, ONE path): the file is copied, mode 0600, and `body` gets
84
+ * `{ KUBECONFIG: <copy> }`. Without it, the copy starts EMPTY, for `get-credentials` to fill.
85
+ * The directory is removed when `body` settles.
86
+ */
87
+ export async function withPrivateKubeconfig<T>(
88
+ opts: { readonly source?: string | undefined },
89
+ body: (env: { readonly KUBECONFIG: string }) => Promise<T>,
90
+ ): Promise<T> {
91
+ // Everything that can refuse does so BEFORE a directory exists.
92
+ let content: Buffer | undefined;
93
+ if (opts.source !== undefined) {
94
+ const sourcePath = singleKubeconfigPath(opts.source);
95
+ await assertRegularFile(sourcePath);
96
+ content = await readFile(sourcePath);
97
+ }
98
+ const base = scratchBase();
99
+ const dir = await mkdtemp(join(base, "nimbus-kubeconfig-"));
100
+ try {
101
+ const file = join(dir, "config");
102
+ // `wx`: the file is new by construction, and an existing one is an error, not a reuse.
103
+ await writeFile(file, content ?? "", {
104
+ mode: 0o600,
105
+ flag: constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY,
106
+ });
107
+ return await body({ KUBECONFIG: file });
108
+ } finally {
109
+ await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
110
+ }
111
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * private-scratch — a private directory for one tool call's files, removed when the call settles.
3
+ *
4
+ * The CLI-backed write tools hand their CLI a file rather than an argument: a CloudFormation
5
+ * template, a Kubernetes manifest. Each call gets its own 0700 directory under the configured
6
+ * scratch base, so concurrent calls never share a file and nothing outlives the call.
7
+ *
8
+ * The base is `NIMBUS_CONNECTOR_SCRATCH_DIR` (absolute) when set. When it is not, `os.tmpdir()`,
9
+ * with one exception: a Windows connector running under the gateway REFUSES. The gateway sandboxes
10
+ * a Windows connector in an AppContainer and grants it a per-action directory through that env
11
+ * var; the user temp directory is not that grant, so a file written there is either refused by
12
+ * the sandbox or, worse, shared with every other process of the user. Failing closed names the
13
+ * missing grant instead.
14
+ */
15
+
16
+ import { mkdtemp, rm } from "node:fs/promises";
17
+ import { tmpdir } from "node:os";
18
+ import { isAbsolute, join } from "node:path";
19
+ import { getConnectorMode } from "./connector-mode.ts";
20
+
21
+ /** Names the directory scratch directories are made in; an absolute path. */
22
+ export const SCRATCH_DIR_ENV = "NIMBUS_CONNECTOR_SCRATCH_DIR";
23
+
24
+ /**
25
+ * The configured scratch base, or `undefined` when none is configured. Throws on a relative
26
+ * value: a relative base would resolve against whatever the connector's cwd happens to be.
27
+ */
28
+ export function configuredScratchBase(): string | undefined {
29
+ const configured = process.env[SCRATCH_DIR_ENV]?.trim();
30
+ if (configured === undefined || configured === "") return undefined;
31
+ if (!isAbsolute(configured)) {
32
+ throw new Error(`${SCRATCH_DIR_ENV} must be an absolute path`);
33
+ }
34
+ return configured;
35
+ }
36
+
37
+ /**
38
+ * The base a {@link withScratchDir} directory is made under (see the module comment for the
39
+ * Windows-under-the-gateway refusal). `platform` is a parameter so both sides are testable.
40
+ */
41
+ export function scratchDirBase(platform: NodeJS.Platform = process.platform): string {
42
+ const configured = configuredScratchBase();
43
+ if (configured !== undefined) return configured;
44
+ if (platform === "win32" && getConnectorMode() === "gateway") {
45
+ throw new Error(
46
+ `${SCRATCH_DIR_ENV} is not set: on Windows the gateway must grant this connector a scratch ` +
47
+ "directory, and the user temp directory is not one",
48
+ );
49
+ }
50
+ return tmpdir();
51
+ }
52
+
53
+ /**
54
+ * Run `body` with a fresh 0700 directory of its own, removed when `body` settles, success or
55
+ * failure. `prefix` names the directory (`nimbus-cfn-`, `nimbus-k8s-`).
56
+ */
57
+ export async function withScratchDir<T>(
58
+ prefix: string,
59
+ body: (dir: string) => Promise<T>,
60
+ ): Promise<T> {
61
+ const base = scratchDirBase();
62
+ const dir = await mkdtemp(join(base, prefix));
63
+ try {
64
+ return await body(dir);
65
+ } finally {
66
+ await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
67
+ }
68
+ }
@@ -1,4 +1,4 @@
1
- import { nimbusSpawn } from "./nimbus-spawn.ts";
1
+ import { nimbusSpawn, type SpawnResult } from "./nimbus-spawn.ts";
2
2
 
3
3
  export type RunCliJsonResult = { ok: true; data: unknown } | { ok: false; message: string };
4
4
 
@@ -75,3 +75,19 @@ export async function runCliJsonThrowing(
75
75
  }
76
76
  return r.data;
77
77
  }
78
+
79
+ /**
80
+ * Spawn `command` and hand back its exit code with its output, for a CLI whose non-zero exits are
81
+ * answers rather than failures (`terraform plan -detailed-exitcode` exits 2 for "changes";
82
+ * `kubectl diff` exits 1 for "differences"). The caller maps every code itself, so an
83
+ * unexpected one is never taken for success. An empty command is code 1, as nimbusSpawn reports.
84
+ */
85
+ export async function runCliCode(
86
+ command: readonly string[],
87
+ env: Record<string, string | undefined>,
88
+ ): Promise<SpawnResult> {
89
+ if (command.length === 0) {
90
+ return { code: 1, stdout: "", stderr: "empty command" };
91
+ }
92
+ return nimbusSpawn(command, env);
93
+ }