@checkstack/gitops-common 0.1.1 → 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.
- package/CHANGELOG.md +32 -0
- package/package.json +1 -1
- package/src/access.ts +1 -1
- package/src/entity-kind-registry.ts +62 -1
- package/src/index.ts +6 -5
- package/src/provenance-types.ts +2 -0
- package/src/rpc-contract.ts +34 -1
- package/src/secret-field.test.ts +146 -28
- package/src/secret-field.ts +67 -32
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# @checkstack/gitops-common
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 8ef367a: Added `registerSpecSchemaDocumentation` to EntityKindRegistry to allow plugins to provide detailed JSON Schemas for specific configurations. The frontend now displays these registered schemas as dropdown alternatives, improving the developer experience when authoring GitOps configurations.
|
|
8
|
+
- cb65e9d: ### Schema-driven secret resolution, rotation invalidation, and security hardening
|
|
9
|
+
|
|
10
|
+
**Breaking**: Replaced `{ secretRef: "..." }` object syntax with `${{ secrets.NAME }}` template interpolation. The `secretField()`, `secretRefSchema`, `isSecretRef`, `SecretRef`, and `ResolvedSecretField` exports have been removed from `@checkstack/gitops-common`.
|
|
11
|
+
|
|
12
|
+
**Breaking**: `ReconcileContext.resolveSecretsBySchema()` now returns `{ resolved: T; warnings: string[] }` instead of `T` directly. Plugins must destructure the result. Warnings contain messages for `${{ secrets.NAME }}` templates found in non-secret fields (fields without `x-secret` annotation).
|
|
13
|
+
|
|
14
|
+
**New features**:
|
|
15
|
+
|
|
16
|
+
- Secrets can be referenced in **any string field** using `${{ secrets.NAME }}` syntax
|
|
17
|
+
- Inline interpolation is supported: `"postgres://user:${{ secrets.DB_PASS }}@host/db"`
|
|
18
|
+
- Resolution is **schema-driven** — reuses the existing `configString({ "x-secret": true })` pattern from DynamicForm
|
|
19
|
+
- Secret rotation now automatically invalidates affected entities, triggering re-reconciliation on the next sync cycle
|
|
20
|
+
- New `getSecretUsage` RPC endpoint to look up which entities reference a given secret
|
|
21
|
+
- Secrets UI now shows an expandable usage panel per secret showing referencing entities
|
|
22
|
+
- Reconciliation warnings: templates in non-secret fields are detected and surfaced in the provenance UI
|
|
23
|
+
- New `secretNameSchema` and `SECRET_NAME_REGEX` exports for validating secret names
|
|
24
|
+
|
|
25
|
+
**Security**:
|
|
26
|
+
|
|
27
|
+
- Secret names are validated at creation: must start with a letter, contain only `[a-zA-Z0-9_-]`, max 63 chars
|
|
28
|
+
- Secrets are validated to exist at sync time but **not pre-resolved** into the spec
|
|
29
|
+
- Templates in `metadata` fields are **rejected** to prevent secret leaks via display fields
|
|
30
|
+
- Only fields with `x-secret` schema annotations get resolved — no escape hatch
|
|
31
|
+
- Templates in non-secret fields emit warnings (stored in provenance, visible in UI) instead of silently passing
|
|
32
|
+
|
|
33
|
+
**Migration**: Update YAML descriptors to use `${{ secrets.NAME }}` instead of `secretRef: name`. Remove `secretField()` imports from plugin schemas — use `configString({ "x-secret": true })` to annotate secret fields. Destructure `const { resolved } = await context.resolveSecretsBySchema({ value, schema })` (return type changed from `T` to `{ resolved: T; warnings: string[] }`).
|
|
34
|
+
|
|
3
35
|
## 0.1.1
|
|
4
36
|
|
|
5
37
|
### Patch Changes
|
package/package.json
CHANGED
package/src/access.ts
CHANGED
|
@@ -25,6 +25,32 @@ export interface ReconcileContext {
|
|
|
25
25
|
kind: string;
|
|
26
26
|
entityName: string;
|
|
27
27
|
}) => Promise<string | undefined>;
|
|
28
|
+
/**
|
|
29
|
+
* Schema-driven secret resolution.
|
|
30
|
+
*
|
|
31
|
+
* Walks the provided Zod schema to find fields annotated with
|
|
32
|
+
* `configString({ "x-secret": true })`, then resolves `${{ secrets.NAME }}`
|
|
33
|
+
* templates **only** in those fields. Non-secret fields are returned as-is.
|
|
34
|
+
*
|
|
35
|
+
* Returns warnings for any templates found in non-secret fields — these
|
|
36
|
+
* templates will NOT be resolved and should be surfaced to the user.
|
|
37
|
+
*
|
|
38
|
+
* Use the plugin's actual typed schema (e.g., the strategy config schema)
|
|
39
|
+
* rather than the generic GitOps spec schema — the typed schema carries the
|
|
40
|
+
* `x-secret` annotations.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```typescript
|
|
44
|
+
* const { resolved, warnings } = await context.resolveSecretsBySchema({
|
|
45
|
+
* value: entity.spec.config,
|
|
46
|
+
* schema: strategy.config.schema,
|
|
47
|
+
* });
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
resolveSecretsBySchema: <T>(params: {
|
|
51
|
+
value: T;
|
|
52
|
+
schema: z.ZodTypeAny;
|
|
53
|
+
}) => Promise<{ resolved: T; warnings: string[] }>;
|
|
28
54
|
}
|
|
29
55
|
|
|
30
56
|
/**
|
|
@@ -43,7 +69,7 @@ export interface EntityKindDefinition<TSpec = unknown> {
|
|
|
43
69
|
|
|
44
70
|
/**
|
|
45
71
|
* Called when an entity of this kind is discovered or updated via GitOps.
|
|
46
|
-
* The entity's spec is fully validated and all
|
|
72
|
+
* The entity's spec is fully validated and all `${{ secrets.NAME }}` templates are resolved.
|
|
47
73
|
*
|
|
48
74
|
* Must return the plugin-specific entity ID (e.g., the catalog system UUID).
|
|
49
75
|
* The reconciler engine stores this in provenance for generic frontend lookups.
|
|
@@ -101,6 +127,33 @@ export interface EntityKindExtensionDefinition<TExtensionSpec = unknown> {
|
|
|
101
127
|
}) => Promise<void>;
|
|
102
128
|
}
|
|
103
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Definition for documenting a specific field variant within a kind's spec.
|
|
132
|
+
*/
|
|
133
|
+
export interface SpecSchemaDocumentation {
|
|
134
|
+
/** Which field this documentation applies to (e.g., "config", "collectors[].config") */
|
|
135
|
+
fieldPath: string;
|
|
136
|
+
/**
|
|
137
|
+
* Optional unique identifier for this variant.
|
|
138
|
+
* Useful when other variants depend on this one being selected.
|
|
139
|
+
*/
|
|
140
|
+
variantId?: string;
|
|
141
|
+
/** Human-readable label shown in the dropdown (e.g., "HTTP Strategy") */
|
|
142
|
+
label: string;
|
|
143
|
+
/** Optional markdown description shown alongside the schema */
|
|
144
|
+
description?: string;
|
|
145
|
+
/** The Zod schema for this variant */
|
|
146
|
+
schema: z.ZodTypeAny;
|
|
147
|
+
/**
|
|
148
|
+
* If specified, this variant will only be shown if the selected variant
|
|
149
|
+
* in the target fieldPath matches one of the provided variantIds.
|
|
150
|
+
*/
|
|
151
|
+
conditions?: Array<{
|
|
152
|
+
fieldPath: string;
|
|
153
|
+
variantIds: string[];
|
|
154
|
+
}>;
|
|
155
|
+
}
|
|
156
|
+
|
|
104
157
|
/**
|
|
105
158
|
* The registry interface exposed via the Extension Point.
|
|
106
159
|
* Plugins call these methods during their `register()` phase.
|
|
@@ -113,4 +166,12 @@ export interface EntityKindRegistry {
|
|
|
113
166
|
registerKindExtension<TExtensionSpec>(
|
|
114
167
|
definition: EntityKindExtensionDefinition<TExtensionSpec>,
|
|
115
168
|
): void;
|
|
169
|
+
|
|
170
|
+
/** Register documentation for a specific field variant within a kind's spec. */
|
|
171
|
+
registerSpecSchemaDocumentation(
|
|
172
|
+
params: {
|
|
173
|
+
apiVersion: string;
|
|
174
|
+
kind: string;
|
|
175
|
+
} & SpecSchemaDocumentation,
|
|
176
|
+
): void;
|
|
116
177
|
}
|
package/src/index.ts
CHANGED
|
@@ -7,11 +7,11 @@ export {
|
|
|
7
7
|
type EntityMetadata,
|
|
8
8
|
} from "./entity-envelope";
|
|
9
9
|
export {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
SECRET_NAME_REGEX,
|
|
11
|
+
SECRET_TEMPLATE_REGEX,
|
|
12
|
+
secretNameSchema,
|
|
13
|
+
secretTemplateSchema,
|
|
14
|
+
collectSecretNames,
|
|
15
15
|
} from "./secret-field";
|
|
16
16
|
export {
|
|
17
17
|
entityRefSchema,
|
|
@@ -23,6 +23,7 @@ export {
|
|
|
23
23
|
type EntityKindExtensionDefinition,
|
|
24
24
|
type EntityKindRegistry,
|
|
25
25
|
type ReconcileContext,
|
|
26
|
+
type SpecSchemaDocumentation,
|
|
26
27
|
} from "./entity-kind-registry";
|
|
27
28
|
export { gitopsAccess, gitopsAccessRules } from "./access";
|
|
28
29
|
export { gitopsRoutes } from "./routes";
|
package/src/provenance-types.ts
CHANGED
|
@@ -19,6 +19,8 @@ export const provenanceSchema = z.object({
|
|
|
19
19
|
lastSyncHash: z.string(),
|
|
20
20
|
status: provenanceStatusSchema,
|
|
21
21
|
errorMessage: z.string().nullable(),
|
|
22
|
+
/** Warnings about unresolved secret templates in non-secret fields. */
|
|
23
|
+
warnings: z.array(z.string()),
|
|
22
24
|
lastSyncedAt: z.date(),
|
|
23
25
|
createdAt: z.date(),
|
|
24
26
|
});
|
package/src/rpc-contract.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
provenanceStatusSchema,
|
|
7
7
|
deletionPolicySchema,
|
|
8
8
|
} from "./provenance-types";
|
|
9
|
+
import { secretNameSchema } from "./secret-field";
|
|
9
10
|
import { z } from "zod";
|
|
10
11
|
|
|
11
12
|
export const gitopsContract = {
|
|
@@ -175,7 +176,7 @@ export const gitopsContract = {
|
|
|
175
176
|
})
|
|
176
177
|
.input(
|
|
177
178
|
z.object({
|
|
178
|
-
name:
|
|
179
|
+
name: secretNameSchema,
|
|
179
180
|
value: z.string().min(1),
|
|
180
181
|
description: z.string().optional(),
|
|
181
182
|
}),
|
|
@@ -218,6 +219,24 @@ export const gitopsContract = {
|
|
|
218
219
|
.input(z.object({ name: z.string() }))
|
|
219
220
|
.output(z.object({ value: z.string() })),
|
|
220
221
|
|
|
222
|
+
/** Look up which entities reference a given secret. */
|
|
223
|
+
getSecretUsage: proc({
|
|
224
|
+
operationType: "query",
|
|
225
|
+
userType: "authenticated",
|
|
226
|
+
access: [gitopsAccess.secret.read],
|
|
227
|
+
})
|
|
228
|
+
.input(z.object({ secretName: z.string() }))
|
|
229
|
+
.output(
|
|
230
|
+
z.array(
|
|
231
|
+
z.object({
|
|
232
|
+
kind: z.string(),
|
|
233
|
+
entityName: z.string(),
|
|
234
|
+
repository: z.string(),
|
|
235
|
+
filePath: z.string(),
|
|
236
|
+
}),
|
|
237
|
+
),
|
|
238
|
+
),
|
|
239
|
+
|
|
221
240
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
222
241
|
// KIND REGISTRY (browsing)
|
|
223
242
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -232,6 +251,7 @@ export const gitopsContract = {
|
|
|
232
251
|
z.object({
|
|
233
252
|
apiVersion: z.string(),
|
|
234
253
|
kind: z.string(),
|
|
254
|
+
metadataSchema: z.record(z.string(), z.unknown()),
|
|
235
255
|
specSchema: z.record(z.string(), z.unknown()),
|
|
236
256
|
extensions: z.array(
|
|
237
257
|
z.object({
|
|
@@ -239,6 +259,19 @@ export const gitopsContract = {
|
|
|
239
259
|
specSchema: z.record(z.string(), z.unknown()),
|
|
240
260
|
}),
|
|
241
261
|
),
|
|
262
|
+
specSchemaDocumentation: z.array(
|
|
263
|
+
z.object({
|
|
264
|
+
fieldPath: z.string(),
|
|
265
|
+
variantId: z.string().optional(),
|
|
266
|
+
label: z.string(),
|
|
267
|
+
description: z.string().optional(),
|
|
268
|
+
specSchema: z.record(z.string(), z.unknown()),
|
|
269
|
+
conditions: z.array(z.object({
|
|
270
|
+
fieldPath: z.string(),
|
|
271
|
+
variantIds: z.array(z.string()),
|
|
272
|
+
})).optional(),
|
|
273
|
+
}),
|
|
274
|
+
),
|
|
242
275
|
}),
|
|
243
276
|
),
|
|
244
277
|
),
|
package/src/secret-field.test.ts
CHANGED
|
@@ -1,50 +1,168 @@
|
|
|
1
1
|
import { describe, it, expect } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
SECRET_TEMPLATE_REGEX,
|
|
4
|
+
secretNameSchema,
|
|
5
|
+
collectSecretNames,
|
|
6
|
+
secretTemplateSchema,
|
|
7
|
+
} from "./secret-field";
|
|
3
8
|
|
|
4
|
-
describe("
|
|
5
|
-
|
|
9
|
+
describe("SECRET_TEMPLATE_REGEX", () => {
|
|
10
|
+
it("matches a simple secret template", () => {
|
|
11
|
+
SECRET_TEMPLATE_REGEX.lastIndex = 0;
|
|
12
|
+
const match = SECRET_TEMPLATE_REGEX.exec("${{ secrets.DB_PASS }}");
|
|
13
|
+
expect(match).not.toBeNull();
|
|
14
|
+
expect(match![1]).toBe("DB_PASS");
|
|
15
|
+
});
|
|
6
16
|
|
|
7
|
-
it("
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
17
|
+
it("matches templates with extra whitespace", () => {
|
|
18
|
+
SECRET_TEMPLATE_REGEX.lastIndex = 0;
|
|
19
|
+
const match = SECRET_TEMPLATE_REGEX.exec("${{ secrets.MY_KEY }}");
|
|
20
|
+
expect(match).not.toBeNull();
|
|
21
|
+
expect(match![1]).toBe("MY_KEY");
|
|
11
22
|
});
|
|
12
23
|
|
|
13
|
-
it("
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
24
|
+
it("matches hyphenated secret names", () => {
|
|
25
|
+
SECRET_TEMPLATE_REGEX.lastIndex = 0;
|
|
26
|
+
const match = SECRET_TEMPLATE_REGEX.exec("${{ secrets.prod-db-pass }}");
|
|
27
|
+
expect(match).not.toBeNull();
|
|
28
|
+
expect(match![1]).toBe("prod-db-pass");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("matches multiple templates in one string", () => {
|
|
32
|
+
const str =
|
|
33
|
+
"postgres://${{ secrets.DB_USER }}:${{ secrets.DB_PASS }}@host/db";
|
|
34
|
+
const matches: string[] = [];
|
|
35
|
+
SECRET_TEMPLATE_REGEX.lastIndex = 0;
|
|
36
|
+
let match: RegExpExecArray | null;
|
|
37
|
+
while ((match = SECRET_TEMPLATE_REGEX.exec(str)) !== null) {
|
|
38
|
+
matches.push(match[1]);
|
|
18
39
|
}
|
|
40
|
+
expect(matches).toEqual(["DB_USER", "DB_PASS"]);
|
|
19
41
|
});
|
|
20
42
|
|
|
21
|
-
it("
|
|
22
|
-
|
|
23
|
-
expect(
|
|
43
|
+
it("does not match plain strings", () => {
|
|
44
|
+
SECRET_TEMPLATE_REGEX.lastIndex = 0;
|
|
45
|
+
expect(SECRET_TEMPLATE_REGEX.exec("just a string")).toBeNull();
|
|
24
46
|
});
|
|
25
47
|
|
|
26
|
-
it("
|
|
27
|
-
|
|
28
|
-
expect(
|
|
48
|
+
it("does not match malformed templates", () => {
|
|
49
|
+
SECRET_TEMPLATE_REGEX.lastIndex = 0;
|
|
50
|
+
expect(SECRET_TEMPLATE_REGEX.exec("${ secrets.WRONG }")).toBeNull();
|
|
51
|
+
SECRET_TEMPLATE_REGEX.lastIndex = 0;
|
|
52
|
+
expect(SECRET_TEMPLATE_REGEX.exec("${{ notSecrets.WRONG }}")).toBeNull();
|
|
29
53
|
});
|
|
54
|
+
});
|
|
30
55
|
|
|
31
|
-
|
|
32
|
-
|
|
56
|
+
describe("secretTemplateSchema", () => {
|
|
57
|
+
it("accepts a valid template string", () => {
|
|
58
|
+
const result = secretTemplateSchema.safeParse("${{ secrets.MY_SECRET }}");
|
|
59
|
+
expect(result.success).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("rejects a plain string without template", () => {
|
|
63
|
+
const result = secretTemplateSchema.safeParse("just-a-string");
|
|
33
64
|
expect(result.success).toBe(false);
|
|
34
65
|
});
|
|
35
66
|
});
|
|
36
67
|
|
|
37
|
-
describe("
|
|
38
|
-
it("
|
|
39
|
-
const
|
|
40
|
-
|
|
68
|
+
describe("collectSecretNames", () => {
|
|
69
|
+
it("extracts a single secret from a top-level string", () => {
|
|
70
|
+
const result = collectSecretNames({
|
|
71
|
+
value: { password: "${{ secrets.DB_PASS }}" },
|
|
72
|
+
});
|
|
73
|
+
expect(result).toEqual(["DB_PASS"]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("returns empty array when no secrets are referenced", () => {
|
|
77
|
+
const result = collectSecretNames({
|
|
78
|
+
value: { host: "localhost", port: 5432 },
|
|
79
|
+
});
|
|
80
|
+
expect(result).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("extracts secrets from nested objects", () => {
|
|
84
|
+
const result = collectSecretNames({
|
|
85
|
+
value: {
|
|
86
|
+
connection: {
|
|
87
|
+
host: "db.internal",
|
|
88
|
+
password: "${{ secrets.NESTED_PASS }}",
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
expect(result).toEqual(["NESTED_PASS"]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("extracts secrets from arrays", () => {
|
|
96
|
+
const result = collectSecretNames({
|
|
97
|
+
value: {
|
|
98
|
+
credentials: [
|
|
99
|
+
{ name: "db", secret: "${{ secrets.DB_PASS }}" },
|
|
100
|
+
{ name: "api", secret: "${{ secrets.API_KEY }}" },
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
expect(result).toEqual(["DB_PASS", "API_KEY"]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("extracts multiple secrets from a single interpolated string", () => {
|
|
108
|
+
const result = collectSecretNames({
|
|
109
|
+
value: {
|
|
110
|
+
url: "postgres://${{ secrets.DB_USER }}:${{ secrets.DB_PASS }}@host/db",
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
expect(result).toEqual(["DB_USER", "DB_PASS"]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("deduplicates secret names", () => {
|
|
117
|
+
const result = collectSecretNames({
|
|
118
|
+
value: {
|
|
119
|
+
primary: "${{ secrets.SHARED }}",
|
|
120
|
+
secondary: "${{ secrets.SHARED }}",
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
expect(result).toEqual(["SHARED"]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("handles null and undefined values gracefully", () => {
|
|
127
|
+
const result = collectSecretNames({
|
|
128
|
+
value: { a: null, b: undefined, c: "${{ secrets.VALID }}" },
|
|
129
|
+
});
|
|
130
|
+
expect(result).toEqual(["VALID"]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("ignores non-string primitives", () => {
|
|
134
|
+
const result = collectSecretNames({
|
|
135
|
+
value: { num: 42, bool: true, str: "${{ secrets.ONLY_THIS }}" },
|
|
136
|
+
});
|
|
137
|
+
expect(result).toEqual(["ONLY_THIS"]);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("secretNameSchema", () => {
|
|
142
|
+
it.each(["DB_PASS", "my-secret", "ApiKey123", "a", "prod_db_pass_v2"])("accepts valid name: %s", (name) => {
|
|
143
|
+
expect(secretNameSchema.safeParse(name).success).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it.each([
|
|
147
|
+
"has space",
|
|
148
|
+
"has\ttab",
|
|
149
|
+
"123starts-with-digit",
|
|
150
|
+
"has.dot",
|
|
151
|
+
"no@special",
|
|
152
|
+
"no/slashes",
|
|
153
|
+
"${{ secrets.NOPE }}",
|
|
154
|
+
"",
|
|
155
|
+
])("rejects invalid name: %s", (name) => {
|
|
156
|
+
expect(secretNameSchema.safeParse(name).success).toBe(false);
|
|
41
157
|
});
|
|
42
158
|
|
|
43
|
-
it("
|
|
44
|
-
|
|
159
|
+
it("rejects names exceeding 63 characters", () => {
|
|
160
|
+
const longName = "A".repeat(64);
|
|
161
|
+
expect(secretNameSchema.safeParse(longName).success).toBe(false);
|
|
45
162
|
});
|
|
46
163
|
|
|
47
|
-
it("
|
|
48
|
-
|
|
164
|
+
it("accepts names at the 63-character limit", () => {
|
|
165
|
+
const maxName = "A".repeat(63);
|
|
166
|
+
expect(secretNameSchema.safeParse(maxName).success).toBe(true);
|
|
49
167
|
});
|
|
50
168
|
});
|
package/src/secret-field.ts
CHANGED
|
@@ -1,47 +1,82 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Valid characters for a secret name: letters, digits, underscores, and hyphens.
|
|
5
|
+
* This must stay in sync with the capture group in {@link SECRET_TEMPLATE_REGEX}.
|
|
6
6
|
*/
|
|
7
|
-
export const
|
|
8
|
-
secretRef: z.string().min(1, "Secret reference name must not be empty"),
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
export type SecretRef = z.infer<typeof secretRefSchema>;
|
|
7
|
+
export const SECRET_NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
|
|
12
8
|
|
|
13
9
|
/**
|
|
14
|
-
*
|
|
10
|
+
* Zod schema for validating secret names at creation time.
|
|
11
|
+
* Ensures the name can be referenced via `${{ secrets.NAME }}`.
|
|
15
12
|
*/
|
|
16
|
-
export
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
13
|
+
export const secretNameSchema = z
|
|
14
|
+
.string()
|
|
15
|
+
.min(1)
|
|
16
|
+
.max(63)
|
|
17
|
+
.regex(
|
|
18
|
+
SECRET_NAME_REGEX,
|
|
19
|
+
"Secret names must start with a letter and contain only letters, digits, underscores, or hyphens",
|
|
23
20
|
);
|
|
24
|
-
}
|
|
25
21
|
|
|
26
22
|
/**
|
|
27
|
-
*
|
|
23
|
+
* Regex matching ${{ secrets.NAME }} template expressions in strings.
|
|
24
|
+
* Captures the secret name in group 1.
|
|
25
|
+
* Supports multiple occurrences within a single string value.
|
|
28
26
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* password:
|
|
33
|
-
* secretRef: production-db-creds # reference to secret store
|
|
34
|
-
* ```
|
|
35
|
-
*
|
|
36
|
-
* The GitOps reconciliation engine resolves all secretRef values before calling
|
|
37
|
-
* plugin reconcilers, so plugins always receive plain strings.
|
|
27
|
+
* @example
|
|
28
|
+
* "${{ secrets.DB_PASS }}" → captures "DB_PASS"
|
|
29
|
+
* "postgres://u:${{ secrets.PASS }}@${{ secrets.HOST }}/db" → captures "PASS", "HOST"
|
|
38
30
|
*/
|
|
39
|
-
export
|
|
40
|
-
|
|
41
|
-
}
|
|
31
|
+
export const SECRET_TEMPLATE_REGEX =
|
|
32
|
+
/\$\{\{\s*secrets\.([a-zA-Z0-9_-]+)\s*\}\}/g;
|
|
42
33
|
|
|
43
34
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
35
|
+
* Zod schema for validating secret template strings.
|
|
36
|
+
* Matches strings containing at least one ${{ secrets.NAME }} pattern.
|
|
46
37
|
*/
|
|
47
|
-
export
|
|
38
|
+
export const secretTemplateSchema = z.string().regex(
|
|
39
|
+
/\$\{\{\s*secrets\.[a-zA-Z0-9_-]+\s*\}\}/,
|
|
40
|
+
"Must contain a ${{ secrets.NAME }} reference",
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Recursively walks a value and collects all unique secret names
|
|
45
|
+
* referenced via ${{ secrets.NAME }} patterns in string values.
|
|
46
|
+
*
|
|
47
|
+
* @returns Deduplicated array of secret names found in the value tree
|
|
48
|
+
*/
|
|
49
|
+
export function collectSecretNames(params: { value: unknown }): string[] {
|
|
50
|
+
const names = new Set<string>();
|
|
51
|
+
collectFromValue(params.value, names);
|
|
52
|
+
return [...names];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function collectFromValue(value: unknown, names: Set<string>): void {
|
|
56
|
+
if (value === null || value === undefined) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (typeof value === "string") {
|
|
61
|
+
// Reset regex lastIndex for safe reuse (global flag)
|
|
62
|
+
SECRET_TEMPLATE_REGEX.lastIndex = 0;
|
|
63
|
+
let match: RegExpExecArray | null;
|
|
64
|
+
while ((match = SECRET_TEMPLATE_REGEX.exec(value)) !== null) {
|
|
65
|
+
names.add(match[1]);
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (Array.isArray(value)) {
|
|
71
|
+
for (const item of value) {
|
|
72
|
+
collectFromValue(item, names);
|
|
73
|
+
}
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (typeof value === "object") {
|
|
78
|
+
for (const val of Object.values(value)) {
|
|
79
|
+
collectFromValue(val, names);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|