@checkstack/gitops-backend 0.1.2 → 0.2.1
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 +50 -0
- package/drizzle/0001_wandering_leech.sql +1 -0
- package/drizzle/0002_far_lady_vermin.sql +1 -0
- package/drizzle/meta/0001_snapshot.json +317 -0
- package/drizzle/meta/0002_snapshot.json +324 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +2 -2
- package/src/index.ts +3 -0
- package/src/kind-registry.test.ts +96 -0
- package/src/kind-registry.ts +67 -3
- package/src/router.ts +36 -3
- package/src/schema.ts +6 -2
- package/src/secret-resolver.test.ts +279 -40
- package/src/secret-resolver.ts +194 -27
- package/src/sync/reconciler-delete.test.ts +4 -0
- package/src/sync/reconciler.ts +70 -21
- package/src/sync/sort-entities.ts +2 -0
|
@@ -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
|
|
3
|
+
"version": "0.2.1",
|
|
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.
|
|
17
|
+
"@checkstack/gitops-common": "0.2.0",
|
|
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
|
@@ -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
|
});
|
package/src/kind-registry.ts
CHANGED
|
@@ -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
|
|
53
|
+
return row ? { ...row, warnings: row.warnings ?? [] } : null;
|
|
52
54
|
});
|
|
53
55
|
|
|
54
56
|
const listProvenance = os.listProvenance.handler(async ({ input }) => {
|
|
55
|
-
const
|
|
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
|
|
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
|
|
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(),
|