@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.
- package/CHANGELOG.md +67 -0
- package/drizzle/0000_tense_stryfe.sql +46 -0
- package/drizzle/meta/0000_snapshot.json +310 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +7 -0
- package/package.json +37 -0
- package/src/index.ts +136 -0
- package/src/kind-registry.test.ts +262 -0
- package/src/kind-registry.ts +191 -0
- package/src/router.ts +355 -0
- package/src/schema.ts +77 -0
- package/src/scrapers/github-scraper.test.ts +355 -0
- package/src/scrapers/github-scraper.ts +263 -0
- package/src/scrapers/gitlab-scraper.test.ts +296 -0
- package/src/scrapers/gitlab-scraper.ts +242 -0
- package/src/scrapers/types.ts +52 -0
- package/src/secret-resolver.test.ts +86 -0
- package/src/secret-resolver.ts +54 -0
- package/src/sync/document-parser.test.ts +116 -0
- package/src/sync/document-parser.ts +124 -0
- package/src/sync/reconciler-delete.test.ts +123 -0
- package/src/sync/reconciler.ts +476 -0
- package/src/sync/sort-entities.test.ts +481 -0
- package/src/sync/sort-entities.ts +100 -0
- package/src/sync/sync-worker.ts +158 -0
- package/tsconfig.json +4 -0
|
@@ -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
|
+
}
|