@checkstack/healthcheck-backend 0.15.1 → 0.16.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 +12 -0
- package/package.json +1 -1
- package/src/index.ts +5 -0
- package/src/router.test.ts +64 -0
- package/src/router.ts +22 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @checkstack/healthcheck-backend
|
|
2
2
|
|
|
3
|
+
## 0.16.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 80cbc51: Enforce GitOps provenance lock on backend API endpoints to prevent manual configuration drift for synchronized resources.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Updated dependencies [80cbc51]
|
|
12
|
+
- @checkstack/catalog-backend@0.5.0
|
|
13
|
+
- @checkstack/satellite-backend@0.2.8
|
|
14
|
+
|
|
3
15
|
## 0.15.1
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { satelliteHooks } from "@checkstack/satellite-backend";
|
|
|
30
30
|
import { CatalogApi } from "@checkstack/catalog-common";
|
|
31
31
|
import { MaintenanceApi } from "@checkstack/maintenance-common";
|
|
32
32
|
import { IncidentApi } from "@checkstack/incident-common";
|
|
33
|
+
import { GitOpsApi } from "@checkstack/gitops-common";
|
|
33
34
|
import { healthCheckHooks } from "./hooks";
|
|
34
35
|
import { registerSearchProvider } from "@checkstack/command-backend";
|
|
35
36
|
import { resolveRoute } from "@checkstack/common";
|
|
@@ -164,6 +165,9 @@ export default createBackendPlugin({
|
|
|
164
165
|
// Create incident client for notification suppression checks
|
|
165
166
|
const incidentClient = rpcClient.forPlugin(IncidentApi);
|
|
166
167
|
|
|
168
|
+
// Create gitops client for provenance lock checks
|
|
169
|
+
const gitOpsClient = rpcClient.forPlugin(GitOpsApi);
|
|
170
|
+
|
|
167
171
|
// Setup queue-based health check worker
|
|
168
172
|
await setupHealthCheckWorker({
|
|
169
173
|
db: database,
|
|
@@ -189,6 +193,7 @@ export default createBackendPlugin({
|
|
|
189
193
|
database: database as SafeDatabase<typeof schema>,
|
|
190
194
|
registry: healthCheckRegistry,
|
|
191
195
|
collectorRegistry,
|
|
196
|
+
gitOpsClient,
|
|
192
197
|
getEmitHook: () => storedEmitHook,
|
|
193
198
|
});
|
|
194
199
|
rpc.registerRouter(healthCheckRouter, healthCheckContract);
|
package/src/router.test.ts
CHANGED
|
@@ -50,10 +50,15 @@ describe("HealthCheck Router", () => {
|
|
|
50
50
|
getCollectorsForPlugin: mock(() => []),
|
|
51
51
|
};
|
|
52
52
|
|
|
53
|
+
const mockGitOpsClient = {
|
|
54
|
+
getProvenance: mock<any>(() => Promise.resolve(null)),
|
|
55
|
+
};
|
|
56
|
+
|
|
53
57
|
const router = createHealthCheckRouter({
|
|
54
58
|
database: mockDb as never,
|
|
55
59
|
registry: mockRegistry,
|
|
56
60
|
collectorRegistry: mockCollectorRegistry as never,
|
|
61
|
+
gitOpsClient: mockGitOpsClient as never,
|
|
57
62
|
getEmitHook: () => undefined,
|
|
58
63
|
});
|
|
59
64
|
|
|
@@ -169,4 +174,63 @@ describe("HealthCheck Router", () => {
|
|
|
169
174
|
);
|
|
170
175
|
expect(result).toHaveLength(0);
|
|
171
176
|
});
|
|
177
|
+
|
|
178
|
+
describe("GitOps Provenance Enforcement", () => {
|
|
179
|
+
it("allows deleteConfiguration when GitOps lock is not present", async () => {
|
|
180
|
+
mockGitOpsClient.getProvenance.mockResolvedValueOnce(null);
|
|
181
|
+
const context = createMockRpcContext({ user: mockUser });
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
await call(router.deleteConfiguration, "config-1", { context });
|
|
185
|
+
} catch (e: any) {
|
|
186
|
+
// If it throws anything other than FORBIDDEN, it passed the lock check
|
|
187
|
+
expect(e.code).not.toBe("FORBIDDEN");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
expect(mockGitOpsClient.getProvenance).toHaveBeenCalledWith({
|
|
191
|
+
kind: "Healthcheck",
|
|
192
|
+
entityId: "config-1"
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("throws FORBIDDEN when deleting a GitOps locked configuration", async () => {
|
|
197
|
+
mockGitOpsClient.getProvenance.mockResolvedValueOnce({
|
|
198
|
+
id: "prov-1", kind: "Healthcheck", entityId: "config-1",
|
|
199
|
+
providerId: "prov", entityName: "c1", status: "synced",
|
|
200
|
+
lastSyncedAt: new Date(), createdAt: new Date(), updatedAt: new Date(),
|
|
201
|
+
repository: "", filePath: "", fileSha: ""
|
|
202
|
+
});
|
|
203
|
+
const context = createMockRpcContext({ user: mockUser });
|
|
204
|
+
|
|
205
|
+
let error;
|
|
206
|
+
try {
|
|
207
|
+
await call(router.deleteConfiguration, "config-1", { context });
|
|
208
|
+
} catch (e) {
|
|
209
|
+
error = e;
|
|
210
|
+
}
|
|
211
|
+
expect(error).toBeDefined();
|
|
212
|
+
expect((error as any).code).toBe("FORBIDDEN");
|
|
213
|
+
expect((error as any).message).toContain("managed by GitOps");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("throws FORBIDDEN when associating a system that is GitOps locked", async () => {
|
|
217
|
+
mockGitOpsClient.getProvenance.mockResolvedValueOnce({
|
|
218
|
+
id: "prov-1", kind: "System", entityId: "sys-1",
|
|
219
|
+
providerId: "prov", entityName: "s1", status: "synced",
|
|
220
|
+
lastSyncedAt: new Date(), createdAt: new Date(), updatedAt: new Date(),
|
|
221
|
+
repository: "", filePath: "", fileSha: ""
|
|
222
|
+
});
|
|
223
|
+
const context = createMockRpcContext({ user: mockUser });
|
|
224
|
+
|
|
225
|
+
let error;
|
|
226
|
+
try {
|
|
227
|
+
await call(router.associateSystem, { systemId: "sys-1", body: { configurationId: "12345678-1234-4234-8234-123456789012", enabled: true, satelliteIds: [], includeLocal: false } }, { context });
|
|
228
|
+
} catch (e: any) {
|
|
229
|
+
error = e;
|
|
230
|
+
if (e.code !== "FORBIDDEN") console.log(e.issues || e.message);
|
|
231
|
+
}
|
|
232
|
+
expect(error).toBeDefined();
|
|
233
|
+
expect((error as any).code).toBe("FORBIDDEN");
|
|
234
|
+
});
|
|
235
|
+
});
|
|
172
236
|
});
|
package/src/router.ts
CHANGED
|
@@ -13,6 +13,8 @@ import { HealthCheckService } from "./service";
|
|
|
13
13
|
import { healthCheckHooks } from "./hooks";
|
|
14
14
|
import * as schema from "./schema";
|
|
15
15
|
import { toJsonSchemaWithChartMeta } from "./schema-utils";
|
|
16
|
+
import type { InferClient } from "@checkstack/common";
|
|
17
|
+
import { GitOpsApi } from "@checkstack/gitops-common";
|
|
16
18
|
|
|
17
19
|
/**
|
|
18
20
|
* Creates the healthcheck router using contract-based implementation.
|
|
@@ -24,6 +26,7 @@ export const createHealthCheckRouter = (opts: {
|
|
|
24
26
|
database: SafeDatabase<typeof schema>;
|
|
25
27
|
registry: HealthCheckRegistry;
|
|
26
28
|
collectorRegistry: CollectorRegistry;
|
|
29
|
+
gitOpsClient: InferClient<typeof GitOpsApi>;
|
|
27
30
|
getEmitHook: () => ((hook: { id: string }, payload: Record<string, unknown>) => Promise<void>) | undefined;
|
|
28
31
|
}) => {
|
|
29
32
|
const { database, registry, collectorRegistry, getEmitHook } = opts;
|
|
@@ -35,6 +38,18 @@ export const createHealthCheckRouter = (opts: {
|
|
|
35
38
|
.$context<RpcContext>()
|
|
36
39
|
.use(autoAuthMiddleware);
|
|
37
40
|
|
|
41
|
+
const enforceNotGitOpsLocked = async (kind: string, entityId: string) => {
|
|
42
|
+
const provenance = await opts.gitOpsClient.getProvenance({
|
|
43
|
+
kind,
|
|
44
|
+
entityId,
|
|
45
|
+
});
|
|
46
|
+
if (provenance) {
|
|
47
|
+
throw new ORPCError("FORBIDDEN", {
|
|
48
|
+
message: `${kind} is managed by GitOps and cannot be modified manually.`,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
38
53
|
return os.router({
|
|
39
54
|
getStrategies: os.getStrategies.handler(async ({ context }) => {
|
|
40
55
|
return context.healthCheckRegistry.getStrategiesWithMeta().map((r) => ({
|
|
@@ -101,6 +116,7 @@ export const createHealthCheckRouter = (opts: {
|
|
|
101
116
|
}),
|
|
102
117
|
|
|
103
118
|
updateConfiguration: os.updateConfiguration.handler(async ({ input }) => {
|
|
119
|
+
await enforceNotGitOpsLocked("Healthcheck", input.id);
|
|
104
120
|
const config = await service.updateConfiguration(input.id, input.body);
|
|
105
121
|
if (!config) {
|
|
106
122
|
throw new ORPCError("NOT_FOUND", {
|
|
@@ -111,14 +127,17 @@ export const createHealthCheckRouter = (opts: {
|
|
|
111
127
|
}),
|
|
112
128
|
|
|
113
129
|
deleteConfiguration: os.deleteConfiguration.handler(async ({ input }) => {
|
|
130
|
+
await enforceNotGitOpsLocked("Healthcheck", input);
|
|
114
131
|
await service.deleteConfiguration(input);
|
|
115
132
|
}),
|
|
116
133
|
|
|
117
134
|
pauseConfiguration: os.pauseConfiguration.handler(async ({ input }) => {
|
|
135
|
+
await enforceNotGitOpsLocked("Healthcheck", input);
|
|
118
136
|
await service.pauseConfiguration(input);
|
|
119
137
|
}),
|
|
120
138
|
|
|
121
139
|
resumeConfiguration: os.resumeConfiguration.handler(async ({ input }) => {
|
|
140
|
+
await enforceNotGitOpsLocked("Healthcheck", input);
|
|
122
141
|
await service.resumeConfiguration(input);
|
|
123
142
|
}),
|
|
124
143
|
|
|
@@ -135,6 +154,7 @@ export const createHealthCheckRouter = (opts: {
|
|
|
135
154
|
),
|
|
136
155
|
|
|
137
156
|
associateSystem: os.associateSystem.handler(async ({ input, context }) => {
|
|
157
|
+
await enforceNotGitOpsLocked("System", input.systemId);
|
|
138
158
|
await service.associateSystem({
|
|
139
159
|
systemId: input.systemId,
|
|
140
160
|
configurationId: input.body.configurationId,
|
|
@@ -173,6 +193,7 @@ export const createHealthCheckRouter = (opts: {
|
|
|
173
193
|
}),
|
|
174
194
|
|
|
175
195
|
disassociateSystem: os.disassociateSystem.handler(async ({ input }) => {
|
|
196
|
+
await enforceNotGitOpsLocked("System", input.systemId);
|
|
176
197
|
await service.disassociateSystem(input.systemId, input.configId);
|
|
177
198
|
|
|
178
199
|
// Notify subscribers that assignments changed
|
|
@@ -191,6 +212,7 @@ export const createHealthCheckRouter = (opts: {
|
|
|
191
212
|
|
|
192
213
|
updateRetentionConfig: os.updateRetentionConfig.handler(
|
|
193
214
|
async ({ input }) => {
|
|
215
|
+
await enforceNotGitOpsLocked("System", input.systemId);
|
|
194
216
|
await service.updateRetentionConfig(
|
|
195
217
|
input.systemId,
|
|
196
218
|
input.configurationId,
|