@checkstack/healthcheck-backend 1.4.0 → 1.6.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 +303 -0
- package/drizzle/0018_abnormal_preak.sql +10 -0
- package/drizzle/meta/0018_snapshot.json +600 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +26 -21
- package/src/ai/assertion-validation.test.ts +117 -0
- package/src/ai/assertion-validation.ts +147 -0
- package/src/ai/healthcheck-capabilities.test.ts +158 -0
- package/src/ai/healthcheck-capabilities.ts +217 -0
- package/src/ai/healthcheck-delete.test.ts +81 -0
- package/src/ai/healthcheck-delete.ts +81 -0
- package/src/ai/healthcheck-projection.test.ts +36 -0
- package/src/ai/healthcheck-propose.test.ts +268 -0
- package/src/ai/healthcheck-propose.ts +290 -0
- package/src/ai/healthcheck-script-tools.test.ts +93 -0
- package/src/ai/healthcheck-script-tools.ts +179 -0
- package/src/ai/healthcheck-update.test.ts +123 -0
- package/src/ai/healthcheck-update.ts +123 -0
- package/src/ai/notify-subscribers.test.ts +109 -0
- package/src/ai/notify-subscribers.ts +176 -0
- package/src/ai/register-ai-tools.test.ts +41 -0
- package/src/ai/register-ai-tools.ts +53 -0
- package/src/ai/shell-env-table.test.ts +47 -0
- package/src/automations.test.ts +2 -1
- package/src/automations.ts +9 -1
- package/src/collector-script-test.test.ts +53 -1
- package/src/collector-script-test.ts +59 -7
- package/src/effective-environments.test.ts +93 -0
- package/src/effective-environments.ts +64 -0
- package/src/health-entity-id.ts +57 -0
- package/src/health-entity.test.ts +405 -31
- package/src/health-entity.ts +99 -43
- package/src/health-state.ts +41 -4
- package/src/healthcheck-gitops-kinds.test.ts +95 -0
- package/src/healthcheck-gitops-kinds.ts +56 -13
- package/src/index.ts +33 -0
- package/src/migration-chain-contract.test.ts +57 -0
- package/src/queue-executor.test.ts +814 -0
- package/src/queue-executor.ts +342 -50
- package/src/realtime-aggregation.test.ts +30 -0
- package/src/realtime-aggregation.ts +16 -0
- package/src/retention-job.ts +167 -93
- package/src/retention-rollup.test.ts +118 -0
- package/src/router.test.ts +120 -1
- package/src/router.ts +20 -0
- package/src/schema.ts +44 -6
- package/src/service.ts +199 -43
- package/src/state-evaluator.test.ts +50 -5
- package/src/state-evaluator.ts +9 -2
- package/src/state-transitions.test.ts +104 -0
- package/src/state-transitions.ts +39 -1
- package/src/validate-configuration.test.ts +205 -0
- package/src/validate-configuration.ts +159 -0
- package/tsconfig.json +9 -0
|
@@ -29,10 +29,17 @@ import {
|
|
|
29
29
|
|
|
30
30
|
export type CollectorScriptTestKind = "typescript" | "shell";
|
|
31
31
|
|
|
32
|
-
/** Curated check/system metadata a collector script can read. */
|
|
32
|
+
/** Curated check/system/environment metadata a collector script can read. */
|
|
33
33
|
export interface CollectorTestRunContext {
|
|
34
34
|
check?: { id: string; name: string; intervalSeconds: number };
|
|
35
35
|
system?: { id: string; name: string };
|
|
36
|
+
/**
|
|
37
|
+
* The resolved environment for the previewed run. `fields` is the
|
|
38
|
+
* environment's free-form custom metadata. Mirrors the runtime
|
|
39
|
+
* `CollectorRunContext.environment` so the test panel previews exactly the
|
|
40
|
+
* `CHECKSTACK_ENV_*` / `context.environment` surface the real run exposes.
|
|
41
|
+
*/
|
|
42
|
+
environment?: { id: string; name: string; fields: Record<string, unknown> };
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
export interface CollectorScriptTestInput {
|
|
@@ -77,11 +84,42 @@ export interface CollectorScriptTestDeps {
|
|
|
77
84
|
resolutionRoot?: string;
|
|
78
85
|
}
|
|
79
86
|
|
|
87
|
+
const CHECKSTACK_ENV_PREFIX = "CHECKSTACK_ENV_";
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Derive the `CHECKSTACK_ENV_<KEY>` shell var name for a custom field key.
|
|
91
|
+
* Mirrors `toEnvFieldShellKey` in `@checkstack/healthcheck-script-backend`
|
|
92
|
+
* (kept local - we don't import across plugins) so the test panel and the
|
|
93
|
+
* real run produce identical var names. Splits camelCase, uppercases,
|
|
94
|
+
* collapses non-alphanumeric runs to `_`, trims leading/trailing `_` using a
|
|
95
|
+
* ReDoS-safe negative look-behind.
|
|
96
|
+
*/
|
|
97
|
+
function toEnvFieldShellKey(key: string): string {
|
|
98
|
+
const normalized = key
|
|
99
|
+
.replaceAll(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
100
|
+
.toUpperCase()
|
|
101
|
+
.replaceAll(/[^A-Z0-9]+/g, "_")
|
|
102
|
+
.replaceAll(/^_+|(?<!_)_+$/g, "");
|
|
103
|
+
return `${CHECKSTACK_ENV_PREFIX}${normalized}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Stringify a custom-field value for a shell env var. */
|
|
107
|
+
function stringifyFieldValue(value: unknown): string {
|
|
108
|
+
if (value === null || value === undefined) return "";
|
|
109
|
+
if (typeof value === "string") return value;
|
|
110
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
111
|
+
return String(value);
|
|
112
|
+
}
|
|
113
|
+
return JSON.stringify(value);
|
|
114
|
+
}
|
|
115
|
+
|
|
80
116
|
/**
|
|
81
117
|
* Map curated run-context metadata to the reserved `CHECKSTACK_*` env vars
|
|
82
|
-
* the shell collector exposes. Mirrors `runContextEnv`
|
|
83
|
-
* `@checkstack/healthcheck-script-backend`
|
|
84
|
-
* across plugins). Only emits vars for the
|
|
118
|
+
* the shell collector exposes. Mirrors `runContextEnv` /
|
|
119
|
+
* `buildEnvironmentShellEnv` in `@checkstack/healthcheck-script-backend`
|
|
120
|
+
* (kept local - we don't import across plugins). Only emits vars for the
|
|
121
|
+
* parts of the context provided. Custom-field key collisions keep the first
|
|
122
|
+
* and skip later ones (first-wins, never last-write-wins).
|
|
85
123
|
*/
|
|
86
124
|
export function buildShellRunContextEnv(
|
|
87
125
|
runContext: CollectorTestRunContext | undefined,
|
|
@@ -98,13 +136,24 @@ export function buildShellRunContextEnv(
|
|
|
98
136
|
env.CHECKSTACK_SYSTEM_ID = runContext.system.id;
|
|
99
137
|
env.CHECKSTACK_SYSTEM_NAME = runContext.system.name;
|
|
100
138
|
}
|
|
139
|
+
if (runContext?.environment) {
|
|
140
|
+
env.CHECKSTACK_ENV_ID = runContext.environment.id;
|
|
141
|
+
env.CHECKSTACK_ENV_NAME = runContext.environment.name;
|
|
142
|
+
const claimed = new Set<string>();
|
|
143
|
+
for (const [key, value] of Object.entries(runContext.environment.fields)) {
|
|
144
|
+
const shellKey = toEnvFieldShellKey(key);
|
|
145
|
+
if (shellKey === CHECKSTACK_ENV_PREFIX || claimed.has(shellKey)) continue;
|
|
146
|
+
env[shellKey] = stringifyFieldValue(value);
|
|
147
|
+
claimed.add(shellKey);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
101
150
|
return env;
|
|
102
151
|
}
|
|
103
152
|
|
|
104
153
|
/**
|
|
105
154
|
* Build the `globalThis.context` object the inline-script (TS) collector
|
|
106
|
-
* sees: `{ config, check?, system? }`. Matches the runtime
|
|
107
|
-
* test mirrors production.
|
|
155
|
+
* sees: `{ config, check?, system?, environment? }`. Matches the runtime
|
|
156
|
+
* collector so a test mirrors production.
|
|
108
157
|
*/
|
|
109
158
|
export function buildCollectorContext(
|
|
110
159
|
input: Pick<CollectorScriptTestInput, "config" | "runContext">,
|
|
@@ -113,6 +162,9 @@ export function buildCollectorContext(
|
|
|
113
162
|
config: input.config ?? {},
|
|
114
163
|
...(input.runContext?.check ? { check: input.runContext.check } : {}),
|
|
115
164
|
...(input.runContext?.system ? { system: input.runContext.system } : {}),
|
|
165
|
+
...(input.runContext?.environment
|
|
166
|
+
? { environment: input.runContext.environment }
|
|
167
|
+
: {}),
|
|
116
168
|
};
|
|
117
169
|
}
|
|
118
170
|
|
|
@@ -193,7 +245,7 @@ export async function runCollectorScriptTest({
|
|
|
193
245
|
script: input.script,
|
|
194
246
|
context: buildCollectorContext(input),
|
|
195
247
|
timeoutMs: input.timeoutMs,
|
|
196
|
-
helperModuleName: "@checkstack/healthcheck",
|
|
248
|
+
helperModuleName: "@checkstack/sdk/healthcheck",
|
|
197
249
|
helperFunctionName: "defineHealthCheck",
|
|
198
250
|
...(Object.keys(secretTest.env).length > 0
|
|
199
251
|
? { env: secretTest.env }
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { resolveEffectiveEnvironments } from "./effective-environments";
|
|
3
|
+
import type { Environment } from "@checkstack/catalog-common";
|
|
4
|
+
|
|
5
|
+
const env = (
|
|
6
|
+
id: string,
|
|
7
|
+
name: string,
|
|
8
|
+
metadata: Record<string, unknown> | null = {},
|
|
9
|
+
): Environment => ({
|
|
10
|
+
id,
|
|
11
|
+
name,
|
|
12
|
+
description: null,
|
|
13
|
+
systemIds: [],
|
|
14
|
+
metadata,
|
|
15
|
+
createdAt: new Date(),
|
|
16
|
+
updatedAt: new Date(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("resolveEffectiveEnvironments", () => {
|
|
20
|
+
const membership = [
|
|
21
|
+
env("prod", "Production", { baseUrl: "https://prod" }),
|
|
22
|
+
env("staging", "Staging", { baseUrl: "https://staging" }),
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
it("null selector returns ALL current environments", () => {
|
|
26
|
+
const result = resolveEffectiveEnvironments({
|
|
27
|
+
environmentIds: null,
|
|
28
|
+
membership,
|
|
29
|
+
});
|
|
30
|
+
expect(result.map((e) => e.id)).toEqual(["prod", "staging"]);
|
|
31
|
+
expect(result[0]).toEqual({
|
|
32
|
+
id: "prod",
|
|
33
|
+
name: "Production",
|
|
34
|
+
fields: { baseUrl: "https://prod" },
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("undefined selector behaves like null (all environments)", () => {
|
|
39
|
+
const result = resolveEffectiveEnvironments({
|
|
40
|
+
environmentIds: undefined,
|
|
41
|
+
membership,
|
|
42
|
+
});
|
|
43
|
+
expect(result.map((e) => e.id)).toEqual(["prod", "staging"]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("empty array selector opts out (env-less single run)", () => {
|
|
47
|
+
const result = resolveEffectiveEnvironments({
|
|
48
|
+
environmentIds: [],
|
|
49
|
+
membership,
|
|
50
|
+
});
|
|
51
|
+
expect(result).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("explicit subset returns exactly those, intersected with membership", () => {
|
|
55
|
+
const result = resolveEffectiveEnvironments({
|
|
56
|
+
environmentIds: ["staging"],
|
|
57
|
+
membership,
|
|
58
|
+
});
|
|
59
|
+
expect(result.map((e) => e.id)).toEqual(["staging"]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("preserves membership order regardless of selector order", () => {
|
|
63
|
+
const result = resolveEffectiveEnvironments({
|
|
64
|
+
environmentIds: ["staging", "prod"],
|
|
65
|
+
membership,
|
|
66
|
+
});
|
|
67
|
+
expect(result.map((e) => e.id)).toEqual(["prod", "staging"]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("silently drops explicit ids no longer in membership (stale-ref prune)", () => {
|
|
71
|
+
const result = resolveEffectiveEnvironments({
|
|
72
|
+
environmentIds: ["prod", "deleted-env"],
|
|
73
|
+
membership,
|
|
74
|
+
});
|
|
75
|
+
expect(result.map((e) => e.id)).toEqual(["prod"]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("null metadata becomes empty fields", () => {
|
|
79
|
+
const result = resolveEffectiveEnvironments({
|
|
80
|
+
environmentIds: null,
|
|
81
|
+
membership: [env("e1", "E1", null)],
|
|
82
|
+
});
|
|
83
|
+
expect(result[0]?.fields).toEqual({});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("empty membership under null selector yields env-less (empty result)", () => {
|
|
87
|
+
const result = resolveEffectiveEnvironments({
|
|
88
|
+
environmentIds: null,
|
|
89
|
+
membership: [],
|
|
90
|
+
});
|
|
91
|
+
expect(result).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Environment } from "@checkstack/catalog-common";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The resolved environment a single fanned-out run executes for. A subset of
|
|
5
|
+
* the catalog `Environment` carrying only the run-relevant fields. `fields`
|
|
6
|
+
* is the environment's free-form custom metadata (verbatim values) - it is
|
|
7
|
+
* surfaced to collectors via `CollectorRunContext.environment.fields`
|
|
8
|
+
* (metadata only, never secrets).
|
|
9
|
+
*/
|
|
10
|
+
export interface EffectiveEnvironment {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
fields: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve the effective set of environments a (config, system) assignment
|
|
18
|
+
* fans out into for one tick.
|
|
19
|
+
*
|
|
20
|
+
* Semantics (locked, §2/§7.1 of the environments plan):
|
|
21
|
+
* - `environmentIds === null` => ALL environments the system currently
|
|
22
|
+
* belongs to (the `membership` set).
|
|
23
|
+
* - `environmentIds === []` => OPT OUT: an empty result, meaning the check
|
|
24
|
+
* runs exactly ONCE with no environment in context (env-less run).
|
|
25
|
+
* - non-empty `environmentIds` => exactly those ids, INTERSECTED with the
|
|
26
|
+
* current membership. An explicit id no longer in membership silently
|
|
27
|
+
* drops (consistent with stale-ref pruning in the GitOps groups reconcile).
|
|
28
|
+
*
|
|
29
|
+
* The caller turns an empty result into a single env-less run (so a fanned
|
|
30
|
+
* check with no effective environments behaves exactly like the pre-feature
|
|
31
|
+
* single run). A non-empty result drives one run per environment.
|
|
32
|
+
*
|
|
33
|
+
* Membership order is preserved so fan-out order is deterministic.
|
|
34
|
+
*/
|
|
35
|
+
export function resolveEffectiveEnvironments({
|
|
36
|
+
environmentIds,
|
|
37
|
+
membership,
|
|
38
|
+
}: {
|
|
39
|
+
environmentIds: string[] | null | undefined;
|
|
40
|
+
membership: Environment[];
|
|
41
|
+
}): EffectiveEnvironment[] {
|
|
42
|
+
const toEffective = (env: Environment): EffectiveEnvironment => ({
|
|
43
|
+
id: env.id,
|
|
44
|
+
name: env.name,
|
|
45
|
+
fields: env.metadata ?? {},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// null / undefined => all current environments.
|
|
49
|
+
if (environmentIds === null || environmentIds === undefined) {
|
|
50
|
+
return membership.map((env) => toEffective(env));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// [] => opt out (env-less single run).
|
|
54
|
+
if (environmentIds.length === 0) {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Non-empty => explicit subset intersected with current membership,
|
|
59
|
+
// preserving membership order; stale ids silently drop.
|
|
60
|
+
const wanted = new Set(environmentIds);
|
|
61
|
+
return membership
|
|
62
|
+
.filter((env) => wanted.has(env.id))
|
|
63
|
+
.map((env) => toEffective(env));
|
|
64
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `health` reactive entity id-shape (§7.4.1, Phase 3b).
|
|
3
|
+
*
|
|
4
|
+
* The reactive engine keys entities by a single opaque string id. The `health`
|
|
5
|
+
* kind encodes TWO views into that one id space:
|
|
6
|
+
*
|
|
7
|
+
* - **System rollup** — the BARE `"<systemId>"`. Unchanged from the pre-3b
|
|
8
|
+
* contract: it is the worst-status rollup across the system's environments
|
|
9
|
+
* (and env-less runs). Dashboards, badges, and existing system-level
|
|
10
|
+
* automations reference this id and keep working WITHOUT re-authoring.
|
|
11
|
+
* - **Per-environment** — `"<systemId>::<environmentId>"` (double-colon
|
|
12
|
+
* separator). Catalog `text` ids never contain `::`, so the separator is
|
|
13
|
+
* unambiguous. State shape is identical; only the id distinguishes them.
|
|
14
|
+
*
|
|
15
|
+
* This module is the SINGLE source of truth for encoding / decoding that id so
|
|
16
|
+
* the read accessor, the write helper, the change deriver, the payload mapper,
|
|
17
|
+
* and (read-only) the automation scope enrichment all agree on the shape.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** The separator between systemId and environmentId in a per-env entity id. */
|
|
21
|
+
export const HEALTH_ENTITY_ID_SEPARATOR = "::";
|
|
22
|
+
|
|
23
|
+
/** A decoded `health` entity id. `environmentId === null` ⇒ the system rollup. */
|
|
24
|
+
export interface ParsedHealthEntityId {
|
|
25
|
+
systemId: string;
|
|
26
|
+
/** null for the bare system rollup id; the env id for a per-env id. */
|
|
27
|
+
environmentId: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Encode a `health` entity id from its parts. A `null`/`undefined`
|
|
32
|
+
* environmentId yields the bare rollup id `"<systemId>"`; a concrete
|
|
33
|
+
* environmentId yields `"<systemId>::<environmentId>"`.
|
|
34
|
+
*/
|
|
35
|
+
export function encodeHealthEntityId(args: {
|
|
36
|
+
systemId: string;
|
|
37
|
+
environmentId?: string | null;
|
|
38
|
+
}): string {
|
|
39
|
+
const { systemId, environmentId } = args;
|
|
40
|
+
if (environmentId === null || environmentId === undefined) return systemId;
|
|
41
|
+
return `${systemId}${HEALTH_ENTITY_ID_SEPARATOR}${environmentId}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Decode a `health` entity id into `(systemId, environmentId)`. An id with no
|
|
46
|
+
* separator is the system rollup (`environmentId: null`). Splits on the FIRST
|
|
47
|
+
* separator so a (hypothetical) env id containing `::` still resolves a stable
|
|
48
|
+
* systemId; catalog ids don't contain `::`, so this is defensive only.
|
|
49
|
+
*/
|
|
50
|
+
export function parseHealthEntityId(id: string): ParsedHealthEntityId {
|
|
51
|
+
const idx = id.indexOf(HEALTH_ENTITY_ID_SEPARATOR);
|
|
52
|
+
if (idx === -1) return { systemId: id, environmentId: null };
|
|
53
|
+
return {
|
|
54
|
+
systemId: id.slice(0, idx),
|
|
55
|
+
environmentId: id.slice(idx + HEALTH_ENTITY_ID_SEPARATOR.length),
|
|
56
|
+
};
|
|
57
|
+
}
|