@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
package/src/sync/reconciler.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { Logger, SafeDatabase } from "@checkstack/backend-api";
|
|
2
2
|
import type { EntityEnvelope } from "@checkstack/gitops-common";
|
|
3
|
+
import { collectSecretNames } from "@checkstack/gitops-common";
|
|
4
|
+
import { z } from "zod";
|
|
3
5
|
import type { InternalEntityKindRegistry } from "../kind-registry";
|
|
4
6
|
import type { SecretStore } from "../secret-resolver";
|
|
5
7
|
import type { DiscoveredFile, Scraper, FetchFn } from "../scrapers/types";
|
|
6
8
|
import { parseEntityDocuments } from "./document-parser";
|
|
7
9
|
import { sortEntitiesByDependency, type CollectedEntity } from "./sort-entities";
|
|
8
|
-
import {
|
|
10
|
+
import { resolveSecretsBySchema } from "../secret-resolver";
|
|
9
11
|
import * as schema from "../schema";
|
|
10
12
|
import { eq, and } from "drizzle-orm";
|
|
11
13
|
import { v4 as uuidv4 } from "uuid";
|
|
@@ -117,7 +119,8 @@ export async function reconcileProvider(
|
|
|
117
119
|
for (const { entity, contentHash } of parseResult.entities) {
|
|
118
120
|
const entityKey = `${entity.kind}::${entity.metadata.name}`;
|
|
119
121
|
seenEntityKeys.add(entityKey);
|
|
120
|
-
|
|
122
|
+
const secretRefs = collectSecretNames({ value: entity.spec });
|
|
123
|
+
collected.push({ entity, contentHash, file, secretRefs });
|
|
121
124
|
}
|
|
122
125
|
}
|
|
123
126
|
|
|
@@ -125,13 +128,14 @@ export async function reconcileProvider(
|
|
|
125
128
|
const sorted = sortEntitiesByDependency({ entities: collected });
|
|
126
129
|
|
|
127
130
|
// 4. Reconcile in dependency order (provenance written immediately per entity)
|
|
128
|
-
for (const { entity, contentHash, file } of sorted) {
|
|
131
|
+
for (const { entity, contentHash, file, secretRefs } of sorted) {
|
|
129
132
|
const entityKey = `${entity.kind}::${entity.metadata.name}`;
|
|
130
133
|
|
|
131
134
|
try {
|
|
132
135
|
await reconcileEntity({
|
|
133
136
|
entity,
|
|
134
137
|
contentHash,
|
|
138
|
+
secretRefs: secretRefs ?? [],
|
|
135
139
|
file,
|
|
136
140
|
providerId,
|
|
137
141
|
db,
|
|
@@ -150,6 +154,7 @@ export async function reconcileProvider(
|
|
|
150
154
|
entity,
|
|
151
155
|
file,
|
|
152
156
|
contentHash,
|
|
157
|
+
secretRefs: secretRefs ?? [],
|
|
153
158
|
status: "error",
|
|
154
159
|
errorMessage: String(error),
|
|
155
160
|
});
|
|
@@ -179,6 +184,7 @@ export async function reconcileProvider(
|
|
|
179
184
|
async function reconcileEntity(params: {
|
|
180
185
|
entity: EntityEnvelope;
|
|
181
186
|
contentHash: string;
|
|
187
|
+
secretRefs: string[];
|
|
182
188
|
file: DiscoveredFile;
|
|
183
189
|
providerId: string;
|
|
184
190
|
db: Db;
|
|
@@ -190,6 +196,7 @@ async function reconcileEntity(params: {
|
|
|
190
196
|
const {
|
|
191
197
|
entity,
|
|
192
198
|
contentHash,
|
|
199
|
+
secretRefs,
|
|
193
200
|
file,
|
|
194
201
|
providerId,
|
|
195
202
|
db,
|
|
@@ -217,7 +224,25 @@ async function reconcileEntity(params: {
|
|
|
217
224
|
return rows[0]?.entityId;
|
|
218
225
|
};
|
|
219
226
|
|
|
220
|
-
|
|
227
|
+
// Accumulates warnings from all resolveSecretsBySchema calls during this reconciliation
|
|
228
|
+
const reconcileWarnings: string[] = [];
|
|
229
|
+
|
|
230
|
+
const context = {
|
|
231
|
+
logger,
|
|
232
|
+
resolveEntityRef,
|
|
233
|
+
resolveSecretsBySchema: async <T>(params: {
|
|
234
|
+
value: T;
|
|
235
|
+
schema: z.ZodTypeAny;
|
|
236
|
+
}): Promise<{ resolved: T; warnings: string[] }> => {
|
|
237
|
+
const result = await resolveSecretsBySchema({
|
|
238
|
+
value: params.value as unknown,
|
|
239
|
+
schema: params.schema,
|
|
240
|
+
secretStore,
|
|
241
|
+
});
|
|
242
|
+
reconcileWarnings.push(...result.warnings);
|
|
243
|
+
return { resolved: result.resolved as T, warnings: result.warnings };
|
|
244
|
+
},
|
|
245
|
+
};
|
|
221
246
|
|
|
222
247
|
// Look up registered kind
|
|
223
248
|
const kindDef = kindRegistry.getKind({
|
|
@@ -244,6 +269,22 @@ async function reconcileEntity(params: {
|
|
|
244
269
|
);
|
|
245
270
|
}
|
|
246
271
|
|
|
272
|
+
// Reject secret templates in metadata (display fields must never contain secrets)
|
|
273
|
+
const metadataSecrets = collectSecretNames({ value: entity.metadata });
|
|
274
|
+
if (metadataSecrets.length > 0) {
|
|
275
|
+
throw new Error(
|
|
276
|
+
`Secret templates are not allowed in metadata fields (found: ${metadataSecrets.join(", ")}). ` +
|
|
277
|
+
`Secrets can only be referenced in the spec section.`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Validate all referenced secrets exist before reconciliation.
|
|
282
|
+
// This catches typos / missing secrets early even though resolution is deferred
|
|
283
|
+
// to when the plugin explicitly calls context.resolveSecretsBySchema().
|
|
284
|
+
for (const name of secretRefs) {
|
|
285
|
+
await secretStore.resolve(name);
|
|
286
|
+
}
|
|
287
|
+
|
|
247
288
|
// Check provenance for diff
|
|
248
289
|
const existingProvenance = await db
|
|
249
290
|
.select()
|
|
@@ -263,15 +304,10 @@ async function reconcileEntity(params: {
|
|
|
263
304
|
return;
|
|
264
305
|
}
|
|
265
306
|
|
|
266
|
-
//
|
|
267
|
-
|
|
268
|
-
spec: entity.spec as Record<string, unknown>,
|
|
269
|
-
secretStore,
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
// Call base kind reconciler
|
|
307
|
+
// Call base kind reconciler with raw spec (secrets NOT pre-resolved).
|
|
308
|
+
// Plugins use context.resolveSecretsBySchema() to resolve specific fields.
|
|
273
309
|
const reconcileResult = await kindDef.reconcile({
|
|
274
|
-
entity:
|
|
310
|
+
entity: entity as typeof entity & { spec: Record<string, unknown> },
|
|
275
311
|
existingEntityId: existing?.entityId ?? undefined,
|
|
276
312
|
context,
|
|
277
313
|
});
|
|
@@ -283,12 +319,12 @@ async function reconcileEntity(params: {
|
|
|
283
319
|
});
|
|
284
320
|
|
|
285
321
|
for (const ext of extensions) {
|
|
286
|
-
const extensionSpec = (
|
|
322
|
+
const extensionSpec = (entity.spec as Record<string, unknown>)[
|
|
287
323
|
ext.namespace
|
|
288
324
|
];
|
|
289
325
|
if (extensionSpec !== undefined) {
|
|
290
326
|
await ext.reconcile({
|
|
291
|
-
entity
|
|
327
|
+
entity,
|
|
292
328
|
extensionSpec,
|
|
293
329
|
entityId: reconcileResult.entityId,
|
|
294
330
|
context,
|
|
@@ -303,8 +339,10 @@ async function reconcileEntity(params: {
|
|
|
303
339
|
entity,
|
|
304
340
|
file,
|
|
305
341
|
contentHash,
|
|
342
|
+
secretRefs,
|
|
306
343
|
entityId: reconcileResult.entityId,
|
|
307
344
|
status: "synced",
|
|
345
|
+
warnings: reconcileWarnings,
|
|
308
346
|
});
|
|
309
347
|
|
|
310
348
|
if (existing) {
|
|
@@ -343,7 +381,8 @@ async function detectOrphans(params: {
|
|
|
343
381
|
kind: prov.kind,
|
|
344
382
|
});
|
|
345
383
|
|
|
346
|
-
|
|
384
|
+
// Do not call delete reconciler if it's a pending error record
|
|
385
|
+
if (kindDef?.delete && !prov.entityId.startsWith("pending-")) {
|
|
347
386
|
try {
|
|
348
387
|
await kindDef.delete({
|
|
349
388
|
entityName: prov.entityName,
|
|
@@ -354,6 +393,8 @@ async function detectOrphans(params: {
|
|
|
354
393
|
// eslint-disable-next-line unicorn/no-useless-undefined
|
|
355
394
|
return undefined;
|
|
356
395
|
},
|
|
396
|
+
resolveSecretsBySchema: async <T>(params: { value: T; schema: z.ZodTypeAny }): Promise<{ resolved: T; warnings: string[] }> =>
|
|
397
|
+
({ resolved: params.value, warnings: [] }),
|
|
357
398
|
},
|
|
358
399
|
});
|
|
359
400
|
} catch (deleteError) {
|
|
@@ -395,10 +436,13 @@ async function upsertProvenance(params: {
|
|
|
395
436
|
entity: EntityEnvelope;
|
|
396
437
|
file: DiscoveredFile;
|
|
397
438
|
contentHash: string;
|
|
439
|
+
secretRefs: string[];
|
|
398
440
|
/** Required for synced status. On error, may be omitted to preserve existing value. */
|
|
399
441
|
entityId?: string;
|
|
400
442
|
status: "synced" | "error";
|
|
401
443
|
errorMessage?: string;
|
|
444
|
+
/** Warnings about unresolved secret templates in non-secret fields. */
|
|
445
|
+
warnings?: string[];
|
|
402
446
|
}): Promise<void> {
|
|
403
447
|
const {
|
|
404
448
|
db,
|
|
@@ -406,9 +450,11 @@ async function upsertProvenance(params: {
|
|
|
406
450
|
entity,
|
|
407
451
|
file,
|
|
408
452
|
contentHash,
|
|
453
|
+
secretRefs,
|
|
409
454
|
entityId,
|
|
410
455
|
status,
|
|
411
456
|
errorMessage,
|
|
457
|
+
warnings,
|
|
412
458
|
} = params;
|
|
413
459
|
const existing = await db
|
|
414
460
|
.select()
|
|
@@ -425,10 +471,12 @@ async function upsertProvenance(params: {
|
|
|
425
471
|
.update(schema.provenance)
|
|
426
472
|
.set({
|
|
427
473
|
lastSyncHash: contentHash,
|
|
474
|
+
secretRefs,
|
|
428
475
|
// Preserve existing entityId on error retries; update on successful reconcile
|
|
429
476
|
...(entityId ? { entityId } : {}),
|
|
430
477
|
status,
|
|
431
478
|
errorMessage: errorMessage ?? null, // eslint-disable-line unicorn/no-null
|
|
479
|
+
warnings: warnings ?? [],
|
|
432
480
|
repository: file.repository,
|
|
433
481
|
filePath: file.filePath,
|
|
434
482
|
lastSyncedAt: new Date(),
|
|
@@ -437,24 +485,25 @@ async function upsertProvenance(params: {
|
|
|
437
485
|
return;
|
|
438
486
|
}
|
|
439
487
|
|
|
440
|
-
// First-time error: no entityId available yet
|
|
441
|
-
//
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
}
|
|
488
|
+
// First-time error: no entityId available yet because the entity failed to create.
|
|
489
|
+
// We MUST create a provenance record anyway so the error shows in the UI.
|
|
490
|
+
// We use a "pending-" prefix so the orphan detector knows not to call the delete reconciler.
|
|
491
|
+
const resolvedEntityId = entityId ?? `pending-${uuidv4()}`;
|
|
445
492
|
|
|
446
493
|
await db.insert(schema.provenance).values({
|
|
447
494
|
id: uuidv4(),
|
|
448
495
|
apiVersion: entity.apiVersion,
|
|
449
496
|
kind: entity.kind,
|
|
450
497
|
entityName: entity.metadata.name,
|
|
451
|
-
entityId,
|
|
498
|
+
entityId: resolvedEntityId,
|
|
452
499
|
providerId,
|
|
453
500
|
repository: file.repository,
|
|
454
501
|
filePath: file.filePath,
|
|
455
502
|
lastSyncHash: contentHash,
|
|
503
|
+
secretRefs,
|
|
456
504
|
status,
|
|
457
505
|
errorMessage: errorMessage ?? null, // eslint-disable-line unicorn/no-null
|
|
506
|
+
warnings: warnings ?? [],
|
|
458
507
|
});
|
|
459
508
|
}
|
|
460
509
|
|
|
@@ -8,6 +8,8 @@ export interface CollectedEntity {
|
|
|
8
8
|
entity: EntityEnvelope;
|
|
9
9
|
contentHash: string;
|
|
10
10
|
file: DiscoveredFile;
|
|
11
|
+
/** Secret names referenced via ${{ secrets.NAME }} in this entity's spec. */
|
|
12
|
+
secretRefs?: string[];
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
// ─── Topological Sort ──────────────────────────────────────────────────────
|