@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.
- package/CHANGELOG.md +50 -0
- package/drizzle/0001_wandering_leech.sql +1 -0
- package/drizzle/0002_far_lady_vermin.sql +1 -0
- package/drizzle/meta/0001_snapshot.json +317 -0
- package/drizzle/meta/0002_snapshot.json +324 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +2 -2
- package/src/index.ts +3 -0
- package/src/kind-registry.test.ts +96 -0
- package/src/kind-registry.ts +67 -3
- package/src/router.ts +36 -3
- package/src/schema.ts +6 -2
- package/src/secret-resolver.test.ts +279 -40
- package/src/secret-resolver.ts +194 -27
- package/src/sync/reconciler-delete.test.ts +4 -0
- package/src/sync/reconciler.ts +70 -21
- package/src/sync/sort-entities.ts +2 -0
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { describe, it, expect } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { configString } from "@checkstack/backend-api";
|
|
4
|
+
import { resolveSecretsBySchema } from "./secret-resolver";
|
|
3
5
|
|
|
4
6
|
const mockSecretStore = {
|
|
5
7
|
resolve: async (name: string): Promise<string> => {
|
|
6
8
|
const secrets: Record<string, string> = {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
+
DB_PASS: "s3cret!",
|
|
10
|
+
API_KEY: "key-12345",
|
|
11
|
+
DB_USER: "admin",
|
|
12
|
+
DB_HOST: "db.production.internal",
|
|
9
13
|
};
|
|
10
14
|
const value = secrets[name];
|
|
11
15
|
if (!value) throw new Error(`Secret not found: ${name}`);
|
|
@@ -13,52 +17,110 @@ const mockSecretStore = {
|
|
|
13
17
|
},
|
|
14
18
|
};
|
|
15
19
|
|
|
16
|
-
describe("
|
|
17
|
-
it("resolves a
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
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,
|
|
21
30
|
secretStore: mockSecretStore,
|
|
22
31
|
});
|
|
23
|
-
|
|
32
|
+
|
|
33
|
+
expect(resolved).toEqual({ host: "localhost", password: "s3cret!" });
|
|
34
|
+
expect(warnings).toEqual([]);
|
|
24
35
|
});
|
|
25
36
|
|
|
26
|
-
it("leaves
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
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,
|
|
30
49
|
secretStore: mockSecretStore,
|
|
31
50
|
});
|
|
32
|
-
|
|
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");
|
|
33
59
|
});
|
|
34
60
|
|
|
35
|
-
it("resolves
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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",
|
|
40
70
|
},
|
|
41
|
-
|
|
42
|
-
const resolved = await resolveSecrets({
|
|
43
|
-
spec,
|
|
71
|
+
schema,
|
|
44
72
|
secretStore: mockSecretStore,
|
|
45
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
|
+
|
|
46
97
|
expect(resolved).toEqual({
|
|
47
98
|
connection: { host: "db.internal", password: "s3cret!" },
|
|
48
99
|
});
|
|
100
|
+
expect(warnings).toEqual([]);
|
|
49
101
|
});
|
|
50
102
|
|
|
51
|
-
it("resolves
|
|
52
|
-
const
|
|
53
|
-
credentials:
|
|
54
|
-
{
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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,
|
|
60
121
|
secretStore: mockSecretStore,
|
|
61
122
|
});
|
|
123
|
+
|
|
62
124
|
expect(resolved).toEqual({
|
|
63
125
|
credentials: [
|
|
64
126
|
{ name: "db", secret: "s3cret!" },
|
|
@@ -67,20 +129,197 @@ describe("resolveSecrets", () => {
|
|
|
67
129
|
});
|
|
68
130
|
});
|
|
69
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
|
+
|
|
70
190
|
it("throws when a referenced secret is not found", async () => {
|
|
71
|
-
const
|
|
191
|
+
const schema = z.object({
|
|
192
|
+
password: configString({ "x-secret": true }),
|
|
193
|
+
});
|
|
194
|
+
|
|
72
195
|
await expect(
|
|
73
|
-
|
|
74
|
-
|
|
196
|
+
resolveSecretsBySchema({
|
|
197
|
+
value: { password: "${{ secrets.NONEXISTENT }}" },
|
|
198
|
+
schema,
|
|
199
|
+
secretStore: mockSecretStore,
|
|
200
|
+
}),
|
|
201
|
+
).rejects.toThrow("Secret not found: NONEXISTENT");
|
|
75
202
|
});
|
|
76
203
|
|
|
77
|
-
it("
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
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,
|
|
81
229
|
secretStore: mockSecretStore,
|
|
82
230
|
});
|
|
83
|
-
|
|
84
|
-
expect(resolved
|
|
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([]);
|
|
85
324
|
});
|
|
86
325
|
});
|
package/src/secret-resolver.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { SECRET_TEMPLATE_REGEX } from "@checkstack/gitops-common";
|
|
3
|
+
import { isSecretSchema } from "@checkstack/backend-api";
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* Interface for resolving secret names to their decrypted values.
|
|
@@ -8,47 +10,212 @@ export interface SecretStore {
|
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
/**
|
|
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.
|
|
13
28
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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"`
|
|
16
39
|
*/
|
|
17
|
-
export async function
|
|
18
|
-
|
|
40
|
+
export async function resolveSecretsBySchema(params: {
|
|
41
|
+
value: unknown;
|
|
42
|
+
schema: z.ZodTypeAny;
|
|
19
43
|
secretStore: SecretStore;
|
|
20
|
-
}): Promise<
|
|
21
|
-
const {
|
|
22
|
-
|
|
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 };
|
|
23
49
|
}
|
|
24
50
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
+
|
|
29
63
|
if (value === null || value === undefined) {
|
|
30
64
|
return value;
|
|
31
65
|
}
|
|
32
66
|
|
|
33
|
-
//
|
|
34
|
-
if (
|
|
35
|
-
|
|
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;
|
|
36
73
|
}
|
|
37
74
|
|
|
38
|
-
//
|
|
39
|
-
if (
|
|
40
|
-
|
|
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;
|
|
41
83
|
}
|
|
42
84
|
|
|
43
|
-
//
|
|
44
|
-
if (typeof value === "object") {
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
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
|
+
}
|
|
48
100
|
}
|
|
49
|
-
|
|
101
|
+
|
|
102
|
+
return result;
|
|
50
103
|
}
|
|
51
104
|
|
|
52
|
-
//
|
|
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
|
|
53
151
|
return value;
|
|
54
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
|
+
}
|
|
@@ -42,6 +42,8 @@ describe("Kind delete reconciler wiring", () => {
|
|
|
42
42
|
error: () => {},
|
|
43
43
|
},
|
|
44
44
|
resolveEntityRef: async () => undefined,
|
|
45
|
+
resolveSecretsBySchema: async <T>(params: { value: T }): Promise<{ resolved: T; warnings: string[] }> =>
|
|
46
|
+
({ resolved: params.value, warnings: [] }),
|
|
45
47
|
},
|
|
46
48
|
});
|
|
47
49
|
|
|
@@ -105,6 +107,8 @@ describe("Kind delete reconciler wiring", () => {
|
|
|
105
107
|
error: () => {},
|
|
106
108
|
},
|
|
107
109
|
resolveEntityRef: async () => undefined,
|
|
110
|
+
resolveSecretsBySchema: async <T>(params: { value: T }): Promise<{ resolved: T; warnings: string[] }> =>
|
|
111
|
+
({ resolved: params.value, warnings: [] }),
|
|
108
112
|
},
|
|
109
113
|
}),
|
|
110
114
|
).rejects.toThrow("DB connection failed");
|