@checkstack/healthcheck-script-backend 0.6.1 → 0.7.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 +97 -0
- package/package.json +5 -5
- package/src/execute-collector.test.ts +178 -0
- package/src/execute-collector.ts +77 -18
- package/src/index.ts +18 -2
- package/src/inline-script-collector.test.ts +107 -5
- package/src/inline-script-collector.ts +19 -7
- package/src/migration-chain-contract.test.ts +50 -0
- package/src/shell-env.test.ts +86 -0
- package/src/shell-env.ts +89 -0
- package/src/strategy.test.ts +24 -0
- package/src/strategy.ts +21 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,102 @@
|
|
|
1
1
|
# @checkstack/healthcheck-script-backend
|
|
2
2
|
|
|
3
|
+
## 0.7.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 9dcc848: Add environments as a first-class catalog primitive, with per-environment health-check fan-out, config templating, per-environment reactive health, and script run-context exposure.
|
|
8
|
+
|
|
9
|
+
- Catalog primitive: an environment is a sibling of groups - a named, instance-global record carrying free-form custom fields (baseUrl, region, tier, ...) that any system can belong to many-to-many. New `environments` + `systems_environments` tables, `EnvironmentSchema` + create/update schemas, `EntityService` environment CRUD and membership joins, RPC endpoints gated by a new `catalogAccess.environment` access rule, a GitOps `Environment` kind + `System.environments` extension, and frontend management (an `EnvironmentEditor`, an Environments management panel, and a per-system environment picker). The Environments card's Add/Edit/Delete affordances are gated on `catalogAccess.environment.manage`.
|
|
10
|
+
- Per-environment fan-out: run identity becomes `(systemId, configurationId, environmentId)`. Runs, aggregates, and state transitions gain a nullable `environmentId`. The health-check assignment gains an `environmentIds` selector with three modes (All / Specific / None; `null` and `[]` are distinct). The queue executor resolves the effective environment set via the catalog `resolveSystemEnvironments` read and executes one isolated run per environment.
|
|
11
|
+
- Config templating: a new `x-templatable` config-field marker renders a string field through the template engine at execute time, against `{ environment, check, system }`. A shared `renderTemplatableConfig` and a `renderTemplatePreview` helper (re-exported from `@checkstack/template-engine`) keep editor previews identical to the run-time render. The HTTP collector's `url`, `headers[].value`, and `body` are templatable, rendered per environment (the strategy client build moves inside the per-env loop); the `url`'s `.url()` validation moves post-render. Secrets resolve before templating; a field marked both secret and `x-templatable` is rejected at plugin load. `DynamicForm` shows a live "Preview" line, and the catalog `EnvironmentPreviewPicker` ("Preview as: <environment>") drives it in the collector editor (only when the schema has a templatable field).
|
|
12
|
+
- Script run-context: `CollectorRunContext` gains an optional `environment` field (`{ id, name, fields }`, metadata only). Shell collectors receive `CHECKSTACK_ENV_ID` / `_NAME` / `CHECKSTACK_ENV_<FIELD>` vars; inline TS collectors read `globalThis.context.environment`; the editor test panel mirrors both. The env-less path is unchanged.
|
|
13
|
+
- Per-environment reactive health (see BREAKING below), env-keyed read/write paths, env-qualified serialization locks, an optional `trigger.payload.environmentId`, per-environment isolation, and an `ENVIRONMENT_RESOLUTION_FAILED` signal when catalog resolution degrades to a single env-less run.
|
|
14
|
+
|
|
15
|
+
BREAKING CHANGES: the reactive `health` entity's id-shape and cardinality change. It now encodes two views: per-environment (id `"<systemId>::<environmentId>"`) and a system rollup (id `"<systemId>"`, the worst status across environments + env-less runs). The rollup PRESERVES the pre-existing system-level contract - dashboards, status badges, and automations referencing health by `systemId` keep working without re-authoring - but the entity's contract surface changed (new id-shape, higher cardinality, new payload field), so it is flagged breaking. `getBulkHealthState` parses env-qualified ids and keys results by the original id.
|
|
16
|
+
|
|
17
|
+
State and scale: membership and custom fields live only in catalog Postgres and are re-read every tick via the cross-plugin RPC; env-keyed health reads from shared `health_check_runs` / aggregates / transitions (compute-on-read). Every pod resolves the same effective set and the same per-environment health. No pod-local environment state.
|
|
18
|
+
|
|
19
|
+
Also: `unwrapSchema` in `zod-config.ts` loops instead of single-pass-stripping so multi-layer wrappers (`.optional().default()`) still resolve `x-templatable` meta. The env-less `{{ environment.* }}` run notice logs at `debug` (a legitimate recurring configuration), while the post-render HTTP `.url()` check still fails a genuinely-broken empty render with a clear "Rendered URL is invalid" error.
|
|
20
|
+
|
|
21
|
+
This is a beta minor.
|
|
22
|
+
|
|
23
|
+
- 9dcc848: Health-check strategy and collector configs now migrate-then-validate when loaded, instead of being cast/rendered raw.
|
|
24
|
+
|
|
25
|
+
These configs declared `version: 2`/`3` migrations but the load path never ran them: stored values are persisted UNVERSIONED, and the executor cast them straight to the strategy/collector type. Both the execution path (`queue-executor`) and the read API (`mapConfig`, feeding router / frontend / gitops `getConfiguration`) now use assume-v1-on-read (`Versioned.parseAssumingV1`): wrap as version 1, run the declared chain, then validate. Order is preserved: migrate -> secret resolve -> template render -> execute. An unregistered strategy/collector or a failed migrate falls back to the raw stored blob rather than dropping the configuration. Every reshaper migration is now IDEMPOTENT, guarding on its legacy discriminator so already-current data passes through untouched.
|
|
26
|
+
|
|
27
|
+
BREAKING CHANGE: for any config GENUINELY at version 1 in the database (e.g. an HTTP strategy still carrying `url`/`method`, or an execute collector still carrying `command`/`args`), the declared migrations now actually RUN on load, so the loaded/returned shape changes for such rows. This is the intended fix - those fields were already supposed to have been migrated away. Configs already at the current shape are unaffected. No data backfill is performed; migration is applied on every read.
|
|
28
|
+
|
|
29
|
+
This is a beta minor.
|
|
30
|
+
|
|
31
|
+
- 9dcc848: Layered OS-level script sandbox, secure and fail-closed by default (epic #247).
|
|
32
|
+
|
|
33
|
+
Script and shell health checks and the `run_shell` / `run_script` automation actions now run inside a layered OS-level sandbox by default. The sandbox lives in `core/backend-api/src/script-sandbox/` (the single source of truth) and is enforced inside the shared runners, so it applies wherever a job runs.
|
|
34
|
+
|
|
35
|
+
Layers:
|
|
36
|
+
|
|
37
|
+
- Resource caps (CPU / memory / PID / FD / file-size, via `prlimit` on capable Linux; ESM JS-heap cap via `--max-old-space-size`; portable wall-clock timeout) and an OOM-safe streaming output cap.
|
|
38
|
+
- Privilege drop via a NON-ROOT supervisor model: the shipped images run the supervisor as non-root uid `65532`, so every sandboxed script inherits non-root and can never be host-root; filesystem + network confinement is delivered by ROOTLESS `bwrap`/`nsjail` via unprivileged user namespaces. `enforced.privilege` is truthful (true only when the child cannot run as host-root). Runners no longer pass `uid`/`gid` to `Bun.spawn` (a silent no-op and a forward-compat hazard).
|
|
39
|
+
- Filesystem isolation (`scratch-only` / `scratch-plus-ro`) confining the child to its per-run scratch dir over a read-only base; the interpreter path is RO-bound so the runtime execs, and `TMPDIR` is pinned to the in-namespace tmpfs.
|
|
40
|
+
- Network egress control: `deny` (routeless loopback-only netns), `allowlist` (real plumbed egress via macvlan OR rootless slirp4netns + an in-kernel nftables filter), and an always-on metadata / link-local block (`169.254.0.0/16`, `fe80::/10`, `fc00::/7`). No-blackhole invariant: `enforced.network` is never true when egress is actually severed or unfiltered; unpluggable egress degrades to surfaced host net.
|
|
41
|
+
- Per-run fork-bomb containment via RLIMIT*NPROC inside the fresh per-run user+PID namespace; a centralized forbidden-env denylist (`LD_PRELOAD`, `LD_LIBRARY_PATH`, `DYLD*_`, `NODE*OPTIONS`, `BUN*_`, caller `PATH` overrides).
|
|
42
|
+
- A validated tuned seccomp profile (`deploy/seccomp/checkstack-userns.json`) and a live `clone(CLONE_NEWUSER|CLONE_NEWNET)` capability probe (not the static sysctl), shipped by default in both Dockerfiles, `docker-compose.yml`, and `deploy/k8s/checkstack-sandbox.yaml`.
|
|
43
|
+
|
|
44
|
+
Global policy and operator surface:
|
|
45
|
+
|
|
46
|
+
- The global sandbox policy lives in ONE durable row owned by `script-packages` (its `ConfigService` row in shared `plugin_configs`). A single process-wide provider serves every runner; the two script plugins no longer register competing providers. A dedicated admin-only `script-sandbox.manage` permission gates both reading and writing the policy. New `getSandboxPolicy` / `setSandboxPolicy` endpoints and a Settings -> Script Sandbox admin UI (`enabled`, `onUnavailable`, network/filesystem/privilege modes, allow list, metadata block, resource caps). The startup capability/readiness log is emitted in-process by `script-packages-backend` (no fragile init-order RPC self-loop), and on a host that cannot enforce a layer a one-time startup warning explains the two local-dev paths (Docker, or set the global policy to `degrade`).
|
|
47
|
+
- Satellite relay: the WS protocol carries the resolved policy in the `authenticated` message and a `sandbox_policy` push-on-change; a satellite caches the last relayed policy and resolves every run through it.
|
|
48
|
+
|
|
49
|
+
BREAKING CHANGES (platform in BETA, shipped as minor):
|
|
50
|
+
|
|
51
|
+
- Scripts run sandboxed by default. The shipped global default is FAIL-CLOSED (`onUnavailable: "fail"`): when a requested layer cannot be enforced the run is REFUSED (clean `exitCode: -1`, never an unsandboxed spawn) rather than silently degrading. Deployments on hosts that cannot enforce a layer (no bubblewrap, user namespaces blocked, no `/proc` unmask) must run the official images with the documented runtime flags (the bundled seccomp profile + `systempaths=unconfined`, or k8s `procMount: Unmasked`), or set the global policy to `degrade`. On macOS / restricted containers the strong layers degrade to the portable subset and are surfaced per run.
|
|
52
|
+
- Default network posture is deny-egress (`allowlist` with an empty allow list, which resolves to the routeless `deny` path). Scripts calling external endpoints fail until those destinations are allowlisted in the global default. The always-on metadata / link-local block applies even under looser modes.
|
|
53
|
+
- The per-action / per-check `sandbox` config override and the transport `ScriptRequest.sandbox` field are removed; policy is global-only, so an automation/check author can no longer weaken the sandbox on their own item. Stored configs carrying a stray `sandbox` key are tolerated (stripped on parse).
|
|
54
|
+
- The shared runners' `run()` no longer accepts a `sandbox` option; callers rely on the global policy provider.
|
|
55
|
+
- A satellite fails closed (most restrictive profile) until it receives the first relayed policy; a relay-read failure or an older core keeps it fail-closed. A relay failure can never loosen a satellite's sandbox.
|
|
56
|
+
|
|
57
|
+
State and scale: the global policy is a single durable Postgres row read identically on every pod. Capability detection is per-process, deterministic from the host kernel, and surfaced per run via the `EffectiveSandbox` report (a Linux pod and a macOS satellite may legitimately differ). `CHECKSTACK_SANDBOX_UID/GID` and macvlan addressing are genuinely per-host infrastructure, surfaced per run, not the queryable policy. The satellite's policy cache is satellite-local transport state. No new pod-local current-state.
|
|
58
|
+
|
|
59
|
+
This is a beta minor.
|
|
60
|
+
|
|
61
|
+
- 9dcc848: Add the auto-generated, version-pinned `@checkstack/sdk` package + codegen, and serve its types live to the in-app editor.
|
|
62
|
+
|
|
63
|
+
- A new committed workspace package `@checkstack/sdk`, generated from the platform's source of truth by `scripts/generate-sdk.ts` (`generate:sdk` / `generate:sdk:check`): a fully-typed oRPC client (`createCheckstackClient`) over the REST surface with one `InferClient` per plugin contract, real script-authoring helpers (`@checkstack/sdk/healthcheck`, `@checkstack/sdk/integration`) whose runtime body is the same identity function the in-app runner injects, per-subpath `.d.ts` under the package `exports` map, and an editor-only ambient bundle. A `generate:sdk:check` CI guard fails when the committed SDK files drift from a fresh generation. The `@checkstack/sdk` version is stamped from `@checkstack/release` and MUST NOT appear in a changeset (a guard enforces this); the `@checkstack/release` bump here advances the release version so the generated SDK can be published later. The generated client also normalizes its base URL without a backtracking-prone regex, closing a CodeQL `js/polynomial-redos` finding.
|
|
64
|
+
- Live editor type injection: a new version-keyed route `GET /api/script-packages/sdk-types/:releaseVersion` (raw handler in `@checkstack/script-packages-backend`) serves the generated SDK editor bundle with `Cache-Control: private, max-age=1y, immutable`; the pure path-build/parse module lives in `@checkstack/script-packages-common`, shared by backend and frontend. A mismatched version returns `409` so the editor refetches and never serves stale types after an upgrade. The frontend `useSdkTypeInjection` hook fetches the bundle once per session and mounts it into Monaco via `addExtraLib`. Schema-narrowed `context.config` / `context.event.payload` editor types stay local; the package-resolving module declarations come from the one published `@checkstack/sdk` source.
|
|
65
|
+
|
|
66
|
+
BREAKING CHANGES: the script-authoring import surface moves from the bare `@checkstack/healthcheck` / `@checkstack/integration` virtual modules to the `@checkstack/sdk/healthcheck` / `@checkstack/sdk/integration` subpaths of the published `@checkstack/sdk` package. The old bare-name imports no longer resolve (an old import now errors in the editor, surfacing the migration). Existing scripts must update the module specifier:
|
|
67
|
+
|
|
68
|
+
- import { defineHealthCheck } from "@checkstack/healthcheck";
|
|
69
|
+
+ import { defineHealthCheck } from "@checkstack/sdk/healthcheck";
|
|
70
|
+
|
|
71
|
+
- import { defineIntegration } from "@checkstack/integration";
|
|
72
|
+
+ import { defineIntegration } from "@checkstack/sdk/integration";
|
|
73
|
+
|
|
74
|
+
The helper names and their runtime behaviour are unchanged - only the module specifier moves. The global (no-import) helper form continues to work unchanged.
|
|
75
|
+
|
|
76
|
+
This is a beta minor.
|
|
77
|
+
|
|
78
|
+
### Patch Changes
|
|
79
|
+
|
|
80
|
+
- Updated dependencies [9dcc848]
|
|
81
|
+
- Updated dependencies [9dcc848]
|
|
82
|
+
- Updated dependencies [9dcc848]
|
|
83
|
+
- Updated dependencies [9dcc848]
|
|
84
|
+
- Updated dependencies [9dcc848]
|
|
85
|
+
- Updated dependencies [9dcc848]
|
|
86
|
+
- Updated dependencies [9dcc848]
|
|
87
|
+
- Updated dependencies [9dcc848]
|
|
88
|
+
- Updated dependencies [9dcc848]
|
|
89
|
+
- Updated dependencies [9dcc848]
|
|
90
|
+
- Updated dependencies [9dcc848]
|
|
91
|
+
- Updated dependencies [9dcc848]
|
|
92
|
+
- Updated dependencies [9dcc848]
|
|
93
|
+
- Updated dependencies [9dcc848]
|
|
94
|
+
- @checkstack/backend-api@0.21.0
|
|
95
|
+
- @checkstack/healthcheck-common@1.5.0
|
|
96
|
+
- @checkstack/common@0.13.0
|
|
97
|
+
- @checkstack/script-packages-backend@0.3.0
|
|
98
|
+
- @checkstack/secrets-common@0.2.0
|
|
99
|
+
|
|
3
100
|
## 0.6.1
|
|
4
101
|
|
|
5
102
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/healthcheck-script-backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"checkstack": {
|
|
@@ -14,11 +14,11 @@
|
|
|
14
14
|
"pack": "bunx @checkstack/scripts plugin-pack"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@checkstack/backend-api": "0.
|
|
17
|
+
"@checkstack/backend-api": "0.20.0",
|
|
18
18
|
"@checkstack/common": "0.12.0",
|
|
19
|
-
"@checkstack/healthcheck-common": "1.
|
|
20
|
-
"@checkstack/script-packages-backend": "0.1
|
|
21
|
-
"@checkstack/secrets-common": "0.0
|
|
19
|
+
"@checkstack/healthcheck-common": "1.4.0",
|
|
20
|
+
"@checkstack/script-packages-backend": "0.2.1",
|
|
21
|
+
"@checkstack/secrets-common": "0.1.0"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"@types/bun": "^1.0.0",
|
|
@@ -148,6 +148,81 @@ describe("ExecuteCollector", () => {
|
|
|
148
148
|
expect(call.env.CHECKSTACK_SYSTEM_ID).toBe("system-9");
|
|
149
149
|
});
|
|
150
150
|
|
|
151
|
+
it("injects CHECKSTACK_ENV_* vars when the run carries an environment", async () => {
|
|
152
|
+
const collector = new ExecuteCollector();
|
|
153
|
+
const client = createMockClient();
|
|
154
|
+
|
|
155
|
+
await collector.execute({
|
|
156
|
+
config: { script: "echo hi", timeout: 3000 },
|
|
157
|
+
client,
|
|
158
|
+
pluginId: "test",
|
|
159
|
+
runContext: {
|
|
160
|
+
check: { id: "check-1", name: "CPU load", intervalSeconds: 60 },
|
|
161
|
+
system: { id: "system-9", name: "web-01" },
|
|
162
|
+
environment: {
|
|
163
|
+
id: "env-prod",
|
|
164
|
+
name: "production",
|
|
165
|
+
fields: { baseUrl: "https://prod.example.com", region: "eu-west-1" },
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const call = (client.exec as ReturnType<typeof mock>).mock.calls[0][0];
|
|
171
|
+
expect(call.env.CHECKSTACK_ENV_ID).toBe("env-prod");
|
|
172
|
+
expect(call.env.CHECKSTACK_ENV_NAME).toBe("production");
|
|
173
|
+
expect(call.env.CHECKSTACK_ENV_BASE_URL).toBe("https://prod.example.com");
|
|
174
|
+
expect(call.env.CHECKSTACK_ENV_REGION).toBe("eu-west-1");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("omits CHECKSTACK_ENV_* vars when the run has no environment", async () => {
|
|
178
|
+
const collector = new ExecuteCollector();
|
|
179
|
+
const client = createMockClient();
|
|
180
|
+
|
|
181
|
+
await collector.execute({
|
|
182
|
+
config: { script: "echo hi", timeout: 3000 },
|
|
183
|
+
client,
|
|
184
|
+
pluginId: "test",
|
|
185
|
+
runContext: {
|
|
186
|
+
check: { id: "check-1", name: "CPU load", intervalSeconds: 60 },
|
|
187
|
+
system: { id: "system-9", name: "web-01" },
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const call = (client.exec as ReturnType<typeof mock>).mock.calls[0][0];
|
|
192
|
+
expect(call.env.CHECKSTACK_ENV_ID).toBeUndefined();
|
|
193
|
+
expect(call.env.CHECKSTACK_ENV_NAME).toBeUndefined();
|
|
194
|
+
expect(
|
|
195
|
+
Object.keys(call.env).some((k) => k.startsWith("CHECKSTACK_ENV_")),
|
|
196
|
+
).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("lets a user config.env key win over a CHECKSTACK_ENV_* metadata key", async () => {
|
|
200
|
+
const collector = new ExecuteCollector();
|
|
201
|
+
const client = createMockClient();
|
|
202
|
+
|
|
203
|
+
await collector.execute({
|
|
204
|
+
config: {
|
|
205
|
+
script: "echo hi",
|
|
206
|
+
env: { CHECKSTACK_ENV_BASE_URL: "user override" },
|
|
207
|
+
timeout: 3000,
|
|
208
|
+
},
|
|
209
|
+
client,
|
|
210
|
+
pluginId: "test",
|
|
211
|
+
runContext: {
|
|
212
|
+
check: { id: "check-1", name: "CPU load", intervalSeconds: 60 },
|
|
213
|
+
system: { id: "system-9", name: "web-01" },
|
|
214
|
+
environment: {
|
|
215
|
+
id: "env-prod",
|
|
216
|
+
name: "production",
|
|
217
|
+
fields: { baseUrl: "https://prod.example.com" },
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const call = (client.exec as ReturnType<typeof mock>).mock.calls[0][0];
|
|
223
|
+
expect(call.env.CHECKSTACK_ENV_BASE_URL).toBe("user override");
|
|
224
|
+
});
|
|
225
|
+
|
|
151
226
|
it("leaves env unchanged when no runContext is supplied (back-compat)", async () => {
|
|
152
227
|
const collector = new ExecuteCollector();
|
|
153
228
|
const client = createMockClient();
|
|
@@ -196,6 +271,59 @@ describe("ExecuteCollector", () => {
|
|
|
196
271
|
// The embedded quotes must be preserved (single-quoted form).
|
|
197
272
|
expect(migrated.script).toMatch(/echo/);
|
|
198
273
|
});
|
|
274
|
+
|
|
275
|
+
it("is IDEMPOTENT: an already-{script} blob is returned unchanged (no fabricated script)", async () => {
|
|
276
|
+
const collector = new ExecuteCollector();
|
|
277
|
+
// CRITICAL safety property: the highest-risk reshape must NOT run on a
|
|
278
|
+
// blob that already has `script`. Without the guard it would shell-quote
|
|
279
|
+
// a missing `command` and fabricate `script: "undefined"`.
|
|
280
|
+
const alreadyV2 = {
|
|
281
|
+
script: "echo already-migrated",
|
|
282
|
+
cwd: "/srv",
|
|
283
|
+
env: { FOO: "bar" },
|
|
284
|
+
timeout: 4000,
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const migrated = await collector.config.parseAssumingV1(alreadyV2);
|
|
288
|
+
|
|
289
|
+
expect(migrated.script).toBe("echo already-migrated");
|
|
290
|
+
expect(migrated.script).not.toContain("undefined");
|
|
291
|
+
expect(migrated.cwd).toBe("/srv");
|
|
292
|
+
expect(migrated.env).toEqual({ FOO: "bar" });
|
|
293
|
+
expect(migrated.timeout).toBe(4000);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("does NOT fabricate a script from a blob carrying both command and script", async () => {
|
|
297
|
+
const collector = new ExecuteCollector();
|
|
298
|
+
// Defensive: presence of `script` wins even if a stray `command` lingers.
|
|
299
|
+
const mixed = {
|
|
300
|
+
command: "rm",
|
|
301
|
+
args: ["-rf", "/"],
|
|
302
|
+
script: "echo safe",
|
|
303
|
+
timeout: 3000,
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const migrated = await collector.config.parseAssumingV1(mixed);
|
|
307
|
+
|
|
308
|
+
expect(migrated.script).toBe("echo safe");
|
|
309
|
+
expect(migrated.script).not.toContain("rm");
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("reshapes a genuine v1 {command} blob via assume-v1-on-read", async () => {
|
|
313
|
+
const collector = new ExecuteCollector();
|
|
314
|
+
const migrated = await collector.config.parseAssumingV1({
|
|
315
|
+
command: "echo",
|
|
316
|
+
args: ["Hello", "World"],
|
|
317
|
+
timeout: 5000,
|
|
318
|
+
});
|
|
319
|
+
expect(migrated.script).toBe("echo Hello World");
|
|
320
|
+
expect(migrated.timeout).toBe(5000);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("has a complete v1->version migration chain", () => {
|
|
324
|
+
const collector = new ExecuteCollector();
|
|
325
|
+
expect(collector.config.validateMigrationChainFromV1()).toBeUndefined();
|
|
326
|
+
});
|
|
199
327
|
});
|
|
200
328
|
|
|
201
329
|
describe("mergeResult", () => {
|
|
@@ -362,3 +490,53 @@ describe("ExecuteCollector secret env injection + source-side masking", () => {
|
|
|
362
490
|
expect(result.result.stdout).toBe("no secrets");
|
|
363
491
|
});
|
|
364
492
|
});
|
|
493
|
+
|
|
494
|
+
describe("ExecuteCollector — global-only sandbox (no per-item override)", () => {
|
|
495
|
+
// Capture what the collector passes to the transport client.
|
|
496
|
+
const makeRecordingClient = (): {
|
|
497
|
+
client: ScriptTransportClient;
|
|
498
|
+
getInput: () => Record<string, unknown> | undefined;
|
|
499
|
+
} => {
|
|
500
|
+
let captured: Record<string, unknown> | undefined;
|
|
501
|
+
return {
|
|
502
|
+
getInput: () => captured,
|
|
503
|
+
client: {
|
|
504
|
+
exec: async (input) => {
|
|
505
|
+
captured = input as unknown as Record<string, unknown>;
|
|
506
|
+
return { exitCode: 0, stdout: "", stderr: "", timedOut: false };
|
|
507
|
+
},
|
|
508
|
+
},
|
|
509
|
+
};
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
it("never sends a `sandbox` field over the transport (policy is global-only)", async () => {
|
|
513
|
+
const { client, getInput } = makeRecordingClient();
|
|
514
|
+
const collector = new ExecuteCollector();
|
|
515
|
+
await collector.execute({
|
|
516
|
+
config: { script: "echo hi", timeout: 5000 },
|
|
517
|
+
client,
|
|
518
|
+
pluginId: "test",
|
|
519
|
+
});
|
|
520
|
+
expect(getInput()?.sandbox).toBeUndefined();
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it("tolerates a stored stray `config.sandbox` (migration: stripped, no crash)", async () => {
|
|
524
|
+
const { client, getInput } = makeRecordingClient();
|
|
525
|
+
const collector = new ExecuteCollector();
|
|
526
|
+
// An old stored config may still carry a `sandbox` key. The schema strips
|
|
527
|
+
// unknown keys on parse; even if it reaches `execute`, the collector must
|
|
528
|
+
// ignore it and never forward it.
|
|
529
|
+
const parsed = collector.config.schema.parse({
|
|
530
|
+
script: "echo hi",
|
|
531
|
+
timeout: 5000,
|
|
532
|
+
sandbox: { network: { mode: "unrestricted" } },
|
|
533
|
+
});
|
|
534
|
+
expect((parsed as Record<string, unknown>).sandbox).toBeUndefined();
|
|
535
|
+
await collector.execute({
|
|
536
|
+
config: parsed,
|
|
537
|
+
client,
|
|
538
|
+
pluginId: "test",
|
|
539
|
+
});
|
|
540
|
+
expect(getInput()?.sandbox).toBeUndefined();
|
|
541
|
+
});
|
|
542
|
+
});
|
package/src/execute-collector.ts
CHANGED
|
@@ -26,6 +26,11 @@ import {
|
|
|
26
26
|
secretEnvMappingSchema,
|
|
27
27
|
maskScriptRunOutput,
|
|
28
28
|
} from "@checkstack/secrets-common";
|
|
29
|
+
import {
|
|
30
|
+
CHECKSTACK_ENV_ID,
|
|
31
|
+
CHECKSTACK_ENV_NAME,
|
|
32
|
+
buildEnvironmentShellEnv,
|
|
33
|
+
} from "./shell-env";
|
|
29
34
|
|
|
30
35
|
// ============================================================================
|
|
31
36
|
// RUN-CONTEXT ENV INJECTION
|
|
@@ -44,7 +49,9 @@ const CHECKSTACK_SYSTEM_NAME = "CHECKSTACK_SYSTEM_NAME";
|
|
|
44
49
|
|
|
45
50
|
/**
|
|
46
51
|
* Map curated run-context metadata to the reserved `CHECKSTACK_*` env
|
|
47
|
-
* vars exposed to the shell script.
|
|
52
|
+
* vars exposed to the shell script. When the run carries a resolved
|
|
53
|
+
* environment (post-fan-out), its id, name, and each custom field are
|
|
54
|
+
* additionally exposed as `CHECKSTACK_ENV_*` vars.
|
|
48
55
|
*/
|
|
49
56
|
function runContextEnv(ctx: CollectorRunContext): Record<string, string> {
|
|
50
57
|
return {
|
|
@@ -53,19 +60,61 @@ function runContextEnv(ctx: CollectorRunContext): Record<string, string> {
|
|
|
53
60
|
[CHECKSTACK_CHECK_INTERVAL_SECONDS]: String(ctx.check.intervalSeconds),
|
|
54
61
|
[CHECKSTACK_SYSTEM_ID]: ctx.system.id,
|
|
55
62
|
[CHECKSTACK_SYSTEM_NAME]: ctx.system.name,
|
|
63
|
+
...(ctx.environment
|
|
64
|
+
? {
|
|
65
|
+
[CHECKSTACK_ENV_ID]: ctx.environment.id,
|
|
66
|
+
[CHECKSTACK_ENV_NAME]: ctx.environment.name,
|
|
67
|
+
...buildEnvironmentShellEnv(ctx.environment.fields),
|
|
68
|
+
}
|
|
69
|
+
: {}),
|
|
56
70
|
};
|
|
57
71
|
}
|
|
58
72
|
|
|
59
73
|
// ============================================================================
|
|
60
|
-
// LEGACY CONFIG (v1) —
|
|
74
|
+
// LEGACY CONFIG (v1) — migration narrowing helpers
|
|
61
75
|
// ============================================================================
|
|
76
|
+
//
|
|
77
|
+
// The migrate input is `unknown` per the versioning chain, so narrowing is
|
|
78
|
+
// done with `typeof`/`in` guards (no casts).
|
|
79
|
+
|
|
80
|
+
/** Type guard: the migrate input is a plain object whose keys can be probed. */
|
|
81
|
+
function isRecord(data: unknown): data is Record<string, unknown> {
|
|
82
|
+
return typeof data === "object" && data !== null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Read the v1 `command` string from a config blob. */
|
|
86
|
+
function readCommand(data: Record<string, unknown>): string {
|
|
87
|
+
return typeof data.command === "string" ? data.command : "";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Read the v1 `args` string[] from a config blob (missing => empty). */
|
|
91
|
+
function readArgs(data: Record<string, unknown>): string[] {
|
|
92
|
+
const value = data.args;
|
|
93
|
+
return Array.isArray(value)
|
|
94
|
+
? value.filter((arg): arg is string => typeof arg === "string")
|
|
95
|
+
: [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Read an optional `cwd` string from a config blob. */
|
|
99
|
+
function readCwd(data: Record<string, unknown>): string | undefined {
|
|
100
|
+
return typeof data.cwd === "string" ? data.cwd : undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Read an optional `env` string->string record from a config blob. */
|
|
104
|
+
function readEnv(
|
|
105
|
+
data: Record<string, unknown>,
|
|
106
|
+
): Record<string, string> | undefined {
|
|
107
|
+
const value = data.env;
|
|
108
|
+
if (!isRecord(value)) return undefined;
|
|
109
|
+
const entries = Object.entries(value).filter(
|
|
110
|
+
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
111
|
+
);
|
|
112
|
+
return Object.fromEntries(entries);
|
|
113
|
+
}
|
|
62
114
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
cwd?: string;
|
|
67
|
-
env?: Record<string, string>;
|
|
68
|
-
timeout: number;
|
|
115
|
+
/** Read a numeric `timeout` field from a config blob (default 30s). */
|
|
116
|
+
function readTimeout(data: Record<string, unknown>): number {
|
|
117
|
+
return typeof data.timeout === "number" ? data.timeout : 30_000;
|
|
69
118
|
}
|
|
70
119
|
|
|
71
120
|
/**
|
|
@@ -230,16 +279,23 @@ export class ExecuteCollector implements CollectorStrategy<
|
|
|
230
279
|
toVersion: 2,
|
|
231
280
|
description:
|
|
232
281
|
"Collapse {command, args} into a single shell script executed via `sh -c`",
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
282
|
+
// IDEMPOTENT + HIGHEST RISK: only reshape a genuine v1 blob — one that
|
|
283
|
+
// has `command` but NO `script`. An already-v2 blob carries `script`,
|
|
284
|
+
// so this guard prevents fabricating `script: "undefined"` from it by
|
|
285
|
+
// shell-quoting a missing `command`. Already-v2 data passes through.
|
|
286
|
+
migrate: (data: unknown): unknown => {
|
|
287
|
+
if (isRecord(data) && "command" in data && !("script" in data)) {
|
|
288
|
+
const parts = [readCommand(data), ...readArgs(data)].map((arg) =>
|
|
289
|
+
shellQuote(arg),
|
|
290
|
+
);
|
|
291
|
+
return {
|
|
292
|
+
script: parts.join(" "),
|
|
293
|
+
cwd: readCwd(data),
|
|
294
|
+
env: readEnv(data),
|
|
295
|
+
timeout: readTimeout(data),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
return data;
|
|
243
299
|
},
|
|
244
300
|
},
|
|
245
301
|
],
|
|
@@ -274,6 +330,9 @@ export class ExecuteCollector implements CollectorStrategy<
|
|
|
274
330
|
...secretEnv,
|
|
275
331
|
};
|
|
276
332
|
|
|
333
|
+
// The OS-level sandbox is GLOBAL-only and resolved by the runner itself
|
|
334
|
+
// (durable cluster default on the core pod, or fail-closed). No per-item
|
|
335
|
+
// override is sent through the transport.
|
|
277
336
|
const response = await client.exec({
|
|
278
337
|
script: config.script,
|
|
279
338
|
cwd: config.cwd,
|
package/src/index.ts
CHANGED
|
@@ -22,6 +22,21 @@ export default createBackendPlugin({
|
|
|
22
22
|
},
|
|
23
23
|
init: async ({ healthCheckRegistry, collectorRegistry, logger }) => {
|
|
24
24
|
logger.debug("🔌 Registering Script Health Check Strategy...");
|
|
25
|
+
// The GLOBAL sandbox policy is owned by `script-packages` (the single
|
|
26
|
+
// source of truth). On the CORE pod it registers the one process-wide
|
|
27
|
+
// policy provider every runner resolves through, so this plugin does
|
|
28
|
+
// NOT register a competing provider (the old per-plugin registration
|
|
29
|
+
// read a DIFFERENT plugin-scoped row → last-writer-wins). On the
|
|
30
|
+
// SATELLITE runtime the satellite registers its own RELAY-backed
|
|
31
|
+
// provider at startup (see `core/satellite/src/index.ts`), which fails
|
|
32
|
+
// closed until the first policy relay arrives.
|
|
33
|
+
//
|
|
34
|
+
// The one-time startup capability/readiness log is emitted IN PROCESS
|
|
35
|
+
// by `script-packages` itself (the single policy owner), so this plugin
|
|
36
|
+
// no longer makes a `getSandboxPolicy` RPC at init — that self-loop POST
|
|
37
|
+
// 404'd whenever this plugin initialised before `script-packages`
|
|
38
|
+
// mounted its router. The runner's enforcement path is unchanged: it
|
|
39
|
+
// resolves the active policy through `script-packages`' provider.
|
|
25
40
|
const strategy = new ScriptHealthCheckStrategy();
|
|
26
41
|
healthCheckRegistry.register(strategy);
|
|
27
42
|
collectorRegistry.register(new ExecuteCollector());
|
|
@@ -32,8 +47,9 @@ export default createBackendPlugin({
|
|
|
32
47
|
// guaranteed by the runner (auto-install disabled); this just points
|
|
33
48
|
// it at the synced tree when present.
|
|
34
49
|
collectorRegistry.register(
|
|
35
|
-
new InlineScriptCollector(
|
|
36
|
-
|
|
50
|
+
new InlineScriptCollector(
|
|
51
|
+
defaultInlineScriptExecutor,
|
|
52
|
+
() => resolveResolutionRootFromStore(resolveScriptPackagesDir()),
|
|
37
53
|
),
|
|
38
54
|
);
|
|
39
55
|
},
|
|
@@ -118,7 +118,7 @@ describe("InlineScriptCollector", () => {
|
|
|
118
118
|
|
|
119
119
|
it("exposes `defineHealthCheck` as a runtime global (no import required)", async () => {
|
|
120
120
|
// The editor advertises `defineHealthCheck` both as an ambient global
|
|
121
|
-
// and as a named export from `@checkstack/healthcheck`. The global
|
|
121
|
+
// and as a named export from `@checkstack/sdk/healthcheck`. The global
|
|
122
122
|
// form lets Monaco autocomplete `defineHea…` on a fresh module
|
|
123
123
|
// without the user typing an import first. This test confirms the
|
|
124
124
|
// runner actually installs the global onto `globalThis` so the
|
|
@@ -138,17 +138,17 @@ describe("InlineScriptCollector", () => {
|
|
|
138
138
|
expect(result.result.message).toBe("via global");
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
-
it("resolves the virtual `@checkstack/healthcheck` defineHealthCheck helper at runtime", async () => {
|
|
141
|
+
it("resolves the virtual `@checkstack/sdk/healthcheck` defineHealthCheck helper at runtime", async () => {
|
|
142
142
|
// The editor advertises `import { defineHealthCheck } from
|
|
143
|
-
// "@checkstack/healthcheck"` for IntelliSense + return-shape type
|
|
143
|
+
// "@checkstack/sdk/healthcheck"` for IntelliSense + return-shape type
|
|
144
144
|
// checking. At runtime, the collector rewrites that import to a
|
|
145
145
|
// sibling helper file. This test verifies the rewrite actually
|
|
146
146
|
// resolves — if it ever regressed (e.g. the regex stopped matching),
|
|
147
|
-
// the script would throw "Cannot find module @checkstack/healthcheck"
|
|
147
|
+
// the script would throw "Cannot find module @checkstack/sdk/healthcheck"
|
|
148
148
|
// and we'd catch it here.
|
|
149
149
|
const config = createConfig({
|
|
150
150
|
script: `
|
|
151
|
-
import { defineHealthCheck } from "@checkstack/healthcheck";
|
|
151
|
+
import { defineHealthCheck } from "@checkstack/sdk/healthcheck";
|
|
152
152
|
export default defineHealthCheck({ success: true, value: 7, message: "via helper" });
|
|
153
153
|
`,
|
|
154
154
|
});
|
|
@@ -364,6 +364,57 @@ describe("InlineScriptCollector", () => {
|
|
|
364
364
|
expect(result.result.message).toBe("web-02:check-7");
|
|
365
365
|
});
|
|
366
366
|
|
|
367
|
+
it("exposes the resolved environment as `context.environment.fields`", async () => {
|
|
368
|
+
const config = createConfig({
|
|
369
|
+
script: `
|
|
370
|
+
// @ts-ignore — context is injected by the runner
|
|
371
|
+
export default { success: true, message: \`\${context.environment.name}:\${context.environment.fields.baseUrl}\` };
|
|
372
|
+
`,
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
const result = await collector.execute({
|
|
376
|
+
config,
|
|
377
|
+
client: mockClient,
|
|
378
|
+
pluginId: "script",
|
|
379
|
+
runContext: {
|
|
380
|
+
check: { id: "check-7", name: "CPU load", intervalSeconds: 30 },
|
|
381
|
+
system: { id: "system-3", name: "web-02" },
|
|
382
|
+
environment: {
|
|
383
|
+
id: "env-prod",
|
|
384
|
+
name: "production",
|
|
385
|
+
fields: { baseUrl: "https://prod.example.com" },
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
expect(result.result.success).toBe(true);
|
|
391
|
+
expect(result.result.message).toBe(
|
|
392
|
+
"production:https://prod.example.com",
|
|
393
|
+
);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("leaves `context.environment` undefined when the run has no environment", async () => {
|
|
397
|
+
const config = createConfig({
|
|
398
|
+
script: `
|
|
399
|
+
// @ts-ignore — context is injected by the runner
|
|
400
|
+
export default { success: true, message: typeof context.environment };
|
|
401
|
+
`,
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
const result = await collector.execute({
|
|
405
|
+
config,
|
|
406
|
+
client: mockClient,
|
|
407
|
+
pluginId: "script",
|
|
408
|
+
runContext: {
|
|
409
|
+
check: { id: "check-7", name: "CPU load", intervalSeconds: 30 },
|
|
410
|
+
system: { id: "system-3", name: "web-02" },
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
expect(result.result.success).toBe(true);
|
|
415
|
+
expect(result.result.message).toBe("undefined");
|
|
416
|
+
});
|
|
417
|
+
|
|
367
418
|
it("still exposes `context.config` and does not crash when runContext is absent (back-compat)", async () => {
|
|
368
419
|
const config = createConfig({
|
|
369
420
|
script: `
|
|
@@ -457,3 +508,54 @@ describe("InlineScriptCollector secret env injection + source-side masking", ()
|
|
|
457
508
|
expect(result.result.message).toBe("nothing secret");
|
|
458
509
|
});
|
|
459
510
|
});
|
|
511
|
+
|
|
512
|
+
describe("InlineScriptCollector — global-only sandbox (no per-item override)", () => {
|
|
513
|
+
const makeRecordingExecutor = (): {
|
|
514
|
+
executor: InlineScriptExecutor;
|
|
515
|
+
getInput: () => Record<string, unknown> | undefined;
|
|
516
|
+
} => {
|
|
517
|
+
let captured: Record<string, unknown> | undefined;
|
|
518
|
+
return {
|
|
519
|
+
getInput: () => captured,
|
|
520
|
+
executor: {
|
|
521
|
+
execute: async (input) => {
|
|
522
|
+
captured = input as unknown as Record<string, unknown>;
|
|
523
|
+
return {
|
|
524
|
+
result: { success: true },
|
|
525
|
+
stdout: "",
|
|
526
|
+
stderr: "",
|
|
527
|
+
timedOut: false,
|
|
528
|
+
};
|
|
529
|
+
},
|
|
530
|
+
},
|
|
531
|
+
};
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
it("never passes a `sandbox` field to the executor (policy is global-only)", async () => {
|
|
535
|
+
const { executor, getInput } = makeRecordingExecutor();
|
|
536
|
+
const collector = new InlineScriptCollector(executor);
|
|
537
|
+
await collector.execute({
|
|
538
|
+
config: createConfig(),
|
|
539
|
+
client: mockClient,
|
|
540
|
+
pluginId: "script",
|
|
541
|
+
});
|
|
542
|
+
expect(getInput()?.sandbox).toBeUndefined();
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("tolerates a stored stray `config.sandbox` (migration: stripped, no crash)", async () => {
|
|
546
|
+
const { executor, getInput } = makeRecordingExecutor();
|
|
547
|
+
const collector = new InlineScriptCollector(executor);
|
|
548
|
+
const parsed = collector.config.schema.parse({
|
|
549
|
+
script: "return true;",
|
|
550
|
+
timeout: 5000,
|
|
551
|
+
sandbox: { network: { mode: "unrestricted" } },
|
|
552
|
+
});
|
|
553
|
+
expect((parsed as Record<string, unknown>).sandbox).toBeUndefined();
|
|
554
|
+
await collector.execute({
|
|
555
|
+
config: parsed,
|
|
556
|
+
client: mockClient,
|
|
557
|
+
pluginId: "script",
|
|
558
|
+
});
|
|
559
|
+
expect(getInput()?.sandbox).toBeUndefined();
|
|
560
|
+
});
|
|
561
|
+
});
|
|
@@ -73,8 +73,8 @@ export interface InlineScriptExecutor {
|
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
75
|
* Default executor — delegates to the shared `EsmScriptRunner`. Wires
|
|
76
|
-
* `globalThis.context = { config, check?, system? }` (the
|
|
77
|
-
* health-check runtime surface) and the
|
|
76
|
+
* `globalThis.context = { config, check?, system?, environment? }` (the
|
|
77
|
+
* inline health-check runtime surface) and the `@checkstack/sdk/healthcheck`
|
|
78
78
|
* module / global `defineHealthCheck` helper.
|
|
79
79
|
*/
|
|
80
80
|
export const defaultInlineScriptExecutor: InlineScriptExecutor = {
|
|
@@ -91,11 +91,17 @@ export const defaultInlineScriptExecutor: InlineScriptExecutor = {
|
|
|
91
91
|
context: {
|
|
92
92
|
config,
|
|
93
93
|
...(runContext
|
|
94
|
-
? {
|
|
94
|
+
? {
|
|
95
|
+
check: runContext.check,
|
|
96
|
+
system: runContext.system,
|
|
97
|
+
...(runContext.environment
|
|
98
|
+
? { environment: runContext.environment }
|
|
99
|
+
: {}),
|
|
100
|
+
}
|
|
95
101
|
: {}),
|
|
96
102
|
},
|
|
97
103
|
timeoutMs,
|
|
98
|
-
helperModuleName: "@checkstack/healthcheck",
|
|
104
|
+
helperModuleName: "@checkstack/sdk/healthcheck",
|
|
99
105
|
helperFunctionName: "defineHealthCheck",
|
|
100
106
|
...(resolutionRoot ? { resolutionRoot } : {}),
|
|
101
107
|
// Inject the resolved secrets as process.env for THIS run only.
|
|
@@ -116,7 +122,7 @@ const inlineScriptConfigSchema = z.object({
|
|
|
116
122
|
"x-editor-types": ["typescript"],
|
|
117
123
|
"x-script-testable": true,
|
|
118
124
|
}).describe(
|
|
119
|
-
"TypeScript/JavaScript module. Use `import { ... } from \"node:os\"` to pull in Node built-ins. The recommended pattern is `export default defineHealthCheck({ success, message?, value? })` — `defineHealthCheck` is provided by `@checkstack/healthcheck` and asserts the return shape at the type level. Throwing also signals failure.",
|
|
125
|
+
"TypeScript/JavaScript module. Use `import { ... } from \"node:os\"` to pull in Node built-ins. The recommended pattern is `export default defineHealthCheck({ success, message?, value? })` — `defineHealthCheck` is provided by `@checkstack/sdk/healthcheck` and asserts the return shape at the type level. Throwing also signals failure.",
|
|
120
126
|
),
|
|
121
127
|
secretEnv: withConfigMeta(secretEnvMappingSchema, { "x-secret-env": true })
|
|
122
128
|
.optional()
|
|
@@ -236,8 +242,11 @@ function normaliseScriptReturn(raw: unknown): NormalisedScriptResult {
|
|
|
236
242
|
* Node built-ins (`node:os`, `node:fs/promises`, `node:child_process`,
|
|
237
243
|
* `node:crypto`, ...), use top-level `await`, and signal its result either
|
|
238
244
|
* via `export default` or — for backwards compatibility — a top-level
|
|
239
|
-
* `return X;`. `globalThis.context` exposes `{ config
|
|
240
|
-
* collector configuration
|
|
245
|
+
* `return X;`. `globalThis.context` exposes `{ config, check?, system?,
|
|
246
|
+
* environment? }` — the collector configuration plus curated run-context
|
|
247
|
+
* metadata. `context.environment` (when the run resolved one) carries
|
|
248
|
+
* `{ id, name, fields }`, where `fields` is the environment's custom
|
|
249
|
+
* metadata, e.g. `globalThis.context.environment.fields.baseUrl`.
|
|
241
250
|
*
|
|
242
251
|
* Subprocess isolation, env scrubbing, temp-dir lifecycle and result
|
|
243
252
|
* marshalling all live in the shared `EsmScriptRunner` in
|
|
@@ -325,6 +334,9 @@ export class InlineScriptCollector implements CollectorStrategy<
|
|
|
325
334
|
// Source-side masking values: the run's delivered secret values.
|
|
326
335
|
const maskValues = Object.values(secretEnv ?? {});
|
|
327
336
|
|
|
337
|
+
// The OS-level sandbox is GLOBAL-only and resolved by the runner itself
|
|
338
|
+
// (durable cluster default on the core pod, or fail-closed). No per-item
|
|
339
|
+
// override is applied here.
|
|
328
340
|
let exec: InlineScriptExecutionResult;
|
|
329
341
|
try {
|
|
330
342
|
const raw = await this.executor.execute({
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract test: every Script health-check Versioned schema stored UNVERSIONED
|
|
3
|
+
* and read back via assume-v1-on-read MUST have a COMPLETE, contiguous
|
|
4
|
+
* migration chain from version 1 to its current `version`. Pure STRUCTURAL
|
|
5
|
+
* check (`validateMigrationChainFromV1`); enumerates every Versioned field
|
|
6
|
+
* (config + result + aggregatedResult) of this plugin's strategy + collectors
|
|
7
|
+
* (including the highest-risk execute collector) so a new collector or a
|
|
8
|
+
* bumped result schema is covered automatically. See the HTTP plugin's
|
|
9
|
+
* equivalent test for the full rationale.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, it } from "bun:test";
|
|
12
|
+
import { ScriptHealthCheckStrategy } from "./strategy";
|
|
13
|
+
import { ExecuteCollector } from "./execute-collector";
|
|
14
|
+
import { InlineScriptCollector } from "./inline-script-collector";
|
|
15
|
+
|
|
16
|
+
describe("script health-check migration-chain contract", () => {
|
|
17
|
+
const strategy = new ScriptHealthCheckStrategy();
|
|
18
|
+
const executeCollector = new ExecuteCollector();
|
|
19
|
+
const inlineCollector = new InlineScriptCollector();
|
|
20
|
+
const configs = [
|
|
21
|
+
{ name: "script strategy config", config: strategy.config },
|
|
22
|
+
{ name: "script strategy result", config: strategy.result },
|
|
23
|
+
{
|
|
24
|
+
name: "script strategy aggregatedResult",
|
|
25
|
+
config: strategy.aggregatedResult,
|
|
26
|
+
},
|
|
27
|
+
{ name: "execute collector config", config: executeCollector.config },
|
|
28
|
+
{ name: "execute collector result", config: executeCollector.result },
|
|
29
|
+
{
|
|
30
|
+
name: "execute collector aggregatedResult",
|
|
31
|
+
config: executeCollector.aggregatedResult,
|
|
32
|
+
},
|
|
33
|
+
{ name: "inline-script collector config", config: inlineCollector.config },
|
|
34
|
+
{ name: "inline-script collector result", config: inlineCollector.result },
|
|
35
|
+
{
|
|
36
|
+
name: "inline-script collector aggregatedResult",
|
|
37
|
+
config: inlineCollector.aggregatedResult,
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
it("every registered Versioned schema has a complete v1->version chain", () => {
|
|
42
|
+
for (const { name, config } of configs) {
|
|
43
|
+
const problem = config.validateMigrationChainFromV1();
|
|
44
|
+
expect(
|
|
45
|
+
problem,
|
|
46
|
+
`${name} (version ${config.version}) has a broken migration chain: ${problem}`,
|
|
47
|
+
).toBeUndefined();
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
CHECKSTACK_ENV_PREFIX,
|
|
4
|
+
buildEnvironmentShellEnv,
|
|
5
|
+
toEnvFieldShellKey,
|
|
6
|
+
} from "./shell-env";
|
|
7
|
+
|
|
8
|
+
describe("toEnvFieldShellKey", () => {
|
|
9
|
+
it("splits camelCase into UPPER_SNAKE with the CHECKSTACK_ENV_ prefix", () => {
|
|
10
|
+
expect(toEnvFieldShellKey("baseUrl")).toBe("CHECKSTACK_ENV_BASE_URL");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("normalizes kebab-case and spaces to single underscores", () => {
|
|
14
|
+
expect(toEnvFieldShellKey("my-weird key")).toBe(
|
|
15
|
+
"CHECKSTACK_ENV_MY_WEIRD_KEY",
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("trims leading/trailing separators", () => {
|
|
20
|
+
expect(toEnvFieldShellKey(".region.")).toBe("CHECKSTACK_ENV_REGION");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("handles digit/letter camel boundaries", () => {
|
|
24
|
+
expect(toEnvFieldShellKey("tier2Name")).toBe("CHECKSTACK_ENV_TIER2_NAME");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("is ReDoS-safe on a long run of separators", () => {
|
|
28
|
+
// Mirrors the automation-common hardening test: a pathological input of
|
|
29
|
+
// 100k separators must not hang.
|
|
30
|
+
expect(toEnvFieldShellKey(".".repeat(100_000) + "a")).toBe(
|
|
31
|
+
"CHECKSTACK_ENV_A",
|
|
32
|
+
);
|
|
33
|
+
expect(toEnvFieldShellKey(".".repeat(100_000))).toBe(CHECKSTACK_ENV_PREFIX);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("buildEnvironmentShellEnv", () => {
|
|
38
|
+
it("emits one CHECKSTACK_ENV_<KEY> var per custom field", () => {
|
|
39
|
+
const env = buildEnvironmentShellEnv({
|
|
40
|
+
baseUrl: "https://prod.example.com",
|
|
41
|
+
region: "eu-west-1",
|
|
42
|
+
});
|
|
43
|
+
expect(env).toEqual({
|
|
44
|
+
CHECKSTACK_ENV_BASE_URL: "https://prod.example.com",
|
|
45
|
+
CHECKSTACK_ENV_REGION: "eu-west-1",
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("stringifies non-string values", () => {
|
|
50
|
+
const env = buildEnvironmentShellEnv({
|
|
51
|
+
replicas: 3,
|
|
52
|
+
enabled: true,
|
|
53
|
+
tags: ["a", "b"],
|
|
54
|
+
missing: null,
|
|
55
|
+
});
|
|
56
|
+
expect(env.CHECKSTACK_ENV_REPLICAS).toBe("3");
|
|
57
|
+
expect(env.CHECKSTACK_ENV_ENABLED).toBe("true");
|
|
58
|
+
expect(env.CHECKSTACK_ENV_TAGS).toBe('["a","b"]');
|
|
59
|
+
expect(env.CHECKSTACK_ENV_MISSING).toBe("");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("keeps the first key and skips a later colliding key (no last-write-wins)", () => {
|
|
63
|
+
const collisions: string[] = [];
|
|
64
|
+
const env = buildEnvironmentShellEnv(
|
|
65
|
+
{ baseUrl: "first", "base-url": "second" },
|
|
66
|
+
(message) => collisions.push(message),
|
|
67
|
+
);
|
|
68
|
+
// Both keys normalize to CHECKSTACK_ENV_BASE_URL; first wins.
|
|
69
|
+
expect(env.CHECKSTACK_ENV_BASE_URL).toBe("first");
|
|
70
|
+
expect(Object.keys(env)).toHaveLength(1);
|
|
71
|
+
expect(collisions).toHaveLength(1);
|
|
72
|
+
expect(collisions[0]).toContain("base-url");
|
|
73
|
+
expect(collisions[0]).toContain("CHECKSTACK_ENV_BASE_URL");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("skips a field that normalizes to an empty name", () => {
|
|
77
|
+
const collisions: string[] = [];
|
|
78
|
+
const env = buildEnvironmentShellEnv(
|
|
79
|
+
{ "...": "ignored", region: "eu" },
|
|
80
|
+
(message) => collisions.push(message),
|
|
81
|
+
);
|
|
82
|
+
expect(env).toEqual({ CHECKSTACK_ENV_REGION: "eu" });
|
|
83
|
+
expect(collisions).toHaveLength(1);
|
|
84
|
+
expect(collisions[0]).toContain("empty shell var name");
|
|
85
|
+
});
|
|
86
|
+
});
|
package/src/shell-env.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reserved `CHECKSTACK_ENV_*` shell env vars exposing a run's resolved
|
|
3
|
+
* environment (id, name, and each custom field) to a shell collector script.
|
|
4
|
+
*
|
|
5
|
+
* The reserved names and the key-normalization rule are kept local to this
|
|
6
|
+
* plugin (mirroring the `CHECKSTACK_CHECK_*` / `CHECKSTACK_SYSTEM_*` reserved
|
|
7
|
+
* names in `execute-collector.ts`) so the collector's dependency surface stays
|
|
8
|
+
* minimal. The normalization mirrors the ReDoS-safe rule used by
|
|
9
|
+
* `@checkstack/automation-common`'s `toShellEnvKey`: uppercase, collapse each
|
|
10
|
+
* run of non-alphanumeric characters to a single `_`, trim leading/trailing
|
|
11
|
+
* `_`. A camelCase boundary is split first (`baseUrl` -> `BASE_URL`).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Reserved env var carrying the resolved environment's id. */
|
|
15
|
+
export const CHECKSTACK_ENV_ID = "CHECKSTACK_ENV_ID";
|
|
16
|
+
/** Reserved env var carrying the resolved environment's name. */
|
|
17
|
+
export const CHECKSTACK_ENV_NAME = "CHECKSTACK_ENV_NAME";
|
|
18
|
+
/** Prefix on every per-custom-field environment shell var. */
|
|
19
|
+
export const CHECKSTACK_ENV_PREFIX = "CHECKSTACK_ENV_";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Derive the `CHECKSTACK_ENV_<KEY>` shell var name for a custom field key.
|
|
23
|
+
*
|
|
24
|
+
* Splits a camelCase boundary into an underscore so `baseUrl` becomes
|
|
25
|
+
* `CHECKSTACK_ENV_BASE_URL`, then uppercases, collapses every run of
|
|
26
|
+
* non-alphanumeric characters to a single `_`, and trims leading/trailing
|
|
27
|
+
* `_`. The trailing-trim uses a negative look-behind to avoid the
|
|
28
|
+
* polynomial-time backtracking of a naive `/^_+|_+$/g` (same hardening as
|
|
29
|
+
* automation-common's `toShellEnvKey`).
|
|
30
|
+
*/
|
|
31
|
+
export function toEnvFieldShellKey(key: string): string {
|
|
32
|
+
const normalized = key
|
|
33
|
+
// Split camelCase / digit-letter boundaries before uppercasing so
|
|
34
|
+
// `baseUrl` -> `base Url` -> `BASE_URL` rather than `BASEURL`.
|
|
35
|
+
.replaceAll(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
36
|
+
.toUpperCase()
|
|
37
|
+
.replaceAll(/[^A-Z0-9]+/g, "_")
|
|
38
|
+
.replaceAll(/^_+|(?<!_)_+$/g, "");
|
|
39
|
+
return `${CHECKSTACK_ENV_PREFIX}${normalized}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build the `CHECKSTACK_ENV_<KEY>` shell vars for an environment's custom
|
|
44
|
+
* fields. Values are stringified (objects/arrays via JSON). Two keys that
|
|
45
|
+
* normalize to the same shell var name collide: the FIRST wins and the
|
|
46
|
+
* later one is skipped + reported via `onCollision` (never last-write-wins).
|
|
47
|
+
* Keys that normalize to an empty name (so just `CHECKSTACK_ENV_`) are
|
|
48
|
+
* skipped + reported the same way.
|
|
49
|
+
*/
|
|
50
|
+
export function buildEnvironmentShellEnv(
|
|
51
|
+
fields: Record<string, unknown>,
|
|
52
|
+
onCollision: (message: string) => void = (message) =>
|
|
53
|
+
console.warn(message),
|
|
54
|
+
): Record<string, string> {
|
|
55
|
+
const env: Record<string, string> = {};
|
|
56
|
+
// Track which original field key first claimed each shell var name so the
|
|
57
|
+
// collision message can name both conflicting keys.
|
|
58
|
+
const claimedBy: Record<string, string> = {};
|
|
59
|
+
|
|
60
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
61
|
+
const shellKey = toEnvFieldShellKey(key);
|
|
62
|
+
if (shellKey === CHECKSTACK_ENV_PREFIX) {
|
|
63
|
+
onCollision(
|
|
64
|
+
`Environment custom field "${key}" normalizes to an empty shell var name; skipping.`,
|
|
65
|
+
);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (shellKey in env) {
|
|
69
|
+
onCollision(
|
|
70
|
+
`Environment custom fields "${claimedBy[shellKey]}" and "${key}" both map to ${shellKey}; keeping the first, skipping "${key}".`,
|
|
71
|
+
);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
env[shellKey] = stringifyFieldValue(value);
|
|
75
|
+
claimedBy[shellKey] = key;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return env;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Stringify a custom-field value for a shell env var. */
|
|
82
|
+
function stringifyFieldValue(value: unknown): string {
|
|
83
|
+
if (value === null || value === undefined) return "";
|
|
84
|
+
if (typeof value === "string") return value;
|
|
85
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
86
|
+
return String(value);
|
|
87
|
+
}
|
|
88
|
+
return JSON.stringify(value);
|
|
89
|
+
}
|
package/src/strategy.test.ts
CHANGED
|
@@ -215,4 +215,28 @@ describe("ScriptHealthCheckStrategy", () => {
|
|
|
215
215
|
expect(aggregated.successRate.rate).toBe(0);
|
|
216
216
|
});
|
|
217
217
|
});
|
|
218
|
+
|
|
219
|
+
describe("config migration (assume-v1-on-read)", () => {
|
|
220
|
+
const strategy = new ScriptHealthCheckStrategy();
|
|
221
|
+
|
|
222
|
+
it("migrates a genuine v1 blob (command/args/...) down to {timeout}", async () => {
|
|
223
|
+
const migrated = await strategy.config.parseAssumingV1({
|
|
224
|
+
command: "/usr/bin/uptime",
|
|
225
|
+
args: ["-p"],
|
|
226
|
+
cwd: "/tmp",
|
|
227
|
+
env: { FOO: "bar" },
|
|
228
|
+
timeout: 9000,
|
|
229
|
+
});
|
|
230
|
+
expect(migrated).toEqual({ timeout: 9000 });
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("is idempotent: an already-current {timeout} blob is unchanged", async () => {
|
|
234
|
+
const migrated = await strategy.config.parseAssumingV1({ timeout: 2500 });
|
|
235
|
+
expect(migrated).toEqual({ timeout: 2500 });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("has a complete v1->version migration chain", () => {
|
|
239
|
+
expect(strategy.config.validateMigrationChainFromV1()).toBeUndefined();
|
|
240
|
+
});
|
|
241
|
+
});
|
|
218
242
|
});
|
package/src/strategy.ts
CHANGED
|
@@ -42,13 +42,19 @@ export const scriptConfigSchema = baseStrategyConfigSchema.extend({});
|
|
|
42
42
|
export type ScriptConfig = z.infer<typeof scriptConfigSchema>;
|
|
43
43
|
export type ScriptConfigInput = z.input<typeof scriptConfigSchema>;
|
|
44
44
|
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
45
|
+
// The migrate input is `unknown` per the versioning chain, so narrowing is
|
|
46
|
+
// done with `typeof`/`in` guards (no casts).
|
|
47
|
+
|
|
48
|
+
/** Type guard: the migrate input is a plain object whose keys can be probed. */
|
|
49
|
+
function isRecord(data: unknown): data is Record<string, unknown> {
|
|
50
|
+
return typeof data === "object" && data !== null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Read a numeric `timeout` field from a legacy/current config blob. */
|
|
54
|
+
function readTimeout(data: unknown): number | undefined {
|
|
55
|
+
if (!isRecord(data)) return undefined;
|
|
56
|
+
const value = data.timeout;
|
|
57
|
+
return typeof value === "number" ? value : undefined;
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
/**
|
|
@@ -201,9 +207,14 @@ export class ScriptHealthCheckStrategy implements HealthCheckStrategy<
|
|
|
201
207
|
fromVersion: 1,
|
|
202
208
|
toVersion: 2,
|
|
203
209
|
description: "Remove command/args/cwd/env (moved to ExecuteCollector)",
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
210
|
+
// IDEMPOTENT: only a genuine v1 blob still carries `command`. An
|
|
211
|
+
// already-v2 blob (just `{ timeout }`) passes through untouched.
|
|
212
|
+
migrate: (data: unknown): unknown => {
|
|
213
|
+
if (isRecord(data) && "command" in data) {
|
|
214
|
+
return { timeout: readTimeout(data) };
|
|
215
|
+
}
|
|
216
|
+
return data;
|
|
217
|
+
},
|
|
207
218
|
},
|
|
208
219
|
],
|
|
209
220
|
});
|