@checkstack/gitops-backend 0.1.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.
@@ -0,0 +1,476 @@
1
+ import type { Logger, SafeDatabase } from "@checkstack/backend-api";
2
+ import type { EntityEnvelope } from "@checkstack/gitops-common";
3
+ import type { InternalEntityKindRegistry } from "../kind-registry";
4
+ import type { SecretStore } from "../secret-resolver";
5
+ import type { DiscoveredFile, Scraper, FetchFn } from "../scrapers/types";
6
+ import { parseEntityDocuments } from "./document-parser";
7
+ import { sortEntitiesByDependency, type CollectedEntity } from "./sort-entities";
8
+ import { resolveSecrets } from "../secret-resolver";
9
+ import * as schema from "../schema";
10
+ import { eq, and } from "drizzle-orm";
11
+ import { v4 as uuidv4 } from "uuid";
12
+
13
+ type Db = SafeDatabase<typeof schema>;
14
+
15
+ // ─── Types ─────────────────────────────────────────────────────────────────
16
+
17
+ interface ReconcileProviderParams {
18
+ providerId: string;
19
+ providerType: "github" | "gitlab";
20
+ target: string;
21
+ pathPattern: string;
22
+ authToken: string;
23
+ baseUrl?: string;
24
+ deletionPolicy: "orphan" | "auto";
25
+ db: Db;
26
+ logger: Logger;
27
+ kindRegistry: InternalEntityKindRegistry;
28
+ secretStore: SecretStore;
29
+ scraper: Scraper;
30
+ fetchFn?: FetchFn;
31
+ }
32
+
33
+ interface ReconcileResult {
34
+ created: number;
35
+ updated: number;
36
+ unchanged: number;
37
+ orphaned: number;
38
+ deleted: number;
39
+ errors: number;
40
+ }
41
+
42
+ // ─── Main Reconciler ───────────────────────────────────────────────────────
43
+
44
+ /**
45
+ * Runs a full reconciliation cycle for a single provider:
46
+ * 1. Scrape files from the Git provider
47
+ * 2. Parse YAML → entity envelopes
48
+ * 3. For each entity: validate → resolve secrets → reconcile → update provenance
49
+ * 4. Detect orphans (provenance entries not seen in this sync)
50
+ */
51
+ export async function reconcileProvider(
52
+ params: ReconcileProviderParams,
53
+ ): Promise<ReconcileResult> {
54
+ const {
55
+ providerId,
56
+ target,
57
+ pathPattern,
58
+ authToken,
59
+ baseUrl,
60
+ deletionPolicy,
61
+ db,
62
+ logger,
63
+ kindRegistry,
64
+ secretStore,
65
+ scraper,
66
+ fetchFn,
67
+ } = params;
68
+
69
+ const result: ReconcileResult = {
70
+ created: 0,
71
+ updated: 0,
72
+ unchanged: 0,
73
+ orphaned: 0,
74
+ deleted: 0,
75
+ errors: 0,
76
+ };
77
+
78
+ // 1. Scrape
79
+ let discoveredFiles: DiscoveredFile[];
80
+ try {
81
+ discoveredFiles = await scraper.discoverFiles({
82
+ target,
83
+ pathPattern,
84
+ authToken,
85
+ baseUrl,
86
+ logger,
87
+ fetch: fetchFn,
88
+ });
89
+ } catch (error) {
90
+ logger.error(
91
+ `Reconciler: scraper failed for provider ${providerId}: ${error}`,
92
+ );
93
+ // Update provider with sync error
94
+ await updateProviderSyncStatus({ db, providerId, error: String(error) });
95
+ throw error;
96
+ }
97
+
98
+ logger.debug(
99
+ `Reconciler: scraped ${discoveredFiles.length} file(s) from provider ${providerId}`,
100
+ );
101
+
102
+ // 2. Collect all entities from all files
103
+ const seenEntityKeys = new Set<string>();
104
+ const collected: CollectedEntity[] = [];
105
+
106
+ for (const file of discoveredFiles) {
107
+ const parseResult = parseEntityDocuments({ content: file.content });
108
+
109
+ // Log parse errors
110
+ for (const error of parseResult.errors) {
111
+ logger.error(
112
+ `Reconciler: parse error in ${file.repository}/${file.filePath} (doc ${error.documentIndex}): ${error.message}`,
113
+ );
114
+ result.errors++;
115
+ }
116
+
117
+ for (const { entity, contentHash } of parseResult.entities) {
118
+ const entityKey = `${entity.kind}::${entity.metadata.name}`;
119
+ seenEntityKeys.add(entityKey);
120
+ collected.push({ entity, contentHash, file });
121
+ }
122
+ }
123
+
124
+ // 3. Sort by dependency order (topological sort via entity refs)
125
+ const sorted = sortEntitiesByDependency({ entities: collected });
126
+
127
+ // 4. Reconcile in dependency order (provenance written immediately per entity)
128
+ for (const { entity, contentHash, file } of sorted) {
129
+ const entityKey = `${entity.kind}::${entity.metadata.name}`;
130
+
131
+ try {
132
+ await reconcileEntity({
133
+ entity,
134
+ contentHash,
135
+ file,
136
+ providerId,
137
+ db,
138
+ logger,
139
+ kindRegistry,
140
+ secretStore,
141
+ result,
142
+ });
143
+ } catch (error) {
144
+ logger.error(
145
+ `Reconciler: error reconciling ${entityKey} from ${file.repository}/${file.filePath}: ${error}`,
146
+ );
147
+ await upsertProvenance({
148
+ db,
149
+ providerId,
150
+ entity,
151
+ file,
152
+ contentHash,
153
+ status: "error",
154
+ errorMessage: String(error),
155
+ });
156
+ result.errors++;
157
+ }
158
+ }
159
+
160
+ // 5. Detect orphans
161
+ await detectOrphans({
162
+ db,
163
+ providerId,
164
+ seenEntityKeys,
165
+ deletionPolicy,
166
+ kindRegistry,
167
+ logger,
168
+ result,
169
+ });
170
+
171
+ // 6. Update provider sync status
172
+ await updateProviderSyncStatus({ db, providerId });
173
+
174
+ return result;
175
+ }
176
+
177
+ // ─── Entity Reconciliation ─────────────────────────────────────────────────
178
+
179
+ async function reconcileEntity(params: {
180
+ entity: EntityEnvelope;
181
+ contentHash: string;
182
+ file: DiscoveredFile;
183
+ providerId: string;
184
+ db: Db;
185
+ logger: Logger;
186
+ kindRegistry: InternalEntityKindRegistry;
187
+ secretStore: SecretStore;
188
+ result: ReconcileResult;
189
+ }): Promise<void> {
190
+ const {
191
+ entity,
192
+ contentHash,
193
+ file,
194
+ providerId,
195
+ db,
196
+ logger,
197
+ kindRegistry,
198
+ secretStore,
199
+ result,
200
+ } = params;
201
+
202
+ // Build resolve function that queries local provenance (no RPC round-trip)
203
+ const resolveEntityRef = async (refParams: {
204
+ kind: string;
205
+ entityName: string;
206
+ }): Promise<string | undefined> => {
207
+ const rows = await db
208
+ .select({ entityId: schema.provenance.entityId })
209
+ .from(schema.provenance)
210
+ .where(
211
+ and(
212
+ eq(schema.provenance.kind, refParams.kind),
213
+ eq(schema.provenance.entityName, refParams.entityName),
214
+ eq(schema.provenance.status, "synced"),
215
+ ),
216
+ );
217
+ return rows[0]?.entityId;
218
+ };
219
+
220
+ const context = { logger, resolveEntityRef };
221
+
222
+ // Look up registered kind
223
+ const kindDef = kindRegistry.getKind({
224
+ apiVersion: entity.apiVersion,
225
+ kind: entity.kind,
226
+ });
227
+
228
+ if (!kindDef) {
229
+ throw new Error(
230
+ `Unknown entity kind: ${entity.kind} (${entity.apiVersion})`,
231
+ );
232
+ }
233
+
234
+ // Validate spec against merged schema
235
+ const mergedSchema = kindRegistry.getMergedSpecSchema({
236
+ apiVersion: entity.apiVersion,
237
+ kind: entity.kind,
238
+ });
239
+
240
+ const validationResult = mergedSchema.safeParse(entity.spec);
241
+ if (!validationResult.success) {
242
+ throw new Error(
243
+ `Spec validation failed: ${validationResult.error.message}`,
244
+ );
245
+ }
246
+
247
+ // Check provenance for diff
248
+ const existingProvenance = await db
249
+ .select()
250
+ .from(schema.provenance)
251
+ .where(
252
+ and(
253
+ eq(schema.provenance.kind, entity.kind),
254
+ eq(schema.provenance.entityName, entity.metadata.name),
255
+ ),
256
+ );
257
+
258
+ const existing = existingProvenance[0];
259
+
260
+ if (existing && existing.lastSyncHash === contentHash) {
261
+ // Unchanged — skip reconciliation
262
+ result.unchanged++;
263
+ return;
264
+ }
265
+
266
+ // Resolve secrets in the spec
267
+ const resolvedSpec = await resolveSecrets({
268
+ spec: entity.spec as Record<string, unknown>,
269
+ secretStore,
270
+ });
271
+
272
+ // Call base kind reconciler
273
+ const reconcileResult = await kindDef.reconcile({
274
+ entity: { ...entity, spec: resolvedSpec },
275
+ existingEntityId: existing?.entityId ?? undefined,
276
+ context,
277
+ });
278
+
279
+ // Call extension reconcilers for present namespaces
280
+ const extensions = kindRegistry.getExtensions({
281
+ apiVersion: entity.apiVersion,
282
+ kind: entity.kind,
283
+ });
284
+
285
+ for (const ext of extensions) {
286
+ const extensionSpec = (resolvedSpec as Record<string, unknown>)[
287
+ ext.namespace
288
+ ];
289
+ if (extensionSpec !== undefined) {
290
+ await ext.reconcile({
291
+ entity: { ...entity, spec: resolvedSpec },
292
+ extensionSpec,
293
+ entityId: reconcileResult.entityId,
294
+ context,
295
+ });
296
+ }
297
+ }
298
+
299
+ // Update provenance
300
+ await upsertProvenance({
301
+ db,
302
+ providerId,
303
+ entity,
304
+ file,
305
+ contentHash,
306
+ entityId: reconcileResult.entityId,
307
+ status: "synced",
308
+ });
309
+
310
+ if (existing) {
311
+ result.updated++;
312
+ } else {
313
+ result.created++;
314
+ }
315
+ }
316
+
317
+ // ─── Orphan Detection ──────────────────────────────────────────────────────
318
+
319
+ async function detectOrphans(params: {
320
+ db: Db;
321
+ providerId: string;
322
+ seenEntityKeys: Set<string>;
323
+ deletionPolicy: "orphan" | "auto";
324
+ kindRegistry: InternalEntityKindRegistry;
325
+ logger: Logger;
326
+ result: ReconcileResult;
327
+ }): Promise<void> {
328
+ const { db, providerId, seenEntityKeys, deletionPolicy, logger, result } =
329
+ params;
330
+
331
+ const allProvenance = await db
332
+ .select()
333
+ .from(schema.provenance)
334
+ .where(eq(schema.provenance.providerId, providerId));
335
+
336
+ for (const prov of allProvenance) {
337
+ const key = `${prov.kind}::${prov.entityName}`;
338
+ if (!seenEntityKeys.has(key)) {
339
+ if (deletionPolicy === "auto") {
340
+ // Call the kind's delete reconciler before removing provenance
341
+ const kindDef = params.kindRegistry.getKind({
342
+ apiVersion: prov.apiVersion,
343
+ kind: prov.kind,
344
+ });
345
+
346
+ if (kindDef?.delete) {
347
+ try {
348
+ await kindDef.delete({
349
+ entityName: prov.entityName,
350
+ entityId: prov.entityId,
351
+ context: {
352
+ logger,
353
+ resolveEntityRef: async () => {
354
+ // eslint-disable-next-line unicorn/no-useless-undefined
355
+ return undefined;
356
+ },
357
+ },
358
+ });
359
+ } catch (deleteError) {
360
+ logger.error(
361
+ `Reconciler: delete reconciler failed for ${key}: ${deleteError}`,
362
+ );
363
+ result.errors++;
364
+ continue;
365
+ }
366
+ }
367
+
368
+ await db
369
+ .delete(schema.provenance)
370
+ .where(eq(schema.provenance.id, prov.id));
371
+ result.deleted++;
372
+ logger.debug(
373
+ `Reconciler: auto-deleted orphaned entity ${key} (provider: ${providerId})`,
374
+ );
375
+ } else {
376
+ // Mark as orphaned
377
+ await db
378
+ .update(schema.provenance)
379
+ .set({ status: "orphaned" })
380
+ .where(eq(schema.provenance.id, prov.id));
381
+ result.orphaned++;
382
+ logger.debug(
383
+ `Reconciler: marked entity ${key} as orphaned (provider: ${providerId})`,
384
+ );
385
+ }
386
+ }
387
+ }
388
+ }
389
+
390
+ // ─── Helpers ───────────────────────────────────────────────────────────────
391
+
392
+ async function upsertProvenance(params: {
393
+ db: Db;
394
+ providerId: string;
395
+ entity: EntityEnvelope;
396
+ file: DiscoveredFile;
397
+ contentHash: string;
398
+ /** Required for synced status. On error, may be omitted to preserve existing value. */
399
+ entityId?: string;
400
+ status: "synced" | "error";
401
+ errorMessage?: string;
402
+ }): Promise<void> {
403
+ const {
404
+ db,
405
+ providerId,
406
+ entity,
407
+ file,
408
+ contentHash,
409
+ entityId,
410
+ status,
411
+ errorMessage,
412
+ } = params;
413
+ const existing = await db
414
+ .select()
415
+ .from(schema.provenance)
416
+ .where(
417
+ and(
418
+ eq(schema.provenance.kind, entity.kind),
419
+ eq(schema.provenance.entityName, entity.metadata.name),
420
+ ),
421
+ );
422
+
423
+ if (existing[0]) {
424
+ await db
425
+ .update(schema.provenance)
426
+ .set({
427
+ lastSyncHash: contentHash,
428
+ // Preserve existing entityId on error retries; update on successful reconcile
429
+ ...(entityId ? { entityId } : {}),
430
+ status,
431
+ errorMessage: errorMessage ?? null, // eslint-disable-line unicorn/no-null
432
+ repository: file.repository,
433
+ filePath: file.filePath,
434
+ lastSyncedAt: new Date(),
435
+ })
436
+ .where(eq(schema.provenance.id, existing[0].id));
437
+ return;
438
+ }
439
+
440
+ // First-time error: no entityId available yet, skip provenance creation.
441
+ // The entity will be retried on the next sync cycle.
442
+ if (!entityId) {
443
+ return;
444
+ }
445
+
446
+ await db.insert(schema.provenance).values({
447
+ id: uuidv4(),
448
+ apiVersion: entity.apiVersion,
449
+ kind: entity.kind,
450
+ entityName: entity.metadata.name,
451
+ entityId,
452
+ providerId,
453
+ repository: file.repository,
454
+ filePath: file.filePath,
455
+ lastSyncHash: contentHash,
456
+ status,
457
+ errorMessage: errorMessage ?? null, // eslint-disable-line unicorn/no-null
458
+ });
459
+ }
460
+
461
+ async function updateProviderSyncStatus(params: {
462
+ db: Db;
463
+ providerId: string;
464
+ error?: string;
465
+ }): Promise<void> {
466
+ const { db, providerId, error } = params;
467
+
468
+ await db
469
+ .update(schema.providers)
470
+ .set({
471
+ lastSyncAt: new Date(),
472
+ lastSyncError: error ?? null, // eslint-disable-line unicorn/no-null
473
+ updatedAt: new Date(),
474
+ })
475
+ .where(eq(schema.providers.id, providerId));
476
+ }