@checkstack/gitops-backend 0.1.1 → 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.1",
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,13 +1,14 @@
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";
5
6
  import type { SafeDatabase } from "@checkstack/backend-api";
6
7
  import type { QueueManager } from "@checkstack/queue-api";
7
8
  import type { InternalEntityKindRegistry } from "./kind-registry";
8
- import { triggerSyncForProvider } from "./sync/sync-worker";
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) => {
@@ -82,6 +86,7 @@ export const createGitOpsRouter = ({
82
86
 
83
87
  const createProvider = os.createProvider.handler(async ({ input }) => {
84
88
  const id = uuidv4();
89
+ const syncInterval = input.syncInterval ?? 300;
85
90
  await db.insert(schema.providers).values({
86
91
  id,
87
92
  type: input.type,
@@ -89,9 +94,17 @@ export const createGitOpsRouter = ({
89
94
  pathPattern: input.pathPattern,
90
95
  baseUrl: input.baseUrl ?? null, // eslint-disable-line unicorn/no-null
91
96
  authToken: input.authToken ? encrypt(input.authToken) : null, // eslint-disable-line unicorn/no-null
92
- syncInterval: input.syncInterval ?? 300,
97
+ syncInterval,
93
98
  deletionPolicy: input.deletionPolicy ?? "orphan",
94
99
  });
100
+
101
+ // Schedule recurring sync for the new provider
102
+ await scheduleSyncForProvider({
103
+ queueManager,
104
+ providerId: id,
105
+ syncIntervalSeconds: syncInterval,
106
+ });
107
+
95
108
  return { id };
96
109
  });
97
110
 
@@ -127,6 +140,15 @@ export const createGitOpsRouter = ({
127
140
  .set(updates)
128
141
  .where(eq(schema.providers.id, input.id));
129
142
 
143
+ // If syncInterval changed, reschedule the recurring job
144
+ if (input.data.syncInterval !== undefined) {
145
+ await scheduleSyncForProvider({
146
+ queueManager,
147
+ providerId: input.id,
148
+ syncIntervalSeconds: input.data.syncInterval,
149
+ });
150
+ }
151
+
130
152
  return { success: true };
131
153
  });
132
154
 
@@ -145,6 +167,12 @@ export const createGitOpsRouter = ({
145
167
  // Provenance entries are cascade-deleted via FK constraint
146
168
  await db.delete(schema.providers).where(eq(schema.providers.id, input.id));
147
169
 
170
+ // Cancel the recurring sync job
171
+ await cancelSyncForProvider({
172
+ queueManager,
173
+ providerId: input.id,
174
+ });
175
+
148
176
  return { success: true };
149
177
  });
150
178
 
@@ -212,6 +240,8 @@ export const createGitOpsRouter = ({
212
240
  // eslint-disable-next-line unicorn/no-useless-undefined
213
241
  return undefined;
214
242
  },
243
+ resolveSecretsBySchema: async <T>(params: { value: T; schema: z.ZodTypeAny }): Promise<{ resolved: T; warnings: string[] }> =>
244
+ ({ resolved: params.value, warnings: [] }),
215
245
  },
216
246
  });
217
247
  } catch (deleteError) {
@@ -303,6 +333,16 @@ export const createGitOpsRouter = ({
303
333
  .set({ encryptedValue, updatedAt: new Date() })
304
334
  .where(eq(schema.secrets.id, input.id));
305
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
+
306
346
  return { success: true };
307
347
  });
308
348
 
@@ -328,6 +368,22 @@ export const createGitOpsRouter = ({
328
368
  return { value: decrypt(secret.encryptedValue) };
329
369
  });
330
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
+
331
387
  // ─── Kind Registry ────────────────────────────────────────────────────
332
388
 
333
389
  const listKinds = os.listKinds.handler(async () => {
@@ -351,6 +407,7 @@ export const createGitOpsRouter = ({
351
407
  rotateSecret,
352
408
  deleteSecret,
353
409
  resolveSecret,
410
+ getSecretUsage,
354
411
  listKinds,
355
412
  });
356
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(),