@checkstack/gitops-backend 0.1.2 → 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.
@@ -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) {
@@ -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 ──────────────────────────────────────────────────────