@checkstack/healthcheck-backend 1.14.0 → 1.15.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 +94 -0
- package/package.json +29 -29
- package/src/ai/healthcheck-propose.ts +9 -0
- package/src/ai/healthcheck-update.ts +5 -1
- package/src/config-secrets-backfill.test.ts +142 -0
- package/src/config-secrets-backfill.ts +129 -0
- package/src/config-secrets.test.ts +771 -0
- package/src/config-secrets.ts +361 -0
- package/src/healthcheck-gitops-kinds.ts +42 -31
- package/src/index.ts +47 -1
- package/src/queue-executor.ts +52 -3
- package/src/router-config-secrets.test.ts +165 -0
- package/src/router-create-and-assign.test.ts +5 -1
- package/src/router.ts +39 -7
- package/src/service-config-secrets.test.ts +680 -0
- package/src/service.ts +443 -47
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,99 @@
|
|
|
1
1
|
# @checkstack/healthcheck-backend
|
|
2
2
|
|
|
3
|
+
## 1.15.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- faf98f5: Security: config secrets (health-check strategy/collector credentials such as
|
|
8
|
+
SSH passwords, DB credentials, HTTP auth, and integration connection
|
|
9
|
+
credentials) ride ONE shared, domain-agnostic extraction channel instead of
|
|
10
|
+
being stored as plaintext or re-implemented per plugin.
|
|
11
|
+
|
|
12
|
+
New primitive and shared service:
|
|
13
|
+
|
|
14
|
+
- `configSecret({ id })` (in `@checkstack/backend-api`) declares an
|
|
15
|
+
extraction-channel secret keyed by a STABLE `id`, independent of field name or
|
|
16
|
+
position, so renaming or reordering a field never orphans its value. Use it
|
|
17
|
+
(not `configString({ "x-secret": true })`) for any credential whose config is
|
|
18
|
+
relayed to a satellite, projected to AI, or diffed by GitOps. `validateSecretIds`
|
|
19
|
+
rejects, at plugin registration, an `x-secret` field with no `id`, a duplicate
|
|
20
|
+
`id`, or a secret nested in an un-keyable container (array / record / tuple /
|
|
21
|
+
map) - so a mis-keyable schema fails boot rather than at run time.
|
|
22
|
+
- `ConfigSecretChannel` (in `@checkstack/secrets-backend`) is the single
|
|
23
|
+
extract / inflate / collect / redact / merge / delete / prune implementation.
|
|
24
|
+
Health-checks and integration connections both BIND it to their own scope
|
|
25
|
+
(marker prefix + internal-secret key layout); neither re-implements the walk.
|
|
26
|
+
|
|
27
|
+
Lifecycle (both bindings):
|
|
28
|
+
|
|
29
|
+
- **Write**: an inline value is extracted into the encrypted internal secret
|
|
30
|
+
store; the stored config keeps only an opaque marker. `${{ secrets.NAME }}`
|
|
31
|
+
references are stored verbatim and resolve through the active backend (local
|
|
32
|
+
or Vault) at run time.
|
|
33
|
+
- **Read**: configuration and connection reads strip `x-secret` values and
|
|
34
|
+
internal markers while keeping `${{ secrets.NAME }}` references visible; the
|
|
35
|
+
AI `getConfigurations` tool and create/update responses are redacted too. A
|
|
36
|
+
value never reaches a browser or an AI model context.
|
|
37
|
+
- **Run**: the core executor inflates markers/references in memory just before
|
|
38
|
+
the client is built. Satellites receive markers only and fetch values
|
|
39
|
+
just-in-time over the authenticated WS channel, per run, never persisted, then
|
|
40
|
+
fail CLOSED if any marker/reference survives resolution.
|
|
41
|
+
- **No orphan**: clearing a secret, removing a field/collector, swapping an
|
|
42
|
+
inline value for a reference, updating a connection, or deleting a
|
|
43
|
+
configuration/connection deletes the now-unreferenced internal secret. Cleanup
|
|
44
|
+
is schema-free (scans markers by prefix) and best-effort on delete, so it works
|
|
45
|
+
even when the owning plugin is uninstalled and never blocks a delete.
|
|
46
|
+
- **Forged-marker safe**: extract/inflate key each internal secret by the
|
|
47
|
+
SCHEMA leaf's stable `id`, never by an id parsed out of a stored marker string,
|
|
48
|
+
so a crafted marker can never resolve or delete another scope's secret.
|
|
49
|
+
|
|
50
|
+
Health-checks additionally get an idempotent, advisory-locked backfill that
|
|
51
|
+
moves pre-existing plaintext values into the internal store, and per-config-id
|
|
52
|
+
locking so concurrent writers across pods can never leave a dangling marker.
|
|
53
|
+
Integration connection credentials keep their released `__connref__:` marker
|
|
54
|
+
prefix and key layout (id equals the flat field name), so existing stored
|
|
55
|
+
connections are byte-compatible.
|
|
56
|
+
|
|
57
|
+
BREAKING CHANGES:
|
|
58
|
+
|
|
59
|
+
- Configuration and connection reads no longer include `x-secret` field values
|
|
60
|
+
(clients must treat blank-on-save as keep-existing; the bundled editors
|
|
61
|
+
already do).
|
|
62
|
+
- Satellites must be upgraded together with the core: an old satellite cannot
|
|
63
|
+
resolve the markers a new core stores, so its credentialed checks fail until
|
|
64
|
+
upgraded.
|
|
65
|
+
|
|
66
|
+
### Patch Changes
|
|
67
|
+
|
|
68
|
+
- Updated dependencies [faf98f5]
|
|
69
|
+
- Updated dependencies [faf98f5]
|
|
70
|
+
- @checkstack/ai-backend@0.10.6
|
|
71
|
+
- @checkstack/backend-api@0.29.0
|
|
72
|
+
- @checkstack/secrets-backend@0.3.0
|
|
73
|
+
- @checkstack/secrets-common@0.3.0
|
|
74
|
+
- @checkstack/common@0.20.0
|
|
75
|
+
- @checkstack/healthcheck-common@1.12.0
|
|
76
|
+
- @checkstack/satellite-backend@0.8.0
|
|
77
|
+
- @checkstack/automation-backend@0.10.8
|
|
78
|
+
- @checkstack/catalog-backend@1.6.6
|
|
79
|
+
- @checkstack/incident-backend@1.9.4
|
|
80
|
+
- @checkstack/command-backend@0.2.18
|
|
81
|
+
- @checkstack/gitops-backend@0.5.18
|
|
82
|
+
- @checkstack/script-packages-backend@0.3.22
|
|
83
|
+
- @checkstack/status-page-backend@0.4.5
|
|
84
|
+
- @checkstack/gitops-common@0.7.1
|
|
85
|
+
- @checkstack/sdk@0.122.1
|
|
86
|
+
- @checkstack/ai-common@0.6.4
|
|
87
|
+
- @checkstack/cache-api@0.3.17
|
|
88
|
+
- @checkstack/catalog-common@2.6.1
|
|
89
|
+
- @checkstack/incident-common@1.7.1
|
|
90
|
+
- @checkstack/maintenance-common@1.8.1
|
|
91
|
+
- @checkstack/notification-common@1.5.1
|
|
92
|
+
- @checkstack/queue-api@0.3.17
|
|
93
|
+
- @checkstack/signal-common@0.2.15
|
|
94
|
+
- @checkstack/status-page-common@0.5.1
|
|
95
|
+
- @checkstack/cache-utils@0.2.22
|
|
96
|
+
|
|
3
97
|
## 1.14.0
|
|
4
98
|
|
|
5
99
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/healthcheck-backend",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.15.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -14,32 +14,32 @@
|
|
|
14
14
|
"lint:code": "eslint . --max-warnings 0"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@checkstack/ai-backend": "0.10.
|
|
18
|
-
"@checkstack/ai-common": "0.6.
|
|
19
|
-
"@checkstack/automation-backend": "0.10.
|
|
20
|
-
"@checkstack/backend-api": "0.
|
|
21
|
-
"@checkstack/cache-api": "0.3.
|
|
22
|
-
"@checkstack/cache-utils": "0.2.
|
|
23
|
-
"@checkstack/catalog-backend": "1.6.
|
|
24
|
-
"@checkstack/catalog-common": "2.6.
|
|
25
|
-
"@checkstack/command-backend": "0.2.
|
|
26
|
-
"@checkstack/common": "0.
|
|
27
|
-
"@checkstack/gitops-backend": "0.5.
|
|
28
|
-
"@checkstack/gitops-common": "0.7.
|
|
29
|
-
"@checkstack/healthcheck-common": "1.
|
|
30
|
-
"@checkstack/incident-backend": "1.9.
|
|
31
|
-
"@checkstack/incident-common": "1.7.
|
|
32
|
-
"@checkstack/maintenance-common": "1.8.
|
|
33
|
-
"@checkstack/notification-common": "1.5.
|
|
34
|
-
"@checkstack/queue-api": "0.3.
|
|
35
|
-
"@checkstack/satellite-backend": "0.
|
|
36
|
-
"@checkstack/script-packages-backend": "0.3.
|
|
37
|
-
"@checkstack/sdk": "0.
|
|
38
|
-
"@checkstack/secrets-backend": "0.
|
|
39
|
-
"@checkstack/secrets-common": "0.
|
|
40
|
-
"@checkstack/signal-common": "0.2.
|
|
41
|
-
"@checkstack/status-page-backend": "0.4.
|
|
42
|
-
"@checkstack/status-page-common": "0.5.
|
|
17
|
+
"@checkstack/ai-backend": "0.10.6",
|
|
18
|
+
"@checkstack/ai-common": "0.6.4",
|
|
19
|
+
"@checkstack/automation-backend": "0.10.8",
|
|
20
|
+
"@checkstack/backend-api": "0.29.0",
|
|
21
|
+
"@checkstack/cache-api": "0.3.17",
|
|
22
|
+
"@checkstack/cache-utils": "0.2.22",
|
|
23
|
+
"@checkstack/catalog-backend": "1.6.6",
|
|
24
|
+
"@checkstack/catalog-common": "2.6.1",
|
|
25
|
+
"@checkstack/command-backend": "0.2.18",
|
|
26
|
+
"@checkstack/common": "0.20.0",
|
|
27
|
+
"@checkstack/gitops-backend": "0.5.18",
|
|
28
|
+
"@checkstack/gitops-common": "0.7.1",
|
|
29
|
+
"@checkstack/healthcheck-common": "1.12.0",
|
|
30
|
+
"@checkstack/incident-backend": "1.9.4",
|
|
31
|
+
"@checkstack/incident-common": "1.7.1",
|
|
32
|
+
"@checkstack/maintenance-common": "1.8.1",
|
|
33
|
+
"@checkstack/notification-common": "1.5.1",
|
|
34
|
+
"@checkstack/queue-api": "0.3.17",
|
|
35
|
+
"@checkstack/satellite-backend": "0.8.0",
|
|
36
|
+
"@checkstack/script-packages-backend": "0.3.22",
|
|
37
|
+
"@checkstack/sdk": "0.122.1",
|
|
38
|
+
"@checkstack/secrets-backend": "0.3.0",
|
|
39
|
+
"@checkstack/secrets-common": "0.3.0",
|
|
40
|
+
"@checkstack/signal-common": "0.2.15",
|
|
41
|
+
"@checkstack/status-page-backend": "0.4.5",
|
|
42
|
+
"@checkstack/status-page-common": "0.5.1",
|
|
43
43
|
"@hono/zod-validator": "^0.7.6",
|
|
44
44
|
"@orpc/contract": "^1.14.4",
|
|
45
45
|
"@orpc/server": "^1.14.4",
|
|
@@ -52,8 +52,8 @@
|
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@checkstack/drizzle-helper": "0.0.6",
|
|
55
|
-
"@checkstack/scripts": "0.7.
|
|
56
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
55
|
+
"@checkstack/scripts": "0.7.1",
|
|
56
|
+
"@checkstack/test-utils-backend": "0.1.52",
|
|
57
57
|
"@checkstack/tsconfig": "0.0.7",
|
|
58
58
|
"@types/bun": "^1.0.0",
|
|
59
59
|
"@types/tdigest": "^0.1.5",
|
|
@@ -103,9 +103,17 @@ const UNASSIGNED_GUIDANCE =
|
|
|
103
103
|
export async function validateHealthcheckDraft({
|
|
104
104
|
input,
|
|
105
105
|
rpcClient,
|
|
106
|
+
existingConfigurationId,
|
|
106
107
|
}: {
|
|
107
108
|
input: HealthcheckProposeInput;
|
|
108
109
|
rpcClient: RpcClient;
|
|
110
|
+
/**
|
|
111
|
+
* When validating an UPDATE, the id of the config being edited. The server
|
|
112
|
+
* restores that config's stored secrets before validating, so a kept secret
|
|
113
|
+
* (redacted out of the read) does not fail a required-secret check. Omit for
|
|
114
|
+
* a create draft, where blank secrets stay required.
|
|
115
|
+
*/
|
|
116
|
+
existingConfigurationId?: string;
|
|
109
117
|
}): Promise<{ strategy: HealthCheckStrategyDto; collectors: CollectorDto[] }> {
|
|
110
118
|
const healthcheckClient = rpcClient.forPlugin(HealthCheckApi);
|
|
111
119
|
|
|
@@ -116,6 +124,7 @@ export async function validateHealthcheckDraft({
|
|
|
116
124
|
config: input.config,
|
|
117
125
|
intervalSeconds: input.intervalSeconds,
|
|
118
126
|
collectors: input.collectors,
|
|
127
|
+
...(existingConfigurationId ? { existingConfigurationId } : {}),
|
|
119
128
|
});
|
|
120
129
|
if (!validation.valid) {
|
|
121
130
|
throw new HealthcheckProposeValidationError(
|
|
@@ -82,10 +82,14 @@ export function createHealthcheckUpdateTool(): RegisteredAiTool<
|
|
|
82
82
|
}
|
|
83
83
|
const merged = mergeConfig({ existing, body: input.body });
|
|
84
84
|
// Deep-validate the merged result (throws with structured detail if invalid,
|
|
85
|
-
// including bad assertion fields/operators).
|
|
85
|
+
// including bad assertion fields/operators). Pass the id so the server
|
|
86
|
+
// restores stored secrets before validating - `existing.config` is
|
|
87
|
+
// redacted, so a kept `x-secret` field arrives blank and would otherwise
|
|
88
|
+
// fail a required-secret check even for a name-only edit.
|
|
86
89
|
const { strategy } = await validateHealthcheckDraft({
|
|
87
90
|
input: merged,
|
|
88
91
|
rpcClient,
|
|
92
|
+
existingConfigurationId: input.id,
|
|
89
93
|
});
|
|
90
94
|
// before -> after diff over the updatable fields only (the existing config
|
|
91
95
|
// also carries id/timestamps/paused that are not part of an update).
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, it, expect, mock } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { configSecret, Versioned } from "@checkstack/backend-api";
|
|
4
|
+
import type { InternalSecretsService } from "@checkstack/secrets-backend";
|
|
5
|
+
import { internalSecretName } from "@checkstack/secrets-common";
|
|
6
|
+
import { backfillConfigSecrets } from "./config-secrets-backfill";
|
|
7
|
+
import {
|
|
8
|
+
healthcheckConfigLockKey,
|
|
9
|
+
isHealthcheckSecretMarker,
|
|
10
|
+
} from "./config-secrets";
|
|
11
|
+
|
|
12
|
+
const collectorSchema = z.object({
|
|
13
|
+
url: z.string(),
|
|
14
|
+
password: configSecret({ id: "password" }).optional(),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function fakeInternalSecrets(): InternalSecretsService & {
|
|
18
|
+
store: Map<string, string>;
|
|
19
|
+
} {
|
|
20
|
+
const store = new Map<string, string>();
|
|
21
|
+
return {
|
|
22
|
+
store,
|
|
23
|
+
async set({ parts, value }) {
|
|
24
|
+
store.set(internalSecretName(...parts), value);
|
|
25
|
+
},
|
|
26
|
+
async get({ parts }) {
|
|
27
|
+
return store.get(internalSecretName(...parts));
|
|
28
|
+
},
|
|
29
|
+
async delete({ parts }) {
|
|
30
|
+
store.delete(internalSecretName(...parts));
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const noopLogger = {
|
|
36
|
+
debug: mock(() => {}),
|
|
37
|
+
info: mock(() => {}),
|
|
38
|
+
warn: mock(() => {}),
|
|
39
|
+
error: mock(() => {}),
|
|
40
|
+
} as never;
|
|
41
|
+
|
|
42
|
+
describe("backfillConfigSecrets", () => {
|
|
43
|
+
it("migrates a registered collector's inline secret even under an UNREGISTERED strategy, per config lock", async () => {
|
|
44
|
+
// One legacy row: strategy plugin is gone, but the collector plugin is
|
|
45
|
+
// present and its config carries an inline plaintext secret.
|
|
46
|
+
let row: Record<string, unknown> = {
|
|
47
|
+
id: "cfg-bf",
|
|
48
|
+
strategyId: "gone.strategy",
|
|
49
|
+
config: { url: "https://x", password: "strategy-plain" }, // no schema -> left as-is
|
|
50
|
+
collectors: [
|
|
51
|
+
{
|
|
52
|
+
id: "c1",
|
|
53
|
+
collectorId: "reg.collector",
|
|
54
|
+
config: { url: "https://y", password: "collector-plain" },
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
const updates: Record<string, unknown>[] = [];
|
|
59
|
+
const db = {
|
|
60
|
+
// Initial scan: `db.select().from(table)` awaited directly.
|
|
61
|
+
// Per-row re-read: `db.select().from(table).where(eq(...))`.
|
|
62
|
+
select: () => ({
|
|
63
|
+
from: () =>
|
|
64
|
+
Object.assign(Promise.resolve([row]), {
|
|
65
|
+
where: () => Promise.resolve([row]),
|
|
66
|
+
}),
|
|
67
|
+
}),
|
|
68
|
+
update: () => ({
|
|
69
|
+
set: (set: Record<string, unknown>) => {
|
|
70
|
+
updates.push(set);
|
|
71
|
+
row = { ...row, ...set };
|
|
72
|
+
return { where: () => Promise.resolve(undefined) };
|
|
73
|
+
},
|
|
74
|
+
}),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const registry = { getStrategy: mock(() => undefined) };
|
|
78
|
+
const collectorRegistry = {
|
|
79
|
+
getCollector: mock((id: string) =>
|
|
80
|
+
id === "reg.collector"
|
|
81
|
+
? { collector: { config: new Versioned({ version: 1, schema: collectorSchema }) } }
|
|
82
|
+
: undefined,
|
|
83
|
+
),
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const lockKeys: string[] = [];
|
|
87
|
+
const advisoryLock = {
|
|
88
|
+
tryAcquire: mock(async () => ({ release: mock(async () => {}) })),
|
|
89
|
+
withXactLock: async ({ key, fn }: { key: string; fn: () => Promise<unknown> }) => {
|
|
90
|
+
lockKeys.push(key);
|
|
91
|
+
return fn();
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const internalSecrets = fakeInternalSecrets();
|
|
96
|
+
|
|
97
|
+
await backfillConfigSecrets({
|
|
98
|
+
db: db as never,
|
|
99
|
+
registry: registry as never,
|
|
100
|
+
collectorRegistry: collectorRegistry as never,
|
|
101
|
+
internalSecrets,
|
|
102
|
+
advisoryLock: advisoryLock as never,
|
|
103
|
+
logger: noopLogger,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Per-config lock was taken for the row.
|
|
107
|
+
expect(lockKeys).toEqual([healthcheckConfigLockKey("cfg-bf")]);
|
|
108
|
+
|
|
109
|
+
// The collector's inline secret was migrated to a marker + the store.
|
|
110
|
+
const persistedCollectors = updates.at(-1)?.collectors as Array<{
|
|
111
|
+
config: Record<string, unknown>;
|
|
112
|
+
}>;
|
|
113
|
+
expect(
|
|
114
|
+
isHealthcheckSecretMarker(persistedCollectors[0].config.password as string),
|
|
115
|
+
).toBe(true);
|
|
116
|
+
expect([...internalSecrets.store.values()]).toContain("collector-plain");
|
|
117
|
+
|
|
118
|
+
// The strategy config (no schema to walk) is left untouched - never wiped,
|
|
119
|
+
// and its legacy plaintext is NOT migrated (nothing else we can do).
|
|
120
|
+
const persistedConfig = updates.at(-1)?.config as Record<string, unknown>;
|
|
121
|
+
expect(persistedConfig.password).toBe("strategy-plain");
|
|
122
|
+
expect([...internalSecrets.store.values()]).not.toContain("strategy-plain");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("skips the scan entirely when another pod holds the backfill lock", async () => {
|
|
126
|
+
const advisoryLock = {
|
|
127
|
+
tryAcquire: mock(async () => undefined), // lock not acquired
|
|
128
|
+
withXactLock: mock(async () => {
|
|
129
|
+
throw new Error("must not run when the global lock is unavailable");
|
|
130
|
+
}),
|
|
131
|
+
};
|
|
132
|
+
await backfillConfigSecrets({
|
|
133
|
+
db: {} as never,
|
|
134
|
+
registry: {} as never,
|
|
135
|
+
collectorRegistry: {} as never,
|
|
136
|
+
internalSecrets: fakeInternalSecrets(),
|
|
137
|
+
advisoryLock: advisoryLock as never,
|
|
138
|
+
logger: noopLogger,
|
|
139
|
+
});
|
|
140
|
+
expect(advisoryLock.withXactLock).not.toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { eq } from "drizzle-orm";
|
|
2
|
+
import type {
|
|
3
|
+
AdvisoryLockService,
|
|
4
|
+
HealthCheckRegistry,
|
|
5
|
+
CollectorRegistry,
|
|
6
|
+
Logger,
|
|
7
|
+
SafeDatabase,
|
|
8
|
+
} from "@checkstack/backend-api";
|
|
9
|
+
import type { InternalSecretsService } from "@checkstack/secrets-backend";
|
|
10
|
+
import type { CollectorConfigEntry } from "@checkstack/healthcheck-common";
|
|
11
|
+
import { healthCheckConfigurations } from "./schema";
|
|
12
|
+
import * as schema from "./schema";
|
|
13
|
+
import {
|
|
14
|
+
extractConfigurationSecrets,
|
|
15
|
+
healthcheckConfigLockKey,
|
|
16
|
+
} from "./config-secrets";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One-time (idempotent) boot backfill: move inline `x-secret` values that
|
|
20
|
+
* pre-date the config-secrets channel out of stored health-check
|
|
21
|
+
* configurations and into internal secrets.
|
|
22
|
+
*
|
|
23
|
+
* Rows written after the channel shipped hold only markers / `${{ secrets.* }}`
|
|
24
|
+
* references, which the extraction walk skips - so re-running is a no-op and
|
|
25
|
+
* the job is safe to run on EVERY boot. A cross-pod advisory lock ensures a
|
|
26
|
+
* single pod performs the scan; the others skip (the winner's result is in
|
|
27
|
+
* the shared DB either way).
|
|
28
|
+
*
|
|
29
|
+
* Fail-open per row: one row with an unregistered strategy (plugin removed)
|
|
30
|
+
* or a broken shape must not wedge boot - it is logged and left as-is; the
|
|
31
|
+
* executor treats its bare literal exactly as before (legacy pass-through).
|
|
32
|
+
*/
|
|
33
|
+
export async function backfillConfigSecrets({
|
|
34
|
+
db,
|
|
35
|
+
registry,
|
|
36
|
+
collectorRegistry,
|
|
37
|
+
internalSecrets,
|
|
38
|
+
advisoryLock,
|
|
39
|
+
logger,
|
|
40
|
+
}: {
|
|
41
|
+
db: SafeDatabase<typeof schema>;
|
|
42
|
+
registry: HealthCheckRegistry;
|
|
43
|
+
collectorRegistry: CollectorRegistry;
|
|
44
|
+
internalSecrets: InternalSecretsService;
|
|
45
|
+
advisoryLock: AdvisoryLockService;
|
|
46
|
+
logger: Logger;
|
|
47
|
+
}): Promise<void> {
|
|
48
|
+
const lock = await advisoryLock.tryAcquire("healthcheck:config-secrets-backfill");
|
|
49
|
+
if (!lock) {
|
|
50
|
+
logger.debug("Config-secrets backfill already running on another pod, skipping");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const rows = await db.select().from(healthCheckConfigurations);
|
|
56
|
+
let migratedRows = 0;
|
|
57
|
+
let movedValues = 0;
|
|
58
|
+
|
|
59
|
+
for (const { id: rowId } of rows) {
|
|
60
|
+
// Serialize each row's read-modify-write under the SAME per-config lock
|
|
61
|
+
// updateConfiguration uses, and re-read the row INSIDE the lock. Without
|
|
62
|
+
// this the backfill could write back a pre-lock snapshot over a concurrent
|
|
63
|
+
// edit - resurrecting a just-rotated secret and reverting the edit.
|
|
64
|
+
await advisoryLock.withXactLock({
|
|
65
|
+
key: healthcheckConfigLockKey(rowId),
|
|
66
|
+
fn: async () => {
|
|
67
|
+
const [row] = await db
|
|
68
|
+
.select()
|
|
69
|
+
.from(healthCheckConfigurations)
|
|
70
|
+
.where(eq(healthCheckConfigurations.id, rowId));
|
|
71
|
+
if (!row) return; // deleted between the scan and the lock
|
|
72
|
+
|
|
73
|
+
// An unregistered strategy has no schema: its own strategy-level
|
|
74
|
+
// legacy secrets stay as-is (pass-through, as before), but we still
|
|
75
|
+
// migrate every REGISTERED collector's inline secrets rather than
|
|
76
|
+
// skipping the whole row - leaving those plaintext would defeat the
|
|
77
|
+
// backfill. extractConfigurationSecrets handles the undefined schema.
|
|
78
|
+
const strategySchema =
|
|
79
|
+
registry.getStrategy(row.strategyId)?.config.schema;
|
|
80
|
+
if (!strategySchema) {
|
|
81
|
+
logger.warn(
|
|
82
|
+
`Config-secrets backfill: strategy ${row.strategyId} not registered; leaving its strategy config as-is and migrating collector secrets for configuration ${row.id}`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const collectors: CollectorConfigEntry[] | undefined =
|
|
88
|
+
row.collectors ?? undefined;
|
|
89
|
+
const extracted = await extractConfigurationSecrets({
|
|
90
|
+
configurationId: row.id,
|
|
91
|
+
strategySchema,
|
|
92
|
+
config: row.config as Record<string, unknown>,
|
|
93
|
+
collectors,
|
|
94
|
+
getCollectorSchema: (collectorId) =>
|
|
95
|
+
collectorRegistry.getCollector(collectorId)?.collector.config
|
|
96
|
+
.schema,
|
|
97
|
+
internalSecrets,
|
|
98
|
+
});
|
|
99
|
+
if (extracted.extracted === 0) return;
|
|
100
|
+
|
|
101
|
+
await db
|
|
102
|
+
.update(healthCheckConfigurations)
|
|
103
|
+
.set({
|
|
104
|
+
config: extracted.config,
|
|
105
|
+
collectors: extracted.collectors,
|
|
106
|
+
updatedAt: new Date(),
|
|
107
|
+
})
|
|
108
|
+
.where(eq(healthCheckConfigurations.id, row.id));
|
|
109
|
+
migratedRows++;
|
|
110
|
+
movedValues += extracted.extracted;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
logger.warn(
|
|
113
|
+
`Config-secrets backfill: failed for configuration ${row.id}, leaving as-is`,
|
|
114
|
+
error,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (migratedRows > 0) {
|
|
122
|
+
logger.info(
|
|
123
|
+
`Config-secrets backfill: moved ${movedValues} inline secret value(s) from ${migratedRows} configuration(s) into the internal secret store`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
} finally {
|
|
127
|
+
await lock.release();
|
|
128
|
+
}
|
|
129
|
+
}
|