@checkstack/backend-api 0.15.3 → 0.17.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/CHANGELOG.md +112 -0
- package/package.json +4 -4
- package/src/base-strategy-config.ts +19 -0
- package/src/correlation-middleware.test.ts +191 -0
- package/src/esm-script-runner.test.ts +169 -0
- package/src/esm-script-runner.ts +467 -0
- package/src/index.ts +2 -0
- package/src/rpc.ts +100 -0
- package/src/shell-script-runner.ts +175 -0
- package/src/test-utils.ts +18 -6
- package/src/types.ts +34 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { spawn, type Subprocess } from "bun";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared sandbox for executing user-authored shell scripts through
|
|
5
|
+
* `sh -c`.
|
|
6
|
+
*
|
|
7
|
+
* Used by both `@checkstack/healthcheck-script-backend` (the shell
|
|
8
|
+
* health-check strategy) and `@checkstack/integration-script-backend`
|
|
9
|
+
* (the shell integration provider). The two had near-identical inline
|
|
10
|
+
* implementations; this module is the canonical version.
|
|
11
|
+
*
|
|
12
|
+
* Why a curated env: a script author already has authority to execute
|
|
13
|
+
* arbitrary shell, but we must not leak the satellite's own secrets to
|
|
14
|
+
* them. The forwarded env is the minimum needed for ordinary commands
|
|
15
|
+
* (`awk`, `curl`, `git`, locale-aware tools) to behave correctly:
|
|
16
|
+
* `PATH` so binaries resolve, `HOME` / `USER` so tools find their
|
|
17
|
+
* config, `LANG` / `LC_*` so output is parseable, `TZ` so timestamps
|
|
18
|
+
* are consistent, `TMPDIR` so `mktemp` works. Everything else
|
|
19
|
+
* (DB URLs, signing keys, queue creds, etc.) is dropped.
|
|
20
|
+
*
|
|
21
|
+
* Cleanup is `finally`-guaranteed: the timeout handle is cleared so a
|
|
22
|
+
* fast script doesn't leak an event-loop timer, and any straggler
|
|
23
|
+
* subprocess is `.kill()`-ed (idempotent on an already-exited
|
|
24
|
+
* process). This matches the pattern in the ESM script runner.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// =============================================================================
|
|
28
|
+
// PUBLIC TYPES
|
|
29
|
+
// =============================================================================
|
|
30
|
+
|
|
31
|
+
export interface ShellScriptRunResult {
|
|
32
|
+
/** Exit code reported by the subprocess. -1 if it never started or timed out. */
|
|
33
|
+
exitCode: number;
|
|
34
|
+
/** Captured stdout, trimmed of trailing newlines. */
|
|
35
|
+
stdout: string;
|
|
36
|
+
/** Captured stderr, trimmed of trailing newlines. */
|
|
37
|
+
stderr: string;
|
|
38
|
+
/** True if the timeout fired before the subprocess exited. */
|
|
39
|
+
timedOut: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ShellScriptRunOptions {
|
|
43
|
+
/** Shell-script source. Fed verbatim to `sh -c`, so pipes, redirects, etc. work. */
|
|
44
|
+
script: string;
|
|
45
|
+
/** Maximum execution time in milliseconds. */
|
|
46
|
+
timeoutMs: number;
|
|
47
|
+
/** Optional working directory for the subprocess. */
|
|
48
|
+
cwd?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Optional extra environment variables. Merged on top of the
|
|
51
|
+
* safe-vars whitelist (`PATH`, `HOME`, ...). User-supplied values
|
|
52
|
+
* win on key collision.
|
|
53
|
+
*
|
|
54
|
+
* Note: callers should validate the keys themselves if they need to
|
|
55
|
+
* forbid `LD_PRELOAD`, `NODE_OPTIONS`, etc. — at the shared-runner
|
|
56
|
+
* layer we accept whatever the caller passes, because the legitimate
|
|
57
|
+
* use cases (e.g. integration shell scripts injecting `PAYLOAD_*`
|
|
58
|
+
* vars) vary too much.
|
|
59
|
+
*/
|
|
60
|
+
env?: Record<string, string>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Injectable interface. Production code calls
|
|
65
|
+
* `defaultShellScriptRunner.run()`; tests can pass a mock to skip the
|
|
66
|
+
* actual subprocess spawn.
|
|
67
|
+
*/
|
|
68
|
+
export interface ShellScriptRunner {
|
|
69
|
+
run(options: ShellScriptRunOptions): Promise<ShellScriptRunResult>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// =============================================================================
|
|
73
|
+
// INTERNALS
|
|
74
|
+
// =============================================================================
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Vars passed through to the subprocess. We intentionally do NOT
|
|
78
|
+
* forward the satellite's full env so backend secrets (DB URLs, API
|
|
79
|
+
* tokens, signing keys) never reach user-authored scripts.
|
|
80
|
+
*/
|
|
81
|
+
const SAFE_ENV_VARS = [
|
|
82
|
+
"PATH",
|
|
83
|
+
"HOME",
|
|
84
|
+
"USER",
|
|
85
|
+
"LANG",
|
|
86
|
+
"LC_ALL",
|
|
87
|
+
"LC_CTYPE",
|
|
88
|
+
"TZ",
|
|
89
|
+
"TMPDIR",
|
|
90
|
+
"HOSTNAME",
|
|
91
|
+
"SHELL",
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
function pickSafeEnv(): Record<string, string> {
|
|
95
|
+
const env: Record<string, string> = {};
|
|
96
|
+
for (const key of SAFE_ENV_VARS) {
|
|
97
|
+
const value = process.env[key];
|
|
98
|
+
if (value !== undefined) {
|
|
99
|
+
env[key] = value;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return env;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// =============================================================================
|
|
106
|
+
// DEFAULT RUNNER
|
|
107
|
+
// =============================================================================
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Default runner implementation. Production code should use this; tests
|
|
111
|
+
* can substitute a mock that conforms to {@link ShellScriptRunner}.
|
|
112
|
+
*/
|
|
113
|
+
export const defaultShellScriptRunner: ShellScriptRunner = {
|
|
114
|
+
async run({ script, timeoutMs, cwd, env }) {
|
|
115
|
+
let proc: Subprocess | undefined;
|
|
116
|
+
let timedOut = false;
|
|
117
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
118
|
+
|
|
119
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
120
|
+
timeoutHandle = setTimeout(() => {
|
|
121
|
+
timedOut = true;
|
|
122
|
+
proc?.kill();
|
|
123
|
+
reject(new Error("Script execution timed out"));
|
|
124
|
+
}, timeoutMs);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
// Execute through `sh -c` so the user's script can use pipes,
|
|
129
|
+
// redirects, variable expansion, conditionals, command
|
|
130
|
+
// substitution, etc. — i.e. behave like a real shell script
|
|
131
|
+
// rather than a single argv vector.
|
|
132
|
+
proc = spawn({
|
|
133
|
+
cmd: ["sh", "-c", script],
|
|
134
|
+
cwd,
|
|
135
|
+
env: { ...pickSafeEnv(), ...env },
|
|
136
|
+
stdout: "pipe",
|
|
137
|
+
stderr: "pipe",
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const [stdout, stderr, exitCode] = await Promise.race([
|
|
141
|
+
Promise.all([
|
|
142
|
+
new Response(proc.stdout as ReadableStream).text(),
|
|
143
|
+
new Response(proc.stderr as ReadableStream).text(),
|
|
144
|
+
proc.exited,
|
|
145
|
+
]),
|
|
146
|
+
timeoutPromise,
|
|
147
|
+
]);
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
exitCode,
|
|
151
|
+
stdout: stdout.trim(),
|
|
152
|
+
stderr: stderr.trim(),
|
|
153
|
+
timedOut: false,
|
|
154
|
+
};
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (timedOut) {
|
|
157
|
+
return {
|
|
158
|
+
exitCode: -1,
|
|
159
|
+
stdout: "",
|
|
160
|
+
stderr: "Script execution timed out",
|
|
161
|
+
timedOut: true,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
throw error;
|
|
165
|
+
} finally {
|
|
166
|
+
if (timeoutHandle !== undefined) {
|
|
167
|
+
clearTimeout(timeoutHandle);
|
|
168
|
+
}
|
|
169
|
+
// Idempotent — no-op when the subprocess has already exited
|
|
170
|
+
// cleanly, but guarantees we never leave a runaway `sh` from
|
|
171
|
+
// an exception path.
|
|
172
|
+
proc?.kill();
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
};
|
package/src/test-utils.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mock } from "bun:test";
|
|
2
2
|
import { RpcContext, EmitHookFn } from "./rpc";
|
|
3
|
+
import { Logger } from "./types";
|
|
3
4
|
import { SafeDatabase } from "./plugin-system";
|
|
4
5
|
import { HealthCheckRegistry } from "./health-check";
|
|
5
6
|
import { CollectorRegistry } from "./collector-registry";
|
|
@@ -9,6 +10,22 @@ import {
|
|
|
9
10
|
CacheManager,
|
|
10
11
|
} from "@checkstack/cache-api";
|
|
11
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Build a mock `Logger` whose `.child(...)` returns another mock logger
|
|
15
|
+
* (recursively). Matches the structural contract that
|
|
16
|
+
* `correlationMiddleware` and other binding sites rely on.
|
|
17
|
+
*/
|
|
18
|
+
function createMockLogger(): Logger {
|
|
19
|
+
const logger: Logger = {
|
|
20
|
+
info: mock(),
|
|
21
|
+
error: mock(),
|
|
22
|
+
warn: mock(),
|
|
23
|
+
debug: mock(),
|
|
24
|
+
child: mock(() => createMockLogger()),
|
|
25
|
+
};
|
|
26
|
+
return logger;
|
|
27
|
+
}
|
|
28
|
+
|
|
12
29
|
/**
|
|
13
30
|
* Creates a mocked oRPC context for testing.
|
|
14
31
|
* By default provides an authenticated user with wildcard access.
|
|
@@ -19,12 +36,7 @@ export function createMockRpcContext(
|
|
|
19
36
|
return {
|
|
20
37
|
pluginMetadata: { pluginId: "test-plugin" },
|
|
21
38
|
db: mock() as unknown as SafeDatabase<Record<string, unknown>>,
|
|
22
|
-
logger:
|
|
23
|
-
info: mock(),
|
|
24
|
-
error: mock(),
|
|
25
|
-
warn: mock(),
|
|
26
|
-
debug: mock(),
|
|
27
|
-
},
|
|
39
|
+
logger: createMockLogger(),
|
|
28
40
|
fetch: {
|
|
29
41
|
fetch: mock(),
|
|
30
42
|
forPlugin: mock().mockReturnValue({
|
package/src/types.ts
CHANGED
|
@@ -1,11 +1,45 @@
|
|
|
1
1
|
import { ZodSchema } from "zod";
|
|
2
2
|
import { ClientDefinition, InferClient } from "@checkstack/common";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Backend logger interface used everywhere in the platform via `RpcContext.logger`
|
|
6
|
+
* and the various `coreServices.logger` accessors.
|
|
7
|
+
*
|
|
8
|
+
* Each method accepts a free-form trailing argument list (`...args: unknown[]`)
|
|
9
|
+
* so the long-standing varargs callsites — `logger.error("…", err)` where `err`
|
|
10
|
+
* is an `Error`, or `logger.info("…", value1, value2)` — keep working unchanged.
|
|
11
|
+
*
|
|
12
|
+
* For NEW code, prefer the structured-metadata shape:
|
|
13
|
+
*
|
|
14
|
+
* logger.info("did something", { userId, durationMs });
|
|
15
|
+
*
|
|
16
|
+
* Winston's `splat` handling treats a single trailing plain object as
|
|
17
|
+
* structured metadata (merged into the log entry), and an `Error` instance as
|
|
18
|
+
* a special-cased error (with stack). Either shape lands in the same vararg
|
|
19
|
+
* slot here, so this signature covers both without overload churn.
|
|
20
|
+
*
|
|
21
|
+
* Auto-injected metadata (when the request flows through
|
|
22
|
+
* `correlationMiddleware`): `{ correlationId, pluginId, userId? }`. Do NOT
|
|
23
|
+
* include secrets in the structured-metadata object — it is forwarded
|
|
24
|
+
* verbatim to the log destination.
|
|
25
|
+
*/
|
|
4
26
|
export interface Logger {
|
|
5
27
|
info(message: string, ...args: unknown[]): void;
|
|
6
28
|
error(message: string, ...args: unknown[]): void;
|
|
7
29
|
warn(message: string, ...args: unknown[]): void;
|
|
8
30
|
debug(message: string, ...args: unknown[]): void;
|
|
31
|
+
/**
|
|
32
|
+
* Returns a derived logger with the supplied metadata bound to every
|
|
33
|
+
* subsequent log entry. Used by `correlationMiddleware` to attach
|
|
34
|
+
* `{ correlationId, pluginId, userId? }`, and available to handlers that
|
|
35
|
+
* want a tighter scope (e.g. `ctx.logger.child({ jobId })`).
|
|
36
|
+
*
|
|
37
|
+
* Optional only to keep minimal test-mock logger objects compatible with
|
|
38
|
+
* this interface — production loggers (Winston via `core/backend`) always
|
|
39
|
+
* implement it. Call sites that rely on metadata binding should branch
|
|
40
|
+
* on presence and fall back to the base logger when it is not available.
|
|
41
|
+
*/
|
|
42
|
+
child?(meta: Record<string, unknown>): Logger;
|
|
9
43
|
}
|
|
10
44
|
|
|
11
45
|
export interface Fetch {
|