@checkstack/gitops-backend 0.3.7 → 0.4.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.
- package/CHANGELOG.md +81 -0
- package/package.json +8 -7
- package/src/index.ts +32 -17
- package/src/kind-registry.test.ts +94 -0
- package/src/kind-registry.ts +38 -11
- package/src/router.ts +45 -50
- package/src/secret-resolver.ts +9 -219
- package/tsconfig.json +3 -0
- package/src/secret-resolver.test.ts +0 -325
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,86 @@
|
|
|
1
1
|
# @checkstack/gitops-backend
|
|
2
2
|
|
|
3
|
+
## 0.4.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [a57f7db]
|
|
8
|
+
- @checkstack/backend-api@0.20.0
|
|
9
|
+
- @checkstack/secrets-backend@0.1.1
|
|
10
|
+
- @checkstack/command-backend@0.1.33
|
|
11
|
+
- @checkstack/queue-api@0.3.8
|
|
12
|
+
|
|
13
|
+
## 0.4.0
|
|
14
|
+
|
|
15
|
+
### Minor Changes
|
|
16
|
+
|
|
17
|
+
- b995afb: Surface per-variant config documentation for the `Automation` GitOps kind.
|
|
18
|
+
|
|
19
|
+
The GitOps editor and Kind Registry Browser now show the right config schema
|
|
20
|
+
for each automation trigger and provider action when authoring an
|
|
21
|
+
`Automation` YAML, mirroring how the `Healthcheck` kind documents its
|
|
22
|
+
strategy/collector configs:
|
|
23
|
+
|
|
24
|
+
- `triggers[].config` — one entry per registered trigger that declares a
|
|
25
|
+
`configSchema`, conditioned on the chosen `triggers[].event`.
|
|
26
|
+
- `actions[].config` — one entry per registered provider action,
|
|
27
|
+
conditioned on the chosen `actions[].action`.
|
|
28
|
+
|
|
29
|
+
New plugin-author contract on the entity kind registry:
|
|
30
|
+
|
|
31
|
+
- `@checkstack/gitops-common` / `@checkstack/gitops-backend`: add
|
|
32
|
+
`EntityKindRegistry.registerSpecSchemaDocumentationProvider(provider)`. The
|
|
33
|
+
provider is a thunk invoked on every `describeKinds()` (i.e. each time the
|
|
34
|
+
kind-browser RPC is queried), so the docs it returns reflect the current
|
|
35
|
+
state of whatever it reads — order-independent.
|
|
36
|
+
|
|
37
|
+
Why a lazy provider (and not the existing eager
|
|
38
|
+
`registerSpecSchemaDocumentation`): unlike Healthcheck, whose
|
|
39
|
+
strategy/collector registries are core services fully populated before any
|
|
40
|
+
plugin's `afterPluginsReady`, the automation trigger/action registries are
|
|
41
|
+
filled by other plugins across their `init` / `afterPluginsReady` phases with
|
|
42
|
+
no guaranteed ordering. Several plugins (catalog/maintenance/notification)
|
|
43
|
+
register their provider actions in their own `afterPluginsReady`, so the
|
|
44
|
+
previous one-shot eager registration snapshotted a half-populated (often
|
|
45
|
+
empty) registry and the Automation kind's "Additional Schemas" came up empty.
|
|
46
|
+
automation-backend now registers a provider instead, so trigger/action config
|
|
47
|
+
docs always reflect the fully-populated registries.
|
|
48
|
+
|
|
49
|
+
Documentation-only surface; no runtime reconcile behaviour changes.
|
|
50
|
+
|
|
51
|
+
- 270ef29: Add the Secrets platform (Phase 1): a central, plugin-agnostic secret manager with a pluggable backend extension point, a cross-plugin resolver service, and a universal Jenkins-style masking layer.
|
|
52
|
+
|
|
53
|
+
- New packages: `secrets-common` (schemas, contract, `secrets.read`/`secrets.manage`, masking utils), `secrets-backend` (`SecretBackend` extension point, `secretResolverRef`/`secretAdminRef` services, run-scoped masking context, RPC router), `secrets-backend-local` (default AES-256-GCM backend, owns the `secrets` table promoted from gitops), `secrets-frontend` (admin Settings page).
|
|
54
|
+
- Resolution machinery (`resolveSecretsBySchema`, `SecretStore`, `${{ secrets.NAME }}` / `x-secret`) is promoted out of `gitops-backend` into `secrets-backend`. GitOps now resolves and manages secrets through the platform's service refs (single source of truth); its secret table is migrated without loss.
|
|
55
|
+
- Universal masking seam wired at the central script-output boundaries: automation `run_script` / `run_shell` artifacts and the in-UI test panel redact run-scoped secret values from `result`/`stdout`/`stderr`/`error` before persist/return. Phase 1 resolves no run-scoped secrets yet, so masking is a no-op until Phase 2; the seam guarantees the boundary exists.
|
|
56
|
+
- No endpoint returns a secret value to a browser: DTOs expose only name/metadata/`hasValue`.
|
|
57
|
+
|
|
58
|
+
BREAKING CHANGES: `gitops-backend` now depends on `secrets-backend` and resolves/manages secrets through it. The `secrets` table is owned by `secrets-backend-local`; the gitops `secrets` table is retained as a migration source but is no longer the source of truth.
|
|
59
|
+
|
|
60
|
+
### Patch Changes
|
|
61
|
+
|
|
62
|
+
- Updated dependencies [270ef29]
|
|
63
|
+
- Updated dependencies [b995afb]
|
|
64
|
+
- Updated dependencies [270ef29]
|
|
65
|
+
- Updated dependencies [270ef29]
|
|
66
|
+
- Updated dependencies [b995afb]
|
|
67
|
+
- Updated dependencies [270ef29]
|
|
68
|
+
- Updated dependencies [270ef29]
|
|
69
|
+
- Updated dependencies [270ef29]
|
|
70
|
+
- Updated dependencies [270ef29]
|
|
71
|
+
- Updated dependencies [270ef29]
|
|
72
|
+
- Updated dependencies [270ef29]
|
|
73
|
+
- Updated dependencies [270ef29]
|
|
74
|
+
- Updated dependencies [270ef29]
|
|
75
|
+
- Updated dependencies [270ef29]
|
|
76
|
+
- Updated dependencies [270ef29]
|
|
77
|
+
- Updated dependencies [b995afb]
|
|
78
|
+
- @checkstack/backend-api@0.19.0
|
|
79
|
+
- @checkstack/gitops-common@0.5.0
|
|
80
|
+
- @checkstack/secrets-backend@0.1.0
|
|
81
|
+
- @checkstack/command-backend@0.1.32
|
|
82
|
+
- @checkstack/queue-api@0.3.7
|
|
83
|
+
|
|
3
84
|
## 0.3.7
|
|
4
85
|
|
|
5
86
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/gitops-backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -14,11 +14,12 @@
|
|
|
14
14
|
"lint:code": "eslint . --max-warnings 0"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@checkstack/backend-api": "0.
|
|
18
|
-
"@checkstack/gitops-common": "0.4.
|
|
19
|
-
"@checkstack/common": "0.
|
|
20
|
-
"@checkstack/command-backend": "0.1.
|
|
21
|
-
"@checkstack/
|
|
17
|
+
"@checkstack/backend-api": "0.18.0",
|
|
18
|
+
"@checkstack/gitops-common": "0.4.2",
|
|
19
|
+
"@checkstack/common": "0.12.0",
|
|
20
|
+
"@checkstack/command-backend": "0.1.31",
|
|
21
|
+
"@checkstack/secrets-backend": "0.0.1",
|
|
22
|
+
"@checkstack/queue-api": "0.3.6",
|
|
22
23
|
"@orpc/server": "^1.13.2",
|
|
23
24
|
"drizzle-orm": "^0.45.0",
|
|
24
25
|
"minimatch": "^10.0.0",
|
|
@@ -28,7 +29,7 @@
|
|
|
28
29
|
},
|
|
29
30
|
"devDependencies": {
|
|
30
31
|
"@checkstack/drizzle-helper": "0.0.5",
|
|
31
|
-
"@checkstack/scripts": "0.3.
|
|
32
|
+
"@checkstack/scripts": "0.3.4",
|
|
32
33
|
"@checkstack/tsconfig": "0.0.7",
|
|
33
34
|
"@types/bun": "^1.3.5",
|
|
34
35
|
"@types/node": "^20.0.0",
|
package/src/index.ts
CHANGED
|
@@ -17,9 +17,8 @@ import type {
|
|
|
17
17
|
import { createEntityKindRegistry } from "./kind-registry";
|
|
18
18
|
import { createGitOpsRouter } from "./router";
|
|
19
19
|
import { setupSyncWorker } from "./sync/sync-worker";
|
|
20
|
-
import {
|
|
20
|
+
import { secretResolverRef, secretAdminRef } from "@checkstack/secrets-backend";
|
|
21
21
|
import * as schema from "./schema";
|
|
22
|
-
import { eq } from "drizzle-orm";
|
|
23
22
|
|
|
24
23
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
25
24
|
// Extension Points
|
|
@@ -75,6 +74,9 @@ export default createBackendPlugin({
|
|
|
75
74
|
registerSpecSchemaDocumentation(params) {
|
|
76
75
|
kindRegistry.registerSpecSchemaDocumentation(params);
|
|
77
76
|
},
|
|
77
|
+
registerSpecSchemaDocumentationProvider(provider) {
|
|
78
|
+
kindRegistry.registerSpecSchemaDocumentationProvider(provider);
|
|
79
|
+
},
|
|
78
80
|
});
|
|
79
81
|
|
|
80
82
|
env.registerInit({
|
|
@@ -83,18 +85,38 @@ export default createBackendPlugin({
|
|
|
83
85
|
logger: coreServices.logger,
|
|
84
86
|
rpc: coreServices.rpc,
|
|
85
87
|
queueManager: coreServices.queueManager,
|
|
88
|
+
secretResolver: secretResolverRef,
|
|
89
|
+
secretAdmin: secretAdminRef,
|
|
86
90
|
},
|
|
87
|
-
init: async ({
|
|
91
|
+
init: async ({
|
|
92
|
+
logger,
|
|
93
|
+
database,
|
|
94
|
+
rpc,
|
|
95
|
+
queueManager,
|
|
96
|
+
secretResolver,
|
|
97
|
+
secretAdmin,
|
|
98
|
+
}) => {
|
|
88
99
|
logger.debug("🔄 Initializing GitOps Backend...");
|
|
89
100
|
|
|
90
101
|
const db = database as SafeDatabase<typeof schema>;
|
|
91
102
|
|
|
92
|
-
const router = createGitOpsRouter({
|
|
103
|
+
const router = createGitOpsRouter({
|
|
104
|
+
database: db,
|
|
105
|
+
queueManager,
|
|
106
|
+
kindRegistry,
|
|
107
|
+
secretAdmin,
|
|
108
|
+
secretResolver,
|
|
109
|
+
});
|
|
93
110
|
rpc.registerRouter(router, gitopsContract);
|
|
94
111
|
|
|
95
112
|
logger.debug("✅ GitOps Backend initialized.");
|
|
96
113
|
},
|
|
97
|
-
afterPluginsReady: async ({
|
|
114
|
+
afterPluginsReady: async ({
|
|
115
|
+
logger,
|
|
116
|
+
database,
|
|
117
|
+
queueManager,
|
|
118
|
+
secretResolver,
|
|
119
|
+
}) => {
|
|
98
120
|
const registeredKinds = kindRegistry.getKinds();
|
|
99
121
|
logger.debug(
|
|
100
122
|
`🔄 GitOps: ${registeredKinds.length} entity kinds registered: ${registeredKinds.map((k) => k.kind).join(", ")}`,
|
|
@@ -102,19 +124,12 @@ export default createBackendPlugin({
|
|
|
102
124
|
|
|
103
125
|
const db = database as SafeDatabase<typeof schema>;
|
|
104
126
|
|
|
105
|
-
//
|
|
127
|
+
// Resolve ${{ secrets.NAME }} via the central Secrets platform
|
|
128
|
+
// (secretResolverRef) instead of reading the gitops secrets table
|
|
129
|
+
// directly. The Secrets platform owns the table now.
|
|
106
130
|
const secretStore = {
|
|
107
|
-
resolve:
|
|
108
|
-
|
|
109
|
-
.select()
|
|
110
|
-
.from(schema.secrets)
|
|
111
|
-
.where(eq(schema.secrets.name, name));
|
|
112
|
-
const secret = rows[0];
|
|
113
|
-
if (!secret) {
|
|
114
|
-
throw new Error(`Secret not found: ${name}`);
|
|
115
|
-
}
|
|
116
|
-
return decrypt(secret.encryptedValue);
|
|
117
|
-
},
|
|
131
|
+
resolve: (name: string): Promise<string> =>
|
|
132
|
+
secretResolver.resolveSecret({ name }),
|
|
118
133
|
};
|
|
119
134
|
|
|
120
135
|
// Bootstrap sync worker
|
|
@@ -355,4 +355,98 @@ describe("EntityKindRegistry", () => {
|
|
|
355
355
|
expect(described[0].specSchemaDocumentation).toHaveLength(1);
|
|
356
356
|
});
|
|
357
357
|
});
|
|
358
|
+
|
|
359
|
+
describe("registerSpecSchemaDocumentationProvider", () => {
|
|
360
|
+
it("invokes providers on every describe, reflecting current state", () => {
|
|
361
|
+
const registry = createEntityKindRegistry();
|
|
362
|
+
|
|
363
|
+
registry.registerKind({
|
|
364
|
+
apiVersion: CHECKSTACK_API_VERSION,
|
|
365
|
+
kind: "Automation",
|
|
366
|
+
specSchema: z.object({ actions: z.array(z.unknown()) }),
|
|
367
|
+
reconcile: async () => ({ entityId: "test-id" }),
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// A mutable source the provider reads — modelling a registry that is
|
|
371
|
+
// populated AFTER the provider is registered.
|
|
372
|
+
const source: Array<{ id: string }> = [];
|
|
373
|
+
registry.registerSpecSchemaDocumentationProvider(() =>
|
|
374
|
+
source.map((s) => ({
|
|
375
|
+
apiVersion: CHECKSTACK_API_VERSION,
|
|
376
|
+
kind: "Automation",
|
|
377
|
+
fieldPath: "actions[].config",
|
|
378
|
+
variantId: s.id,
|
|
379
|
+
label: s.id,
|
|
380
|
+
schema: z.object({}),
|
|
381
|
+
})),
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
// Empty at first describe.
|
|
385
|
+
expect(
|
|
386
|
+
registry.describeKinds()[0].specSchemaDocumentation,
|
|
387
|
+
).toHaveLength(0);
|
|
388
|
+
|
|
389
|
+
// Source populated later (e.g. another plugin's afterPluginsReady).
|
|
390
|
+
source.push({ id: "jira" }, { id: "teams" });
|
|
391
|
+
|
|
392
|
+
const docs = registry.describeKinds()[0].specSchemaDocumentation;
|
|
393
|
+
expect(docs).toHaveLength(2);
|
|
394
|
+
expect(docs.map((d) => d.variantId).sort()).toEqual(["jira", "teams"]);
|
|
395
|
+
expect(docs[0].fieldPath).toBe("actions[].config");
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("merges provider docs with eagerly registered docs for the same kind", () => {
|
|
399
|
+
const registry = createEntityKindRegistry();
|
|
400
|
+
|
|
401
|
+
registry.registerKind({
|
|
402
|
+
apiVersion: CHECKSTACK_API_VERSION,
|
|
403
|
+
kind: "Automation",
|
|
404
|
+
specSchema: z.object({
|
|
405
|
+
triggers: z.array(z.unknown()),
|
|
406
|
+
actions: z.array(z.unknown()),
|
|
407
|
+
}),
|
|
408
|
+
reconcile: async () => ({ entityId: "test-id" }),
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
registry.registerSpecSchemaDocumentation({
|
|
412
|
+
apiVersion: CHECKSTACK_API_VERSION,
|
|
413
|
+
kind: "Automation",
|
|
414
|
+
fieldPath: "triggers[].config",
|
|
415
|
+
variantId: "eager",
|
|
416
|
+
label: "Eager",
|
|
417
|
+
schema: z.object({}),
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
registry.registerSpecSchemaDocumentationProvider(() => [
|
|
421
|
+
{
|
|
422
|
+
apiVersion: CHECKSTACK_API_VERSION,
|
|
423
|
+
kind: "Automation",
|
|
424
|
+
fieldPath: "actions[].config",
|
|
425
|
+
variantId: "lazy",
|
|
426
|
+
label: "Lazy",
|
|
427
|
+
schema: z.object({}),
|
|
428
|
+
},
|
|
429
|
+
]);
|
|
430
|
+
|
|
431
|
+
const docs = registry.describeKinds()[0].specSchemaDocumentation;
|
|
432
|
+
expect(docs.map((d) => d.variantId).sort()).toEqual(["eager", "lazy"]);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("ignores provider docs whose kind has no base definition", () => {
|
|
436
|
+
const registry = createEntityKindRegistry();
|
|
437
|
+
|
|
438
|
+
registry.registerSpecSchemaDocumentationProvider(() => [
|
|
439
|
+
{
|
|
440
|
+
apiVersion: CHECKSTACK_API_VERSION,
|
|
441
|
+
kind: "Unregistered",
|
|
442
|
+
fieldPath: "config",
|
|
443
|
+
label: "X",
|
|
444
|
+
schema: z.object({}),
|
|
445
|
+
},
|
|
446
|
+
]);
|
|
447
|
+
|
|
448
|
+
// No base definition for "Unregistered" → not described.
|
|
449
|
+
expect(registry.describeKinds()).toHaveLength(0);
|
|
450
|
+
});
|
|
451
|
+
});
|
|
358
452
|
});
|
package/src/kind-registry.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
EntityKindExtensionDefinition,
|
|
6
6
|
EntityKindRegistry,
|
|
7
7
|
SpecSchemaDocumentation,
|
|
8
|
+
SpecSchemaDocumentationProvider,
|
|
8
9
|
} from "@checkstack/gitops-common";
|
|
9
10
|
import { entityMetadataSchema } from "@checkstack/gitops-common";
|
|
10
11
|
|
|
@@ -26,6 +27,13 @@ function kindKey(params: { apiVersion: string; kind: string }): string {
|
|
|
26
27
|
*/
|
|
27
28
|
export function createEntityKindRegistry() {
|
|
28
29
|
const kinds = new Map<string, RegisteredKind>();
|
|
30
|
+
/**
|
|
31
|
+
* Lazy documentation providers, invoked on every `describeKinds()` so the
|
|
32
|
+
* docs they return reflect the current state of whatever they read (e.g.
|
|
33
|
+
* the automation trigger/action registries, populated across plugins with
|
|
34
|
+
* no guaranteed ordering).
|
|
35
|
+
*/
|
|
36
|
+
const docProviders: SpecSchemaDocumentationProvider[] = [];
|
|
29
37
|
|
|
30
38
|
const registry: EntityKindRegistry & {
|
|
31
39
|
/** Get a registered kind definition. */
|
|
@@ -148,6 +156,10 @@ export function createEntityKindRegistry() {
|
|
|
148
156
|
});
|
|
149
157
|
},
|
|
150
158
|
|
|
159
|
+
registerSpecSchemaDocumentationProvider(provider) {
|
|
160
|
+
docProviders.push(provider);
|
|
161
|
+
},
|
|
162
|
+
|
|
151
163
|
getKind(params) {
|
|
152
164
|
return kinds.get(kindKey(params))?.definition;
|
|
153
165
|
},
|
|
@@ -209,7 +221,20 @@ export function createEntityKindRegistry() {
|
|
|
209
221
|
}>;
|
|
210
222
|
}> = [];
|
|
211
223
|
|
|
212
|
-
|
|
224
|
+
// Collect lazy-provider docs once per describe, grouped by kind key.
|
|
225
|
+
// Each provider re-reads its source, so this reflects the current
|
|
226
|
+
// registry state (order-independent).
|
|
227
|
+
const providedDocsByKey = new Map<string, SpecSchemaDocumentation[]>();
|
|
228
|
+
for (const provider of docProviders) {
|
|
229
|
+
for (const entry of provider()) {
|
|
230
|
+
const key = kindKey(entry);
|
|
231
|
+
const list = providedDocsByKey.get(key) ?? [];
|
|
232
|
+
list.push(entry);
|
|
233
|
+
providedDocsByKey.set(key, list);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
for (const [key, registered] of kinds.entries()) {
|
|
213
238
|
if (!registered.definition) continue;
|
|
214
239
|
|
|
215
240
|
const def = registered.definition;
|
|
@@ -222,16 +247,18 @@ export function createEntityKindRegistry() {
|
|
|
222
247
|
}),
|
|
223
248
|
);
|
|
224
249
|
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
250
|
+
const allDocs = [
|
|
251
|
+
...registered.specSchemaDocumentation,
|
|
252
|
+
...(providedDocsByKey.get(key) ?? []),
|
|
253
|
+
];
|
|
254
|
+
const specSchemaDocumentation = allDocs.map((doc) => ({
|
|
255
|
+
fieldPath: doc.fieldPath,
|
|
256
|
+
variantId: doc.variantId,
|
|
257
|
+
label: doc.label,
|
|
258
|
+
description: doc.description,
|
|
259
|
+
specSchema: toJsonSchema(doc.schema),
|
|
260
|
+
conditions: doc.conditions,
|
|
261
|
+
}));
|
|
235
262
|
|
|
236
263
|
result.push({
|
|
237
264
|
apiVersion: def.apiVersion,
|
package/src/router.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { implement, ORPCError } from "@orpc/server";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { autoAuthMiddleware, correlationMiddleware, type RpcContext } from "@checkstack/backend-api";
|
|
4
|
-
import { encrypt, decrypt } from "@checkstack/backend-api";
|
|
3
|
+
import { autoAuthMiddleware, correlationMiddleware, encrypt, type RpcContext } from "@checkstack/backend-api";
|
|
5
4
|
import { gitopsContract, deriveSourceUrl } from "@checkstack/gitops-common";
|
|
6
5
|
import type { SafeDatabase } from "@checkstack/backend-api";
|
|
7
6
|
import type { QueueManager } from "@checkstack/queue-api";
|
|
7
|
+
import type {
|
|
8
|
+
SecretAdminService,
|
|
9
|
+
SecretResolverService,
|
|
10
|
+
} from "@checkstack/secrets-backend";
|
|
8
11
|
import type { InternalEntityKindRegistry } from "./kind-registry";
|
|
9
12
|
import { triggerSyncForProvider, scheduleSyncForProvider, cancelSyncForProvider } from "./sync/sync-worker";
|
|
10
13
|
import * as schema from "./schema";
|
|
@@ -26,12 +29,18 @@ export interface GitOpsRouterDeps {
|
|
|
26
29
|
database: SafeDatabase<typeof schema>;
|
|
27
30
|
queueManager: QueueManager;
|
|
28
31
|
kindRegistry: InternalEntityKindRegistry;
|
|
32
|
+
/** Central Secrets platform admin service (single source of truth). */
|
|
33
|
+
secretAdmin: SecretAdminService;
|
|
34
|
+
/** Central Secrets platform resolver (service-only). */
|
|
35
|
+
secretResolver: SecretResolverService;
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
export const createGitOpsRouter = ({
|
|
32
39
|
database: db,
|
|
33
40
|
queueManager,
|
|
34
41
|
kindRegistry,
|
|
42
|
+
secretAdmin,
|
|
43
|
+
secretResolver,
|
|
35
44
|
}: GitOpsRouterDeps) => {
|
|
36
45
|
// ─── Provenance ──────────────────────────────────────────────────────
|
|
37
46
|
|
|
@@ -312,92 +321,78 @@ export const createGitOpsRouter = ({
|
|
|
312
321
|
|
|
313
322
|
// ─── Secret Management ───────────────────────────────────────────────
|
|
314
323
|
|
|
324
|
+
// Secret management is delegated to the central Secrets platform via
|
|
325
|
+
// secretAdminRef so there is a single source of truth. The gitops table
|
|
326
|
+
// is no longer the home of secrets (rows were migrated to the platform's
|
|
327
|
+
// local backend); these handlers proxy to keep the existing gitops UI
|
|
328
|
+
// working until it is retired.
|
|
315
329
|
const listSecrets = os.listSecrets.handler(async () => {
|
|
316
|
-
const
|
|
317
|
-
return
|
|
318
|
-
id:
|
|
319
|
-
name:
|
|
320
|
-
description:
|
|
321
|
-
createdAt:
|
|
322
|
-
updatedAt:
|
|
330
|
+
const metadata = await secretAdmin.list();
|
|
331
|
+
return metadata.map((m) => ({
|
|
332
|
+
id: m.id,
|
|
333
|
+
name: m.name,
|
|
334
|
+
description: m.description,
|
|
335
|
+
createdAt: m.createdAt,
|
|
336
|
+
updatedAt: m.updatedAt,
|
|
323
337
|
}));
|
|
324
338
|
});
|
|
325
339
|
|
|
326
340
|
const createSecret = os.createSecret.handler(async ({ input }) => {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
.select()
|
|
330
|
-
.from(schema.secrets)
|
|
331
|
-
.where(eq(schema.secrets.name, input.name));
|
|
332
|
-
|
|
333
|
-
if (existing[0]) {
|
|
341
|
+
const existing = await secretAdmin.list();
|
|
342
|
+
if (existing.some((m) => m.name === input.name)) {
|
|
334
343
|
throw new ORPCError("CONFLICT", {
|
|
335
344
|
message: `Secret with name "${input.name}" already exists`,
|
|
336
345
|
});
|
|
337
346
|
}
|
|
338
|
-
|
|
339
|
-
const id = uuidv4();
|
|
340
|
-
const encryptedValue = encrypt(input.value);
|
|
341
|
-
await db.insert(schema.secrets).values({
|
|
342
|
-
id,
|
|
347
|
+
await secretAdmin.setSecret({
|
|
343
348
|
name: input.name,
|
|
344
|
-
|
|
349
|
+
value: input.value,
|
|
345
350
|
description: input.description,
|
|
346
351
|
});
|
|
347
|
-
|
|
352
|
+
const after = await secretAdmin.list();
|
|
353
|
+
const meta = after.find((m) => m.name === input.name);
|
|
354
|
+
return { id: meta?.id ?? input.name, name: input.name };
|
|
348
355
|
});
|
|
349
356
|
|
|
350
357
|
const rotateSecret = os.rotateSecret.handler(async ({ input }) => {
|
|
351
|
-
const existing = await
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
.where(eq(schema.secrets.id, input.id));
|
|
355
|
-
|
|
356
|
-
if (!existing[0]) {
|
|
358
|
+
const existing = await secretAdmin.list();
|
|
359
|
+
const meta = existing.find((m) => m.id === input.id);
|
|
360
|
+
if (!meta) {
|
|
357
361
|
throw new ORPCError("NOT_FOUND", {
|
|
358
362
|
message: `Secret not found: ${input.id}`,
|
|
359
363
|
});
|
|
360
364
|
}
|
|
361
365
|
|
|
362
|
-
|
|
363
|
-
await db
|
|
364
|
-
.update(schema.secrets)
|
|
365
|
-
.set({ encryptedValue, updatedAt: new Date() })
|
|
366
|
-
.where(eq(schema.secrets.id, input.id));
|
|
366
|
+
await secretAdmin.setSecret({ name: meta.name, value: input.value });
|
|
367
367
|
|
|
368
368
|
// Invalidate provenance for all entities referencing this secret
|
|
369
|
-
// so the next sync cycle re-reconciles them with the updated value
|
|
370
|
-
const secretName = existing[0].name;
|
|
369
|
+
// so the next sync cycle re-reconciles them with the updated value.
|
|
371
370
|
await db
|
|
372
371
|
.update(schema.provenance)
|
|
373
372
|
.set({ lastSyncHash: "" })
|
|
374
|
-
.where(
|
|
375
|
-
sql`${secretName} = ANY(${schema.provenance.secretRefs})`,
|
|
376
|
-
);
|
|
373
|
+
.where(sql`${meta.name} = ANY(${schema.provenance.secretRefs})`);
|
|
377
374
|
|
|
378
375
|
return { success: true };
|
|
379
376
|
});
|
|
380
377
|
|
|
381
378
|
const deleteSecret = os.deleteSecret.handler(async ({ input }) => {
|
|
382
|
-
await
|
|
383
|
-
|
|
379
|
+
const existing = await secretAdmin.list();
|
|
380
|
+
const meta = existing.find((m) => m.id === input.id);
|
|
381
|
+
if (meta) {
|
|
382
|
+
await secretAdmin.deleteSecret({ name: meta.name });
|
|
383
|
+
}
|
|
384
384
|
return { success: true };
|
|
385
385
|
});
|
|
386
386
|
|
|
387
387
|
const resolveSecret = os.resolveSecret.handler(async ({ input }) => {
|
|
388
|
-
|
|
389
|
-
.
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
const secret = rows[0];
|
|
394
|
-
if (!secret) {
|
|
388
|
+
try {
|
|
389
|
+
const value = await secretResolver.resolveSecret({ name: input.name });
|
|
390
|
+
return { value };
|
|
391
|
+
} catch {
|
|
395
392
|
throw new ORPCError("NOT_FOUND", {
|
|
396
393
|
message: `Secret not found: ${input.name}`,
|
|
397
394
|
});
|
|
398
395
|
}
|
|
399
|
-
|
|
400
|
-
return { value: decrypt(secret.encryptedValue) };
|
|
401
396
|
});
|
|
402
397
|
|
|
403
398
|
const getSecretUsage = os.getSecretUsage.handler(async ({ input }) => {
|
package/src/secret-resolver.ts
CHANGED
|
@@ -1,221 +1,11 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
import { SECRET_TEMPLATE_REGEX } from "@checkstack/gitops-common";
|
|
3
|
-
import { isSecretSchema } from "@checkstack/backend-api";
|
|
4
|
-
|
|
5
1
|
/**
|
|
6
|
-
*
|
|
2
|
+
* The schema-driven secret resolver was promoted to the central Secrets
|
|
3
|
+
* platform (`@checkstack/secrets-backend`). This module re-exports it so
|
|
4
|
+
* gitops's sync worker / reconciler keep their existing import paths while
|
|
5
|
+
* the canonical implementation + tests live in secrets-backend.
|
|
7
6
|
*/
|
|
8
|
-
export
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
* Result of schema-driven secret resolution.
|
|
14
|
-
*/
|
|
15
|
-
export interface SecretResolutionResult<T> {
|
|
16
|
-
/** The value with x-secret fields resolved. */
|
|
17
|
-
resolved: T;
|
|
18
|
-
/**
|
|
19
|
-
* Warnings for `${{ secrets.NAME }}` templates found in non-secret fields.
|
|
20
|
-
* These templates will NOT be resolved — the field must be annotated with
|
|
21
|
-
* `configString({ "x-secret": true })` for resolution to occur.
|
|
22
|
-
*/
|
|
23
|
-
warnings: string[];
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Schema-driven secret resolver.
|
|
28
|
-
*
|
|
29
|
-
* Walks a Zod schema to find fields annotated with `configString({ "x-secret": true })`,
|
|
30
|
-
* then resolves `${{ secrets.NAME }}` templates **only** in those fields.
|
|
31
|
-
* Non-secret fields are returned as-is, preventing accidental leaks into display fields.
|
|
32
|
-
*
|
|
33
|
-
* Templates found in non-secret fields are reported as warnings — the value
|
|
34
|
-
* is still returned unmodified, but the caller can surface these to the user.
|
|
35
|
-
*
|
|
36
|
-
* Supports both full-value templates and inline interpolation:
|
|
37
|
-
* - `"${{ secrets.DB_PASS }}"` → `"actual-password"`
|
|
38
|
-
* - `"postgres://user:${{ secrets.PASS }}@host/db"` → `"postgres://user:actual-password@host/db"`
|
|
39
|
-
*/
|
|
40
|
-
export async function resolveSecretsBySchema(params: {
|
|
41
|
-
value: unknown;
|
|
42
|
-
schema: z.ZodTypeAny;
|
|
43
|
-
secretStore: SecretStore;
|
|
44
|
-
}): Promise<SecretResolutionResult<unknown>> {
|
|
45
|
-
const { value, schema, secretStore } = params;
|
|
46
|
-
const warnings: string[] = [];
|
|
47
|
-
const resolved = await walkAndResolve({ value, schema, secretStore, warnings, path: "" });
|
|
48
|
-
return { resolved, warnings };
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// ─── Recursive Walker ──────────────────────────────────────────────────────
|
|
52
|
-
|
|
53
|
-
async function walkAndResolve(params: {
|
|
54
|
-
value: unknown;
|
|
55
|
-
schema: z.ZodTypeAny;
|
|
56
|
-
secretStore: SecretStore;
|
|
57
|
-
warnings: string[];
|
|
58
|
-
path: string;
|
|
59
|
-
}): Promise<unknown> {
|
|
60
|
-
const { value, secretStore, warnings, path } = params;
|
|
61
|
-
const schema = unwrapZod(params.schema);
|
|
62
|
-
|
|
63
|
-
if (value === null || value === undefined) {
|
|
64
|
-
return value;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// Leaf node: if this schema is marked x-secret, resolve templates in the string
|
|
68
|
-
if (isSecretSchema(schema)) {
|
|
69
|
-
if (typeof value === "string") {
|
|
70
|
-
return resolveTemplateString({ value, secretStore });
|
|
71
|
-
}
|
|
72
|
-
return value;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Non-secret string leaf: check for unresolved templates and warn
|
|
76
|
-
if (typeof value === "string" && containsSecretTemplate(value)) {
|
|
77
|
-
const fieldPath = path || "(root)";
|
|
78
|
-
warnings.push(
|
|
79
|
-
`Field "${fieldPath}" contains a secret template but is not marked as a secret field. ` +
|
|
80
|
-
`Add configString({ "x-secret": true }) to the schema for this field to enable resolution.`,
|
|
81
|
-
);
|
|
82
|
-
return value;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Object: recurse into each property using the schema's shape
|
|
86
|
-
if (schema instanceof z.ZodObject && typeof value === "object" && !Array.isArray(value)) {
|
|
87
|
-
const shape = schema.shape as Record<string, z.ZodTypeAny>;
|
|
88
|
-
const result: Record<string, unknown> = { ...(value as Record<string, unknown>) };
|
|
89
|
-
|
|
90
|
-
for (const [key, fieldSchema] of Object.entries(shape)) {
|
|
91
|
-
if (key in result) {
|
|
92
|
-
result[key] = await walkAndResolve({
|
|
93
|
-
value: result[key],
|
|
94
|
-
schema: fieldSchema,
|
|
95
|
-
secretStore,
|
|
96
|
-
warnings,
|
|
97
|
-
path: path ? `${path}.${key}` : key,
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
return result;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Array: recurse into each element using the element schema
|
|
106
|
-
if (schema instanceof z.ZodArray && Array.isArray(value)) {
|
|
107
|
-
const elementSchema = schema.element as z.ZodTypeAny;
|
|
108
|
-
return Promise.all(
|
|
109
|
-
value.map((item, index) =>
|
|
110
|
-
walkAndResolve({
|
|
111
|
-
value: item,
|
|
112
|
-
schema: elementSchema,
|
|
113
|
-
secretStore,
|
|
114
|
-
warnings,
|
|
115
|
-
path: `${path}[${index}]`,
|
|
116
|
-
}),
|
|
117
|
-
),
|
|
118
|
-
);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// Discriminated union: find the matching variant and recurse
|
|
122
|
-
if (schema instanceof z.ZodDiscriminatedUnion && typeof value === "object" && value !== null) {
|
|
123
|
-
const discriminator = (schema.def as { discriminator: string }).discriminator;
|
|
124
|
-
const discriminatorValue = (value as Record<string, unknown>)[discriminator];
|
|
125
|
-
const options = schema.options as z.ZodObject<z.ZodRawShape>[];
|
|
126
|
-
const matchedVariant = options.find((option) => {
|
|
127
|
-
const discField = option.shape[discriminator];
|
|
128
|
-
if (discField instanceof z.ZodLiteral) {
|
|
129
|
-
return discField.value === discriminatorValue;
|
|
130
|
-
}
|
|
131
|
-
return false;
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
if (matchedVariant) {
|
|
135
|
-
return walkAndResolve({ value, schema: matchedVariant, secretStore, warnings, path });
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// Union (oneOf/anyOf without discriminator): try each variant
|
|
140
|
-
if (schema instanceof z.ZodUnion && typeof value === "object" && value !== null) {
|
|
141
|
-
const options = schema.options as z.ZodTypeAny[];
|
|
142
|
-
for (const option of options) {
|
|
143
|
-
const parsed = option.safeParse(value);
|
|
144
|
-
if (parsed.success) {
|
|
145
|
-
return walkAndResolve({ value, schema: option, secretStore, warnings, path });
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// No schema match — return value unchanged
|
|
151
|
-
return value;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Quick check whether a string contains any `${{ secrets.NAME }}` pattern.
|
|
156
|
-
*/
|
|
157
|
-
function containsSecretTemplate(value: string): boolean {
|
|
158
|
-
SECRET_TEMPLATE_REGEX.lastIndex = 0;
|
|
159
|
-
return SECRET_TEMPLATE_REGEX.test(value);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// ─── Zod Unwrapping ────────────────────────────────────────────────────────
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Unwraps Optional, Default, and Nullable wrappers to get the inner schema.
|
|
166
|
-
*/
|
|
167
|
-
function unwrapZod(schema: z.ZodTypeAny): z.ZodTypeAny {
|
|
168
|
-
let unwrapped = schema;
|
|
169
|
-
|
|
170
|
-
if (unwrapped instanceof z.ZodOptional) {
|
|
171
|
-
unwrapped = unwrapped.unwrap() as z.ZodTypeAny;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
if (unwrapped instanceof z.ZodDefault) {
|
|
175
|
-
unwrapped = unwrapped.def.innerType as z.ZodTypeAny;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
if (unwrapped instanceof z.ZodNullable) {
|
|
179
|
-
unwrapped = unwrapped.unwrap() as z.ZodTypeAny;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
return unwrapped;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// ─── Template Resolution ───────────────────────────────────────────────────
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Resolves all `${{ secrets.NAME }}` patterns in a string.
|
|
189
|
-
* Each unique secret name is resolved once (cached per call) to avoid duplicate lookups.
|
|
190
|
-
*/
|
|
191
|
-
async function resolveTemplateString(params: {
|
|
192
|
-
value: string;
|
|
193
|
-
secretStore: SecretStore;
|
|
194
|
-
}): Promise<string> {
|
|
195
|
-
const { value, secretStore } = params;
|
|
196
|
-
|
|
197
|
-
// Collect all secret names first
|
|
198
|
-
const secretNames = new Set<string>();
|
|
199
|
-
SECRET_TEMPLATE_REGEX.lastIndex = 0;
|
|
200
|
-
let match: RegExpExecArray | null;
|
|
201
|
-
while ((match = SECRET_TEMPLATE_REGEX.exec(value)) !== null) {
|
|
202
|
-
secretNames.add(match[1]);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// No templates found — return as-is
|
|
206
|
-
if (secretNames.size === 0) {
|
|
207
|
-
return value;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// Resolve all unique secret names
|
|
211
|
-
const resolvedValues = new Map<string, string>();
|
|
212
|
-
for (const name of secretNames) {
|
|
213
|
-
resolvedValues.set(name, await secretStore.resolve(name));
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Replace all template expressions with resolved values
|
|
217
|
-
SECRET_TEMPLATE_REGEX.lastIndex = 0;
|
|
218
|
-
return value.replaceAll(SECRET_TEMPLATE_REGEX, (_fullMatch, secretName: string) => {
|
|
219
|
-
return resolvedValues.get(secretName)!;
|
|
220
|
-
});
|
|
221
|
-
}
|
|
7
|
+
export {
|
|
8
|
+
resolveSecretsBySchema,
|
|
9
|
+
type SecretStore,
|
|
10
|
+
type SecretResolutionResult,
|
|
11
|
+
} from "@checkstack/secrets-backend";
|
package/tsconfig.json
CHANGED
|
@@ -1,325 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "bun:test";
|
|
2
|
-
import { z } from "zod";
|
|
3
|
-
import { configString } from "@checkstack/backend-api";
|
|
4
|
-
import { resolveSecretsBySchema } from "./secret-resolver";
|
|
5
|
-
|
|
6
|
-
const mockSecretStore = {
|
|
7
|
-
resolve: async (name: string): Promise<string> => {
|
|
8
|
-
const secrets: Record<string, string> = {
|
|
9
|
-
DB_PASS: "s3cret!",
|
|
10
|
-
API_KEY: "key-12345",
|
|
11
|
-
DB_USER: "admin",
|
|
12
|
-
DB_HOST: "db.production.internal",
|
|
13
|
-
};
|
|
14
|
-
const value = secrets[name];
|
|
15
|
-
if (!value) throw new Error(`Secret not found: ${name}`);
|
|
16
|
-
return value;
|
|
17
|
-
},
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
describe("resolveSecretsBySchema", () => {
|
|
21
|
-
it("resolves a field marked with x-secret", async () => {
|
|
22
|
-
const schema = z.object({
|
|
23
|
-
host: z.string(),
|
|
24
|
-
password: configString({ "x-secret": true }),
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
const { resolved, warnings } = await resolveSecretsBySchema({
|
|
28
|
-
value: { host: "localhost", password: "${{ secrets.DB_PASS }}" },
|
|
29
|
-
schema,
|
|
30
|
-
secretStore: mockSecretStore,
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
expect(resolved).toEqual({ host: "localhost", password: "s3cret!" });
|
|
34
|
-
expect(warnings).toEqual([]);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("leaves non-secret fields untouched and emits warnings", async () => {
|
|
38
|
-
const schema = z.object({
|
|
39
|
-
description: z.string(),
|
|
40
|
-
password: configString({ "x-secret": true }),
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
const { resolved, warnings } = await resolveSecretsBySchema({
|
|
44
|
-
value: {
|
|
45
|
-
description: "Contains ${{ secrets.DB_PASS }} but should NOT be resolved",
|
|
46
|
-
password: "${{ secrets.DB_PASS }}",
|
|
47
|
-
},
|
|
48
|
-
schema,
|
|
49
|
-
secretStore: mockSecretStore,
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
expect(resolved).toEqual({
|
|
53
|
-
description: "Contains ${{ secrets.DB_PASS }} but should NOT be resolved",
|
|
54
|
-
password: "s3cret!",
|
|
55
|
-
});
|
|
56
|
-
expect(warnings).toHaveLength(1);
|
|
57
|
-
expect(warnings[0]).toContain("description");
|
|
58
|
-
expect(warnings[0]).toContain("not marked as a secret field");
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("resolves inline interpolation in secret fields", async () => {
|
|
62
|
-
const schema = z.object({
|
|
63
|
-
connectionString: configString({ "x-secret": true }),
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
const { resolved } = await resolveSecretsBySchema({
|
|
67
|
-
value: {
|
|
68
|
-
connectionString:
|
|
69
|
-
"postgres://${{ secrets.DB_USER }}:${{ secrets.DB_PASS }}@${{ secrets.DB_HOST }}/mydb",
|
|
70
|
-
},
|
|
71
|
-
schema,
|
|
72
|
-
secretStore: mockSecretStore,
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
expect(resolved).toEqual({
|
|
76
|
-
connectionString:
|
|
77
|
-
"postgres://admin:s3cret!@db.production.internal/mydb",
|
|
78
|
-
});
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("resolves secrets in nested objects", async () => {
|
|
82
|
-
const schema = z.object({
|
|
83
|
-
connection: z.object({
|
|
84
|
-
host: z.string(),
|
|
85
|
-
password: configString({ "x-secret": true }),
|
|
86
|
-
}),
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
const { resolved, warnings } = await resolveSecretsBySchema({
|
|
90
|
-
value: {
|
|
91
|
-
connection: { host: "db.internal", password: "${{ secrets.DB_PASS }}" },
|
|
92
|
-
},
|
|
93
|
-
schema,
|
|
94
|
-
secretStore: mockSecretStore,
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
expect(resolved).toEqual({
|
|
98
|
-
connection: { host: "db.internal", password: "s3cret!" },
|
|
99
|
-
});
|
|
100
|
-
expect(warnings).toEqual([]);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it("resolves secrets in arrays of objects", async () => {
|
|
104
|
-
const schema = z.object({
|
|
105
|
-
credentials: z.array(
|
|
106
|
-
z.object({
|
|
107
|
-
name: z.string(),
|
|
108
|
-
secret: configString({ "x-secret": true }),
|
|
109
|
-
}),
|
|
110
|
-
),
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
const { resolved } = await resolveSecretsBySchema({
|
|
114
|
-
value: {
|
|
115
|
-
credentials: [
|
|
116
|
-
{ name: "db", secret: "${{ secrets.DB_PASS }}" },
|
|
117
|
-
{ name: "api", secret: "${{ secrets.API_KEY }}" },
|
|
118
|
-
],
|
|
119
|
-
},
|
|
120
|
-
schema,
|
|
121
|
-
secretStore: mockSecretStore,
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
expect(resolved).toEqual({
|
|
125
|
-
credentials: [
|
|
126
|
-
{ name: "db", secret: "s3cret!" },
|
|
127
|
-
{ name: "api", secret: "key-12345" },
|
|
128
|
-
],
|
|
129
|
-
});
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it("handles optional secret fields", async () => {
|
|
133
|
-
const schema = z.object({
|
|
134
|
-
password: configString({ "x-secret": true }).optional(),
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
const { resolved } = await resolveSecretsBySchema({
|
|
138
|
-
value: { password: "${{ secrets.DB_PASS }}" },
|
|
139
|
-
schema,
|
|
140
|
-
secretStore: mockSecretStore,
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
expect(resolved).toEqual({ password: "s3cret!" });
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it("handles default-wrapped secret fields", async () => {
|
|
147
|
-
const schema = z.object({
|
|
148
|
-
password: configString({ "x-secret": true }).default("fallback"),
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
const { resolved } = await resolveSecretsBySchema({
|
|
152
|
-
value: { password: "${{ secrets.DB_PASS }}" },
|
|
153
|
-
schema,
|
|
154
|
-
secretStore: mockSecretStore,
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
expect(resolved).toEqual({ password: "s3cret!" });
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
it("returns non-secret objects unchanged", async () => {
|
|
161
|
-
const schema = z.object({
|
|
162
|
-
host: z.string(),
|
|
163
|
-
port: z.number(),
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
const { resolved, warnings } = await resolveSecretsBySchema({
|
|
167
|
-
value: { host: "localhost", port: 5432 },
|
|
168
|
-
schema,
|
|
169
|
-
secretStore: mockSecretStore,
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
expect(resolved).toEqual({ host: "localhost", port: 5432 });
|
|
173
|
-
expect(warnings).toEqual([]);
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
it("handles null and undefined values gracefully", async () => {
|
|
177
|
-
const schema = z.object({
|
|
178
|
-
password: configString({ "x-secret": true }).optional(),
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
const { resolved } = await resolveSecretsBySchema({
|
|
182
|
-
value: { password: undefined },
|
|
183
|
-
schema,
|
|
184
|
-
secretStore: mockSecretStore,
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
expect(resolved).toEqual({ password: undefined });
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it("throws when a referenced secret is not found", async () => {
|
|
191
|
-
const schema = z.object({
|
|
192
|
-
password: configString({ "x-secret": true }),
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
await expect(
|
|
196
|
-
resolveSecretsBySchema({
|
|
197
|
-
value: { password: "${{ secrets.NONEXISTENT }}" },
|
|
198
|
-
schema,
|
|
199
|
-
secretStore: mockSecretStore,
|
|
200
|
-
}),
|
|
201
|
-
).rejects.toThrow("Secret not found: NONEXISTENT");
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
it("resolves templates with extra whitespace", async () => {
|
|
205
|
-
const schema = z.object({
|
|
206
|
-
password: configString({ "x-secret": true }),
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
const { resolved } = await resolveSecretsBySchema({
|
|
210
|
-
value: { password: "${{ secrets.DB_PASS }}" },
|
|
211
|
-
schema,
|
|
212
|
-
secretStore: mockSecretStore,
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
expect(resolved).toEqual({ password: "s3cret!" });
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
it("preserves fields not in the schema (extra keys in value)", async () => {
|
|
219
|
-
const schema = z.object({
|
|
220
|
-
password: configString({ "x-secret": true }),
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
const { resolved } = await resolveSecretsBySchema({
|
|
224
|
-
value: {
|
|
225
|
-
password: "${{ secrets.DB_PASS }}",
|
|
226
|
-
extraField: "should remain",
|
|
227
|
-
},
|
|
228
|
-
schema,
|
|
229
|
-
secretStore: mockSecretStore,
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
expect(resolved).toEqual({
|
|
233
|
-
password: "s3cret!",
|
|
234
|
-
extraField: "should remain",
|
|
235
|
-
});
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
it("does not resolve secret templates in secret fields when no template is present", async () => {
|
|
239
|
-
const schema = z.object({
|
|
240
|
-
password: configString({ "x-secret": true }),
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
const { resolved } = await resolveSecretsBySchema({
|
|
244
|
-
value: { password: "plain-password" },
|
|
245
|
-
schema,
|
|
246
|
-
secretStore: mockSecretStore,
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
expect(resolved).toEqual({ password: "plain-password" });
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
// ─── Warning tests ──────────────────────────────────────────────────────
|
|
253
|
-
|
|
254
|
-
it("warns for template in nested non-secret field with correct path", async () => {
|
|
255
|
-
const schema = z.object({
|
|
256
|
-
connection: z.object({
|
|
257
|
-
host: z.string(),
|
|
258
|
-
password: configString({ "x-secret": true }),
|
|
259
|
-
}),
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
const { resolved, warnings } = await resolveSecretsBySchema({
|
|
263
|
-
value: {
|
|
264
|
-
connection: {
|
|
265
|
-
host: "${{ secrets.DB_HOST }}",
|
|
266
|
-
password: "${{ secrets.DB_PASS }}",
|
|
267
|
-
},
|
|
268
|
-
},
|
|
269
|
-
schema,
|
|
270
|
-
secretStore: mockSecretStore,
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
// Password resolved, host left as-is
|
|
274
|
-
expect(resolved).toEqual({
|
|
275
|
-
connection: {
|
|
276
|
-
host: "${{ secrets.DB_HOST }}",
|
|
277
|
-
password: "s3cret!",
|
|
278
|
-
},
|
|
279
|
-
});
|
|
280
|
-
expect(warnings).toHaveLength(1);
|
|
281
|
-
expect(warnings[0]).toContain("connection.host");
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
it("warns for template in array element non-secret field with index", async () => {
|
|
285
|
-
const schema = z.object({
|
|
286
|
-
items: z.array(
|
|
287
|
-
z.object({
|
|
288
|
-
label: z.string(),
|
|
289
|
-
secret: configString({ "x-secret": true }),
|
|
290
|
-
}),
|
|
291
|
-
),
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
const { warnings } = await resolveSecretsBySchema({
|
|
295
|
-
value: {
|
|
296
|
-
items: [
|
|
297
|
-
{ label: "${{ secrets.DB_PASS }}", secret: "${{ secrets.DB_PASS }}" },
|
|
298
|
-
],
|
|
299
|
-
},
|
|
300
|
-
schema,
|
|
301
|
-
secretStore: mockSecretStore,
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
expect(warnings).toHaveLength(1);
|
|
305
|
-
expect(warnings[0]).toContain("items[0].label");
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
it("emits no warnings when all templates are in secret fields", async () => {
|
|
309
|
-
const schema = z.object({
|
|
310
|
-
password: configString({ "x-secret": true }),
|
|
311
|
-
apiKey: configString({ "x-secret": true }),
|
|
312
|
-
});
|
|
313
|
-
|
|
314
|
-
const { warnings } = await resolveSecretsBySchema({
|
|
315
|
-
value: {
|
|
316
|
-
password: "${{ secrets.DB_PASS }}",
|
|
317
|
-
apiKey: "${{ secrets.API_KEY }}",
|
|
318
|
-
},
|
|
319
|
-
schema,
|
|
320
|
-
secretStore: mockSecretStore,
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
expect(warnings).toEqual([]);
|
|
324
|
-
});
|
|
325
|
-
});
|