@checkstack/maintenance-backend 1.1.6 → 1.2.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 +137 -0
- package/package.json +10 -10
- package/src/automations.test.ts +368 -0
- package/src/automations.ts +422 -0
- package/src/hooks.ts +7 -2
- package/src/index.ts +34 -54
- package/tsconfig.json +3 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,142 @@
|
|
|
1
1
|
# @checkstack/maintenance-backend
|
|
2
2
|
|
|
3
|
+
## 1.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 41c77f4: feat(automation): type enum-able trigger/artifact fields as enums for editor value autocompletion
|
|
8
|
+
|
|
9
|
+
The automation editor's staged completion offers concrete values after a
|
|
10
|
+
comparator (`{{ trigger.payload.severity == "high" }}`) only when the
|
|
11
|
+
field's JSON Schema carries an `enum`. Several trigger payload + artifact
|
|
12
|
+
schemas declared closed-set fields as loose `z.string()`, so no values
|
|
13
|
+
were suggested. Tightened them to the canonical enums that already
|
|
14
|
+
existed in each plugin's `-common` package (and matched the hook payload
|
|
15
|
+
types in lockstep so the trigger's `payloadSchema` and `hook` keep the
|
|
16
|
+
same `TPayload`):
|
|
17
|
+
|
|
18
|
+
- **incident** — trigger payloads: `severity` → `IncidentSeverityEnum`,
|
|
19
|
+
`status` / `statusChange` → `IncidentStatusEnum`.
|
|
20
|
+
- **healthcheck** — trigger payloads: `previousStatus` / `newStatus` /
|
|
21
|
+
`status` → `HealthCheckStatusSchema` (across systemDegraded,
|
|
22
|
+
systemHealthy, systemHealthChanged, checkFailed; plus checkCompleted's
|
|
23
|
+
hook type).
|
|
24
|
+
- **dependency** — trigger + artifact: `impactType` → `ImpactTypeSchema`;
|
|
25
|
+
impactPropagated `previousState` / `newState` → `DerivedStateSchema`.
|
|
26
|
+
Also deduped the inline `impactTypeSchema` action-config enum to reuse
|
|
27
|
+
the canonical `ImpactTypeSchema`.
|
|
28
|
+
- **maintenance** — trigger + artifact: `status` →
|
|
29
|
+
`MaintenanceStatusEnum`; deduped the inline `maintenanceStatusEnum`
|
|
30
|
+
(used by `add_update.statusChange`) to the canonical one.
|
|
31
|
+
- **slo** — `achievement.unlocked` trigger + hook: `achievement` →
|
|
32
|
+
`AchievementTypeSchema`.
|
|
33
|
+
|
|
34
|
+
Runtime behaviour is unchanged — these fields always carried valid enum
|
|
35
|
+
values (the underlying records are enum-constrained); only the schema
|
|
36
|
+
types were loose. The hook payload generics are now precise too, which
|
|
37
|
+
caught one stale test fixture asserting an invalid `impactType: "soft"`.
|
|
38
|
+
|
|
39
|
+
Fields that look enum-ish but are genuinely free-form were intentionally
|
|
40
|
+
left as `z.string()`: satellite `region` (user-entered), Jira issue
|
|
41
|
+
`status` (per-instance workflow name), notification `strategyQualifiedId`
|
|
42
|
+
/ `errorMessage`, healthcheck collector `result`, and script
|
|
43
|
+
`stdout` / `stderr`.
|
|
44
|
+
|
|
45
|
+
- 41c77f4: feat(maintenance): Phase 9 — actions + system-shaped helpers
|
|
46
|
+
|
|
47
|
+
- Triggers `maintenance.created`, `maintenance.updated` are unchanged;
|
|
48
|
+
they're now lifted out of the inline `register()` block into
|
|
49
|
+
`automations.ts` alongside the new actions.
|
|
50
|
+
- Actions `maintenance.create`, `maintenance.update`,
|
|
51
|
+
`maintenance.add_update` wrapping `MaintenanceService`. Each emits
|
|
52
|
+
the appropriate `maintenanceHooks.*` so downstream automations and
|
|
53
|
+
caches react identically to RPC-driven changes; `add_update`
|
|
54
|
+
re-fetches the window before emitting so the hook payload reflects
|
|
55
|
+
the new status.
|
|
56
|
+
- The two deferred catalog actions land here as
|
|
57
|
+
`maintenance.set_system` (schedule a `now → now+durationMinutes`
|
|
58
|
+
window covering a single system — the "park this system" operation)
|
|
59
|
+
and `maintenance.clear_system` (close every active or scheduled
|
|
60
|
+
window covering a given system — the "let it back into rotation"
|
|
61
|
+
operation).
|
|
62
|
+
- Artifact type `maintenance.window` for downstream steps to consume.
|
|
63
|
+
|
|
64
|
+
### Patch Changes
|
|
65
|
+
|
|
66
|
+
- 41c77f4: feat(automation): one-time migration of webhook subscriptions + remove legacy integration backend
|
|
67
|
+
|
|
68
|
+
**BREAKING CHANGES** (platform is in BETA — no major bump):
|
|
69
|
+
|
|
70
|
+
- `IntegrationProvider` no longer carries `config` (subscription
|
|
71
|
+
config) or `deliver`. The interface now models a connection provider
|
|
72
|
+
only: connection schema + `getConnectionOptions` + `testConnection`.
|
|
73
|
+
- The legacy subscription / delivery-log / event endpoints
|
|
74
|
+
(`listSubscriptions`, `createSubscription`, `getDeliveryLogs`,
|
|
75
|
+
`listEventTypes`, …) are removed from `integrationContract`.
|
|
76
|
+
- `delivery-coordinator`, `hook-subscriber`, `event-registry`, and the
|
|
77
|
+
`integrationEventExtensionPoint` are deleted. Plugins that
|
|
78
|
+
previously called `integrationEvents.registerEvent(...)` now
|
|
79
|
+
register their hooks as automation triggers via
|
|
80
|
+
`automationTriggerExtensionPoint.registerTrigger(...)`.
|
|
81
|
+
- Frontend pages `IntegrationsPage` and `DeliveryLogsPage` are gone;
|
|
82
|
+
the integration plugin's only remaining UI is connection
|
|
83
|
+
management. Subscription management lives under `/automation/...`.
|
|
84
|
+
- `webhook_subscriptions` and `delivery_logs` tables stay in the
|
|
85
|
+
database for one release as a safety net (no code reads or writes
|
|
86
|
+
them), and will be dropped in a follow-up migration.
|
|
87
|
+
|
|
88
|
+
**New**:
|
|
89
|
+
|
|
90
|
+
- `jira.create_issue`, `teams.post_message`, `webex.post_message`,
|
|
91
|
+
`webhook.send`, `integration-script.run_shell`, and
|
|
92
|
+
`integration-script.run_script` actions registered against the
|
|
93
|
+
Automation Platform with matching `*.message`, `*.delivery`,
|
|
94
|
+
`shell.result`, and `script.result` artifact types. The script
|
|
95
|
+
plugin exposes **two** actions — `run_shell` runs bash via the
|
|
96
|
+
shared `ShellScriptRunner` (Monaco `shell` editor), `run_script`
|
|
97
|
+
runs an ESM module in a Bun subprocess via `EsmScriptRunner`
|
|
98
|
+
(Monaco `typescript` editor + `defineIntegration` helper) — to
|
|
99
|
+
preserve the legacy provider split. `jira.create_issue` keeps the
|
|
100
|
+
dynamic field-mapping dropdown (driven by
|
|
101
|
+
`JIRA_RESOLVERS.FIELD_OPTIONS`).
|
|
102
|
+
- One-time data migration runs on boot in
|
|
103
|
+
`automation-backend.afterPluginsReady`. It reads
|
|
104
|
+
`webhook_subscriptions` via a new service RPC
|
|
105
|
+
`IntegrationApi.listLegacySubscriptions`, translates each row into
|
|
106
|
+
a single-trigger / single-action automation (marked with
|
|
107
|
+
`managed_by = "migrated-subscription:<id>"`), and is idempotent
|
|
108
|
+
across restarts.
|
|
109
|
+
- Failed translations are recorded in a new
|
|
110
|
+
`automation_migration_failures` table and surfaced via
|
|
111
|
+
`AutomationApi.listMigrationFailures` /
|
|
112
|
+
`acknowledgeMigrationFailure` so admins can review and re-create
|
|
113
|
+
failed entries by hand.
|
|
114
|
+
|
|
115
|
+
- Updated dependencies [e2d6f25]
|
|
116
|
+
- Updated dependencies [41c77f4]
|
|
117
|
+
- Updated dependencies [e1a2077]
|
|
118
|
+
- Updated dependencies [41c77f4]
|
|
119
|
+
- Updated dependencies [41c77f4]
|
|
120
|
+
- Updated dependencies [41c77f4]
|
|
121
|
+
- Updated dependencies [41c77f4]
|
|
122
|
+
- Updated dependencies [41c77f4]
|
|
123
|
+
- Updated dependencies [41c77f4]
|
|
124
|
+
- Updated dependencies [6d52276]
|
|
125
|
+
- Updated dependencies [6d52276]
|
|
126
|
+
- Updated dependencies [35bc682]
|
|
127
|
+
- @checkstack/automation-backend@0.2.0
|
|
128
|
+
- @checkstack/catalog-backend@1.2.0
|
|
129
|
+
- @checkstack/common@0.12.0
|
|
130
|
+
- @checkstack/backend-api@0.18.0
|
|
131
|
+
- @checkstack/catalog-common@2.2.3
|
|
132
|
+
- @checkstack/maintenance-common@1.2.3
|
|
133
|
+
- @checkstack/auth-common@0.7.2
|
|
134
|
+
- @checkstack/command-backend@0.1.31
|
|
135
|
+
- @checkstack/notification-common@1.2.1
|
|
136
|
+
- @checkstack/signal-common@0.2.5
|
|
137
|
+
- @checkstack/cache-api@0.3.6
|
|
138
|
+
- @checkstack/cache-utils@0.2.11
|
|
139
|
+
|
|
3
140
|
## 1.1.6
|
|
4
141
|
|
|
5
142
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/maintenance-backend",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -11,21 +11,21 @@
|
|
|
11
11
|
"typecheck": "tsgo -b",
|
|
12
12
|
"generate": "drizzle-kit generate",
|
|
13
13
|
"lint": "bun run lint:code",
|
|
14
|
-
"lint:code": "eslint . --max-warnings 0"
|
|
14
|
+
"lint:code": "eslint . --max-warnings 0",
|
|
15
|
+
"test": "bun test"
|
|
15
16
|
},
|
|
16
17
|
"dependencies": {
|
|
17
|
-
"@checkstack/backend-api": "0.17.
|
|
18
|
-
"@checkstack/cache-api": "0.3.
|
|
19
|
-
"@checkstack/cache-utils": "0.2.
|
|
18
|
+
"@checkstack/backend-api": "0.17.1",
|
|
19
|
+
"@checkstack/cache-api": "0.3.5",
|
|
20
|
+
"@checkstack/cache-utils": "0.2.10",
|
|
20
21
|
"@checkstack/maintenance-common": "1.2.2",
|
|
21
22
|
"@checkstack/notification-common": "1.2.0",
|
|
22
23
|
"@checkstack/catalog-common": "2.2.2",
|
|
23
|
-
"@checkstack/catalog-backend": "1.1.
|
|
24
|
+
"@checkstack/catalog-backend": "1.1.6",
|
|
24
25
|
"@checkstack/auth-common": "0.7.1",
|
|
25
|
-
"@checkstack/command-backend": "0.1.
|
|
26
|
+
"@checkstack/command-backend": "0.1.30",
|
|
26
27
|
"@checkstack/signal-common": "0.2.4",
|
|
27
|
-
"@checkstack/
|
|
28
|
-
"@checkstack/integration-common": "0.5.0",
|
|
28
|
+
"@checkstack/automation-backend": "0.1.0",
|
|
29
29
|
"drizzle-orm": "^0.45.0",
|
|
30
30
|
"zod": "^4.2.1",
|
|
31
31
|
"@checkstack/common": "0.11.0",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@checkstack/drizzle-helper": "0.0.5",
|
|
36
36
|
"@checkstack/scripts": "0.3.3",
|
|
37
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
37
|
+
"@checkstack/test-utils-backend": "0.1.30",
|
|
38
38
|
"@checkstack/tsconfig": "0.0.7",
|
|
39
39
|
"@types/bun": "^1.0.0",
|
|
40
40
|
"drizzle-kit": "^0.31.10",
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Behaviour tests for the maintenance automation triggers + actions.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it, mock } from "bun:test";
|
|
5
|
+
import type { Logger } from "@checkstack/backend-api";
|
|
6
|
+
import { createMockLogger } from "@checkstack/test-utils-backend";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
createMaintenanceActions,
|
|
10
|
+
maintenanceArtifactType,
|
|
11
|
+
maintenanceCreatedTrigger,
|
|
12
|
+
maintenanceTriggers,
|
|
13
|
+
maintenanceUpdatedTrigger,
|
|
14
|
+
} from "./automations";
|
|
15
|
+
import { maintenanceHooks } from "./hooks";
|
|
16
|
+
import type { MaintenanceService } from "./service";
|
|
17
|
+
|
|
18
|
+
const logger = createMockLogger() as Logger;
|
|
19
|
+
|
|
20
|
+
const ctxBase = {
|
|
21
|
+
runId: "run-1",
|
|
22
|
+
automationId: "auto-1",
|
|
23
|
+
contextKey: null,
|
|
24
|
+
logger,
|
|
25
|
+
getService: async <T,>(): Promise<T> => {
|
|
26
|
+
throw new Error("not used");
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// ─── Triggers ──────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
describe("maintenance triggers", () => {
|
|
33
|
+
it("exposes two triggers in a stable order", () => {
|
|
34
|
+
expect(maintenanceTriggers).toHaveLength(2);
|
|
35
|
+
expect(maintenanceTriggers[0]).toBe(
|
|
36
|
+
maintenanceCreatedTrigger as (typeof maintenanceTriggers)[number],
|
|
37
|
+
);
|
|
38
|
+
expect(maintenanceTriggers[1]).toBe(
|
|
39
|
+
maintenanceUpdatedTrigger as (typeof maintenanceTriggers)[number],
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("extracts maintenanceId as the contextKey on both triggers", () => {
|
|
44
|
+
const payload = {
|
|
45
|
+
maintenanceId: "m-1",
|
|
46
|
+
systemIds: ["sys-1"],
|
|
47
|
+
title: "Deploy",
|
|
48
|
+
status: "scheduled" as const,
|
|
49
|
+
startAt: "2026-05-29T11:00:00Z",
|
|
50
|
+
endAt: "2026-05-29T12:00:00Z",
|
|
51
|
+
};
|
|
52
|
+
expect(maintenanceCreatedTrigger.contextKey?.(payload)).toBe("m-1");
|
|
53
|
+
expect(
|
|
54
|
+
maintenanceUpdatedTrigger.contextKey?.({ ...payload, action: "updated" }),
|
|
55
|
+
).toBe("m-1");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("requires action enum on updated payload", () => {
|
|
59
|
+
const ok = maintenanceUpdatedTrigger.payloadSchema.safeParse({
|
|
60
|
+
maintenanceId: "m-1",
|
|
61
|
+
systemIds: [],
|
|
62
|
+
title: "Deploy",
|
|
63
|
+
status: "completed",
|
|
64
|
+
startAt: "2026-05-29T11:00:00Z",
|
|
65
|
+
endAt: "2026-05-29T12:00:00Z",
|
|
66
|
+
action: "closed",
|
|
67
|
+
});
|
|
68
|
+
const bad = maintenanceUpdatedTrigger.payloadSchema.safeParse({
|
|
69
|
+
maintenanceId: "m-1",
|
|
70
|
+
systemIds: [],
|
|
71
|
+
title: "Deploy",
|
|
72
|
+
status: "completed",
|
|
73
|
+
startAt: "2026-05-29T11:00:00Z",
|
|
74
|
+
endAt: "2026-05-29T12:00:00Z",
|
|
75
|
+
});
|
|
76
|
+
expect(ok.success).toBe(true);
|
|
77
|
+
expect(bad.success).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("maintenanceArtifactType", () => {
|
|
82
|
+
it("validates the canonical artifact shape", () => {
|
|
83
|
+
const ok = maintenanceArtifactType.schema.safeParse({
|
|
84
|
+
maintenanceId: "m-1",
|
|
85
|
+
status: "scheduled",
|
|
86
|
+
systemIds: ["sys-1"],
|
|
87
|
+
startAt: "2026-05-29T11:00:00Z",
|
|
88
|
+
endAt: "2026-05-29T12:00:00Z",
|
|
89
|
+
});
|
|
90
|
+
expect(ok.success).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ─── Actions ───────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
interface FakeMaintenance {
|
|
97
|
+
id: string;
|
|
98
|
+
title: string;
|
|
99
|
+
description?: string;
|
|
100
|
+
status: string;
|
|
101
|
+
systemIds: string[];
|
|
102
|
+
startAt: Date;
|
|
103
|
+
endAt: Date;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function makeService(args: {
|
|
107
|
+
rowToReturn?: FakeMaintenance;
|
|
108
|
+
updateReturn?: FakeMaintenance | undefined;
|
|
109
|
+
getMaintenanceReturn?: FakeMaintenance | undefined;
|
|
110
|
+
activeForSystem?: FakeMaintenance[];
|
|
111
|
+
closeReturn?: FakeMaintenance | undefined;
|
|
112
|
+
}): MaintenanceService & {
|
|
113
|
+
createMock: ReturnType<typeof mock>;
|
|
114
|
+
updateMock: ReturnType<typeof mock>;
|
|
115
|
+
addUpdateMock: ReturnType<typeof mock>;
|
|
116
|
+
getMock: ReturnType<typeof mock>;
|
|
117
|
+
activeMock: ReturnType<typeof mock>;
|
|
118
|
+
closeMock: ReturnType<typeof mock>;
|
|
119
|
+
} {
|
|
120
|
+
const createMock = mock(async (_input: unknown) => args.rowToReturn);
|
|
121
|
+
const updateMock = mock(async (_input: unknown) => args.updateReturn);
|
|
122
|
+
const addUpdateMock = mock(async (_input: unknown) => ({
|
|
123
|
+
id: "upd-1",
|
|
124
|
+
maintenanceId: "m-1",
|
|
125
|
+
message: "x",
|
|
126
|
+
statusChange: undefined,
|
|
127
|
+
createdBy: undefined,
|
|
128
|
+
createdAt: new Date(),
|
|
129
|
+
}));
|
|
130
|
+
const getMock = mock(async (_id: string) => args.getMaintenanceReturn);
|
|
131
|
+
const activeMock = mock(async (_id: string) => args.activeForSystem ?? []);
|
|
132
|
+
const closeMock = mock(async (_id: string, _msg?: string) => args.closeReturn);
|
|
133
|
+
return {
|
|
134
|
+
createMaintenance: createMock,
|
|
135
|
+
updateMaintenance: updateMock,
|
|
136
|
+
addUpdate: addUpdateMock,
|
|
137
|
+
getMaintenance: getMock,
|
|
138
|
+
getMaintenancesForSystem: activeMock,
|
|
139
|
+
closeMaintenance: closeMock,
|
|
140
|
+
createMock,
|
|
141
|
+
updateMock,
|
|
142
|
+
addUpdateMock,
|
|
143
|
+
getMock,
|
|
144
|
+
activeMock,
|
|
145
|
+
closeMock,
|
|
146
|
+
} as unknown as MaintenanceService & {
|
|
147
|
+
createMock: ReturnType<typeof mock>;
|
|
148
|
+
updateMock: ReturnType<typeof mock>;
|
|
149
|
+
addUpdateMock: ReturnType<typeof mock>;
|
|
150
|
+
getMock: ReturnType<typeof mock>;
|
|
151
|
+
activeMock: ReturnType<typeof mock>;
|
|
152
|
+
closeMock: ReturnType<typeof mock>;
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const sampleRow: FakeMaintenance = {
|
|
157
|
+
id: "m-1",
|
|
158
|
+
title: "Deploy",
|
|
159
|
+
status: "scheduled",
|
|
160
|
+
systemIds: ["sys-1"],
|
|
161
|
+
startAt: new Date("2026-05-29T11:00:00Z"),
|
|
162
|
+
endAt: new Date("2026-05-29T12:00:00Z"),
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
describe("maintenance.create", () => {
|
|
166
|
+
it("creates a maintenance, fires maintenanceCreated, and emits a maintenance.window artifact", async () => {
|
|
167
|
+
const service = makeService({ rowToReturn: sampleRow });
|
|
168
|
+
const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
|
|
169
|
+
const [create] = createMaintenanceActions({
|
|
170
|
+
service,
|
|
171
|
+
emitHook: emitHook as never,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const result = await create!.execute({
|
|
175
|
+
...ctxBase,
|
|
176
|
+
consumedArtifacts: {},
|
|
177
|
+
config: {
|
|
178
|
+
title: "Deploy",
|
|
179
|
+
systemIds: ["sys-1"],
|
|
180
|
+
startAt: "2026-05-29T11:00:00Z",
|
|
181
|
+
endAt: "2026-05-29T12:00:00Z",
|
|
182
|
+
} as never,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
expect(result.success).toBe(true);
|
|
186
|
+
if (!result.success) return;
|
|
187
|
+
expect(result.externalId).toBe("m-1");
|
|
188
|
+
expect(emitHook).toHaveBeenCalledTimes(1);
|
|
189
|
+
expect(emitHook.mock.calls[0]![0]).toBe(maintenanceHooks.maintenanceCreated);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("maintenance.update", () => {
|
|
194
|
+
it("returns failure when the maintenance doesn't exist", async () => {
|
|
195
|
+
const service = makeService({ updateReturn: undefined });
|
|
196
|
+
const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
|
|
197
|
+
const [, update] = createMaintenanceActions({
|
|
198
|
+
service,
|
|
199
|
+
emitHook: emitHook as never,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const result = await update!.execute({
|
|
203
|
+
...ctxBase,
|
|
204
|
+
consumedArtifacts: {},
|
|
205
|
+
config: { maintenanceId: "missing", title: "x" } as never,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
expect(result.success).toBe(false);
|
|
209
|
+
if (result.success) return;
|
|
210
|
+
expect(result.error).toMatch(/not found/i);
|
|
211
|
+
expect(emitHook).not.toHaveBeenCalled();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("emits maintenanceUpdated with action='updated' on success", async () => {
|
|
215
|
+
const service = makeService({ updateReturn: sampleRow });
|
|
216
|
+
const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
|
|
217
|
+
const [, update] = createMaintenanceActions({
|
|
218
|
+
service,
|
|
219
|
+
emitHook: emitHook as never,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const result = await update!.execute({
|
|
223
|
+
...ctxBase,
|
|
224
|
+
consumedArtifacts: {},
|
|
225
|
+
config: { maintenanceId: "m-1", title: "Deploy v2" } as never,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
expect(result.success).toBe(true);
|
|
229
|
+
const emitCall = emitHook.mock.calls[0]!;
|
|
230
|
+
expect(emitCall[0]).toBe(maintenanceHooks.maintenanceUpdated);
|
|
231
|
+
expect((emitCall[1] as { action: string }).action).toBe("updated");
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe("maintenance.add_update", () => {
|
|
236
|
+
it("uses action='closed' when statusChange is 'completed'", async () => {
|
|
237
|
+
const service = makeService({
|
|
238
|
+
getMaintenanceReturn: { ...sampleRow, status: "completed" },
|
|
239
|
+
});
|
|
240
|
+
const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
|
|
241
|
+
const [, , addUpdate] = createMaintenanceActions({
|
|
242
|
+
service,
|
|
243
|
+
emitHook: emitHook as never,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const result = await addUpdate!.execute({
|
|
247
|
+
...ctxBase,
|
|
248
|
+
consumedArtifacts: {},
|
|
249
|
+
config: {
|
|
250
|
+
maintenanceId: "m-1",
|
|
251
|
+
message: "done",
|
|
252
|
+
statusChange: "completed",
|
|
253
|
+
} as never,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
expect(result.success).toBe(true);
|
|
257
|
+
expect((emitHook.mock.calls[0]![1] as { action: string }).action).toBe(
|
|
258
|
+
"closed",
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("returns failure when the maintenance vanishes between addUpdate and getMaintenance", async () => {
|
|
263
|
+
const service = makeService({ getMaintenanceReturn: undefined });
|
|
264
|
+
const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
|
|
265
|
+
const [, , addUpdate] = createMaintenanceActions({
|
|
266
|
+
service,
|
|
267
|
+
emitHook: emitHook as never,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const result = await addUpdate!.execute({
|
|
271
|
+
...ctxBase,
|
|
272
|
+
consumedArtifacts: {},
|
|
273
|
+
config: { maintenanceId: "m-1", message: "x" } as never,
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
expect(result.success).toBe(false);
|
|
277
|
+
if (result.success) return;
|
|
278
|
+
expect(result.error).toMatch(/not found/i);
|
|
279
|
+
expect(emitHook).not.toHaveBeenCalled();
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe("maintenance.set_system", () => {
|
|
284
|
+
it("schedules a now+durationMinutes window covering one system", async () => {
|
|
285
|
+
const service = makeService({ rowToReturn: sampleRow });
|
|
286
|
+
const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
|
|
287
|
+
const fixedNow = new Date("2026-05-29T11:00:00Z");
|
|
288
|
+
const [, , , setSystem] = createMaintenanceActions({
|
|
289
|
+
service,
|
|
290
|
+
emitHook: emitHook as never,
|
|
291
|
+
now: () => fixedNow,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
await setSystem!.execute({
|
|
295
|
+
...ctxBase,
|
|
296
|
+
consumedArtifacts: {},
|
|
297
|
+
config: {
|
|
298
|
+
systemId: "sys-1",
|
|
299
|
+
durationMinutes: 60,
|
|
300
|
+
} as never,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
expect(service.createMock).toHaveBeenCalledTimes(1);
|
|
304
|
+
const call = service.createMock.mock.calls[0]![0] as {
|
|
305
|
+
systemIds: string[];
|
|
306
|
+
startAt: Date;
|
|
307
|
+
endAt: Date;
|
|
308
|
+
};
|
|
309
|
+
expect(call.systemIds).toEqual(["sys-1"]);
|
|
310
|
+
expect(call.startAt.toISOString()).toBe("2026-05-29T11:00:00.000Z");
|
|
311
|
+
expect(call.endAt.toISOString()).toBe("2026-05-29T12:00:00.000Z");
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
describe("maintenance.clear_system", () => {
|
|
316
|
+
it("closes every active window for the system + emits one updated hook per close", async () => {
|
|
317
|
+
const window1 = { ...sampleRow, id: "m-1" };
|
|
318
|
+
const window2 = { ...sampleRow, id: "m-2" };
|
|
319
|
+
const service = makeService({
|
|
320
|
+
activeForSystem: [window1, window2],
|
|
321
|
+
closeReturn: window1,
|
|
322
|
+
});
|
|
323
|
+
// closeMaintenance returns the same row both times in the fixture
|
|
324
|
+
// — that's fine for this test; we only assert the count + ids.
|
|
325
|
+
const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
|
|
326
|
+
const actions = createMaintenanceActions({
|
|
327
|
+
service,
|
|
328
|
+
emitHook: emitHook as never,
|
|
329
|
+
});
|
|
330
|
+
const clearSystem = actions[4]!;
|
|
331
|
+
|
|
332
|
+
const result = await clearSystem.execute({
|
|
333
|
+
...ctxBase,
|
|
334
|
+
consumedArtifacts: {},
|
|
335
|
+
config: { systemId: "sys-1" } as never,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
expect(result.success).toBe(true);
|
|
339
|
+
expect(service.closeMock).toHaveBeenCalledTimes(2);
|
|
340
|
+
expect(emitHook).toHaveBeenCalledTimes(2);
|
|
341
|
+
for (const call of emitHook.mock.calls) {
|
|
342
|
+
expect(call[0]).toBe(maintenanceHooks.maintenanceUpdated);
|
|
343
|
+
expect((call[1] as { action: string }).action).toBe("closed");
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("succeeds and emits an empty artifact when no windows are active for the system", async () => {
|
|
348
|
+
const service = makeService({ activeForSystem: [] });
|
|
349
|
+
const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
|
|
350
|
+
const actions = createMaintenanceActions({
|
|
351
|
+
service,
|
|
352
|
+
emitHook: emitHook as never,
|
|
353
|
+
});
|
|
354
|
+
const clearSystem = actions[4]!;
|
|
355
|
+
|
|
356
|
+
const result = await clearSystem.execute({
|
|
357
|
+
...ctxBase,
|
|
358
|
+
consumedArtifacts: {},
|
|
359
|
+
config: { systemId: "sys-1" } as never,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
expect(result.success).toBe(true);
|
|
363
|
+
if (!result.success) return;
|
|
364
|
+
const artifact = result.artifact as { closedMaintenanceIds: string[] };
|
|
365
|
+
expect(artifact.closedMaintenanceIds).toEqual([]);
|
|
366
|
+
expect(emitHook).not.toHaveBeenCalled();
|
|
367
|
+
});
|
|
368
|
+
});
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maintenance triggers + actions registered with the Automation Platform.
|
|
3
|
+
*
|
|
4
|
+
* Triggers re-expose `maintenanceHooks` as automation entry points
|
|
5
|
+
* (`maintenance.created`, `maintenance.updated`). The existing index
|
|
6
|
+
* registered these inline; this module owns them now alongside the
|
|
7
|
+
* actions so the plugin's automation surface lives in one place.
|
|
8
|
+
*
|
|
9
|
+
* Actions wrap `MaintenanceService` for `create`, `update`, and
|
|
10
|
+
* `add_update`. The catalog Phase-9 chunk deferred two additional
|
|
11
|
+
* "system-shaped" actions to this chunk:
|
|
12
|
+
*
|
|
13
|
+
* - `set_system`: schedule a maintenance window that starts now and
|
|
14
|
+
* covers a single system, for a given duration. The convenient
|
|
15
|
+
* "park this system for an hour" operation.
|
|
16
|
+
* - `clear_system`: close every active/scheduled maintenance that
|
|
17
|
+
* covers a given system. The convenient "let it back into rotation
|
|
18
|
+
* even if maintenance was over-scheduled" operation.
|
|
19
|
+
*
|
|
20
|
+
* Mutation actions emit the matching hooks themselves (via the
|
|
21
|
+
* `emitHook` factory dep) so downstream automations + caches react
|
|
22
|
+
* the same way they do when the mutation comes in via RPC.
|
|
23
|
+
*/
|
|
24
|
+
import { z } from "zod";
|
|
25
|
+
import { Versioned, type Hook } from "@checkstack/backend-api";
|
|
26
|
+
import type {
|
|
27
|
+
ActionDefinition,
|
|
28
|
+
TriggerDefinition,
|
|
29
|
+
} from "@checkstack/automation-backend";
|
|
30
|
+
import {
|
|
31
|
+
MaintenanceStatusEnum,
|
|
32
|
+
type MaintenanceStatus,
|
|
33
|
+
} from "@checkstack/maintenance-common";
|
|
34
|
+
|
|
35
|
+
import { maintenanceHooks } from "./hooks";
|
|
36
|
+
import type { MaintenanceService } from "./service";
|
|
37
|
+
|
|
38
|
+
// ─── Payload schemas ───────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
const maintenanceCreatedPayloadSchema = z.object({
|
|
41
|
+
maintenanceId: z.string(),
|
|
42
|
+
systemIds: z.array(z.string()),
|
|
43
|
+
title: z.string(),
|
|
44
|
+
description: z.string().optional(),
|
|
45
|
+
status: MaintenanceStatusEnum,
|
|
46
|
+
startAt: z.string(),
|
|
47
|
+
endAt: z.string(),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const maintenanceUpdatedPayloadSchema = z.object({
|
|
51
|
+
maintenanceId: z.string(),
|
|
52
|
+
systemIds: z.array(z.string()),
|
|
53
|
+
title: z.string(),
|
|
54
|
+
description: z.string().optional(),
|
|
55
|
+
status: MaintenanceStatusEnum,
|
|
56
|
+
startAt: z.string(),
|
|
57
|
+
endAt: z.string(),
|
|
58
|
+
action: z.enum(["updated", "closed"]),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ─── Triggers ──────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
export const maintenanceCreatedTrigger: TriggerDefinition<
|
|
64
|
+
z.infer<typeof maintenanceCreatedPayloadSchema>
|
|
65
|
+
> = {
|
|
66
|
+
id: "created",
|
|
67
|
+
displayName: "Maintenance Created",
|
|
68
|
+
description: "Fired when a new maintenance is scheduled",
|
|
69
|
+
category: "Maintenance",
|
|
70
|
+
icon: "Wrench",
|
|
71
|
+
payloadSchema: maintenanceCreatedPayloadSchema,
|
|
72
|
+
hook: maintenanceHooks.maintenanceCreated,
|
|
73
|
+
contextKey: (p) => p.maintenanceId,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const maintenanceUpdatedTrigger: TriggerDefinition<
|
|
77
|
+
z.infer<typeof maintenanceUpdatedPayloadSchema>
|
|
78
|
+
> = {
|
|
79
|
+
id: "updated",
|
|
80
|
+
displayName: "Maintenance Updated",
|
|
81
|
+
description: "Fired when a maintenance is updated or closed",
|
|
82
|
+
category: "Maintenance",
|
|
83
|
+
icon: "Wrench",
|
|
84
|
+
payloadSchema: maintenanceUpdatedPayloadSchema,
|
|
85
|
+
hook: maintenanceHooks.maintenanceUpdated,
|
|
86
|
+
contextKey: (p) => p.maintenanceId,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const maintenanceTriggers: TriggerDefinition<unknown>[] = [
|
|
90
|
+
maintenanceCreatedTrigger as TriggerDefinition<unknown>,
|
|
91
|
+
maintenanceUpdatedTrigger as TriggerDefinition<unknown>,
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
// ─── Action configs ────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
const createConfigSchema = z.object({
|
|
97
|
+
title: z.string().min(1),
|
|
98
|
+
description: z.string().optional(),
|
|
99
|
+
systemIds: z.array(z.string()).min(1),
|
|
100
|
+
startAt: z
|
|
101
|
+
.string()
|
|
102
|
+
.describe("ISO timestamp when the window starts"),
|
|
103
|
+
endAt: z.string().describe("ISO timestamp when the window ends"),
|
|
104
|
+
suppressNotifications: z.boolean().optional().default(false),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const updateConfigSchema = z.object({
|
|
108
|
+
maintenanceId: z.string().min(1),
|
|
109
|
+
title: z.string().optional(),
|
|
110
|
+
description: z.string().optional(),
|
|
111
|
+
systemIds: z.array(z.string()).optional(),
|
|
112
|
+
startAt: z.string().optional(),
|
|
113
|
+
endAt: z.string().optional(),
|
|
114
|
+
suppressNotifications: z.boolean().optional(),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const addUpdateConfigSchema = z.object({
|
|
118
|
+
maintenanceId: z.string().min(1),
|
|
119
|
+
message: z.string().min(1),
|
|
120
|
+
statusChange: MaintenanceStatusEnum.optional(),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const setSystemConfigSchema = z.object({
|
|
124
|
+
systemId: z.string().min(1),
|
|
125
|
+
title: z.string().optional(),
|
|
126
|
+
description: z.string().optional(),
|
|
127
|
+
durationMinutes: z
|
|
128
|
+
.number()
|
|
129
|
+
.int()
|
|
130
|
+
.min(1)
|
|
131
|
+
.max(30 * 24 * 60)
|
|
132
|
+
.describe("Window length in minutes (1 min – 30 days)"),
|
|
133
|
+
suppressNotifications: z.boolean().optional().default(false),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const clearSystemConfigSchema = z.object({
|
|
137
|
+
systemId: z.string().min(1),
|
|
138
|
+
message: z
|
|
139
|
+
.string()
|
|
140
|
+
.optional()
|
|
141
|
+
.describe(
|
|
142
|
+
"Note appended to the maintenance update log; defaults to a generic 'cleared by automation' message",
|
|
143
|
+
),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ─── Artifact ──────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
const maintenanceArtifactSchema = z.object({
|
|
149
|
+
maintenanceId: z.string(),
|
|
150
|
+
status: MaintenanceStatusEnum,
|
|
151
|
+
systemIds: z.array(z.string()),
|
|
152
|
+
startAt: z.string(),
|
|
153
|
+
endAt: z.string(),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
export type MaintenanceArtifact = z.infer<typeof maintenanceArtifactSchema>;
|
|
157
|
+
|
|
158
|
+
export const maintenanceArtifactType = {
|
|
159
|
+
id: "window",
|
|
160
|
+
displayName: "Maintenance Window",
|
|
161
|
+
description: "Maintenance row touched (created/updated/closed) by an automation",
|
|
162
|
+
schema: maintenanceArtifactSchema,
|
|
163
|
+
} as const;
|
|
164
|
+
|
|
165
|
+
// ─── Action factory ────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
export interface MaintenanceActionDeps {
|
|
168
|
+
service: MaintenanceService;
|
|
169
|
+
emitHook: <T>(hook: Hook<T>, payload: T) => Promise<void>;
|
|
170
|
+
/**
|
|
171
|
+
* Override for `Date.now()`. Only used by `set_system` to compute
|
|
172
|
+
* `endAt = now + durationMinutes`. Tests inject a fixed clock; the
|
|
173
|
+
* default uses the real wall clock.
|
|
174
|
+
*/
|
|
175
|
+
now?: () => Date;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function toArtifact(maint: {
|
|
179
|
+
id: string;
|
|
180
|
+
status: MaintenanceStatus;
|
|
181
|
+
systemIds: string[];
|
|
182
|
+
startAt: Date | string;
|
|
183
|
+
endAt: Date | string;
|
|
184
|
+
}): MaintenanceArtifact {
|
|
185
|
+
return {
|
|
186
|
+
maintenanceId: maint.id,
|
|
187
|
+
status: maint.status,
|
|
188
|
+
systemIds: maint.systemIds,
|
|
189
|
+
startAt:
|
|
190
|
+
maint.startAt instanceof Date ? maint.startAt.toISOString() : maint.startAt,
|
|
191
|
+
endAt: maint.endAt instanceof Date ? maint.endAt.toISOString() : maint.endAt,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function createMaintenanceActions(
|
|
196
|
+
deps: MaintenanceActionDeps,
|
|
197
|
+
): ActionDefinition<unknown, unknown>[] {
|
|
198
|
+
const now = deps.now ?? (() => new Date());
|
|
199
|
+
|
|
200
|
+
const createAction: ActionDefinition<
|
|
201
|
+
z.infer<typeof createConfigSchema>,
|
|
202
|
+
MaintenanceArtifact
|
|
203
|
+
> = {
|
|
204
|
+
id: "create",
|
|
205
|
+
displayName: "Schedule Maintenance",
|
|
206
|
+
description: "Schedule a new maintenance window",
|
|
207
|
+
category: "Maintenance",
|
|
208
|
+
icon: "Wrench",
|
|
209
|
+
config: new Versioned({ version: 1, schema: createConfigSchema }),
|
|
210
|
+
produces: "maintenance.window",
|
|
211
|
+
execute: async ({ config, logger }) => {
|
|
212
|
+
const created = await deps.service.createMaintenance({
|
|
213
|
+
title: config.title,
|
|
214
|
+
description: config.description,
|
|
215
|
+
systemIds: config.systemIds,
|
|
216
|
+
startAt: new Date(config.startAt),
|
|
217
|
+
endAt: new Date(config.endAt),
|
|
218
|
+
suppressNotifications: config.suppressNotifications,
|
|
219
|
+
});
|
|
220
|
+
const artifact = toArtifact(created);
|
|
221
|
+
await deps.emitHook(maintenanceHooks.maintenanceCreated, {
|
|
222
|
+
maintenanceId: created.id,
|
|
223
|
+
systemIds: created.systemIds,
|
|
224
|
+
title: created.title,
|
|
225
|
+
description: created.description,
|
|
226
|
+
status: created.status,
|
|
227
|
+
startAt: artifact.startAt,
|
|
228
|
+
endAt: artifact.endAt,
|
|
229
|
+
});
|
|
230
|
+
logger.info(`Automation scheduled maintenance ${created.id}`);
|
|
231
|
+
return {
|
|
232
|
+
success: true,
|
|
233
|
+
externalId: created.id,
|
|
234
|
+
artifact,
|
|
235
|
+
};
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const updateAction: ActionDefinition<
|
|
240
|
+
z.infer<typeof updateConfigSchema>,
|
|
241
|
+
MaintenanceArtifact
|
|
242
|
+
> = {
|
|
243
|
+
id: "update",
|
|
244
|
+
displayName: "Update Maintenance",
|
|
245
|
+
description: "Update an existing maintenance window's metadata or schedule",
|
|
246
|
+
category: "Maintenance",
|
|
247
|
+
icon: "Wrench",
|
|
248
|
+
config: new Versioned({ version: 1, schema: updateConfigSchema }),
|
|
249
|
+
produces: "maintenance.window",
|
|
250
|
+
execute: async ({ config, logger }) => {
|
|
251
|
+
const updated = await deps.service.updateMaintenance({
|
|
252
|
+
id: config.maintenanceId,
|
|
253
|
+
title: config.title,
|
|
254
|
+
description: config.description,
|
|
255
|
+
systemIds: config.systemIds,
|
|
256
|
+
startAt: config.startAt ? new Date(config.startAt) : undefined,
|
|
257
|
+
endAt: config.endAt ? new Date(config.endAt) : undefined,
|
|
258
|
+
suppressNotifications: config.suppressNotifications,
|
|
259
|
+
});
|
|
260
|
+
if (!updated) {
|
|
261
|
+
return {
|
|
262
|
+
success: false,
|
|
263
|
+
error: `Maintenance not found: ${config.maintenanceId}`,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
const artifact = toArtifact(updated);
|
|
267
|
+
await deps.emitHook(maintenanceHooks.maintenanceUpdated, {
|
|
268
|
+
maintenanceId: updated.id,
|
|
269
|
+
systemIds: updated.systemIds,
|
|
270
|
+
title: updated.title,
|
|
271
|
+
description: updated.description,
|
|
272
|
+
status: updated.status,
|
|
273
|
+
startAt: artifact.startAt,
|
|
274
|
+
endAt: artifact.endAt,
|
|
275
|
+
action: "updated",
|
|
276
|
+
});
|
|
277
|
+
logger.info(`Automation updated maintenance ${updated.id}`);
|
|
278
|
+
return { success: true, externalId: updated.id, artifact };
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const addUpdateAction: ActionDefinition<
|
|
283
|
+
z.infer<typeof addUpdateConfigSchema>,
|
|
284
|
+
MaintenanceArtifact
|
|
285
|
+
> = {
|
|
286
|
+
id: "add_update",
|
|
287
|
+
displayName: "Add Maintenance Update",
|
|
288
|
+
description: "Append a status-update note to a maintenance window",
|
|
289
|
+
category: "Maintenance",
|
|
290
|
+
icon: "MessageSquarePlus",
|
|
291
|
+
config: new Versioned({ version: 1, schema: addUpdateConfigSchema }),
|
|
292
|
+
produces: "maintenance.window",
|
|
293
|
+
execute: async ({ config, logger }) => {
|
|
294
|
+
await deps.service.addUpdate({
|
|
295
|
+
maintenanceId: config.maintenanceId,
|
|
296
|
+
message: config.message,
|
|
297
|
+
statusChange: config.statusChange,
|
|
298
|
+
});
|
|
299
|
+
// Re-fetch so we surface the latest window state to the next
|
|
300
|
+
// step + so the emitted hook payload matches the (now-updated)
|
|
301
|
+
// row.
|
|
302
|
+
const refreshed = await deps.service.getMaintenance(config.maintenanceId);
|
|
303
|
+
if (!refreshed) {
|
|
304
|
+
return {
|
|
305
|
+
success: false,
|
|
306
|
+
error: `Maintenance ${config.maintenanceId} not found after update`,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
const artifact = toArtifact(refreshed);
|
|
310
|
+
await deps.emitHook(maintenanceHooks.maintenanceUpdated, {
|
|
311
|
+
maintenanceId: refreshed.id,
|
|
312
|
+
systemIds: refreshed.systemIds,
|
|
313
|
+
title: refreshed.title,
|
|
314
|
+
description: refreshed.description,
|
|
315
|
+
status: refreshed.status,
|
|
316
|
+
startAt: artifact.startAt,
|
|
317
|
+
endAt: artifact.endAt,
|
|
318
|
+
action: config.statusChange === "completed" ? "closed" : "updated",
|
|
319
|
+
});
|
|
320
|
+
logger.info(`Automation added update to maintenance ${refreshed.id}`);
|
|
321
|
+
return { success: true, externalId: refreshed.id, artifact };
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const setSystemAction: ActionDefinition<
|
|
326
|
+
z.infer<typeof setSystemConfigSchema>,
|
|
327
|
+
MaintenanceArtifact
|
|
328
|
+
> = {
|
|
329
|
+
id: "set_system",
|
|
330
|
+
displayName: "Set System Maintenance",
|
|
331
|
+
description:
|
|
332
|
+
"Schedule a maintenance window covering a single system, starting now and lasting `durationMinutes` minutes.",
|
|
333
|
+
category: "Maintenance",
|
|
334
|
+
icon: "Wrench",
|
|
335
|
+
config: new Versioned({ version: 1, schema: setSystemConfigSchema }),
|
|
336
|
+
produces: "maintenance.window",
|
|
337
|
+
execute: async ({ config, logger }) => {
|
|
338
|
+
const startAt = now();
|
|
339
|
+
const endAt = new Date(
|
|
340
|
+
startAt.getTime() + config.durationMinutes * 60_000,
|
|
341
|
+
);
|
|
342
|
+
const created = await deps.service.createMaintenance({
|
|
343
|
+
title: config.title ?? `Automation maintenance (${config.systemId})`,
|
|
344
|
+
description: config.description,
|
|
345
|
+
systemIds: [config.systemId],
|
|
346
|
+
startAt,
|
|
347
|
+
endAt,
|
|
348
|
+
suppressNotifications: config.suppressNotifications,
|
|
349
|
+
});
|
|
350
|
+
const artifact = toArtifact(created);
|
|
351
|
+
await deps.emitHook(maintenanceHooks.maintenanceCreated, {
|
|
352
|
+
maintenanceId: created.id,
|
|
353
|
+
systemIds: created.systemIds,
|
|
354
|
+
title: created.title,
|
|
355
|
+
description: created.description,
|
|
356
|
+
status: created.status,
|
|
357
|
+
startAt: artifact.startAt,
|
|
358
|
+
endAt: artifact.endAt,
|
|
359
|
+
});
|
|
360
|
+
logger.info(
|
|
361
|
+
`Automation parked system ${config.systemId} via maintenance ${created.id}`,
|
|
362
|
+
);
|
|
363
|
+
return { success: true, externalId: created.id, artifact };
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
interface ClearSystemArtifact {
|
|
368
|
+
systemId: string;
|
|
369
|
+
closedMaintenanceIds: string[];
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const clearSystemAction: ActionDefinition<
|
|
373
|
+
z.infer<typeof clearSystemConfigSchema>,
|
|
374
|
+
ClearSystemArtifact
|
|
375
|
+
> = {
|
|
376
|
+
id: "clear_system",
|
|
377
|
+
displayName: "Clear System Maintenance",
|
|
378
|
+
description:
|
|
379
|
+
"Close every active or scheduled maintenance window that covers this system.",
|
|
380
|
+
category: "Maintenance",
|
|
381
|
+
icon: "Wrench",
|
|
382
|
+
config: new Versioned({ version: 1, schema: clearSystemConfigSchema }),
|
|
383
|
+
produces: "maintenance.window",
|
|
384
|
+
execute: async ({ config, logger }) => {
|
|
385
|
+
const active = await deps.service.getMaintenancesForSystem(config.systemId);
|
|
386
|
+
const closedIds: string[] = [];
|
|
387
|
+
const message = config.message ?? "Cleared by automation";
|
|
388
|
+
for (const window of active) {
|
|
389
|
+
const closed = await deps.service.closeMaintenance(window.id, message);
|
|
390
|
+
if (!closed) continue;
|
|
391
|
+
closedIds.push(closed.id);
|
|
392
|
+
const artifact = toArtifact(closed);
|
|
393
|
+
await deps.emitHook(maintenanceHooks.maintenanceUpdated, {
|
|
394
|
+
maintenanceId: closed.id,
|
|
395
|
+
systemIds: closed.systemIds,
|
|
396
|
+
title: closed.title,
|
|
397
|
+
description: closed.description,
|
|
398
|
+
status: closed.status,
|
|
399
|
+
startAt: artifact.startAt,
|
|
400
|
+
endAt: artifact.endAt,
|
|
401
|
+
action: "closed",
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
logger.info(
|
|
405
|
+
`Automation cleared maintenance for system ${config.systemId} (${closedIds.length} window(s))`,
|
|
406
|
+
);
|
|
407
|
+
return {
|
|
408
|
+
success: true,
|
|
409
|
+
externalId: config.systemId,
|
|
410
|
+
artifact: { systemId: config.systemId, closedMaintenanceIds: closedIds },
|
|
411
|
+
};
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
return [
|
|
416
|
+
createAction as ActionDefinition<unknown, unknown>,
|
|
417
|
+
updateAction as ActionDefinition<unknown, unknown>,
|
|
418
|
+
addUpdateAction as ActionDefinition<unknown, unknown>,
|
|
419
|
+
setSystemAction as ActionDefinition<unknown, unknown>,
|
|
420
|
+
clearSystemAction as ActionDefinition<unknown, unknown>,
|
|
421
|
+
];
|
|
422
|
+
}
|
package/src/hooks.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { createHook } from "@checkstack/backend-api";
|
|
2
|
+
import type { MaintenanceStatus } from "@checkstack/maintenance-common";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Maintenance hooks for cross-plugin communication.
|
|
5
6
|
* Other plugins can subscribe to these hooks to react to maintenance lifecycle events.
|
|
6
7
|
* These hooks are registered as integration events for webhook subscriptions.
|
|
8
|
+
*
|
|
9
|
+
* `status` carries the canonical `MaintenanceStatus` enum value, so
|
|
10
|
+
* automation triggers built on these hooks can offer the known values
|
|
11
|
+
* for `==` comparisons in the editor.
|
|
7
12
|
*/
|
|
8
13
|
export const maintenanceHooks = {
|
|
9
14
|
/**
|
|
@@ -15,7 +20,7 @@ export const maintenanceHooks = {
|
|
|
15
20
|
systemIds: string[];
|
|
16
21
|
title: string;
|
|
17
22
|
description?: string;
|
|
18
|
-
status:
|
|
23
|
+
status: MaintenanceStatus;
|
|
19
24
|
startAt: string;
|
|
20
25
|
endAt: string;
|
|
21
26
|
}>("maintenance.created"),
|
|
@@ -29,7 +34,7 @@ export const maintenanceHooks = {
|
|
|
29
34
|
systemIds: string[];
|
|
30
35
|
title: string;
|
|
31
36
|
description?: string;
|
|
32
|
-
status:
|
|
37
|
+
status: MaintenanceStatus;
|
|
33
38
|
startAt: string;
|
|
34
39
|
endAt: string;
|
|
35
40
|
action: "updated" | "closed";
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import * as schema from "./schema";
|
|
2
2
|
import type { SafeDatabase } from "@checkstack/backend-api";
|
|
3
|
-
import { z } from "zod";
|
|
4
3
|
import {
|
|
5
4
|
maintenanceAccessRules,
|
|
6
5
|
maintenanceAccess,
|
|
@@ -13,7 +12,11 @@ import {
|
|
|
13
12
|
} from "@checkstack/maintenance-common";
|
|
14
13
|
|
|
15
14
|
import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
|
|
16
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
automationActionExtensionPoint,
|
|
17
|
+
automationArtifactTypeExtensionPoint,
|
|
18
|
+
automationTriggerExtensionPoint,
|
|
19
|
+
} from "@checkstack/automation-backend";
|
|
17
20
|
import {
|
|
18
21
|
NotificationApi,
|
|
19
22
|
specToRegistration,
|
|
@@ -24,33 +27,12 @@ import { CatalogApi } from "@checkstack/catalog-common";
|
|
|
24
27
|
import { AuthApi } from "@checkstack/auth-common";
|
|
25
28
|
import { registerSearchProvider } from "@checkstack/command-backend";
|
|
26
29
|
import { resolveRoute, type InferClient } from "@checkstack/common";
|
|
27
|
-
import { maintenanceHooks } from "./hooks";
|
|
28
30
|
import { createMaintenanceCache } from "./cache";
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const maintenanceCreatedPayloadSchema = z.object({
|
|
35
|
-
maintenanceId: z.string(),
|
|
36
|
-
systemIds: z.array(z.string()),
|
|
37
|
-
title: z.string(),
|
|
38
|
-
description: z.string().optional(),
|
|
39
|
-
status: z.string(),
|
|
40
|
-
startAt: z.string(),
|
|
41
|
-
endAt: z.string(),
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
const maintenanceUpdatedPayloadSchema = z.object({
|
|
45
|
-
maintenanceId: z.string(),
|
|
46
|
-
systemIds: z.array(z.string()),
|
|
47
|
-
title: z.string(),
|
|
48
|
-
description: z.string().optional(),
|
|
49
|
-
status: z.string(),
|
|
50
|
-
startAt: z.string(),
|
|
51
|
-
endAt: z.string(),
|
|
52
|
-
action: z.enum(["updated", "closed"]),
|
|
53
|
-
});
|
|
31
|
+
import {
|
|
32
|
+
createMaintenanceActions,
|
|
33
|
+
maintenanceArtifactType,
|
|
34
|
+
maintenanceTriggers,
|
|
35
|
+
} from "./automations";
|
|
54
36
|
|
|
55
37
|
// Queue and job constants
|
|
56
38
|
const STATUS_TRANSITION_QUEUE = "maintenance-status-transitions";
|
|
@@ -70,32 +52,19 @@ export default createBackendPlugin({
|
|
|
70
52
|
maintenanceGroupSubscription,
|
|
71
53
|
]);
|
|
72
54
|
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
{
|
|
80
|
-
hook: maintenanceHooks.maintenanceCreated,
|
|
81
|
-
displayName: "Maintenance Created",
|
|
82
|
-
description: "Fired when a new maintenance is scheduled",
|
|
83
|
-
category: "Maintenance",
|
|
84
|
-
payloadSchema: maintenanceCreatedPayloadSchema,
|
|
85
|
-
},
|
|
86
|
-
pluginMetadata,
|
|
87
|
-
);
|
|
88
|
-
|
|
89
|
-
integrationEvents.registerEvent(
|
|
90
|
-
{
|
|
91
|
-
hook: maintenanceHooks.maintenanceUpdated,
|
|
92
|
-
displayName: "Maintenance Updated",
|
|
93
|
-
description: "Fired when a maintenance is updated or closed",
|
|
94
|
-
category: "Maintenance",
|
|
95
|
-
payloadSchema: maintenanceUpdatedPayloadSchema,
|
|
96
|
-
},
|
|
97
|
-
pluginMetadata,
|
|
55
|
+
// ─── Automation Platform: triggers + artifact type ─────────────────
|
|
56
|
+
// Buffered behind the extension point until automation-backend's
|
|
57
|
+
// register() runs. Actions are wired in afterPluginsReady so
|
|
58
|
+
// `emitHook` is available — see below.
|
|
59
|
+
const automationTriggers = env.getExtensionPoint(
|
|
60
|
+
automationTriggerExtensionPoint,
|
|
98
61
|
);
|
|
62
|
+
for (const trigger of maintenanceTriggers) {
|
|
63
|
+
automationTriggers.registerTrigger(trigger, pluginMetadata);
|
|
64
|
+
}
|
|
65
|
+
env
|
|
66
|
+
.getExtensionPoint(automationArtifactTypeExtensionPoint)
|
|
67
|
+
.registerArtifactType(maintenanceArtifactType, pluginMetadata);
|
|
99
68
|
|
|
100
69
|
// Store service reference for afterPluginsReady
|
|
101
70
|
let maintenanceService: MaintenanceService;
|
|
@@ -172,7 +141,18 @@ export default createBackendPlugin({
|
|
|
172
141
|
|
|
173
142
|
logger.debug("✅ Maintenance Backend initialized.");
|
|
174
143
|
},
|
|
175
|
-
afterPluginsReady: async ({ queueManager, logger }) => {
|
|
144
|
+
afterPluginsReady: async ({ queueManager, logger, emitHook }) => {
|
|
145
|
+
// Register automation actions now that `emitHook` is available.
|
|
146
|
+
const automationActions = env.getExtensionPoint(
|
|
147
|
+
automationActionExtensionPoint,
|
|
148
|
+
);
|
|
149
|
+
for (const action of createMaintenanceActions({
|
|
150
|
+
service: maintenanceService,
|
|
151
|
+
emitHook,
|
|
152
|
+
})) {
|
|
153
|
+
automationActions.registerAction(action, pluginMetadata);
|
|
154
|
+
}
|
|
155
|
+
|
|
176
156
|
// Notification subscription specs. Per-resource group lifecycle
|
|
177
157
|
// is platform-managed by notification-backend — maintenance just
|
|
178
158
|
// declares the specs.
|
package/tsconfig.json
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
{
|
|
8
8
|
"path": "../auth-common"
|
|
9
9
|
},
|
|
10
|
+
{
|
|
11
|
+
"path": "../automation-backend"
|
|
12
|
+
},
|
|
10
13
|
{
|
|
11
14
|
"path": "../backend-api"
|
|
12
15
|
},
|
|
@@ -31,12 +34,6 @@
|
|
|
31
34
|
{
|
|
32
35
|
"path": "../drizzle-helper"
|
|
33
36
|
},
|
|
34
|
-
{
|
|
35
|
-
"path": "../integration-backend"
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"path": "../integration-common"
|
|
39
|
-
},
|
|
40
37
|
{
|
|
41
38
|
"path": "../maintenance-common"
|
|
42
39
|
},
|