@checkstack/catalog-backend 0.2.24 → 0.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 +55 -0
- package/package.json +6 -4
- package/src/catalog-gitops-kinds.test.ts +454 -0
- package/src/index.ts +146 -0
- package/src/services/entity-service.ts +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,60 @@
|
|
|
1
1
|
# @checkstack/catalog-backend
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- b01078f: Added GitOps System kind extension for managing system group associations
|
|
8
|
+
|
|
9
|
+
## 0.3.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 6c40b5b: Register catalog System and Group as GitOps entity kinds
|
|
14
|
+
|
|
15
|
+
- **catalog-backend**: Registers `kind: System` and `kind: Group` with the GitOps Entity Kind Registry. The catalog now supports declarative management via YAML descriptors in Git repositories. Systems and groups are reconciled using the `metadata.gitops_entity_name` marker for cross-sync identity lookup.
|
|
16
|
+
- **gitops-backend**: Wires up the delete reconciler for orphan cleanup — both automatic deletion (via `deletionPolicy: "auto"`) and manual orphan confirmation now invoke the owning plugin's `delete()` handler before removing provenance entries.
|
|
17
|
+
|
|
18
|
+
- 6c40b5b: Generalized provenance system and GitOps frontend plugin
|
|
19
|
+
|
|
20
|
+
**Breaking**: `EntityKindDefinition.reconcile()` now returns `{ entityId: string }` instead of `void`. Plugins must return the plugin-specific entity ID (e.g., catalog system UUID) so the engine can store it in provenance.
|
|
21
|
+
|
|
22
|
+
- Added `entityId` column to the provenance table (non-nullable)
|
|
23
|
+
- Reconciler engine passes `existingEntityId` to plugins for updates
|
|
24
|
+
- `getProvenance` now supports lookup by `entityId` in addition to `entityName`
|
|
25
|
+
- Added provider CRUD endpoints: `createProvider`, `updateProvider`, `deleteProvider`
|
|
26
|
+
- Created `gitops-frontend` plugin with provider management, secret management, and sync status dashboard
|
|
27
|
+
- Removed `gitops_entity_name` metadata markers from catalog entities
|
|
28
|
+
- Removed `findSystemByGitOpsName`, `deleteSystemByGitOpsName` (and Group equivalents) from EntityService
|
|
29
|
+
- Added provenance-based UI locking in catalog-frontend: edit/delete/drag disabled for GitOps-managed systems and groups
|
|
30
|
+
|
|
31
|
+
### Patch Changes
|
|
32
|
+
|
|
33
|
+
- 6c40b5b: ### GitOps Ecosystem: Healthcheck Kind Registration (Phase 5)
|
|
34
|
+
|
|
35
|
+
**gitops-common**: Added required `resolveEntityRef` to `ReconcileContext`, enabling extension reconcilers to resolve cross-kind entity references (e.g., healthcheck refs in System extensions).
|
|
36
|
+
|
|
37
|
+
**gitops-backend**: Updated reconciler to populate `resolveEntityRef` by querying local provenance — no RPC round-trip needed.
|
|
38
|
+
|
|
39
|
+
**healthcheck-backend**: Registered `kind: Healthcheck` and `System → healthchecks` extension with the EntityKindRegistry:
|
|
40
|
+
|
|
41
|
+
- Validates strategy configs against registered strategy schemas at reconcile time
|
|
42
|
+
- Validates collector configs against registered collector schemas at reconcile time
|
|
43
|
+
- Manages system ↔ healthcheck associations with automatic stale removal
|
|
44
|
+
|
|
45
|
+
**healthcheck-frontend**: Added GitOps provenance locking to the HealthCheck IDE editor — GitOps-managed health checks show a lock banner and disable editing.
|
|
46
|
+
|
|
47
|
+
**catalog-backend**: Updated test fixtures for new required `resolveEntityRef` context field.
|
|
48
|
+
|
|
49
|
+
- Updated dependencies [6c40b5b]
|
|
50
|
+
- Updated dependencies [6c40b5b]
|
|
51
|
+
- Updated dependencies [6c40b5b]
|
|
52
|
+
- Updated dependencies [6c40b5b]
|
|
53
|
+
- Updated dependencies [6c40b5b]
|
|
54
|
+
- Updated dependencies [6c40b5b]
|
|
55
|
+
- @checkstack/gitops-backend@0.1.0
|
|
56
|
+
- @checkstack/gitops-common@0.1.0
|
|
57
|
+
|
|
3
58
|
## 0.2.24
|
|
4
59
|
|
|
5
60
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/catalog-backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"checkstack": {
|
|
@@ -13,11 +13,13 @@
|
|
|
13
13
|
"lint:code": "eslint . --max-warnings 0"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@checkstack/backend-api": "0.
|
|
16
|
+
"@checkstack/backend-api": "0.12.0",
|
|
17
17
|
"@checkstack/auth-common": "0.6.1",
|
|
18
18
|
"@checkstack/catalog-common": "1.3.1",
|
|
19
|
-
"@checkstack/command-backend": "0.1.
|
|
20
|
-
"@checkstack/auth-backend": "0.4.
|
|
19
|
+
"@checkstack/command-backend": "0.1.19",
|
|
20
|
+
"@checkstack/auth-backend": "0.4.18",
|
|
21
|
+
"@checkstack/gitops-backend": "0.0.1",
|
|
22
|
+
"@checkstack/gitops-common": "0.0.1",
|
|
21
23
|
"@checkstack/notification-common": "0.2.8",
|
|
22
24
|
"@orpc/server": "^1.13.2",
|
|
23
25
|
"drizzle-orm": "^0.45.0",
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { CHECKSTACK_API_VERSION } from "@checkstack/gitops-common";
|
|
4
|
+
import type {
|
|
5
|
+
EntityKindDefinition,
|
|
6
|
+
ReconcileContext,
|
|
7
|
+
} from "@checkstack/gitops-common";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Tests for the catalog-backend's GitOps entity kind registrations.
|
|
11
|
+
*
|
|
12
|
+
* These tests exercise the reconcile/delete logic in isolation by
|
|
13
|
+
* reconstructing the same kind definitions that catalog-backend registers
|
|
14
|
+
* with the entity kind extension point.
|
|
15
|
+
*
|
|
16
|
+
* The generic entityId pattern: reconcile() returns { entityId }, the
|
|
17
|
+
* reconciler engine stores it in provenance, and passes it back as
|
|
18
|
+
* existingEntityId on subsequent reconciles.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// ─── Mock EntityService ────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
interface MockSystem {
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
createdAt: Date;
|
|
28
|
+
updatedAt: Date;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface MockGroup {
|
|
32
|
+
id: string;
|
|
33
|
+
name: string;
|
|
34
|
+
createdAt: Date;
|
|
35
|
+
updatedAt: Date;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function createMockEntityService() {
|
|
39
|
+
const systems: MockSystem[] = [];
|
|
40
|
+
const groups: MockGroup[] = [];
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
systems,
|
|
44
|
+
groups,
|
|
45
|
+
createSystem: mock(async (data: { name: string; description?: string }) => {
|
|
46
|
+
const system: MockSystem = {
|
|
47
|
+
id: `sys-${systems.length + 1}`,
|
|
48
|
+
name: data.name,
|
|
49
|
+
description: data.description,
|
|
50
|
+
createdAt: new Date(),
|
|
51
|
+
updatedAt: new Date(),
|
|
52
|
+
};
|
|
53
|
+
systems.push(system);
|
|
54
|
+
return system;
|
|
55
|
+
}),
|
|
56
|
+
updateSystem: mock(async (id: string, data: Partial<{ name: string; description?: string }>) => {
|
|
57
|
+
const system = systems.find((s) => s.id === id);
|
|
58
|
+
if (system) {
|
|
59
|
+
Object.assign(system, data);
|
|
60
|
+
}
|
|
61
|
+
return system;
|
|
62
|
+
}),
|
|
63
|
+
deleteSystem: mock(async (id: string) => {
|
|
64
|
+
const idx = systems.findIndex((s) => s.id === id);
|
|
65
|
+
if (idx >= 0) systems.splice(idx, 1);
|
|
66
|
+
}),
|
|
67
|
+
createGroup: mock(async (data: { name: string }) => {
|
|
68
|
+
const group: MockGroup = {
|
|
69
|
+
id: `grp-${groups.length + 1}`,
|
|
70
|
+
name: data.name,
|
|
71
|
+
createdAt: new Date(),
|
|
72
|
+
updatedAt: new Date(),
|
|
73
|
+
};
|
|
74
|
+
groups.push(group);
|
|
75
|
+
return group;
|
|
76
|
+
}),
|
|
77
|
+
updateGroup: mock(async (id: string, data: Partial<{ name: string }>) => {
|
|
78
|
+
const group = groups.find((g) => g.id === id);
|
|
79
|
+
if (group) {
|
|
80
|
+
Object.assign(group, data);
|
|
81
|
+
}
|
|
82
|
+
return group;
|
|
83
|
+
}),
|
|
84
|
+
deleteGroup: mock(async (id: string) => {
|
|
85
|
+
const idx = groups.findIndex((g) => g.id === id);
|
|
86
|
+
if (idx >= 0) groups.splice(idx, 1);
|
|
87
|
+
}),
|
|
88
|
+
addSystemToGroup: mock(async (props: { groupId: string; systemId: string }) => {}),
|
|
89
|
+
removeSystemFromGroup: mock(async (props: { groupId: string; systemId: string }) => {}),
|
|
90
|
+
getGroupsForSystem: mock(async (systemId: string) => {
|
|
91
|
+
return [] as { groupId: string, systemId: string }[];
|
|
92
|
+
}),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── Test Context ──────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
const mockContext: ReconcileContext = {
|
|
99
|
+
logger: {
|
|
100
|
+
debug: () => {},
|
|
101
|
+
info: () => {},
|
|
102
|
+
warn: () => {},
|
|
103
|
+
error: () => {},
|
|
104
|
+
},
|
|
105
|
+
resolveEntityRef: async () => undefined,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// ─── Tests ─────────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
describe("Catalog GitOps Kind: System", () => {
|
|
111
|
+
let mockService: ReturnType<typeof createMockEntityService>;
|
|
112
|
+
|
|
113
|
+
const systemSpecSchema = z.object({});
|
|
114
|
+
type SystemSpec = z.infer<typeof systemSpecSchema>;
|
|
115
|
+
|
|
116
|
+
function buildSystemKind(
|
|
117
|
+
svc: ReturnType<typeof createMockEntityService>,
|
|
118
|
+
): EntityKindDefinition<SystemSpec> {
|
|
119
|
+
return {
|
|
120
|
+
apiVersion: CHECKSTACK_API_VERSION,
|
|
121
|
+
kind: "System",
|
|
122
|
+
specSchema: systemSpecSchema,
|
|
123
|
+
reconcile: async ({ entity, existingEntityId, context }) => {
|
|
124
|
+
const displayName = entity.metadata.title ?? entity.metadata.name;
|
|
125
|
+
const description = entity.metadata.description;
|
|
126
|
+
|
|
127
|
+
if (existingEntityId) {
|
|
128
|
+
await svc.updateSystem(existingEntityId, {
|
|
129
|
+
name: displayName,
|
|
130
|
+
description,
|
|
131
|
+
});
|
|
132
|
+
context.logger.info(`Updated system (id: ${existingEntityId})`);
|
|
133
|
+
return { entityId: existingEntityId };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const system = await svc.createSystem({
|
|
137
|
+
name: displayName,
|
|
138
|
+
description,
|
|
139
|
+
});
|
|
140
|
+
context.logger.info(`Created system (id: ${system.id})`);
|
|
141
|
+
return { entityId: system.id };
|
|
142
|
+
},
|
|
143
|
+
delete: async ({ entityId }) => {
|
|
144
|
+
if (entityId) await svc.deleteSystem(entityId);
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
beforeEach(() => {
|
|
150
|
+
mockService = createMockEntityService();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("creates a new system and returns entityId", async () => {
|
|
154
|
+
const kind = buildSystemKind(mockService);
|
|
155
|
+
|
|
156
|
+
const result = await kind.reconcile({
|
|
157
|
+
entity: {
|
|
158
|
+
apiVersion: CHECKSTACK_API_VERSION,
|
|
159
|
+
kind: "System",
|
|
160
|
+
metadata: {
|
|
161
|
+
name: "payment-service",
|
|
162
|
+
title: "Payment Service",
|
|
163
|
+
description: "Handles payments",
|
|
164
|
+
},
|
|
165
|
+
spec: {},
|
|
166
|
+
},
|
|
167
|
+
context: mockContext,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(result.entityId).toBe("sys-1");
|
|
171
|
+
expect(mockService.createSystem).toHaveBeenCalledTimes(1);
|
|
172
|
+
expect(mockService.systems).toHaveLength(1);
|
|
173
|
+
expect(mockService.systems[0].name).toBe("Payment Service");
|
|
174
|
+
expect(mockService.systems[0].description).toBe("Handles payments");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("uses metadata.description for the catalog description", async () => {
|
|
178
|
+
const kind = buildSystemKind(mockService);
|
|
179
|
+
|
|
180
|
+
const result = await kind.reconcile({
|
|
181
|
+
entity: {
|
|
182
|
+
apiVersion: CHECKSTACK_API_VERSION,
|
|
183
|
+
kind: "System",
|
|
184
|
+
metadata: {
|
|
185
|
+
name: "api-gateway",
|
|
186
|
+
description: "Gateway description",
|
|
187
|
+
},
|
|
188
|
+
spec: {},
|
|
189
|
+
},
|
|
190
|
+
context: mockContext,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
expect(result.entityId).toBe("sys-1");
|
|
194
|
+
expect(mockService.systems[0].description).toBe("Gateway description");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("falls back to metadata.name when title is missing", async () => {
|
|
198
|
+
const kind = buildSystemKind(mockService);
|
|
199
|
+
|
|
200
|
+
await kind.reconcile({
|
|
201
|
+
entity: {
|
|
202
|
+
apiVersion: CHECKSTACK_API_VERSION,
|
|
203
|
+
kind: "System",
|
|
204
|
+
metadata: { name: "my-service" },
|
|
205
|
+
spec: {},
|
|
206
|
+
},
|
|
207
|
+
context: mockContext,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
expect(mockService.systems[0].name).toBe("my-service");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("updates an existing system using existingEntityId", async () => {
|
|
214
|
+
const kind = buildSystemKind(mockService);
|
|
215
|
+
|
|
216
|
+
// Pre-populate a system
|
|
217
|
+
mockService.systems.push({
|
|
218
|
+
id: "sys-existing",
|
|
219
|
+
name: "Old Name",
|
|
220
|
+
description: "Old description",
|
|
221
|
+
createdAt: new Date(),
|
|
222
|
+
updatedAt: new Date(),
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const result = await kind.reconcile({
|
|
226
|
+
entity: {
|
|
227
|
+
apiVersion: CHECKSTACK_API_VERSION,
|
|
228
|
+
kind: "System",
|
|
229
|
+
metadata: {
|
|
230
|
+
name: "payment-service",
|
|
231
|
+
title: "Payment Service v2",
|
|
232
|
+
description: "Updated description",
|
|
233
|
+
},
|
|
234
|
+
spec: {},
|
|
235
|
+
},
|
|
236
|
+
existingEntityId: "sys-existing",
|
|
237
|
+
context: mockContext,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
expect(result.entityId).toBe("sys-existing");
|
|
241
|
+
expect(mockService.createSystem).not.toHaveBeenCalled();
|
|
242
|
+
expect(mockService.updateSystem).toHaveBeenCalledTimes(1);
|
|
243
|
+
expect(mockService.systems[0].name).toBe("Payment Service v2");
|
|
244
|
+
expect(mockService.systems[0].description).toBe("Updated description");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("deletes a system by entityId", async () => {
|
|
248
|
+
const kind = buildSystemKind(mockService);
|
|
249
|
+
|
|
250
|
+
mockService.systems.push({
|
|
251
|
+
id: "sys-del",
|
|
252
|
+
name: "To Delete",
|
|
253
|
+
createdAt: new Date(),
|
|
254
|
+
updatedAt: new Date(),
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
await kind.delete!({
|
|
258
|
+
entityName: "old-service",
|
|
259
|
+
entityId: "sys-del",
|
|
260
|
+
context: mockContext,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
expect(mockService.deleteSystem).toHaveBeenCalledWith("sys-del");
|
|
264
|
+
expect(mockService.systems).toHaveLength(0);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("skips delete when entityId is missing", async () => {
|
|
268
|
+
const kind = buildSystemKind(mockService);
|
|
269
|
+
|
|
270
|
+
await kind.delete!({
|
|
271
|
+
entityName: "unknown-service",
|
|
272
|
+
context: mockContext,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
expect(mockService.deleteSystem).not.toHaveBeenCalled();
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe("Catalog GitOps Kind Extension: System -> groups", () => {
|
|
280
|
+
let mockService: ReturnType<typeof createMockEntityService>;
|
|
281
|
+
|
|
282
|
+
beforeEach(() => {
|
|
283
|
+
mockService = createMockEntityService();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("associates system with groups and removes stale ones", async () => {
|
|
287
|
+
// We mock getGroupsForSystem to return one existing association
|
|
288
|
+
mockService.getGroupsForSystem.mockResolvedValueOnce([
|
|
289
|
+
{ groupId: "grp-stale", systemId: "sys-1" }
|
|
290
|
+
]);
|
|
291
|
+
|
|
292
|
+
const mockExtContext: ReconcileContext = {
|
|
293
|
+
...mockContext,
|
|
294
|
+
resolveEntityRef: mock(async ({ entityName }) => {
|
|
295
|
+
if (entityName === "new-group") return "grp-new";
|
|
296
|
+
return undefined;
|
|
297
|
+
}),
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// Simulate the inline reconcile logic from index.ts
|
|
301
|
+
const reconcileExt = async ({ extensionSpec, entityId, context }: any) => {
|
|
302
|
+
if (!extensionSpec || extensionSpec.length === 0) return;
|
|
303
|
+
|
|
304
|
+
const desiredGroupIds = new Set<string>();
|
|
305
|
+
|
|
306
|
+
for (const entry of extensionSpec) {
|
|
307
|
+
const groupId = await context.resolveEntityRef({
|
|
308
|
+
kind: entry.kind,
|
|
309
|
+
entityName: entry.name,
|
|
310
|
+
});
|
|
311
|
+
if (groupId) {
|
|
312
|
+
desiredGroupIds.add(groupId);
|
|
313
|
+
await mockService.addSystemToGroup({ groupId, systemId: entityId });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const currentAssociations = await mockService.getGroupsForSystem(entityId);
|
|
318
|
+
for (const existing of currentAssociations) {
|
|
319
|
+
if (!desiredGroupIds.has(existing.groupId)) {
|
|
320
|
+
await mockService.removeSystemFromGroup({
|
|
321
|
+
groupId: existing.groupId,
|
|
322
|
+
systemId: entityId,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
await reconcileExt({
|
|
329
|
+
entity: { metadata: { name: "my-system" } },
|
|
330
|
+
extensionSpec: [{ kind: "Group", name: "new-group" }],
|
|
331
|
+
entityId: "sys-1",
|
|
332
|
+
context: mockExtContext,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
expect(mockService.addSystemToGroup).toHaveBeenCalledWith({
|
|
336
|
+
groupId: "grp-new",
|
|
337
|
+
systemId: "sys-1",
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
expect(mockService.removeSystemFromGroup).toHaveBeenCalledWith({
|
|
341
|
+
groupId: "grp-stale",
|
|
342
|
+
systemId: "sys-1",
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
describe("Catalog GitOps Kind: Group", () => {
|
|
348
|
+
let mockService: ReturnType<typeof createMockEntityService>;
|
|
349
|
+
|
|
350
|
+
const groupSpecSchema = z.object({});
|
|
351
|
+
type GroupSpec = z.infer<typeof groupSpecSchema>;
|
|
352
|
+
|
|
353
|
+
function buildGroupKind(
|
|
354
|
+
svc: ReturnType<typeof createMockEntityService>,
|
|
355
|
+
): EntityKindDefinition<GroupSpec> {
|
|
356
|
+
return {
|
|
357
|
+
apiVersion: CHECKSTACK_API_VERSION,
|
|
358
|
+
kind: "Group",
|
|
359
|
+
specSchema: groupSpecSchema,
|
|
360
|
+
reconcile: async ({ entity, existingEntityId, context }) => {
|
|
361
|
+
const displayName = entity.metadata.title ?? entity.metadata.name;
|
|
362
|
+
|
|
363
|
+
if (existingEntityId) {
|
|
364
|
+
await svc.updateGroup(existingEntityId, { name: displayName });
|
|
365
|
+
context.logger.info(`Updated group (id: ${existingEntityId})`);
|
|
366
|
+
return { entityId: existingEntityId };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const group = await svc.createGroup({ name: displayName });
|
|
370
|
+
context.logger.info(`Created group (id: ${group.id})`);
|
|
371
|
+
return { entityId: group.id };
|
|
372
|
+
},
|
|
373
|
+
delete: async ({ entityId }) => {
|
|
374
|
+
if (entityId) await svc.deleteGroup(entityId);
|
|
375
|
+
},
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
beforeEach(() => {
|
|
380
|
+
mockService = createMockEntityService();
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("creates a new group and returns entityId", async () => {
|
|
384
|
+
const kind = buildGroupKind(mockService);
|
|
385
|
+
|
|
386
|
+
const result = await kind.reconcile({
|
|
387
|
+
entity: {
|
|
388
|
+
apiVersion: CHECKSTACK_API_VERSION,
|
|
389
|
+
kind: "Group",
|
|
390
|
+
metadata: {
|
|
391
|
+
name: "platform-team",
|
|
392
|
+
title: "Platform Team",
|
|
393
|
+
},
|
|
394
|
+
spec: {},
|
|
395
|
+
},
|
|
396
|
+
context: mockContext,
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
expect(result.entityId).toBe("grp-1");
|
|
400
|
+
expect(mockService.createGroup).toHaveBeenCalledTimes(1);
|
|
401
|
+
expect(mockService.groups).toHaveLength(1);
|
|
402
|
+
expect(mockService.groups[0].name).toBe("Platform Team");
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("updates an existing group using existingEntityId", async () => {
|
|
406
|
+
const kind = buildGroupKind(mockService);
|
|
407
|
+
|
|
408
|
+
mockService.groups.push({
|
|
409
|
+
id: "grp-existing",
|
|
410
|
+
name: "Old Group",
|
|
411
|
+
createdAt: new Date(),
|
|
412
|
+
updatedAt: new Date(),
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
const result = await kind.reconcile({
|
|
416
|
+
entity: {
|
|
417
|
+
apiVersion: CHECKSTACK_API_VERSION,
|
|
418
|
+
kind: "Group",
|
|
419
|
+
metadata: {
|
|
420
|
+
name: "platform-team",
|
|
421
|
+
title: "Platform Engineering",
|
|
422
|
+
},
|
|
423
|
+
spec: {},
|
|
424
|
+
},
|
|
425
|
+
existingEntityId: "grp-existing",
|
|
426
|
+
context: mockContext,
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
expect(result.entityId).toBe("grp-existing");
|
|
430
|
+
expect(mockService.createGroup).not.toHaveBeenCalled();
|
|
431
|
+
expect(mockService.updateGroup).toHaveBeenCalledTimes(1);
|
|
432
|
+
expect(mockService.groups[0].name).toBe("Platform Engineering");
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("deletes a group by entityId", async () => {
|
|
436
|
+
const kind = buildGroupKind(mockService);
|
|
437
|
+
|
|
438
|
+
mockService.groups.push({
|
|
439
|
+
id: "grp-del",
|
|
440
|
+
name: "To Delete",
|
|
441
|
+
createdAt: new Date(),
|
|
442
|
+
updatedAt: new Date(),
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
await kind.delete!({
|
|
446
|
+
entityName: "old-team",
|
|
447
|
+
entityId: "grp-del",
|
|
448
|
+
context: mockContext,
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
expect(mockService.deleteGroup).toHaveBeenCalledWith("grp-del");
|
|
452
|
+
expect(mockService.groups).toHaveLength(0);
|
|
453
|
+
});
|
|
454
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,9 @@ import { authHooks } from "@checkstack/auth-backend";
|
|
|
17
17
|
import { resolveRoute, type InferClient, extractErrorMessage} from "@checkstack/common";
|
|
18
18
|
import { registerSearchProvider } from "@checkstack/command-backend";
|
|
19
19
|
import { EntityService } from "./services/entity-service";
|
|
20
|
+
import { entityKindExtensionPoint } from "@checkstack/gitops-backend";
|
|
21
|
+
import { CHECKSTACK_API_VERSION, entityRefSchema } from "@checkstack/gitops-common";
|
|
22
|
+
import { z } from "zod";
|
|
20
23
|
|
|
21
24
|
// Database schema is still needed for types in creating the router
|
|
22
25
|
import * as schema from "./schema";
|
|
@@ -31,6 +34,146 @@ export default createBackendPlugin({
|
|
|
31
34
|
register(env) {
|
|
32
35
|
env.registerAccessRules(catalogAccessRules);
|
|
33
36
|
|
|
37
|
+
// ─── GitOps Entity Kind Registration ───────────────────────────────
|
|
38
|
+
// Mutable DB reference — populated during init(), consumed by reconcile closures.
|
|
39
|
+
// Safe because reconcile is only called during sync (afterPluginsReady), by which
|
|
40
|
+
// point init() has already completed.
|
|
41
|
+
let gitopsDb: SafeDatabase<typeof schema> | undefined;
|
|
42
|
+
|
|
43
|
+
const kindRegistry = env.getExtensionPoint(entityKindExtensionPoint);
|
|
44
|
+
|
|
45
|
+
// Register kind: System
|
|
46
|
+
kindRegistry.registerKind({
|
|
47
|
+
apiVersion: CHECKSTACK_API_VERSION,
|
|
48
|
+
kind: "System",
|
|
49
|
+
specSchema: z.object({}),
|
|
50
|
+
reconcile: async ({ entity, existingEntityId, context }) => {
|
|
51
|
+
if (!gitopsDb) throw new Error("Catalog database not initialized");
|
|
52
|
+
const entityService = new EntityService(gitopsDb);
|
|
53
|
+
const displayName = entity.metadata.title ?? entity.metadata.name;
|
|
54
|
+
const description = entity.metadata.description;
|
|
55
|
+
|
|
56
|
+
if (existingEntityId) {
|
|
57
|
+
await entityService.updateSystem(existingEntityId, {
|
|
58
|
+
name: displayName,
|
|
59
|
+
description,
|
|
60
|
+
});
|
|
61
|
+
context.logger.info(
|
|
62
|
+
`GitOps: updated System "${displayName}" (id: ${existingEntityId})`,
|
|
63
|
+
);
|
|
64
|
+
return { entityId: existingEntityId };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const system = await entityService.createSystem({
|
|
68
|
+
name: displayName,
|
|
69
|
+
description,
|
|
70
|
+
});
|
|
71
|
+
context.logger.info(
|
|
72
|
+
`GitOps: created System "${displayName}" (id: ${system.id})`,
|
|
73
|
+
);
|
|
74
|
+
return { entityId: system.id };
|
|
75
|
+
},
|
|
76
|
+
delete: async ({ entityId, context }) => {
|
|
77
|
+
if (!gitopsDb) throw new Error("Catalog database not initialized");
|
|
78
|
+
if (!entityId) return;
|
|
79
|
+
const entityService = new EntityService(gitopsDb);
|
|
80
|
+
await entityService.deleteSystem(entityId);
|
|
81
|
+
context.logger.info(`GitOps: deleted System (id: ${entityId})`);
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Register kind extension: System -> groups
|
|
86
|
+
kindRegistry.registerKindExtension({
|
|
87
|
+
apiVersion: CHECKSTACK_API_VERSION,
|
|
88
|
+
kind: "System",
|
|
89
|
+
namespace: "groups",
|
|
90
|
+
specSchema: z.array(entityRefSchema).optional(),
|
|
91
|
+
reconcile: async ({ entity, extensionSpec, entityId, context }) => {
|
|
92
|
+
if (!gitopsDb) throw new Error("Catalog database not initialized");
|
|
93
|
+
if (!extensionSpec || extensionSpec.length === 0) return;
|
|
94
|
+
|
|
95
|
+
const entityService = new EntityService(gitopsDb);
|
|
96
|
+
const systemEntityId = entityId;
|
|
97
|
+
|
|
98
|
+
const desiredGroupIds = new Set<string>();
|
|
99
|
+
|
|
100
|
+
for (const entry of extensionSpec) {
|
|
101
|
+
const groupId = await context.resolveEntityRef({
|
|
102
|
+
kind: entry.kind,
|
|
103
|
+
entityName: entry.name,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (!groupId) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`Cannot resolve ${entry.kind} ref "${entry.name}" — ensure the entity exists`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
desiredGroupIds.add(groupId);
|
|
113
|
+
|
|
114
|
+
await entityService.addSystemToGroup({
|
|
115
|
+
groupId: groupId,
|
|
116
|
+
systemId: systemEntityId,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
context.logger.info(
|
|
120
|
+
`GitOps: associated System "${entity.metadata.name}" with Group "${entry.name}" (${groupId})`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Remove stale associations not in the spec
|
|
125
|
+
const currentAssociations = await entityService.getGroupsForSystem(systemEntityId);
|
|
126
|
+
for (const existing of currentAssociations) {
|
|
127
|
+
if (!desiredGroupIds.has(existing.groupId)) {
|
|
128
|
+
await entityService.removeSystemFromGroup({
|
|
129
|
+
groupId: existing.groupId,
|
|
130
|
+
systemId: systemEntityId,
|
|
131
|
+
});
|
|
132
|
+
context.logger.info(
|
|
133
|
+
`GitOps: removed stale association ${existing.groupId} from System "${entity.metadata.name}"`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Register kind: Group
|
|
141
|
+
kindRegistry.registerKind({
|
|
142
|
+
apiVersion: CHECKSTACK_API_VERSION,
|
|
143
|
+
kind: "Group",
|
|
144
|
+
specSchema: z.object({}),
|
|
145
|
+
reconcile: async ({ entity, existingEntityId, context }) => {
|
|
146
|
+
if (!gitopsDb) throw new Error("Catalog database not initialized");
|
|
147
|
+
const entityService = new EntityService(gitopsDb);
|
|
148
|
+
const displayName = entity.metadata.title ?? entity.metadata.name;
|
|
149
|
+
|
|
150
|
+
if (existingEntityId) {
|
|
151
|
+
await entityService.updateGroup(existingEntityId, {
|
|
152
|
+
name: displayName,
|
|
153
|
+
});
|
|
154
|
+
context.logger.info(
|
|
155
|
+
`GitOps: updated Group "${displayName}" (id: ${existingEntityId})`,
|
|
156
|
+
);
|
|
157
|
+
return { entityId: existingEntityId };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const group = await entityService.createGroup({
|
|
161
|
+
name: displayName,
|
|
162
|
+
});
|
|
163
|
+
context.logger.info(
|
|
164
|
+
`GitOps: created Group "${displayName}" (id: ${group.id})`,
|
|
165
|
+
);
|
|
166
|
+
return { entityId: group.id };
|
|
167
|
+
},
|
|
168
|
+
delete: async ({ entityId, context }) => {
|
|
169
|
+
if (!gitopsDb) throw new Error("Catalog database not initialized");
|
|
170
|
+
if (!entityId) return;
|
|
171
|
+
const entityService = new EntityService(gitopsDb);
|
|
172
|
+
await entityService.deleteGroup(entityId);
|
|
173
|
+
context.logger.info(`GitOps: deleted Group (id: ${entityId})`);
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
34
177
|
env.registerInit({
|
|
35
178
|
schema,
|
|
36
179
|
deps: {
|
|
@@ -42,6 +185,9 @@ export default createBackendPlugin({
|
|
|
42
185
|
init: async ({ database, rpc, rpcClient, logger }) => {
|
|
43
186
|
logger.debug("Initializing Catalog Backend...");
|
|
44
187
|
|
|
188
|
+
// Populate the mutable DB reference for GitOps reconcile closures
|
|
189
|
+
gitopsDb = database as SafeDatabase<typeof schema>;
|
|
190
|
+
|
|
45
191
|
const typedDb = database as SafeDatabase<typeof schema>;
|
|
46
192
|
|
|
47
193
|
// Get notification client for group management and sending notifications
|
|
@@ -144,6 +144,15 @@ export class EntityService {
|
|
|
144
144
|
await this.database.delete(schema.groups).where(eq(schema.groups.id, id));
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
async getGroupsForSystem(systemId: string) {
|
|
148
|
+
const associations = await this.database
|
|
149
|
+
.select()
|
|
150
|
+
.from(schema.systemsGroups)
|
|
151
|
+
.where(eq(schema.systemsGroups.systemId, systemId));
|
|
152
|
+
|
|
153
|
+
return associations;
|
|
154
|
+
}
|
|
155
|
+
|
|
147
156
|
async addSystemToGroup(props: { groupId: string; systemId: string }) {
|
|
148
157
|
const { groupId, systemId } = props;
|
|
149
158
|
await this.database
|