@checkstack/gitops-backend 0.1.2 → 0.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.
@@ -8,6 +8,20 @@
8
8
  "when": 1776797758378,
9
9
  "tag": "0000_tense_stryfe",
10
10
  "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "7",
15
+ "when": 1776929187262,
16
+ "tag": "0001_wandering_leech",
17
+ "breakpoints": true
18
+ },
19
+ {
20
+ "idx": 2,
21
+ "version": "7",
22
+ "when": 1776931984135,
23
+ "tag": "0002_far_lady_vermin",
24
+ "breakpoints": true
11
25
  }
12
26
  ]
13
27
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/gitops-backend",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "checkstack": {
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "dependencies": {
16
16
  "@checkstack/backend-api": "0.12.0",
17
- "@checkstack/gitops-common": "0.1.0",
17
+ "@checkstack/gitops-common": "0.1.1",
18
18
  "@checkstack/common": "0.6.5",
19
19
  "@checkstack/command-backend": "0.1.19",
20
20
  "@checkstack/queue-api": "0.2.13",
package/src/index.ts CHANGED
@@ -72,6 +72,9 @@ export default createBackendPlugin({
72
72
  ) {
73
73
  kindRegistry.registerKindExtension(definition);
74
74
  },
75
+ registerSpecSchemaDocumentation(params) {
76
+ kindRegistry.registerSpecSchemaDocumentation(params);
77
+ },
75
78
  });
76
79
 
77
80
  env.registerInit({
@@ -207,6 +207,7 @@ describe("EntityKindRegistry", () => {
207
207
  const sys = described[0];
208
208
  expect(sys.apiVersion).toBe(CHECKSTACK_API_VERSION);
209
209
  expect(sys.kind).toBe("System");
210
+ expect(sys.metadataSchema).toBeDefined();
210
211
  expect(sys.specSchema).toBeDefined();
211
212
  expect(sys.extensions).toHaveLength(0);
212
213
 
@@ -259,4 +260,99 @@ describe("EntityKindRegistry", () => {
259
260
  expect(described).toHaveLength(0);
260
261
  });
261
262
  });
263
+
264
+ describe("registerSpecSchemaDocumentation", () => {
265
+ it("allows registering documentation for different field paths and multiple entries per path", () => {
266
+ const registry = createEntityKindRegistry();
267
+
268
+ registry.registerKind({
269
+ apiVersion: CHECKSTACK_API_VERSION,
270
+ kind: "Healthcheck",
271
+ specSchema: z.object({ config: z.unknown(), collectors: z.array(z.unknown()) }),
272
+ reconcile: async () => ({ entityId: "test-id" }),
273
+ });
274
+
275
+ // Register two strategies for the 'config' field
276
+ registry.registerSpecSchemaDocumentation({
277
+ apiVersion: CHECKSTACK_API_VERSION,
278
+ kind: "Healthcheck",
279
+ fieldPath: "config",
280
+ variantId: "http-strat",
281
+ label: "HTTP Strategy",
282
+ description: "Configure HTTP health check",
283
+ schema: z.object({ url: z.string() }),
284
+ });
285
+
286
+ registry.registerSpecSchemaDocumentation({
287
+ apiVersion: CHECKSTACK_API_VERSION,
288
+ kind: "Healthcheck",
289
+ fieldPath: "config",
290
+ variantId: "dns-strat",
291
+ label: "DNS Strategy",
292
+ schema: z.object({ hostname: z.string() }), // no description
293
+ });
294
+
295
+ // Register a collector for 'collectors[].config'
296
+ registry.registerSpecSchemaDocumentation({
297
+ apiVersion: CHECKSTACK_API_VERSION,
298
+ kind: "Healthcheck",
299
+ fieldPath: "collectors[].config",
300
+ label: "Ping Collector",
301
+ description: "Ping something",
302
+ schema: z.object({ count: z.number() }),
303
+ conditions: [{
304
+ fieldPath: "config",
305
+ variantIds: ["http-strat", "dns-strat"],
306
+ }],
307
+ });
308
+
309
+ const described = registry.describeKinds();
310
+ expect(described).toHaveLength(1);
311
+
312
+ const docs = described[0].specSchemaDocumentation;
313
+ expect(docs).toHaveLength(3);
314
+
315
+ const configDocs = docs.filter(d => d.fieldPath === "config");
316
+ expect(configDocs).toHaveLength(2);
317
+ expect(configDocs[0].label).toBe("HTTP Strategy");
318
+ expect(configDocs[0].variantId).toBe("http-strat");
319
+ expect(configDocs[0].description).toBe("Configure HTTP health check");
320
+ expect(configDocs[1].label).toBe("DNS Strategy");
321
+ expect(configDocs[1].variantId).toBe("dns-strat");
322
+ expect(configDocs[1].description).toBeUndefined();
323
+
324
+ const collectorDocs = docs.filter(d => d.fieldPath === "collectors[].config");
325
+ expect(collectorDocs).toHaveLength(1);
326
+ expect(collectorDocs[0].label).toBe("Ping Collector");
327
+ expect(collectorDocs[0].conditions).toBeDefined();
328
+ expect(collectorDocs[0].conditions?.[0].variantIds).toEqual(["http-strat", "dns-strat"]);
329
+ });
330
+
331
+ it("allows registering docs before the kind itself is registered", () => {
332
+ const registry = createEntityKindRegistry();
333
+
334
+ registry.registerSpecSchemaDocumentation({
335
+ apiVersion: CHECKSTACK_API_VERSION,
336
+ kind: "Healthcheck",
337
+ fieldPath: "config",
338
+ label: "HTTP Strategy",
339
+ schema: z.object({ url: z.string() }),
340
+ });
341
+
342
+ // Base kind is missing, so describeKinds should skip it
343
+ expect(registry.describeKinds()).toHaveLength(0);
344
+
345
+ registry.registerKind({
346
+ apiVersion: CHECKSTACK_API_VERSION,
347
+ kind: "Healthcheck",
348
+ specSchema: z.object({ config: z.unknown() }),
349
+ reconcile: async () => ({ entityId: "test-id" }),
350
+ });
351
+
352
+ // Now it should be included
353
+ const described = registry.describeKinds();
354
+ expect(described).toHaveLength(1);
355
+ expect(described[0].specSchemaDocumentation).toHaveLength(1);
356
+ });
357
+ });
262
358
  });
@@ -4,12 +4,15 @@ import type {
4
4
  EntityKindDefinition,
5
5
  EntityKindExtensionDefinition,
6
6
  EntityKindRegistry,
7
+ SpecSchemaDocumentation,
7
8
  } from "@checkstack/gitops-common";
9
+ import { entityMetadataSchema } from "@checkstack/gitops-common";
8
10
 
9
11
  /** Internal storage for a registered kind with its extensions. */
10
12
  interface RegisteredKind {
11
13
  definition: EntityKindDefinition<unknown> | undefined;
12
14
  extensions: Map<string, EntityKindExtensionDefinition<unknown>>;
15
+ specSchemaDocumentation: SpecSchemaDocumentation[];
13
16
  }
14
17
 
15
18
  /** Composite key for looking up kinds by apiVersion + kind. */
@@ -46,11 +49,23 @@ export function createEntityKindRegistry() {
46
49
  describeKinds: () => Array<{
47
50
  apiVersion: string;
48
51
  kind: string;
52
+ metadataSchema: Record<string, unknown>;
49
53
  specSchema: Record<string, unknown>;
50
54
  extensions: Array<{
51
55
  namespace: string;
52
56
  specSchema: Record<string, unknown>;
53
57
  }>;
58
+ specSchemaDocumentation: Array<{
59
+ fieldPath: string;
60
+ variantId?: string;
61
+ label: string;
62
+ description?: string;
63
+ specSchema: Record<string, unknown>;
64
+ conditions?: Array<{
65
+ fieldPath: string;
66
+ variantIds: string[];
67
+ }>;
68
+ }>;
54
69
  }>;
55
70
  } = {
56
71
  registerKind<TSpec>(definition: EntityKindDefinition<TSpec>) {
@@ -70,6 +85,7 @@ export function createEntityKindRegistry() {
70
85
  kinds.set(key, {
71
86
  definition: definition as EntityKindDefinition<unknown>,
72
87
  extensions: new Map(),
88
+ specSchemaDocumentation: [],
73
89
  });
74
90
  }
75
91
  },
@@ -91,6 +107,7 @@ export function createEntityKindRegistry() {
91
107
  definition as EntityKindExtensionDefinition<unknown>,
92
108
  ],
93
109
  ]),
110
+ specSchemaDocumentation: [],
94
111
  });
95
112
  return;
96
113
  }
@@ -107,6 +124,30 @@ export function createEntityKindRegistry() {
107
124
  );
108
125
  },
109
126
 
127
+ registerSpecSchemaDocumentation(params) {
128
+ const key = kindKey(params);
129
+ let registered = kinds.get(key);
130
+
131
+ if (!registered) {
132
+ // Allow registering docs before the kind itself
133
+ registered = {
134
+ definition: undefined,
135
+ extensions: new Map(),
136
+ specSchemaDocumentation: [],
137
+ };
138
+ kinds.set(key, registered);
139
+ }
140
+
141
+ registered.specSchemaDocumentation.push({
142
+ fieldPath: params.fieldPath,
143
+ variantId: params.variantId,
144
+ label: params.label,
145
+ description: params.description,
146
+ schema: params.schema,
147
+ conditions: params.conditions,
148
+ });
149
+ },
150
+
110
151
  getKind(params) {
111
152
  return kinds.get(kindKey(params))?.definition;
112
153
  },
@@ -149,20 +190,30 @@ export function createEntityKindRegistry() {
149
190
  const result: Array<{
150
191
  apiVersion: string;
151
192
  kind: string;
193
+ metadataSchema: Record<string, unknown>;
152
194
  specSchema: Record<string, unknown>;
153
195
  extensions: Array<{
154
196
  namespace: string;
155
197
  specSchema: Record<string, unknown>;
156
198
  }>;
199
+ specSchemaDocumentation: Array<{
200
+ fieldPath: string;
201
+ variantId?: string;
202
+ label: string;
203
+ description?: string;
204
+ specSchema: Record<string, unknown>;
205
+ conditions?: Array<{
206
+ fieldPath: string;
207
+ variantIds: string[];
208
+ }>;
209
+ }>;
157
210
  }> = [];
158
211
 
159
212
  for (const registered of kinds.values()) {
160
213
  if (!registered.definition) continue;
161
214
 
162
215
  const def = registered.definition;
163
- const baseSchema = toJsonSchema(
164
- def.specSchema as z.ZodTypeAny,
165
- );
216
+ const baseSchema = toJsonSchema(def.specSchema as z.ZodTypeAny);
166
217
 
167
218
  const extensions = [...registered.extensions.entries()].map(
168
219
  ([namespace, ext]) => ({
@@ -171,11 +222,24 @@ export function createEntityKindRegistry() {
171
222
  }),
172
223
  );
173
224
 
225
+ const specSchemaDocumentation = registered.specSchemaDocumentation.map(
226
+ (doc) => ({
227
+ fieldPath: doc.fieldPath,
228
+ variantId: doc.variantId,
229
+ label: doc.label,
230
+ description: doc.description,
231
+ specSchema: toJsonSchema(doc.schema),
232
+ conditions: doc.conditions,
233
+ }),
234
+ );
235
+
174
236
  result.push({
175
237
  apiVersion: def.apiVersion,
176
238
  kind: def.kind,
239
+ metadataSchema: toJsonSchema(entityMetadataSchema),
177
240
  specSchema: baseSchema,
178
241
  extensions,
242
+ specSchemaDocumentation,
179
243
  });
180
244
  }
181
245
 
package/src/router.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { implement, ORPCError } from "@orpc/server";
2
+ import { z } from "zod";
2
3
  import { autoAuthMiddleware, type RpcContext } from "@checkstack/backend-api";
3
4
  import { encrypt, decrypt } from "@checkstack/backend-api";
4
5
  import { gitopsContract } from "@checkstack/gitops-common";
@@ -7,7 +8,7 @@ import type { QueueManager } from "@checkstack/queue-api";
7
8
  import type { InternalEntityKindRegistry } from "./kind-registry";
8
9
  import { triggerSyncForProvider, scheduleSyncForProvider, cancelSyncForProvider } from "./sync/sync-worker";
9
10
  import * as schema from "./schema";
10
- import { eq, and } from "drizzle-orm";
11
+ import { eq, and, sql } from "drizzle-orm";
11
12
  import { v4 as uuidv4 } from "uuid";
12
13
 
13
14
  /**
@@ -47,12 +48,15 @@ export const createGitOpsRouter = ({
47
48
  .select()
48
49
  .from(schema.provenance)
49
50
  .where(and(...conditions));
51
+ const row = result[0];
50
52
  // eslint-disable-next-line unicorn/no-null
51
- return result[0] ?? null;
53
+ return row ? { ...row, warnings: row.warnings ?? [] } : null;
52
54
  });
53
55
 
54
56
  const listProvenance = os.listProvenance.handler(async ({ input }) => {
55
- const rows = await db.select().from(schema.provenance);
57
+ const rawRows = await db.select().from(schema.provenance);
58
+ // Normalize: ensure warnings is always a string[] (Drizzle may return null for pre-migration rows)
59
+ const rows = rawRows.map((row) => ({ ...row, warnings: row.warnings ?? [] }));
56
60
  if (!input) return rows;
57
61
 
58
62
  return rows.filter((row) => {
@@ -236,6 +240,8 @@ export const createGitOpsRouter = ({
236
240
  // eslint-disable-next-line unicorn/no-useless-undefined
237
241
  return undefined;
238
242
  },
243
+ resolveSecretsBySchema: async <T>(params: { value: T; schema: z.ZodTypeAny }): Promise<{ resolved: T; warnings: string[] }> =>
244
+ ({ resolved: params.value, warnings: [] }),
239
245
  },
240
246
  });
241
247
  } catch (deleteError) {
@@ -327,6 +333,16 @@ export const createGitOpsRouter = ({
327
333
  .set({ encryptedValue, updatedAt: new Date() })
328
334
  .where(eq(schema.secrets.id, input.id));
329
335
 
336
+ // Invalidate provenance for all entities referencing this secret
337
+ // so the next sync cycle re-reconciles them with the updated value
338
+ const secretName = existing[0].name;
339
+ await db
340
+ .update(schema.provenance)
341
+ .set({ lastSyncHash: "" })
342
+ .where(
343
+ sql`${secretName} = ANY(${schema.provenance.secretRefs})`,
344
+ );
345
+
330
346
  return { success: true };
331
347
  });
332
348
 
@@ -352,6 +368,22 @@ export const createGitOpsRouter = ({
352
368
  return { value: decrypt(secret.encryptedValue) };
353
369
  });
354
370
 
371
+ const getSecretUsage = os.getSecretUsage.handler(async ({ input }) => {
372
+ const rows = await db
373
+ .select({
374
+ kind: schema.provenance.kind,
375
+ entityName: schema.provenance.entityName,
376
+ repository: schema.provenance.repository,
377
+ filePath: schema.provenance.filePath,
378
+ })
379
+ .from(schema.provenance)
380
+ .where(
381
+ sql`${input.secretName} = ANY(${schema.provenance.secretRefs})`,
382
+ );
383
+
384
+ return rows;
385
+ });
386
+
355
387
  // ─── Kind Registry ────────────────────────────────────────────────────
356
388
 
357
389
  const listKinds = os.listKinds.handler(async () => {
@@ -375,6 +407,7 @@ export const createGitOpsRouter = ({
375
407
  rotateSecret,
376
408
  deleteSecret,
377
409
  resolveSecret,
410
+ getSecretUsage,
378
411
  listKinds,
379
412
  });
380
413
  };
package/src/schema.ts CHANGED
@@ -57,16 +57,20 @@ export const provenance = pgTable("provenance", {
57
57
  repository: text("repository").notNull(),
58
58
  filePath: text("file_path").notNull(),
59
59
  lastSyncHash: text("last_sync_hash").notNull(),
60
+ /** Secret names referenced via ${{ secrets.NAME }} in this entity's spec. */
61
+ secretRefs: text("secret_refs").array().default([]),
60
62
  status: provenanceStatusEnum("status").notNull().default("synced"),
61
63
  errorMessage: text("error_message"),
64
+ /** Warnings about unresolved secret templates in non-secret fields. */
65
+ warnings: text("warnings").array().default([]),
62
66
  lastSyncedAt: timestamp("last_synced_at").defaultNow().notNull(),
63
67
  createdAt: timestamp("created_at").defaultNow().notNull(),
64
68
  });
65
69
 
66
- /** Secret store for secretRef values in YAML descriptors. */
70
+ /** Secret store for ${{ secrets.NAME }} template values in YAML descriptors. */
67
71
  export const secrets = pgTable("secrets", {
68
72
  id: text("id").primaryKey(),
69
- /** Unique name referenced by secretRef in descriptors. */
73
+ /** Unique name referenced via ${{ secrets.NAME }} in descriptors. */
70
74
  name: text("name").notNull().unique(),
71
75
  /** AES-256-GCM encrypted value (format: iv:authTag:ciphertext). */
72
76
  encryptedValue: text("encrypted_value").notNull(),