@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.
- package/CHANGELOG.md +49 -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 +62 -5
- 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 +63 -14
- package/src/sync/sort-entities.ts +2 -0
- package/src/sync/sync-worker.ts +14 -4
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) {
|
|
@@ -354,6 +392,8 @@ async function detectOrphans(params: {
|
|
|
354
392
|
// eslint-disable-next-line unicorn/no-useless-undefined
|
|
355
393
|
return undefined;
|
|
356
394
|
},
|
|
395
|
+
resolveSecretsBySchema: async <T>(params: { value: T; schema: z.ZodTypeAny }): Promise<{ resolved: T; warnings: string[] }> =>
|
|
396
|
+
({ resolved: params.value, warnings: [] }),
|
|
357
397
|
},
|
|
358
398
|
});
|
|
359
399
|
} catch (deleteError) {
|
|
@@ -395,10 +435,13 @@ async function upsertProvenance(params: {
|
|
|
395
435
|
entity: EntityEnvelope;
|
|
396
436
|
file: DiscoveredFile;
|
|
397
437
|
contentHash: string;
|
|
438
|
+
secretRefs: string[];
|
|
398
439
|
/** Required for synced status. On error, may be omitted to preserve existing value. */
|
|
399
440
|
entityId?: string;
|
|
400
441
|
status: "synced" | "error";
|
|
401
442
|
errorMessage?: string;
|
|
443
|
+
/** Warnings about unresolved secret templates in non-secret fields. */
|
|
444
|
+
warnings?: string[];
|
|
402
445
|
}): Promise<void> {
|
|
403
446
|
const {
|
|
404
447
|
db,
|
|
@@ -406,9 +449,11 @@ async function upsertProvenance(params: {
|
|
|
406
449
|
entity,
|
|
407
450
|
file,
|
|
408
451
|
contentHash,
|
|
452
|
+
secretRefs,
|
|
409
453
|
entityId,
|
|
410
454
|
status,
|
|
411
455
|
errorMessage,
|
|
456
|
+
warnings,
|
|
412
457
|
} = params;
|
|
413
458
|
const existing = await db
|
|
414
459
|
.select()
|
|
@@ -425,10 +470,12 @@ async function upsertProvenance(params: {
|
|
|
425
470
|
.update(schema.provenance)
|
|
426
471
|
.set({
|
|
427
472
|
lastSyncHash: contentHash,
|
|
473
|
+
secretRefs,
|
|
428
474
|
// Preserve existing entityId on error retries; update on successful reconcile
|
|
429
475
|
...(entityId ? { entityId } : {}),
|
|
430
476
|
status,
|
|
431
477
|
errorMessage: errorMessage ?? null, // eslint-disable-line unicorn/no-null
|
|
478
|
+
warnings: warnings ?? [],
|
|
432
479
|
repository: file.repository,
|
|
433
480
|
filePath: file.filePath,
|
|
434
481
|
lastSyncedAt: new Date(),
|
|
@@ -453,8 +500,10 @@ async function upsertProvenance(params: {
|
|
|
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 ──────────────────────────────────────────────────────
|
package/src/sync/sync-worker.ts
CHANGED
|
@@ -165,8 +165,18 @@ export async function triggerSyncForProvider(params: {
|
|
|
165
165
|
const { queueManager, providerId } = params;
|
|
166
166
|
const queue = queueManager.getQueue<SyncJobPayload>(SYNC_QUEUE);
|
|
167
167
|
|
|
168
|
-
await queue.enqueue(
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
168
|
+
await queue.enqueue({ providerId });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Cancels the recurring sync job for a provider (used when deleting a provider).
|
|
173
|
+
*/
|
|
174
|
+
export async function cancelSyncForProvider(params: {
|
|
175
|
+
queueManager: QueueManager;
|
|
176
|
+
providerId: string;
|
|
177
|
+
}): Promise<void> {
|
|
178
|
+
const { queueManager, providerId } = params;
|
|
179
|
+
const queue = queueManager.getQueue<SyncJobPayload>(SYNC_QUEUE);
|
|
180
|
+
|
|
181
|
+
await queue.cancelRecurring(`gitops-sync-${providerId}`);
|
|
172
182
|
}
|