@checkstack/secrets-backend 0.3.2 → 0.3.4
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 +47 -0
- package/package.json +9 -9
- package/src/active-backend.test.ts +90 -0
- package/src/active-backend.ts +21 -0
- package/src/resolver-service.test.ts +32 -1
- package/src/resolver-service.ts +27 -4
- package/src/secret-resolver.ts +10 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,52 @@
|
|
|
1
1
|
# @checkstack/secrets-backend
|
|
2
2
|
|
|
3
|
+
## 0.3.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 43e4484: Kill the redundant active-backend-id read N+1 in secret run resolution.
|
|
8
|
+
Behavior unchanged; performance-only: the same env vars and masking context are
|
|
9
|
+
produced for a run, only the number of active-backend-id config reads drops from
|
|
10
|
+
N (one per distinct secret name) to 1.
|
|
11
|
+
|
|
12
|
+
- The internal `SecretStore` interface gains an optional
|
|
13
|
+
`resolveMany(names: string[]): Promise<Map<string, string>>` batch path
|
|
14
|
+
(the single `resolve` stays for back-compat and single-secret callers).
|
|
15
|
+
- The active-backend store (`createActiveBackendStore`) implements
|
|
16
|
+
`resolveMany`: it resolves the active backend id ONCE for the whole batch and
|
|
17
|
+
then fetches each distinct name through that single backend, de-duping names
|
|
18
|
+
and throwing the same `Secret not found: NAME` on any absent value. The
|
|
19
|
+
per-name backend fetch is inherent; the removed redundancy is the per-name
|
|
20
|
+
active-backend-id config read.
|
|
21
|
+
- `SecretResolverService.resolveForRun` now collects the distinct secret names
|
|
22
|
+
(as before) and resolves them via one `resolveMany` call instead of a per-name
|
|
23
|
+
`resolve` loop. Stores without a batch path fall back to looping `resolve`,
|
|
24
|
+
so behavior is identical for every caller.
|
|
25
|
+
|
|
26
|
+
State & scale: the active backend id still resolves from the shared config
|
|
27
|
+
store, so every pod returns the same answer; no process-local or duplicated
|
|
28
|
+
state is introduced.
|
|
29
|
+
|
|
30
|
+
- Updated dependencies [43e4484]
|
|
31
|
+
- Updated dependencies [43e4484]
|
|
32
|
+
- @checkstack/backend-api@0.31.1
|
|
33
|
+
- @checkstack/command-backend@0.2.22
|
|
34
|
+
|
|
35
|
+
## 0.3.3
|
|
36
|
+
|
|
37
|
+
### Patch Changes
|
|
38
|
+
|
|
39
|
+
- Updated dependencies [f93ee7a]
|
|
40
|
+
- Updated dependencies [f93ee7a]
|
|
41
|
+
- Updated dependencies [d0eddc9]
|
|
42
|
+
- Updated dependencies [d0eddc9]
|
|
43
|
+
- Updated dependencies [d0eddc9]
|
|
44
|
+
- Updated dependencies [f93ee7a]
|
|
45
|
+
- @checkstack/common@0.22.0
|
|
46
|
+
- @checkstack/backend-api@0.31.0
|
|
47
|
+
- @checkstack/command-backend@0.2.21
|
|
48
|
+
- @checkstack/secrets-common@0.3.2
|
|
49
|
+
|
|
3
50
|
## 0.3.2
|
|
4
51
|
|
|
5
52
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/secrets-backend",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"description": "Secrets platform backend: resolver service, masking, backend extension point, RPC router",
|
|
5
5
|
"author": "Checkstack contributors",
|
|
6
6
|
"license": "Elastic-2.0",
|
|
@@ -25,22 +25,22 @@
|
|
|
25
25
|
"test": "bun test"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@checkstack/backend-api": "0.
|
|
29
|
-
"@checkstack/command-backend": "0.2.
|
|
30
|
-
"@checkstack/common": "0.
|
|
31
|
-
"@checkstack/secrets-common": "0.3.
|
|
28
|
+
"@checkstack/backend-api": "0.31.1",
|
|
29
|
+
"@checkstack/command-backend": "0.2.22",
|
|
30
|
+
"@checkstack/common": "0.22.0",
|
|
31
|
+
"@checkstack/secrets-common": "0.3.2",
|
|
32
32
|
"@orpc/server": "^1.14.4",
|
|
33
33
|
"drizzle-orm": "^0.45.0",
|
|
34
34
|
"uuid": "^14.0.0",
|
|
35
35
|
"zod": "^4.2.1"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
|
-
"@checkstack/scripts": "0.7.
|
|
39
|
-
"@checkstack/dev-server": "2.2.
|
|
40
|
-
"@checkstack/backend": "0.
|
|
38
|
+
"@checkstack/scripts": "0.7.3",
|
|
39
|
+
"@checkstack/dev-server": "2.2.8",
|
|
40
|
+
"@checkstack/backend": "0.24.1",
|
|
41
41
|
"@checkstack/tsconfig": "0.0.7",
|
|
42
42
|
"@checkstack/drizzle-helper": "0.0.6",
|
|
43
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
43
|
+
"@checkstack/test-utils-backend": "0.1.56",
|
|
44
44
|
"@types/bun": "^1.3.5",
|
|
45
45
|
"@types/node": "^20.0.0",
|
|
46
46
|
"drizzle-kit": "^0.31.10",
|
|
@@ -43,4 +43,94 @@ describe("createActiveBackendStore", () => {
|
|
|
43
43
|
"Secret not found: absent",
|
|
44
44
|
);
|
|
45
45
|
});
|
|
46
|
+
|
|
47
|
+
describe("resolveMany", () => {
|
|
48
|
+
it("resolves the active backend id exactly once for the whole batch", async () => {
|
|
49
|
+
const registry = createSecretBackendRegistry();
|
|
50
|
+
registry.register(
|
|
51
|
+
fakeBackend("local", { A: "a-val", B: "b-val", C: "c-val" }),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
let idReads = 0;
|
|
55
|
+
const store = createActiveBackendStore({
|
|
56
|
+
backends: registry,
|
|
57
|
+
getActiveBackendId: async () => {
|
|
58
|
+
idReads++;
|
|
59
|
+
return "local";
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const resolved = await store.resolveMany!(["A", "B", "C"]);
|
|
64
|
+
expect(idReads).toBe(1);
|
|
65
|
+
expect(resolved).toEqual(
|
|
66
|
+
new Map([
|
|
67
|
+
["A", "a-val"],
|
|
68
|
+
["B", "b-val"],
|
|
69
|
+
["C", "c-val"],
|
|
70
|
+
]),
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("de-dupes names and reads the active backend id once", async () => {
|
|
75
|
+
const registry = createSecretBackendRegistry();
|
|
76
|
+
let gets = 0;
|
|
77
|
+
registry.register({
|
|
78
|
+
id: "local",
|
|
79
|
+
get: async ({ name }) => {
|
|
80
|
+
gets++;
|
|
81
|
+
return `val-${name}`;
|
|
82
|
+
},
|
|
83
|
+
list: async () => [],
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
let idReads = 0;
|
|
87
|
+
const store = createActiveBackendStore({
|
|
88
|
+
backends: registry,
|
|
89
|
+
getActiveBackendId: async () => {
|
|
90
|
+
idReads++;
|
|
91
|
+
return "local";
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const resolved = await store.resolveMany!(["dup", "dup", "other"]);
|
|
96
|
+
expect(idReads).toBe(1);
|
|
97
|
+
expect(gets).toBe(2); // distinct names only
|
|
98
|
+
expect(resolved).toEqual(
|
|
99
|
+
new Map([
|
|
100
|
+
["dup", "val-dup"],
|
|
101
|
+
["other", "val-other"],
|
|
102
|
+
]),
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("returns an empty map (and reads no backend id) for empty input", async () => {
|
|
107
|
+
const registry = createSecretBackendRegistry();
|
|
108
|
+
registry.register(fakeBackend("local", {}));
|
|
109
|
+
|
|
110
|
+
let idReads = 0;
|
|
111
|
+
const store = createActiveBackendStore({
|
|
112
|
+
backends: registry,
|
|
113
|
+
getActiveBackendId: async () => {
|
|
114
|
+
idReads++;
|
|
115
|
+
return "local";
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const resolved = await store.resolveMany!([]);
|
|
120
|
+
expect(resolved.size).toBe(0);
|
|
121
|
+
expect(idReads).toBe(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("throws Secret not found on any absent name", async () => {
|
|
125
|
+
const registry = createSecretBackendRegistry();
|
|
126
|
+
registry.register(fakeBackend("local", { A: "a-val" }));
|
|
127
|
+
const store = createActiveBackendStore({
|
|
128
|
+
backends: registry,
|
|
129
|
+
getActiveBackendId: async () => "local",
|
|
130
|
+
});
|
|
131
|
+
await expect(store.resolveMany!(["A", "missing"])).rejects.toThrow(
|
|
132
|
+
"Secret not found: missing",
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
46
136
|
});
|
package/src/active-backend.ts
CHANGED
|
@@ -24,5 +24,26 @@ export function createActiveBackendStore({
|
|
|
24
24
|
}
|
|
25
25
|
return value;
|
|
26
26
|
},
|
|
27
|
+
|
|
28
|
+
resolveMany: async (names: string[]): Promise<Map<string, string>> => {
|
|
29
|
+
const distinct = [...new Set(names)];
|
|
30
|
+
const resolved = new Map<string, string>();
|
|
31
|
+
if (distinct.length === 0) {
|
|
32
|
+
return resolved;
|
|
33
|
+
}
|
|
34
|
+
// Resolve the active backend id ONCE for the whole batch. The id
|
|
35
|
+
// read goes through the config store (a DB read), so doing it per
|
|
36
|
+
// name — as a `resolve` loop would — is the redundant N+1 this path
|
|
37
|
+
// removes. The per-name `backend.get` fetch is inherent.
|
|
38
|
+
const backend = backends.get(await getActiveBackendId());
|
|
39
|
+
for (const name of distinct) {
|
|
40
|
+
const value = await backend.get({ name });
|
|
41
|
+
if (value === undefined) {
|
|
42
|
+
throw new Error(`Secret not found: ${name}`);
|
|
43
|
+
}
|
|
44
|
+
resolved.set(name, value);
|
|
45
|
+
}
|
|
46
|
+
return resolved;
|
|
47
|
+
},
|
|
27
48
|
};
|
|
28
49
|
}
|
|
@@ -65,7 +65,7 @@ describe("SecretResolverService.resolveForRun", () => {
|
|
|
65
65
|
).rejects.toThrow("Secret not found: absent");
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
-
it("resolves each distinct secret once even when reused", async () => {
|
|
68
|
+
it("resolves each distinct secret once even when reused (resolve fallback)", async () => {
|
|
69
69
|
let calls = 0;
|
|
70
70
|
const counting: SecretStore = {
|
|
71
71
|
resolve: async (name) => {
|
|
@@ -84,4 +84,35 @@ describe("SecretResolverService.resolveForRun", () => {
|
|
|
84
84
|
// "same" + "other" → 2 distinct resolutions despite 3 references.
|
|
85
85
|
expect(calls).toBe(2);
|
|
86
86
|
});
|
|
87
|
+
|
|
88
|
+
it("uses resolveMany once for the whole batch when the store provides it", async () => {
|
|
89
|
+
let manyCalls = 0;
|
|
90
|
+
let singleCalls = 0;
|
|
91
|
+
const batching: SecretStore = {
|
|
92
|
+
resolve: async (name) => {
|
|
93
|
+
singleCalls++;
|
|
94
|
+
return `val-${name}`;
|
|
95
|
+
},
|
|
96
|
+
resolveMany: async (names) => {
|
|
97
|
+
manyCalls++;
|
|
98
|
+
return new Map(names.map((name) => [name, `val-${name}`]));
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
const service = createSecretResolverService({ secretStore: batching });
|
|
102
|
+
const { env } = await service.resolveForRun({
|
|
103
|
+
secretEnv: {
|
|
104
|
+
A: "${{ secrets.same }}",
|
|
105
|
+
B: "${{ secrets.same }}",
|
|
106
|
+
C: "${{ secrets.other }}",
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
// One batch call, never the per-name single resolve.
|
|
110
|
+
expect(manyCalls).toBe(1);
|
|
111
|
+
expect(singleCalls).toBe(0);
|
|
112
|
+
expect(env).toEqual({
|
|
113
|
+
A: "val-same",
|
|
114
|
+
B: "val-same",
|
|
115
|
+
C: "val-other",
|
|
116
|
+
});
|
|
117
|
+
});
|
|
87
118
|
});
|
package/src/resolver-service.ts
CHANGED
|
@@ -85,10 +85,15 @@ export function createSecretResolverService({
|
|
|
85
85
|
collectSecretNames({ value: Object.values(normalized) }),
|
|
86
86
|
);
|
|
87
87
|
|
|
88
|
-
|
|
89
|
-
for
|
|
90
|
-
|
|
91
|
-
|
|
88
|
+
// Resolve the whole batch in one shot. `resolveMany` (active-backend
|
|
89
|
+
// store) resolves the active backend id ONCE for every name instead
|
|
90
|
+
// of the per-name config read the old `resolve` loop incurred. Stores
|
|
91
|
+
// without a batch path (e.g. a plain `resolve`-only literal) fall back
|
|
92
|
+
// to looping `resolve` — same result, no batch optimization.
|
|
93
|
+
const nameList = [...names];
|
|
94
|
+
const resolved = secretStore.resolveMany
|
|
95
|
+
? await secretStore.resolveMany(nameList)
|
|
96
|
+
: await resolveEach({ secretStore, names: nameList });
|
|
92
97
|
|
|
93
98
|
// Build the env by substituting templates in each mapping value.
|
|
94
99
|
const env: Record<string, string> = {};
|
|
@@ -102,6 +107,24 @@ export function createSecretResolverService({
|
|
|
102
107
|
};
|
|
103
108
|
}
|
|
104
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Fallback batch resolution for a {@link SecretStore} without a native
|
|
112
|
+
* `resolveMany`: resolve each distinct name via the single `resolve`.
|
|
113
|
+
*/
|
|
114
|
+
async function resolveEach({
|
|
115
|
+
secretStore,
|
|
116
|
+
names,
|
|
117
|
+
}: {
|
|
118
|
+
secretStore: SecretStore;
|
|
119
|
+
names: string[];
|
|
120
|
+
}): Promise<Map<string, string>> {
|
|
121
|
+
const resolved = new Map<string, string>();
|
|
122
|
+
for (const name of names) {
|
|
123
|
+
resolved.set(name, await secretStore.resolve(name));
|
|
124
|
+
}
|
|
125
|
+
return resolved;
|
|
126
|
+
}
|
|
127
|
+
|
|
105
128
|
const TEMPLATE_RE = /\$\{\{\s*secrets\.([a-zA-Z0-9_-]+)\s*\}\}/g;
|
|
106
129
|
|
|
107
130
|
function substituteTemplate({
|
package/src/secret-resolver.ts
CHANGED
|
@@ -8,6 +8,16 @@ import { isSecretSchema } from "@checkstack/backend-api";
|
|
|
8
8
|
*/
|
|
9
9
|
export interface SecretStore {
|
|
10
10
|
resolve: (name: string) => Promise<string>;
|
|
11
|
+
/**
|
|
12
|
+
* Resolve many secret names in one batch. Optional — implementations
|
|
13
|
+
* backed by the active backend override this to resolve the active
|
|
14
|
+
* backend id ONCE for the whole batch (instead of the per-name config
|
|
15
|
+
* read `resolve` incurs), returning a `name → value` map keyed by the
|
|
16
|
+
* distinct input names. Throws `Secret not found: NAME` on any absent
|
|
17
|
+
* name, matching `resolve`. Callers that need a batch but face a store
|
|
18
|
+
* without this method fall back to looping `resolve`.
|
|
19
|
+
*/
|
|
20
|
+
resolveMany?: (names: string[]) => Promise<Map<string, string>>;
|
|
11
21
|
}
|
|
12
22
|
|
|
13
23
|
/**
|