@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.
@@ -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 { resolveSecrets } from "../secret-resolver";
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
- collected.push({ entity, contentHash, file });
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
- const context = { logger, resolveEntityRef };
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
- // 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
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: { ...entity, spec: resolvedSpec },
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 = (resolvedSpec as Record<string, unknown>)[
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: { ...entity, spec: resolvedSpec },
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
- if (kindDef?.delete) {
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, skip provenance creation.
441
- // The entity will be retried on the next sync cycle.
442
- if (!entityId) {
443
- return;
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 ──────────────────────────────────────────────────────