@checkstack/catalog-backend 0.2.23 → 0.3.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 CHANGED
@@ -1,5 +1,63 @@
1
1
  # @checkstack/catalog-backend
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 6c40b5b: Register catalog System and Group as GitOps entity kinds
8
+
9
+ - **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.
10
+ - **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.
11
+
12
+ - 6c40b5b: Generalized provenance system and GitOps frontend plugin
13
+
14
+ **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.
15
+
16
+ - Added `entityId` column to the provenance table (non-nullable)
17
+ - Reconciler engine passes `existingEntityId` to plugins for updates
18
+ - `getProvenance` now supports lookup by `entityId` in addition to `entityName`
19
+ - Added provider CRUD endpoints: `createProvider`, `updateProvider`, `deleteProvider`
20
+ - Created `gitops-frontend` plugin with provider management, secret management, and sync status dashboard
21
+ - Removed `gitops_entity_name` metadata markers from catalog entities
22
+ - Removed `findSystemByGitOpsName`, `deleteSystemByGitOpsName` (and Group equivalents) from EntityService
23
+ - Added provenance-based UI locking in catalog-frontend: edit/delete/drag disabled for GitOps-managed systems and groups
24
+
25
+ ### Patch Changes
26
+
27
+ - 6c40b5b: ### GitOps Ecosystem: Healthcheck Kind Registration (Phase 5)
28
+
29
+ **gitops-common**: Added required `resolveEntityRef` to `ReconcileContext`, enabling extension reconcilers to resolve cross-kind entity references (e.g., healthcheck refs in System extensions).
30
+
31
+ **gitops-backend**: Updated reconciler to populate `resolveEntityRef` by querying local provenance — no RPC round-trip needed.
32
+
33
+ **healthcheck-backend**: Registered `kind: Healthcheck` and `System → healthchecks` extension with the EntityKindRegistry:
34
+
35
+ - Validates strategy configs against registered strategy schemas at reconcile time
36
+ - Validates collector configs against registered collector schemas at reconcile time
37
+ - Manages system ↔ healthcheck associations with automatic stale removal
38
+
39
+ **healthcheck-frontend**: Added GitOps provenance locking to the HealthCheck IDE editor — GitOps-managed health checks show a lock banner and disable editing.
40
+
41
+ **catalog-backend**: Updated test fixtures for new required `resolveEntityRef` context field.
42
+
43
+ - Updated dependencies [6c40b5b]
44
+ - Updated dependencies [6c40b5b]
45
+ - Updated dependencies [6c40b5b]
46
+ - Updated dependencies [6c40b5b]
47
+ - Updated dependencies [6c40b5b]
48
+ - Updated dependencies [6c40b5b]
49
+ - @checkstack/gitops-backend@0.1.0
50
+ - @checkstack/gitops-common@0.1.0
51
+
52
+ ## 0.2.24
53
+
54
+ ### Patch Changes
55
+
56
+ - Updated dependencies [26d8bae]
57
+ - @checkstack/backend-api@0.12.0
58
+ - @checkstack/auth-backend@0.4.18
59
+ - @checkstack/command-backend@0.1.19
60
+
3
61
  ## 0.2.23
4
62
 
5
63
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/catalog-backend",
3
- "version": "0.2.23",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "checkstack": {
@@ -13,18 +13,20 @@
13
13
  "lint:code": "eslint . --max-warnings 0"
14
14
  },
15
15
  "dependencies": {
16
- "@checkstack/backend-api": "0.11.0",
17
- "@checkstack/auth-common": "0.6.0",
18
- "@checkstack/catalog-common": "1.3.0",
19
- "@checkstack/command-backend": "0.1.17",
20
- "@checkstack/auth-backend": "0.4.16",
21
- "@checkstack/notification-common": "0.2.7",
16
+ "@checkstack/backend-api": "0.12.0",
17
+ "@checkstack/auth-common": "0.6.1",
18
+ "@checkstack/catalog-common": "1.3.1",
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",
23
+ "@checkstack/notification-common": "0.2.8",
22
24
  "@orpc/server": "^1.13.2",
23
25
  "drizzle-orm": "^0.45.0",
24
26
  "hono": "^4.12.14",
25
27
  "uuid": "^13.0.0",
26
28
  "zod": "^4.2.1",
27
- "@checkstack/common": "0.6.4"
29
+ "@checkstack/common": "0.6.5"
28
30
  },
29
31
  "devDependencies": {
30
32
  "@checkstack/drizzle-helper": "0.0.4",
@@ -0,0 +1,381 @@
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
+ };
89
+ }
90
+
91
+ // ─── Test Context ──────────────────────────────────────────────────────────
92
+
93
+ const mockContext: ReconcileContext = {
94
+ logger: {
95
+ debug: () => {},
96
+ info: () => {},
97
+ warn: () => {},
98
+ error: () => {},
99
+ },
100
+ resolveEntityRef: async () => undefined,
101
+ };
102
+
103
+ // ─── Tests ─────────────────────────────────────────────────────────────────
104
+
105
+ describe("Catalog GitOps Kind: System", () => {
106
+ let mockService: ReturnType<typeof createMockEntityService>;
107
+
108
+ const systemSpecSchema = z.object({});
109
+ type SystemSpec = z.infer<typeof systemSpecSchema>;
110
+
111
+ function buildSystemKind(
112
+ svc: ReturnType<typeof createMockEntityService>,
113
+ ): EntityKindDefinition<SystemSpec> {
114
+ return {
115
+ apiVersion: CHECKSTACK_API_VERSION,
116
+ kind: "System",
117
+ specSchema: systemSpecSchema,
118
+ reconcile: async ({ entity, existingEntityId, context }) => {
119
+ const displayName = entity.metadata.title ?? entity.metadata.name;
120
+ const description = entity.metadata.description;
121
+
122
+ if (existingEntityId) {
123
+ await svc.updateSystem(existingEntityId, {
124
+ name: displayName,
125
+ description,
126
+ });
127
+ context.logger.info(`Updated system (id: ${existingEntityId})`);
128
+ return { entityId: existingEntityId };
129
+ }
130
+
131
+ const system = await svc.createSystem({
132
+ name: displayName,
133
+ description,
134
+ });
135
+ context.logger.info(`Created system (id: ${system.id})`);
136
+ return { entityId: system.id };
137
+ },
138
+ delete: async ({ entityId }) => {
139
+ if (entityId) await svc.deleteSystem(entityId);
140
+ },
141
+ };
142
+ }
143
+
144
+ beforeEach(() => {
145
+ mockService = createMockEntityService();
146
+ });
147
+
148
+ it("creates a new system and returns entityId", async () => {
149
+ const kind = buildSystemKind(mockService);
150
+
151
+ const result = await kind.reconcile({
152
+ entity: {
153
+ apiVersion: CHECKSTACK_API_VERSION,
154
+ kind: "System",
155
+ metadata: {
156
+ name: "payment-service",
157
+ title: "Payment Service",
158
+ description: "Handles payments",
159
+ },
160
+ spec: {},
161
+ },
162
+ context: mockContext,
163
+ });
164
+
165
+ expect(result.entityId).toBe("sys-1");
166
+ expect(mockService.createSystem).toHaveBeenCalledTimes(1);
167
+ expect(mockService.systems).toHaveLength(1);
168
+ expect(mockService.systems[0].name).toBe("Payment Service");
169
+ expect(mockService.systems[0].description).toBe("Handles payments");
170
+ });
171
+
172
+ it("uses metadata.description for the catalog description", async () => {
173
+ const kind = buildSystemKind(mockService);
174
+
175
+ const result = await kind.reconcile({
176
+ entity: {
177
+ apiVersion: CHECKSTACK_API_VERSION,
178
+ kind: "System",
179
+ metadata: {
180
+ name: "api-gateway",
181
+ description: "Gateway description",
182
+ },
183
+ spec: {},
184
+ },
185
+ context: mockContext,
186
+ });
187
+
188
+ expect(result.entityId).toBe("sys-1");
189
+ expect(mockService.systems[0].description).toBe("Gateway description");
190
+ });
191
+
192
+ it("falls back to metadata.name when title is missing", async () => {
193
+ const kind = buildSystemKind(mockService);
194
+
195
+ await kind.reconcile({
196
+ entity: {
197
+ apiVersion: CHECKSTACK_API_VERSION,
198
+ kind: "System",
199
+ metadata: { name: "my-service" },
200
+ spec: {},
201
+ },
202
+ context: mockContext,
203
+ });
204
+
205
+ expect(mockService.systems[0].name).toBe("my-service");
206
+ });
207
+
208
+ it("updates an existing system using existingEntityId", async () => {
209
+ const kind = buildSystemKind(mockService);
210
+
211
+ // Pre-populate a system
212
+ mockService.systems.push({
213
+ id: "sys-existing",
214
+ name: "Old Name",
215
+ description: "Old description",
216
+ createdAt: new Date(),
217
+ updatedAt: new Date(),
218
+ });
219
+
220
+ const result = await kind.reconcile({
221
+ entity: {
222
+ apiVersion: CHECKSTACK_API_VERSION,
223
+ kind: "System",
224
+ metadata: {
225
+ name: "payment-service",
226
+ title: "Payment Service v2",
227
+ description: "Updated description",
228
+ },
229
+ spec: {},
230
+ },
231
+ existingEntityId: "sys-existing",
232
+ context: mockContext,
233
+ });
234
+
235
+ expect(result.entityId).toBe("sys-existing");
236
+ expect(mockService.createSystem).not.toHaveBeenCalled();
237
+ expect(mockService.updateSystem).toHaveBeenCalledTimes(1);
238
+ expect(mockService.systems[0].name).toBe("Payment Service v2");
239
+ expect(mockService.systems[0].description).toBe("Updated description");
240
+ });
241
+
242
+ it("deletes a system by entityId", async () => {
243
+ const kind = buildSystemKind(mockService);
244
+
245
+ mockService.systems.push({
246
+ id: "sys-del",
247
+ name: "To Delete",
248
+ createdAt: new Date(),
249
+ updatedAt: new Date(),
250
+ });
251
+
252
+ await kind.delete!({
253
+ entityName: "old-service",
254
+ entityId: "sys-del",
255
+ context: mockContext,
256
+ });
257
+
258
+ expect(mockService.deleteSystem).toHaveBeenCalledWith("sys-del");
259
+ expect(mockService.systems).toHaveLength(0);
260
+ });
261
+
262
+ it("skips delete when entityId is missing", async () => {
263
+ const kind = buildSystemKind(mockService);
264
+
265
+ await kind.delete!({
266
+ entityName: "unknown-service",
267
+ context: mockContext,
268
+ });
269
+
270
+ expect(mockService.deleteSystem).not.toHaveBeenCalled();
271
+ });
272
+ });
273
+
274
+ describe("Catalog GitOps Kind: Group", () => {
275
+ let mockService: ReturnType<typeof createMockEntityService>;
276
+
277
+ const groupSpecSchema = z.object({});
278
+ type GroupSpec = z.infer<typeof groupSpecSchema>;
279
+
280
+ function buildGroupKind(
281
+ svc: ReturnType<typeof createMockEntityService>,
282
+ ): EntityKindDefinition<GroupSpec> {
283
+ return {
284
+ apiVersion: CHECKSTACK_API_VERSION,
285
+ kind: "Group",
286
+ specSchema: groupSpecSchema,
287
+ reconcile: async ({ entity, existingEntityId, context }) => {
288
+ const displayName = entity.metadata.title ?? entity.metadata.name;
289
+
290
+ if (existingEntityId) {
291
+ await svc.updateGroup(existingEntityId, { name: displayName });
292
+ context.logger.info(`Updated group (id: ${existingEntityId})`);
293
+ return { entityId: existingEntityId };
294
+ }
295
+
296
+ const group = await svc.createGroup({ name: displayName });
297
+ context.logger.info(`Created group (id: ${group.id})`);
298
+ return { entityId: group.id };
299
+ },
300
+ delete: async ({ entityId }) => {
301
+ if (entityId) await svc.deleteGroup(entityId);
302
+ },
303
+ };
304
+ }
305
+
306
+ beforeEach(() => {
307
+ mockService = createMockEntityService();
308
+ });
309
+
310
+ it("creates a new group and returns entityId", async () => {
311
+ const kind = buildGroupKind(mockService);
312
+
313
+ const result = await kind.reconcile({
314
+ entity: {
315
+ apiVersion: CHECKSTACK_API_VERSION,
316
+ kind: "Group",
317
+ metadata: {
318
+ name: "platform-team",
319
+ title: "Platform Team",
320
+ },
321
+ spec: {},
322
+ },
323
+ context: mockContext,
324
+ });
325
+
326
+ expect(result.entityId).toBe("grp-1");
327
+ expect(mockService.createGroup).toHaveBeenCalledTimes(1);
328
+ expect(mockService.groups).toHaveLength(1);
329
+ expect(mockService.groups[0].name).toBe("Platform Team");
330
+ });
331
+
332
+ it("updates an existing group using existingEntityId", async () => {
333
+ const kind = buildGroupKind(mockService);
334
+
335
+ mockService.groups.push({
336
+ id: "grp-existing",
337
+ name: "Old Group",
338
+ createdAt: new Date(),
339
+ updatedAt: new Date(),
340
+ });
341
+
342
+ const result = await kind.reconcile({
343
+ entity: {
344
+ apiVersion: CHECKSTACK_API_VERSION,
345
+ kind: "Group",
346
+ metadata: {
347
+ name: "platform-team",
348
+ title: "Platform Engineering",
349
+ },
350
+ spec: {},
351
+ },
352
+ existingEntityId: "grp-existing",
353
+ context: mockContext,
354
+ });
355
+
356
+ expect(result.entityId).toBe("grp-existing");
357
+ expect(mockService.createGroup).not.toHaveBeenCalled();
358
+ expect(mockService.updateGroup).toHaveBeenCalledTimes(1);
359
+ expect(mockService.groups[0].name).toBe("Platform Engineering");
360
+ });
361
+
362
+ it("deletes a group by entityId", async () => {
363
+ const kind = buildGroupKind(mockService);
364
+
365
+ mockService.groups.push({
366
+ id: "grp-del",
367
+ name: "To Delete",
368
+ createdAt: new Date(),
369
+ updatedAt: new Date(),
370
+ });
371
+
372
+ await kind.delete!({
373
+ entityName: "old-team",
374
+ entityId: "grp-del",
375
+ context: mockContext,
376
+ });
377
+
378
+ expect(mockService.deleteGroup).toHaveBeenCalledWith("grp-del");
379
+ expect(mockService.groups).toHaveLength(0);
380
+ });
381
+ });
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 } 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,91 @@ 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: Group
86
+ kindRegistry.registerKind({
87
+ apiVersion: CHECKSTACK_API_VERSION,
88
+ kind: "Group",
89
+ specSchema: z.object({}),
90
+ reconcile: async ({ entity, existingEntityId, context }) => {
91
+ if (!gitopsDb) throw new Error("Catalog database not initialized");
92
+ const entityService = new EntityService(gitopsDb);
93
+ const displayName = entity.metadata.title ?? entity.metadata.name;
94
+
95
+ if (existingEntityId) {
96
+ await entityService.updateGroup(existingEntityId, {
97
+ name: displayName,
98
+ });
99
+ context.logger.info(
100
+ `GitOps: updated Group "${displayName}" (id: ${existingEntityId})`,
101
+ );
102
+ return { entityId: existingEntityId };
103
+ }
104
+
105
+ const group = await entityService.createGroup({
106
+ name: displayName,
107
+ });
108
+ context.logger.info(
109
+ `GitOps: created Group "${displayName}" (id: ${group.id})`,
110
+ );
111
+ return { entityId: group.id };
112
+ },
113
+ delete: async ({ entityId, context }) => {
114
+ if (!gitopsDb) throw new Error("Catalog database not initialized");
115
+ if (!entityId) return;
116
+ const entityService = new EntityService(gitopsDb);
117
+ await entityService.deleteGroup(entityId);
118
+ context.logger.info(`GitOps: deleted Group (id: ${entityId})`);
119
+ },
120
+ });
121
+
34
122
  env.registerInit({
35
123
  schema,
36
124
  deps: {
@@ -42,6 +130,9 @@ export default createBackendPlugin({
42
130
  init: async ({ database, rpc, rpcClient, logger }) => {
43
131
  logger.debug("Initializing Catalog Backend...");
44
132
 
133
+ // Populate the mutable DB reference for GitOps reconcile closures
134
+ gitopsDb = database as SafeDatabase<typeof schema>;
135
+
45
136
  const typedDb = database as SafeDatabase<typeof schema>;
46
137
 
47
138
  // Get notification client for group management and sending notifications