@checkstack/healthcheck-backend 1.2.0 → 1.4.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 +541 -0
- package/drizzle/0015_quiet_meggan.sql +12 -0
- package/drizzle/0016_complex_maginty.sql +1 -0
- package/drizzle/0017_pretty_caretaker.sql +1 -0
- package/drizzle/meta/0015_snapshot.json +764 -0
- package/drizzle/meta/0016_snapshot.json +644 -0
- package/drizzle/meta/0017_snapshot.json +563 -0
- package/drizzle/meta/_journal.json +21 -0
- package/package.json +24 -21
- package/src/automations.test.ts +234 -0
- package/src/automations.ts +342 -0
- package/src/collector-script-test.test.ts +236 -0
- package/src/collector-script-test.ts +221 -0
- package/src/health-entity.test.ts +698 -0
- package/src/health-entity.ts +369 -0
- package/src/health-state.test.ts +115 -0
- package/src/health-state.ts +333 -0
- package/src/healthcheck-gitops-kinds.test.ts +6 -32
- package/src/healthcheck-gitops-kinds.ts +4 -19
- package/src/hooks.test.ts +19 -6
- package/src/hooks.ts +38 -28
- package/src/index.ts +150 -98
- package/src/queue-executor.test.ts +137 -0
- package/src/queue-executor.ts +282 -380
- package/src/retention-job.ts +65 -1
- package/src/retention-state-transitions.test.ts +49 -0
- package/src/router.test.ts +18 -0
- package/src/router.ts +56 -1
- package/src/schema.ts +34 -54
- package/src/service-assignments.test.ts +184 -0
- package/src/service-notification-policy.test.ts +28 -71
- package/src/service.ts +154 -0
- package/src/state-transitions.test.ts +126 -0
- package/src/state-transitions.ts +112 -0
- package/tsconfig.json +12 -3
- package/src/auto-incident-close-job.ts +0 -164
- package/src/auto-incident.test.ts +0 -196
- package/src/auto-incident.ts +0 -332
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Behaviour tests for the healthcheck automation triggers + actions.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it, mock } from "bun:test";
|
|
5
|
+
import type { Logger } from "@checkstack/backend-api";
|
|
6
|
+
import type { QueueManager } from "@checkstack/queue-api";
|
|
7
|
+
import { createMockLogger } from "@checkstack/test-utils-backend";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
assignmentArtifactType,
|
|
11
|
+
checkFailedTrigger,
|
|
12
|
+
createHealthCheckActions,
|
|
13
|
+
healthCheckTriggers,
|
|
14
|
+
systemDegradedTrigger,
|
|
15
|
+
systemHealthChangedTrigger,
|
|
16
|
+
systemHealthyTrigger,
|
|
17
|
+
} from "./automations";
|
|
18
|
+
import { healthCheckHooks } from "./hooks";
|
|
19
|
+
import type { HealthCheckService } from "./service";
|
|
20
|
+
|
|
21
|
+
const logger = createMockLogger() as Logger;
|
|
22
|
+
|
|
23
|
+
const ctxBase = {
|
|
24
|
+
runId: "run-1",
|
|
25
|
+
automationId: "auto-1",
|
|
26
|
+
contextKey: null,
|
|
27
|
+
logger,
|
|
28
|
+
getService: async <T,>(): Promise<T> => {
|
|
29
|
+
throw new Error("not used");
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe("healthcheck triggers", () => {
|
|
34
|
+
it("exposes four triggers in a stable order", () => {
|
|
35
|
+
expect(healthCheckTriggers).toHaveLength(4);
|
|
36
|
+
expect(healthCheckTriggers[0]).toBe(
|
|
37
|
+
systemDegradedTrigger as unknown as (typeof healthCheckTriggers)[number],
|
|
38
|
+
);
|
|
39
|
+
expect(healthCheckTriggers[1]).toBe(
|
|
40
|
+
systemHealthyTrigger as unknown as (typeof healthCheckTriggers)[number],
|
|
41
|
+
);
|
|
42
|
+
expect(healthCheckTriggers[2]).toBe(
|
|
43
|
+
systemHealthChangedTrigger as unknown as (typeof healthCheckTriggers)[number],
|
|
44
|
+
);
|
|
45
|
+
expect(healthCheckTriggers[3]).toBe(
|
|
46
|
+
checkFailedTrigger as unknown as (typeof healthCheckTriggers)[number],
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("validates checkFailed payload and extracts systemId", () => {
|
|
51
|
+
const ok = checkFailedTrigger.payloadSchema.safeParse({
|
|
52
|
+
systemId: "sys-1",
|
|
53
|
+
configurationId: "cfg-1",
|
|
54
|
+
status: "unhealthy",
|
|
55
|
+
timestamp: "2026-05-29T12:00:00Z",
|
|
56
|
+
});
|
|
57
|
+
expect(ok.success).toBe(true);
|
|
58
|
+
expect(
|
|
59
|
+
checkFailedTrigger.contextKey?.({
|
|
60
|
+
systemId: "sys-1",
|
|
61
|
+
configurationId: "cfg-1",
|
|
62
|
+
status: "unhealthy",
|
|
63
|
+
timestamp: "2026-05-29T12:00:00Z",
|
|
64
|
+
}),
|
|
65
|
+
).toBe("sys-1");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
it("extracts systemId as the contextKey on all three", () => {
|
|
70
|
+
const degradedOrChanged = {
|
|
71
|
+
systemId: "sys-1",
|
|
72
|
+
previousStatus: "healthy",
|
|
73
|
+
newStatus: "degraded",
|
|
74
|
+
healthyChecks: 1,
|
|
75
|
+
totalChecks: 2,
|
|
76
|
+
timestamp: "2026-05-29T11:00:00Z",
|
|
77
|
+
} as const;
|
|
78
|
+
const healthy = {
|
|
79
|
+
systemId: "sys-1",
|
|
80
|
+
previousStatus: "degraded",
|
|
81
|
+
healthyChecks: 2,
|
|
82
|
+
totalChecks: 2,
|
|
83
|
+
timestamp: "2026-05-29T11:00:00Z",
|
|
84
|
+
} as const;
|
|
85
|
+
expect(systemDegradedTrigger.contextKey?.(degradedOrChanged)).toBe("sys-1");
|
|
86
|
+
expect(systemHealthyTrigger.contextKey?.(healthy)).toBe("sys-1");
|
|
87
|
+
expect(systemHealthChangedTrigger.contextKey?.(degradedOrChanged)).toBe(
|
|
88
|
+
"sys-1",
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("assignmentArtifactType", () => {
|
|
94
|
+
it("validates the canonical assignment artifact", () => {
|
|
95
|
+
const ok = assignmentArtifactType.schema.safeParse({
|
|
96
|
+
systemId: "sys-1",
|
|
97
|
+
configurationId: "cfg-1",
|
|
98
|
+
enabled: true,
|
|
99
|
+
});
|
|
100
|
+
expect(ok.success).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
function makeService(args: {
|
|
105
|
+
setAssignmentEnabledReturn?: boolean;
|
|
106
|
+
}): HealthCheckService & { setMock: ReturnType<typeof mock> } {
|
|
107
|
+
const setMock = mock(
|
|
108
|
+
async (_sysId: string, _cfgId: string, _enabled: boolean) =>
|
|
109
|
+
args.setAssignmentEnabledReturn ?? true,
|
|
110
|
+
);
|
|
111
|
+
return {
|
|
112
|
+
setAssignmentEnabled: setMock,
|
|
113
|
+
setMock,
|
|
114
|
+
} as unknown as HealthCheckService & { setMock: ReturnType<typeof mock> };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface QueueEnqueueRecorder {
|
|
118
|
+
queueManager: QueueManager;
|
|
119
|
+
enqueueMock: ReturnType<typeof mock>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function makeQueueManager(): QueueEnqueueRecorder {
|
|
123
|
+
const enqueueMock = mock(async (_payload: unknown) => "job-id");
|
|
124
|
+
const queue = {
|
|
125
|
+
enqueue: enqueueMock,
|
|
126
|
+
// Other queue methods aren't exercised by the action.
|
|
127
|
+
};
|
|
128
|
+
const queueManager = {
|
|
129
|
+
getQueue: () => queue,
|
|
130
|
+
} as unknown as QueueManager;
|
|
131
|
+
return { queueManager, enqueueMock };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
describe("healthcheck.run_now", () => {
|
|
135
|
+
it("enqueues a one-off job and emits an enqueued=true artifact", async () => {
|
|
136
|
+
const service = makeService({});
|
|
137
|
+
const { queueManager, enqueueMock } = makeQueueManager();
|
|
138
|
+
const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
|
|
139
|
+
const [runNow] = createHealthCheckActions({
|
|
140
|
+
service,
|
|
141
|
+
queueManager,
|
|
142
|
+
emitHook: emitHook as never,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const result = await runNow!.execute({
|
|
146
|
+
...ctxBase,
|
|
147
|
+
consumedArtifacts: {},
|
|
148
|
+
config: { systemId: "sys-1", configurationId: "cfg-1" } as never,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(result.success).toBe(true);
|
|
152
|
+
if (!result.success) return;
|
|
153
|
+
expect(result.externalId).toBe("sys-1:cfg-1");
|
|
154
|
+
expect(enqueueMock).toHaveBeenCalledTimes(1);
|
|
155
|
+
expect(enqueueMock.mock.calls[0]![0]).toEqual({
|
|
156
|
+
configId: "cfg-1",
|
|
157
|
+
systemId: "sys-1",
|
|
158
|
+
});
|
|
159
|
+
// run_now doesn't mutate any DB row → no hook to emit.
|
|
160
|
+
expect(emitHook).not.toHaveBeenCalled();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("healthcheck.enable_assignment", () => {
|
|
165
|
+
it("flips enabled=true on the existing row, fires assignmentChanged, and emits the artifact", async () => {
|
|
166
|
+
const service = makeService({ setAssignmentEnabledReturn: true });
|
|
167
|
+
const { queueManager } = makeQueueManager();
|
|
168
|
+
const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
|
|
169
|
+
const [, enable] = createHealthCheckActions({
|
|
170
|
+
service,
|
|
171
|
+
queueManager,
|
|
172
|
+
emitHook: emitHook as never,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const result = await enable!.execute({
|
|
176
|
+
...ctxBase,
|
|
177
|
+
consumedArtifacts: {},
|
|
178
|
+
config: { systemId: "sys-1", configurationId: "cfg-1" } as never,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
expect(result.success).toBe(true);
|
|
182
|
+
if (!result.success) return;
|
|
183
|
+
expect((result.artifact as { enabled: boolean }).enabled).toBe(true);
|
|
184
|
+
expect(service.setMock).toHaveBeenCalledWith("sys-1", "cfg-1", true);
|
|
185
|
+
expect(emitHook).toHaveBeenCalledTimes(1);
|
|
186
|
+
expect(emitHook.mock.calls[0]![0]).toBe(healthCheckHooks.assignmentChanged);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("returns failure when the assignment row does not exist", async () => {
|
|
190
|
+
const service = makeService({ setAssignmentEnabledReturn: false });
|
|
191
|
+
const { queueManager } = makeQueueManager();
|
|
192
|
+
const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
|
|
193
|
+
const [, enable] = createHealthCheckActions({
|
|
194
|
+
service,
|
|
195
|
+
queueManager,
|
|
196
|
+
emitHook: emitHook as never,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const result = await enable!.execute({
|
|
200
|
+
...ctxBase,
|
|
201
|
+
consumedArtifacts: {},
|
|
202
|
+
config: { systemId: "sys-1", configurationId: "missing" } as never,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
expect(result.success).toBe(false);
|
|
206
|
+
if (result.success) return;
|
|
207
|
+
expect(result.error).toMatch(/Assignment not found/);
|
|
208
|
+
expect(emitHook).not.toHaveBeenCalled();
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe("healthcheck.disable_assignment", () => {
|
|
213
|
+
it("flips enabled=false on the existing row and emits the artifact", async () => {
|
|
214
|
+
const service = makeService({ setAssignmentEnabledReturn: true });
|
|
215
|
+
const { queueManager } = makeQueueManager();
|
|
216
|
+
const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
|
|
217
|
+
const [, , disable] = createHealthCheckActions({
|
|
218
|
+
service,
|
|
219
|
+
queueManager,
|
|
220
|
+
emitHook: emitHook as never,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const result = await disable!.execute({
|
|
224
|
+
...ctxBase,
|
|
225
|
+
consumedArtifacts: {},
|
|
226
|
+
config: { systemId: "sys-1", configurationId: "cfg-1" } as never,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
expect(result.success).toBe(true);
|
|
230
|
+
if (!result.success) return;
|
|
231
|
+
expect((result.artifact as { enabled: boolean }).enabled).toBe(false);
|
|
232
|
+
expect(service.setMock).toHaveBeenCalledWith("sys-1", "cfg-1", false);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Healthcheck triggers + actions registered with the Automation Platform.
|
|
3
|
+
*
|
|
4
|
+
* Triggers:
|
|
5
|
+
* - `healthcheck.system.degraded` — existing directional hook
|
|
6
|
+
* - `healthcheck.system.healthy` — existing directional hook
|
|
7
|
+
* - `healthcheck.system.health_changed` — new umbrella hook,
|
|
8
|
+
* fires on every aggregated-health transition. Carries both the
|
|
9
|
+
* previous and new statuses so subscribers don't have to listen
|
|
10
|
+
* to two hooks and coalesce themselves.
|
|
11
|
+
*
|
|
12
|
+
* Actions:
|
|
13
|
+
* - `healthcheck.run_now`: enqueue a one-off run of a specific
|
|
14
|
+
* `(systemId, configurationId)` assignment. The recurring
|
|
15
|
+
* schedule keeps ticking; this just nudges the queue.
|
|
16
|
+
* - `healthcheck.enable_assignment` /
|
|
17
|
+
* `healthcheck.disable_assignment`: flip the `enabled` flag on an
|
|
18
|
+
* existing assignment via `service.setAssignmentEnabled`. Emits
|
|
19
|
+
* the existing `assignmentChanged` hook so the satellite-config
|
|
20
|
+
* relay picks up the change.
|
|
21
|
+
*
|
|
22
|
+
* Mutation actions emit hooks themselves (via the `emitHook` factory
|
|
23
|
+
* dep) so downstream automations + caches react the same way as
|
|
24
|
+
* RPC-driven mutations.
|
|
25
|
+
*/
|
|
26
|
+
import { z } from "zod";
|
|
27
|
+
import { Versioned, type Hook } from "@checkstack/backend-api";
|
|
28
|
+
import type { QueueManager } from "@checkstack/queue-api";
|
|
29
|
+
import type {
|
|
30
|
+
ActionDefinition,
|
|
31
|
+
TriggerDefinition,
|
|
32
|
+
} from "@checkstack/automation-backend";
|
|
33
|
+
import { makeEntityDrivenTriggerSetup } from "@checkstack/automation-backend";
|
|
34
|
+
import { HealthCheckStatusSchema } from "@checkstack/healthcheck-common";
|
|
35
|
+
|
|
36
|
+
import { healthCheckHooks } from "./hooks";
|
|
37
|
+
import {
|
|
38
|
+
HEALTH_CHECK_QUEUE,
|
|
39
|
+
type HealthCheckJobPayload,
|
|
40
|
+
} from "./queue-executor";
|
|
41
|
+
import type { HealthCheckService } from "./service";
|
|
42
|
+
|
|
43
|
+
// ─── Payload schemas — match the hook payloads exactly ─────────────────
|
|
44
|
+
|
|
45
|
+
const systemDegradedPayloadSchema = z.object({
|
|
46
|
+
systemId: z.string(),
|
|
47
|
+
systemName: z.string().optional(),
|
|
48
|
+
previousStatus: HealthCheckStatusSchema,
|
|
49
|
+
newStatus: HealthCheckStatusSchema,
|
|
50
|
+
healthyChecks: z.number(),
|
|
51
|
+
totalChecks: z.number(),
|
|
52
|
+
timestamp: z.string(),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const systemHealthyPayloadSchema = z.object({
|
|
56
|
+
systemId: z.string(),
|
|
57
|
+
systemName: z.string().optional(),
|
|
58
|
+
previousStatus: HealthCheckStatusSchema,
|
|
59
|
+
healthyChecks: z.number(),
|
|
60
|
+
totalChecks: z.number(),
|
|
61
|
+
timestamp: z.string(),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const systemHealthChangedPayloadSchema = z.object({
|
|
65
|
+
systemId: z.string(),
|
|
66
|
+
systemName: z.string().optional(),
|
|
67
|
+
previousStatus: HealthCheckStatusSchema,
|
|
68
|
+
newStatus: HealthCheckStatusSchema,
|
|
69
|
+
healthyChecks: z.number(),
|
|
70
|
+
totalChecks: z.number(),
|
|
71
|
+
timestamp: z.string(),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const checkFailedPayloadSchema = z.object({
|
|
75
|
+
systemId: z.string(),
|
|
76
|
+
configurationId: z.string(),
|
|
77
|
+
status: HealthCheckStatusSchema,
|
|
78
|
+
latencyMs: z.number().optional(),
|
|
79
|
+
result: z.record(z.string(), z.unknown()).optional(),
|
|
80
|
+
timestamp: z.string(),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ─── Triggers ──────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
export const systemDegradedTrigger: TriggerDefinition<
|
|
86
|
+
z.infer<typeof systemDegradedPayloadSchema>
|
|
87
|
+
> = {
|
|
88
|
+
id: "system_degraded",
|
|
89
|
+
displayName: "System Health Degraded",
|
|
90
|
+
description:
|
|
91
|
+
"Fires when a system's health transitions from healthy to degraded/unhealthy",
|
|
92
|
+
category: "Health",
|
|
93
|
+
icon: "HeartPulse",
|
|
94
|
+
payloadSchema: systemDegradedPayloadSchema,
|
|
95
|
+
// Entity-driven (§10.3): fired by the `health` entity change deriver via
|
|
96
|
+
// Stage-1 routing, not a hook. No-op setup keeps it in the editor catalog.
|
|
97
|
+
setup: makeEntityDrivenTriggerSetup<
|
|
98
|
+
z.infer<typeof systemDegradedPayloadSchema>
|
|
99
|
+
>(),
|
|
100
|
+
contextKey: (p) => p.systemId,
|
|
101
|
+
contextKeyLabel: "system",
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const systemHealthyTrigger: TriggerDefinition<
|
|
105
|
+
z.infer<typeof systemHealthyPayloadSchema>
|
|
106
|
+
> = {
|
|
107
|
+
id: "system_healthy",
|
|
108
|
+
displayName: "System Health Restored",
|
|
109
|
+
description: "Fires when a system's health recovers to healthy",
|
|
110
|
+
category: "Health",
|
|
111
|
+
icon: "HeartPulse",
|
|
112
|
+
payloadSchema: systemHealthyPayloadSchema,
|
|
113
|
+
// Entity-driven (§10.3): fired by the `health` entity change deriver.
|
|
114
|
+
setup: makeEntityDrivenTriggerSetup<
|
|
115
|
+
z.infer<typeof systemHealthyPayloadSchema>
|
|
116
|
+
>(),
|
|
117
|
+
contextKey: (p) => p.systemId,
|
|
118
|
+
contextKeyLabel: "system",
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export const systemHealthChangedTrigger: TriggerDefinition<
|
|
122
|
+
z.infer<typeof systemHealthChangedPayloadSchema>
|
|
123
|
+
> = {
|
|
124
|
+
id: "system_health_changed",
|
|
125
|
+
displayName: "System Health Changed",
|
|
126
|
+
description:
|
|
127
|
+
"Fires on every aggregated-health transition — carries previous + new status",
|
|
128
|
+
category: "Health",
|
|
129
|
+
icon: "HeartPulse",
|
|
130
|
+
payloadSchema: systemHealthChangedPayloadSchema,
|
|
131
|
+
// Entity-driven (§10.3): fired by the `health` entity change deriver.
|
|
132
|
+
setup: makeEntityDrivenTriggerSetup<
|
|
133
|
+
z.infer<typeof systemHealthChangedPayloadSchema>
|
|
134
|
+
>(),
|
|
135
|
+
contextKey: (p) => p.systemId,
|
|
136
|
+
contextKeyLabel: "system",
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export const checkFailedTrigger: TriggerDefinition<
|
|
140
|
+
z.infer<typeof checkFailedPayloadSchema>
|
|
141
|
+
> = {
|
|
142
|
+
id: "check_failed",
|
|
143
|
+
displayName: "Health Check Failed",
|
|
144
|
+
description:
|
|
145
|
+
"Fires when an individual check run completes with a non-`healthy` status",
|
|
146
|
+
category: "Health",
|
|
147
|
+
icon: "TriangleAlert",
|
|
148
|
+
payloadSchema: checkFailedPayloadSchema,
|
|
149
|
+
hook: healthCheckHooks.checkFailed,
|
|
150
|
+
contextKey: (p) => p.systemId,
|
|
151
|
+
contextKeyLabel: "system",
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// The flapping trigger + its `flapping_detected` hook were removed. Flapping
|
|
155
|
+
// is now detected in the automation engine by a windowed-count gate on the
|
|
156
|
+
// `system_health_changed` trigger (raw change event + `filter` +
|
|
157
|
+
// `window: { count, minutes, refire: "once" }`) — no per-derived event.
|
|
158
|
+
|
|
159
|
+
// Triggers carry heterogeneous config types (all healthcheck triggers are
|
|
160
|
+
// currently config-less). The registry accepts the `<unknown, unknown>` shape
|
|
161
|
+
// and re-validates config against each trigger's own `configSchema` at load,
|
|
162
|
+
// so the registration array is widened here — mirroring
|
|
163
|
+
// `registerBuiltinTriggers` in automation-backend.
|
|
164
|
+
export const healthCheckTriggers: TriggerDefinition<unknown, unknown>[] = [
|
|
165
|
+
systemDegradedTrigger as unknown as TriggerDefinition<unknown, unknown>,
|
|
166
|
+
systemHealthyTrigger as unknown as TriggerDefinition<unknown, unknown>,
|
|
167
|
+
systemHealthChangedTrigger as unknown as TriggerDefinition<unknown, unknown>,
|
|
168
|
+
checkFailedTrigger as unknown as TriggerDefinition<unknown, unknown>,
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
// ─── Action configs ────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
const runNowConfigSchema = z.object({
|
|
174
|
+
systemId: z.string().min(1).describe("Target system id"),
|
|
175
|
+
configurationId: z
|
|
176
|
+
.string()
|
|
177
|
+
.min(1)
|
|
178
|
+
.describe("Target health-check configuration id"),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const assignmentToggleConfigSchema = z.object({
|
|
182
|
+
systemId: z.string().min(1),
|
|
183
|
+
configurationId: z.string().min(1),
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ─── Artifact ──────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
const assignmentArtifactSchema = z.object({
|
|
189
|
+
systemId: z.string(),
|
|
190
|
+
configurationId: z.string(),
|
|
191
|
+
enabled: z.boolean().optional(),
|
|
192
|
+
enqueued: z.boolean().optional(),
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
export type AssignmentArtifact = z.infer<typeof assignmentArtifactSchema>;
|
|
196
|
+
|
|
197
|
+
export const assignmentArtifactType = {
|
|
198
|
+
id: "assignment",
|
|
199
|
+
displayName: "Healthcheck Assignment",
|
|
200
|
+
description:
|
|
201
|
+
"Identifies the system↔configuration assignment touched by an automation action",
|
|
202
|
+
schema: assignmentArtifactSchema,
|
|
203
|
+
} as const;
|
|
204
|
+
|
|
205
|
+
// ─── Action factory ────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
export interface HealthCheckActionDeps {
|
|
208
|
+
service: HealthCheckService;
|
|
209
|
+
queueManager: QueueManager;
|
|
210
|
+
emitHook: <T>(hook: Hook<T>, payload: T) => Promise<void>;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function createHealthCheckActions(
|
|
214
|
+
deps: HealthCheckActionDeps,
|
|
215
|
+
): ActionDefinition<unknown, unknown>[] {
|
|
216
|
+
const runNow: ActionDefinition<
|
|
217
|
+
z.infer<typeof runNowConfigSchema>,
|
|
218
|
+
AssignmentArtifact
|
|
219
|
+
> = {
|
|
220
|
+
id: "run_now",
|
|
221
|
+
displayName: "Run Health Check Now",
|
|
222
|
+
description:
|
|
223
|
+
"Enqueue a one-off run of the given assignment. Doesn't disturb the recurring schedule.",
|
|
224
|
+
category: "Health",
|
|
225
|
+
icon: "Play",
|
|
226
|
+
config: new Versioned({ version: 1, schema: runNowConfigSchema }),
|
|
227
|
+
produces: "healthcheck.assignment",
|
|
228
|
+
execute: async ({ config, logger }) => {
|
|
229
|
+
const queue = deps.queueManager.getQueue<HealthCheckJobPayload>(
|
|
230
|
+
HEALTH_CHECK_QUEUE,
|
|
231
|
+
);
|
|
232
|
+
await queue.enqueue({
|
|
233
|
+
configId: config.configurationId,
|
|
234
|
+
systemId: config.systemId,
|
|
235
|
+
});
|
|
236
|
+
logger.info(
|
|
237
|
+
`Automation enqueued run for ${config.systemId}:${config.configurationId}`,
|
|
238
|
+
);
|
|
239
|
+
return {
|
|
240
|
+
success: true,
|
|
241
|
+
externalId: `${config.systemId}:${config.configurationId}`,
|
|
242
|
+
artifact: {
|
|
243
|
+
systemId: config.systemId,
|
|
244
|
+
configurationId: config.configurationId,
|
|
245
|
+
enqueued: true,
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const enableAssignment: ActionDefinition<
|
|
252
|
+
z.infer<typeof assignmentToggleConfigSchema>,
|
|
253
|
+
AssignmentArtifact
|
|
254
|
+
> = {
|
|
255
|
+
id: "enable_assignment",
|
|
256
|
+
displayName: "Enable Health Check Assignment",
|
|
257
|
+
description:
|
|
258
|
+
"Flip the `enabled` flag on an existing system↔configuration assignment to true.",
|
|
259
|
+
category: "Health",
|
|
260
|
+
icon: "Power",
|
|
261
|
+
config: new Versioned({ version: 1, schema: assignmentToggleConfigSchema }),
|
|
262
|
+
produces: "healthcheck.assignment",
|
|
263
|
+
execute: async ({ config, logger }) => {
|
|
264
|
+
const updated = await deps.service.setAssignmentEnabled(
|
|
265
|
+
config.systemId,
|
|
266
|
+
config.configurationId,
|
|
267
|
+
true,
|
|
268
|
+
);
|
|
269
|
+
if (!updated) {
|
|
270
|
+
return {
|
|
271
|
+
success: false,
|
|
272
|
+
error: `Assignment not found: ${config.systemId} ↔ ${config.configurationId}`,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
await deps.emitHook(healthCheckHooks.assignmentChanged, {
|
|
276
|
+
systemId: config.systemId,
|
|
277
|
+
configurationId: config.configurationId,
|
|
278
|
+
});
|
|
279
|
+
logger.info(
|
|
280
|
+
`Automation enabled assignment ${config.systemId}:${config.configurationId}`,
|
|
281
|
+
);
|
|
282
|
+
return {
|
|
283
|
+
success: true,
|
|
284
|
+
externalId: `${config.systemId}:${config.configurationId}`,
|
|
285
|
+
artifact: {
|
|
286
|
+
systemId: config.systemId,
|
|
287
|
+
configurationId: config.configurationId,
|
|
288
|
+
enabled: true,
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const disableAssignment: ActionDefinition<
|
|
295
|
+
z.infer<typeof assignmentToggleConfigSchema>,
|
|
296
|
+
AssignmentArtifact
|
|
297
|
+
> = {
|
|
298
|
+
id: "disable_assignment",
|
|
299
|
+
displayName: "Disable Health Check Assignment",
|
|
300
|
+
description:
|
|
301
|
+
"Flip the `enabled` flag on an existing system↔configuration assignment to false.",
|
|
302
|
+
category: "Health",
|
|
303
|
+
icon: "PowerOff",
|
|
304
|
+
config: new Versioned({ version: 1, schema: assignmentToggleConfigSchema }),
|
|
305
|
+
produces: "healthcheck.assignment",
|
|
306
|
+
execute: async ({ config, logger }) => {
|
|
307
|
+
const updated = await deps.service.setAssignmentEnabled(
|
|
308
|
+
config.systemId,
|
|
309
|
+
config.configurationId,
|
|
310
|
+
false,
|
|
311
|
+
);
|
|
312
|
+
if (!updated) {
|
|
313
|
+
return {
|
|
314
|
+
success: false,
|
|
315
|
+
error: `Assignment not found: ${config.systemId} ↔ ${config.configurationId}`,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
await deps.emitHook(healthCheckHooks.assignmentChanged, {
|
|
319
|
+
systemId: config.systemId,
|
|
320
|
+
configurationId: config.configurationId,
|
|
321
|
+
});
|
|
322
|
+
logger.info(
|
|
323
|
+
`Automation disabled assignment ${config.systemId}:${config.configurationId}`,
|
|
324
|
+
);
|
|
325
|
+
return {
|
|
326
|
+
success: true,
|
|
327
|
+
externalId: `${config.systemId}:${config.configurationId}`,
|
|
328
|
+
artifact: {
|
|
329
|
+
systemId: config.systemId,
|
|
330
|
+
configurationId: config.configurationId,
|
|
331
|
+
enabled: false,
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
return [
|
|
338
|
+
runNow as ActionDefinition<unknown, unknown>,
|
|
339
|
+
enableAssignment as ActionDefinition<unknown, unknown>,
|
|
340
|
+
disableAssignment as ActionDefinition<unknown, unknown>,
|
|
341
|
+
];
|
|
342
|
+
}
|