@caelo-cms/provisioning 0.10.53 → 0.10.57
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/dist/agent-db.js +272 -128
- package/dist/audit-job-setup.d.ts +69 -0
- package/dist/audit-job-setup.d.ts.map +1 -0
- package/dist/audit-job-setup.js +317 -0
- package/dist/audit-job-setup.js.map +1 -0
- package/dist/caddy.d.ts +9 -0
- package/dist/caddy.d.ts.map +1 -1
- package/dist/caddy.js +38 -0
- package/dist/caddy.js.map +1 -1
- package/dist/cli-routing.js +1 -1
- package/dist/cli-routing.js.map +1 -1
- package/dist/cli.js +36 -9
- package/dist/cli.js.map +1 -1
- package/dist/compose.js +5 -0
- package/dist/compose.js.map +1 -1
- package/dist/converge-checkpoint.d.ts +66 -0
- package/dist/converge-checkpoint.d.ts.map +1 -0
- package/dist/converge-checkpoint.js +61 -0
- package/dist/converge-checkpoint.js.map +1 -0
- package/dist/converge-job-gcp.d.ts +46 -0
- package/dist/converge-job-gcp.d.ts.map +1 -0
- package/dist/converge-job-gcp.js +368 -0
- package/dist/converge-job-gcp.js.map +1 -0
- package/dist/converge-job-setup.d.ts +62 -0
- package/dist/converge-job-setup.d.ts.map +1 -0
- package/dist/converge-job-setup.js +228 -0
- package/dist/converge-job-setup.js.map +1 -0
- package/dist/converge-job.d.ts +141 -0
- package/dist/converge-job.d.ts.map +1 -0
- package/dist/converge-job.js +325 -0
- package/dist/converge-job.js.map +1 -0
- package/dist/gcp-names.d.ts +25 -1
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +36 -0
- package/dist/gcp-names.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/infra-contract.d.ts +1 -1
- package/dist/infra-contract.d.ts.map +1 -1
- package/dist/infra-contract.js +28 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/install-job.d.ts +11 -3
- package/dist/install-job.d.ts.map +1 -1
- package/dist/install-job.js +22 -12
- package/dist/install-job.js.map +1 -1
- package/dist/lifecycle.d.ts +13 -1
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +115 -58
- package/dist/lifecycle.js.map +1 -1
- package/dist/operator-access.d.ts.map +1 -1
- package/dist/operator-access.js +13 -3
- package/dist/operator-access.js.map +1 -1
- package/dist/private-file.d.ts +26 -0
- package/dist/private-file.d.ts.map +1 -0
- package/dist/private-file.js +49 -0
- package/dist/private-file.js.map +1 -0
- package/dist/release-contract.d.ts +3 -2
- package/dist/release-contract.d.ts.map +1 -1
- package/dist/release-contract.js +8 -2
- package/dist/release-contract.js.map +1 -1
- package/dist/release-manifest.d.ts +2 -0
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js.map +1 -1
- package/dist/remote-mcp-edge.d.ts +114 -0
- package/dist/remote-mcp-edge.d.ts.map +1 -0
- package/dist/remote-mcp-edge.js +116 -0
- package/dist/remote-mcp-edge.js.map +1 -0
- package/dist/remote-mcp.d.ts +117 -0
- package/dist/remote-mcp.d.ts.map +1 -0
- package/dist/remote-mcp.js +379 -0
- package/dist/remote-mcp.js.map +1 -0
- package/dist/runtime-secrets.d.ts +12 -0
- package/dist/runtime-secrets.d.ts.map +1 -1
- package/dist/runtime-secrets.js +32 -4
- package/dist/runtime-secrets.js.map +1 -1
- package/dist/seal-key-rotation.d.ts +38 -0
- package/dist/seal-key-rotation.d.ts.map +1 -0
- package/dist/seal-key-rotation.js +206 -0
- package/dist/seal-key-rotation.js.map +1 -0
- package/dist/stack-contract.d.ts +175 -3
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +183 -3
- package/dist/stack-contract.js.map +1 -1
- package/dist/stack-converge.d.ts +38 -1
- package/dist/stack-converge.d.ts.map +1 -1
- package/dist/stack-converge.js +74 -4
- package/dist/stack-converge.js.map +1 -1
- package/dist/state-lock-lease.d.ts +142 -0
- package/dist/state-lock-lease.d.ts.map +1 -0
- package/dist/state-lock-lease.js +347 -0
- package/dist/state-lock-lease.js.map +1 -0
- package/dist/state-store.d.ts +66 -9
- package/dist/state-store.d.ts.map +1 -1
- package/dist/state-store.js +249 -30
- package/dist/state-store.js.map +1 -1
- package/dist/tar-entries.d.ts.map +1 -1
- package/dist/tar-entries.js +84 -9
- package/dist/tar-entries.js.map +1 -1
- package/dist/upgrade/app-roll.d.ts +36 -7
- package/dist/upgrade/app-roll.d.ts.map +1 -1
- package/dist/upgrade/app-roll.js +88 -19
- package/dist/upgrade/app-roll.js.map +1 -1
- package/dist/upgrade/audit-job-roll.d.ts +38 -0
- package/dist/upgrade/audit-job-roll.d.ts.map +1 -0
- package/dist/upgrade/audit-job-roll.js +43 -0
- package/dist/upgrade/audit-job-roll.js.map +1 -0
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +141 -8
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrade/gcp-lookup.d.ts +27 -10
- package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
- package/dist/upgrade/gcp-lookup.js +46 -7
- package/dist/upgrade/gcp-lookup.js.map +1 -1
- package/dist/upgrade/record-rolled.d.ts +40 -0
- package/dist/upgrade/record-rolled.d.ts.map +1 -0
- package/dist/upgrade/record-rolled.js +46 -0
- package/dist/upgrade/record-rolled.js.map +1 -0
- package/dist/upgrade/upgrade.d.ts +45 -1
- package/dist/upgrade/upgrade.d.ts.map +1 -1
- package/dist/upgrade/upgrade.js +60 -17
- package/dist/upgrade/upgrade.js.map +1 -1
- package/dist/upgrader-assess.d.ts +15 -1
- package/dist/upgrader-assess.d.ts.map +1 -1
- package/dist/upgrader-assess.js +36 -6
- package/dist/upgrader-assess.js.map +1 -1
- package/dist/upgrader-gcp.d.ts +15 -0
- package/dist/upgrader-gcp.d.ts.map +1 -1
- package/dist/upgrader-gcp.js +42 -7
- package/dist/upgrader-gcp.js.map +1 -1
- package/dist/upgrader-iam-preflight.d.ts +123 -0
- package/dist/upgrader-iam-preflight.d.ts.map +1 -0
- package/dist/upgrader-iam-preflight.js +414 -0
- package/dist/upgrader-iam-preflight.js.map +1 -0
- package/dist/upgrader-job.d.ts +33 -9
- package/dist/upgrader-job.d.ts.map +1 -1
- package/dist/upgrader-job.js +169 -27
- package/dist/upgrader-job.js.map +1 -1
- package/dist/upgrader-records.d.ts +58 -0
- package/dist/upgrader-records.d.ts.map +1 -0
- package/dist/upgrader-records.js +87 -0
- package/dist/upgrader-records.js.map +1 -0
- package/dist/upgrader-reporter.d.ts +3 -2
- package/dist/upgrader-reporter.d.ts.map +1 -1
- package/dist/upgrader-reporter.js +10 -2
- package/dist/upgrader-reporter.js.map +1 -1
- package/dist/upgrader-setup.d.ts +25 -3
- package/dist/upgrader-setup.d.ts.map +1 -1
- package/dist/upgrader-setup.js +132 -14
- package/dist/upgrader-setup.js.map +1 -1
- package/dist/wizards/gcp-cost.d.ts.map +1 -1
- package/dist/wizards/gcp-cost.js +14 -6
- package/dist/wizards/gcp-cost.js.map +1 -1
- package/dist/wizards/gcp-pulumi.d.ts +6 -0
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +7 -0
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +130 -4
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +4 -4
- package/src/agent/run.test.ts +15 -1
- package/src/audit-job-setup.test.ts +234 -0
- package/src/audit-job-setup.ts +360 -0
- package/src/caddy-redirects.docker.test.ts +19 -6
- package/src/caddy.test.ts +30 -0
- package/src/caddy.ts +43 -0
- package/src/cli-init-validation.test.ts +44 -0
- package/src/cli-routing.ts +1 -1
- package/src/cli.ts +40 -8
- package/src/compose.ts +5 -0
- package/src/converge-checkpoint.ts +75 -0
- package/src/converge-entrypoint.test.ts +213 -0
- package/src/converge-job-gcp.test.ts +222 -0
- package/src/converge-job-gcp.ts +443 -0
- package/src/converge-job-setup.test.ts +152 -0
- package/src/converge-job-setup.ts +268 -0
- package/src/converge-job.test.ts +486 -0
- package/src/converge-job.ts +542 -0
- package/src/gateway-env-isolation.test.ts +8 -1
- package/src/gcp-names.ts +43 -1
- package/src/gcp-url-map.test.ts +9 -1
- package/src/index.ts +8 -0
- package/src/infra-contract.test.ts +7 -4
- package/src/infra-contract.ts +28 -1
- package/src/install-job.test.ts +3 -1
- package/src/install-job.ts +31 -14
- package/src/lifecycle.ts +137 -71
- package/src/operator-access.test.ts +14 -0
- package/src/operator-access.ts +13 -3
- package/src/private-file.test.ts +80 -0
- package/src/private-file.ts +50 -0
- package/src/release-contract.ts +8 -2
- package/src/release-manifest.test.ts +42 -0
- package/src/release-manifest.ts +2 -0
- package/src/remote-mcp-edge.ts +137 -0
- package/src/remote-mcp.test.ts +517 -0
- package/src/remote-mcp.ts +460 -0
- package/src/runtime-secrets.test.ts +31 -6
- package/src/runtime-secrets.ts +41 -3
- package/src/seal-key-rotation.test.ts +131 -0
- package/src/seal-key-rotation.ts +258 -0
- package/src/stack-contract-parity.test.ts +20 -3
- package/src/stack-contract.test.ts +34 -2
- package/src/stack-contract.ts +305 -3
- package/src/stack-converge-secrets.test.ts +3 -0
- package/src/stack-converge.test.ts +21 -5
- package/src/stack-converge.ts +101 -7
- package/src/state-attach.test.ts +7 -1
- package/src/state-lock-lease.test.ts +302 -0
- package/src/state-lock-lease.ts +423 -0
- package/src/state-lock.test.ts +568 -0
- package/src/state-store.ts +347 -27
- package/src/tar-entries.test.ts +149 -7
- package/src/tar-entries.ts +90 -9
- package/src/upgrade/app-roll.ts +135 -21
- package/src/upgrade/audit-job-roll.ts +68 -0
- package/src/upgrade/converge.ts +170 -7
- package/src/upgrade/gcp-lookup.test.ts +80 -0
- package/src/upgrade/gcp-lookup.ts +65 -8
- package/src/upgrade/record-rolled.test.ts +105 -0
- package/src/upgrade/record-rolled.ts +91 -0
- package/src/upgrade/upgrade.test.ts +279 -5
- package/src/upgrade/upgrade.ts +120 -24
- package/src/upgrader-assess.test.ts +93 -3
- package/src/upgrader-assess.ts +58 -9
- package/src/upgrader-gcp.ts +47 -8
- package/src/upgrader-iam-preflight.test.ts +393 -0
- package/src/upgrader-iam-preflight.ts +510 -0
- package/src/upgrader-job.test.ts +587 -15
- package/src/upgrader-job.ts +237 -53
- package/src/upgrader-records.ts +151 -0
- package/src/upgrader-reporter.test.ts +6 -0
- package/src/upgrader-reporter.ts +7 -2
- package/src/upgrader-setup.test.ts +4 -3
- package/src/upgrader-setup.ts +137 -13
- package/src/wizards/gcp-cost.ts +15 -6
- package/src/wizards/gcp-pulumi.ts +7 -0
- package/src/wizards/gcp.ts +162 -4
- package/stacks/aws/index.ts +18 -0
- package/stacks/azure/index.ts +18 -0
- package/stacks/gcp/index.ts +59 -7
- package/stacks/gcp-firebase/index.ts +50 -6
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "bun:test";
|
|
4
|
+
import type { GcloudRunner } from "./gcloud-retry.js";
|
|
5
|
+
import { rotationRefusal } from "./runtime-secrets.js";
|
|
6
|
+
import {
|
|
7
|
+
finishSealKeyRotation,
|
|
8
|
+
SEAL_KEY_PREVIOUS_ENV,
|
|
9
|
+
SEAL_KEY_ROTATION_WINDOW_DAYS,
|
|
10
|
+
SEAL_ROTATION_LABEL,
|
|
11
|
+
startSealKeyRotation,
|
|
12
|
+
} from "./seal-key-rotation.js";
|
|
13
|
+
|
|
14
|
+
const T = {
|
|
15
|
+
projectId: "acme",
|
|
16
|
+
region: "europe-west1",
|
|
17
|
+
env: "production",
|
|
18
|
+
gatewayService: "caelo-production-gateway-abc",
|
|
19
|
+
};
|
|
20
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
21
|
+
const NOW = Date.UTC(2026, 9, 20);
|
|
22
|
+
|
|
23
|
+
function service(opts: { previous?: boolean; startedAt?: number }) {
|
|
24
|
+
const env: unknown[] = [
|
|
25
|
+
{
|
|
26
|
+
name: "CAELO_MCP_OAUTH_SEAL_KEY",
|
|
27
|
+
valueFrom: { secretKeyRef: { name: "caelo-production-mcp-oauth-seal-key", key: "latest" } },
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
if (opts.previous) {
|
|
31
|
+
env.push({
|
|
32
|
+
name: SEAL_KEY_PREVIOUS_ENV,
|
|
33
|
+
valueFrom: { secretKeyRef: { name: "caelo-production-mcp-oauth-seal-key", key: "3" } },
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return JSON.stringify({
|
|
37
|
+
metadata: {
|
|
38
|
+
labels: opts.startedAt === undefined ? {} : { [SEAL_ROTATION_LABEL]: String(opts.startedAt) },
|
|
39
|
+
},
|
|
40
|
+
spec: { template: { spec: { containers: [{ env }] } } },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function fake(svc: string, versionName = "projects/1/secrets/s/versions/3") {
|
|
45
|
+
const calls: { line: string; stdin?: string }[] = [];
|
|
46
|
+
const run: GcloudRunner = async (args, opts) => {
|
|
47
|
+
const line = args.join(" ");
|
|
48
|
+
calls.push({ line, ...(opts?.stdin !== undefined ? { stdin: opts.stdin } : {}) });
|
|
49
|
+
const ok = (stdout = "") => ({ ok: true, stdout, stderr: "", exitCode: 0 });
|
|
50
|
+
if (line.startsWith("run services describe")) return ok(svc);
|
|
51
|
+
if (line.startsWith("secrets versions describe latest")) return ok(`${versionName}\n`);
|
|
52
|
+
return ok();
|
|
53
|
+
};
|
|
54
|
+
return { run, calls };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("seal-key rotation (#806)", () => {
|
|
58
|
+
it("is offered by rotate-secret (two steps), not refused", () => {
|
|
59
|
+
expect(rotationRefusal("mcp-oauth-seal-key")).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("start: adds a version, keeps the new key at latest and pins the old version as previous", async () => {
|
|
63
|
+
const f = fake(service({}));
|
|
64
|
+
const r = await startSealKeyRotation(T, { run: f.run, nowMs: () => NOW, generate: () => "K" });
|
|
65
|
+
expect(r.ok).toBe(true);
|
|
66
|
+
expect(f.calls.find((c) => c.line.startsWith("secrets versions add"))?.stdin).toBe("K");
|
|
67
|
+
const update = f.calls.find((c) => c.line.startsWith("run services update "))?.line ?? "";
|
|
68
|
+
expect(update).toContain(
|
|
69
|
+
`--update-secrets=CAELO_MCP_OAUTH_SEAL_KEY=caelo-production-mcp-oauth-seal-key:latest,${SEAL_KEY_PREVIOUS_ENV}=caelo-production-mcp-oauth-seal-key:3`,
|
|
70
|
+
);
|
|
71
|
+
expect(update).toContain(`--update-labels=${SEAL_ROTATION_LABEL}=${NOW}`);
|
|
72
|
+
expect(f.calls.at(-1)?.line).toContain("update-traffic caelo-production-gateway-abc");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("start: refuses while a rotation is in progress, before touching the secret", async () => {
|
|
76
|
+
const f = fake(service({ previous: true, startedAt: NOW - DAY }));
|
|
77
|
+
const r = await startSealKeyRotation(T, { run: f.run, nowMs: () => NOW });
|
|
78
|
+
expect(r.ok).toBe(false);
|
|
79
|
+
expect(r.error).toContain("already in progress");
|
|
80
|
+
expect(f.calls.some((c) => c.line.startsWith("secrets versions add"))).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("start: refuses an unreadable version instead of pinning a guess", async () => {
|
|
84
|
+
const f = fake(service({}), "something-else");
|
|
85
|
+
const r = await startSealKeyRotation(T, { run: f.run, nowMs: () => NOW });
|
|
86
|
+
expect(r.ok).toBe(false);
|
|
87
|
+
expect(f.calls.some((c) => c.line.startsWith("secrets versions add"))).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("finish: refuses inside the refresh-token window", async () => {
|
|
91
|
+
const f = fake(service({ previous: true, startedAt: NOW - 29 * DAY }));
|
|
92
|
+
const r = await finishSealKeyRotation(T, { run: f.run, nowMs: () => NOW });
|
|
93
|
+
expect(r.ok).toBe(false);
|
|
94
|
+
expect(r.error).toContain(`${SEAL_KEY_ROTATION_WINDOW_DAYS}-day lifetime`);
|
|
95
|
+
expect(f.calls.some((c) => c.line.startsWith("run services update "))).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("finish --now: removes the previous key inside the window (a leaked key)", async () => {
|
|
99
|
+
const f = fake(service({ previous: true, startedAt: NOW - DAY }));
|
|
100
|
+
const r = await finishSealKeyRotation(T, { run: f.run, nowMs: () => NOW }, { now: true });
|
|
101
|
+
expect(r.ok).toBe(true);
|
|
102
|
+
expect(f.calls.some((c) => c.line.includes(`--remove-secrets=${SEAL_KEY_PREVIOUS_ENV}`))).toBe(
|
|
103
|
+
true,
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("finish: after the window, removes the previous key and the label", async () => {
|
|
108
|
+
const f = fake(service({ previous: true, startedAt: NOW - 31 * DAY }));
|
|
109
|
+
const r = await finishSealKeyRotation(T, { run: f.run, nowMs: () => NOW });
|
|
110
|
+
expect(r.ok).toBe(true);
|
|
111
|
+
const update = f.calls.find((c) => c.line.startsWith("run services update "))?.line ?? "";
|
|
112
|
+
expect(update).toContain(`--remove-secrets=${SEAL_KEY_PREVIOUS_ENV}`);
|
|
113
|
+
expect(update).toContain(`--remove-labels=${SEAL_ROTATION_LABEL}`);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("finish: refuses when no rotation is in progress", async () => {
|
|
117
|
+
const f = fake(service({}));
|
|
118
|
+
const r = await finishSealKeyRotation(T, { run: f.run, nowMs: () => NOW });
|
|
119
|
+
expect(r.ok).toBe(false);
|
|
120
|
+
expect(r.error).toContain("No seal-key rotation is in progress");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("keeps the window equal to the broker's refresh-token lifetime", async () => {
|
|
124
|
+
const src = await Bun.file(
|
|
125
|
+
new URL("../../../apps/api-gateway/src/mcp/token.ts", import.meta.url),
|
|
126
|
+
).text();
|
|
127
|
+
expect(src).toContain(
|
|
128
|
+
`REFRESH_MAX_LIFETIME_SECONDS = ${SEAL_KEY_ROTATION_WINDOW_DAYS} * 24 * 60 * 60`,
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Issue #806 — rotating the remote-MCP broker's seal key without signing
|
|
5
|
+
* every connected MCP client out.
|
|
6
|
+
*
|
|
7
|
+
* The broker seals its OAuth state, codes and refresh tokens under
|
|
8
|
+
* `CAELO_MCP_OAUTH_SEAL_KEY` (api-gateway mcp/seal.ts, envelope v2 with a
|
|
9
|
+
* key id). While `CAELO_MCP_OAUTH_SEAL_KEY_PREVIOUS` is set it also opens
|
|
10
|
+
* values sealed under that key, and seals everything new under the current
|
|
11
|
+
* one. Refresh tokens live at most {@link SEAL_KEY_ROTATION_WINDOW_DAYS}
|
|
12
|
+
* days (mcp/token.ts), so after that window nothing sealed under the old key
|
|
13
|
+
* is still valid and the previous key can go.
|
|
14
|
+
*
|
|
15
|
+
* Two steps, both `cms-provision rotate-secret mcp-oauth-seal-key`:
|
|
16
|
+
*
|
|
17
|
+
* start — add a new version to the secret, then roll the gateway with
|
|
18
|
+
* the current key at `latest` and the previous key PINNED to
|
|
19
|
+
* the version that was latest before (a pinned version never
|
|
20
|
+
* follows the new one), labelled with the start time.
|
|
21
|
+
* finish — (`--finish`) once the window has passed: drop the previous key
|
|
22
|
+
* and the label and roll again. `--finish --now` skips the
|
|
23
|
+
* window — for a leaked key, where signing every client out is
|
|
24
|
+
* the point.
|
|
25
|
+
*
|
|
26
|
+
* Refused instead of guessed (CLAUDE.md §2): starting while a rotation is in
|
|
27
|
+
* progress (a second start would pin the CURRENT key as "previous" and drop
|
|
28
|
+
* the real previous one early), and finishing before the window ends.
|
|
29
|
+
*
|
|
30
|
+
* A `cms-provision upgrade` whose `pulumi up` rewrites the gateway template
|
|
31
|
+
* during the window drops the previous key (the stack does not declare it):
|
|
32
|
+
* clients sealed under it sign in again. That fails safe — no token lives
|
|
33
|
+
* longer than intended — and is documented in the rotate output.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { gcloud as defaultGcloud } from "./gcloud.js";
|
|
37
|
+
import type { GcloudRunner } from "./gcloud-retry.js";
|
|
38
|
+
import { gcpSecretId } from "./gcp-names.js";
|
|
39
|
+
import { initialSecretValue } from "./runtime-secrets.js";
|
|
40
|
+
import { liveContainerEnv } from "./stack-converge.js";
|
|
41
|
+
|
|
42
|
+
/** The gateway env var holding the previous key during a rotation. */
|
|
43
|
+
export const SEAL_KEY_PREVIOUS_ENV = "CAELO_MCP_OAUTH_SEAL_KEY_PREVIOUS";
|
|
44
|
+
/** The gateway env var holding the current key (stack-contract SERVICE_SECRET_ENV). */
|
|
45
|
+
export const SEAL_KEY_ENV = "CAELO_MCP_OAUTH_SEAL_KEY";
|
|
46
|
+
/** Label on the gateway service: when the rotation started (epoch ms). */
|
|
47
|
+
export const SEAL_ROTATION_LABEL = "caelo-seal-rotation-started";
|
|
48
|
+
/**
|
|
49
|
+
* Days the previous key must stay: the broker's refresh-token lifetime
|
|
50
|
+
* (api-gateway mcp/token.ts REFRESH_MAX_LIFETIME_SECONDS, 30 days).
|
|
51
|
+
*/
|
|
52
|
+
export const SEAL_KEY_ROTATION_WINDOW_DAYS = 30;
|
|
53
|
+
|
|
54
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
55
|
+
|
|
56
|
+
/** The deployed gateway a rotation works on. */
|
|
57
|
+
export interface SealRotationTarget {
|
|
58
|
+
readonly projectId: string;
|
|
59
|
+
readonly region: string;
|
|
60
|
+
readonly env: string;
|
|
61
|
+
/** Deployed (Pulumi-suffixed) gateway Cloud Run service name. */
|
|
62
|
+
readonly gatewayService: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface SealRotationReport {
|
|
66
|
+
readonly ok: boolean;
|
|
67
|
+
readonly steps: string[];
|
|
68
|
+
readonly error?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface SealRotationDeps {
|
|
72
|
+
readonly run?: GcloudRunner;
|
|
73
|
+
readonly nowMs?: () => number;
|
|
74
|
+
/** The new key (default: 32 random bytes, base64 — the secret's format). */
|
|
75
|
+
readonly generate?: () => string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface GatewayState {
|
|
79
|
+
readonly previousPinned: boolean;
|
|
80
|
+
readonly startedAtMs: number | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function gatewayState(
|
|
84
|
+
run: GcloudRunner,
|
|
85
|
+
t: SealRotationTarget,
|
|
86
|
+
): Promise<{ ok: true; state: GatewayState } | { ok: false; error: string }> {
|
|
87
|
+
const r = await run([
|
|
88
|
+
"run",
|
|
89
|
+
"services",
|
|
90
|
+
"describe",
|
|
91
|
+
t.gatewayService,
|
|
92
|
+
"--region",
|
|
93
|
+
t.region,
|
|
94
|
+
`--project=${t.projectId}`,
|
|
95
|
+
"--format=json",
|
|
96
|
+
]);
|
|
97
|
+
if (!r.ok) return { ok: false, error: `read the gateway service: ${r.stderr.trim()}` };
|
|
98
|
+
const env = liveContainerEnv(r.stdout);
|
|
99
|
+
const labels =
|
|
100
|
+
(JSON.parse(r.stdout) as { metadata?: { labels?: Record<string, string> } }).metadata?.labels ??
|
|
101
|
+
{};
|
|
102
|
+
const raw = labels[SEAL_ROTATION_LABEL];
|
|
103
|
+
const started = raw !== undefined && /^\d+$/.test(raw) ? Number(raw) : null;
|
|
104
|
+
const current = env.get(SEAL_KEY_ENV);
|
|
105
|
+
if (current?.kind !== "secret") {
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
error: `${SEAL_KEY_ENV} on ${t.gatewayService} is not a Secret Manager reference — run \`cms-provision upgrade\` first (it installs remote MCP's secrets).`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
ok: true,
|
|
113
|
+
state: { previousPinned: env.has(SEAL_KEY_PREVIOUS_ENV), startedAtMs: started },
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function rollFlip(t: SealRotationTarget): string[] {
|
|
118
|
+
return [
|
|
119
|
+
"run",
|
|
120
|
+
"services",
|
|
121
|
+
"update-traffic",
|
|
122
|
+
t.gatewayService,
|
|
123
|
+
"--region",
|
|
124
|
+
t.region,
|
|
125
|
+
`--project=${t.projectId}`,
|
|
126
|
+
"--to-latest",
|
|
127
|
+
"--quiet",
|
|
128
|
+
];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Start a rotation: new key at `latest`, the old one pinned as previous. */
|
|
132
|
+
export async function startSealKeyRotation(
|
|
133
|
+
t: SealRotationTarget,
|
|
134
|
+
deps: SealRotationDeps = {},
|
|
135
|
+
): Promise<SealRotationReport> {
|
|
136
|
+
const run = deps.run ?? defaultGcloud;
|
|
137
|
+
const now = (deps.nowMs ?? Date.now)();
|
|
138
|
+
const generate = deps.generate ?? (() => initialSecretValue("mcp-oauth-seal-key"));
|
|
139
|
+
const secretId = gcpSecretId(t.env, "mcp-oauth-seal-key");
|
|
140
|
+
const project = `--project=${t.projectId}`;
|
|
141
|
+
const steps: string[] = [];
|
|
142
|
+
|
|
143
|
+
const live = await gatewayState(run, t);
|
|
144
|
+
if (!live.ok) return { ok: false, steps, error: live.error };
|
|
145
|
+
if (live.state.previousPinned) {
|
|
146
|
+
const since = live.state.startedAtMs;
|
|
147
|
+
return {
|
|
148
|
+
ok: false,
|
|
149
|
+
steps,
|
|
150
|
+
error: `A seal-key rotation is already in progress${since ? ` (started ${new Date(since).toISOString()})` : ""}. Finish it first with \`cms-provision rotate-secret mcp-oauth-seal-key --finish\` once ${SEAL_KEY_ROTATION_WINDOW_DAYS} days have passed; starting again now would drop the previous key early.`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const latest = await run([
|
|
155
|
+
"secrets",
|
|
156
|
+
"versions",
|
|
157
|
+
"describe",
|
|
158
|
+
"latest",
|
|
159
|
+
`--secret=${secretId}`,
|
|
160
|
+
project,
|
|
161
|
+
"--format=value(name)",
|
|
162
|
+
]);
|
|
163
|
+
const version = /\/versions\/(\d+)\s*$/.exec(latest.stdout.trim())?.[1];
|
|
164
|
+
if (!latest.ok || !version) {
|
|
165
|
+
return {
|
|
166
|
+
ok: false,
|
|
167
|
+
steps,
|
|
168
|
+
error: `find the current version of ${secretId}: ${latest.ok ? `unexpected answer "${latest.stdout.trim()}"` : latest.stderr.trim()}`,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const add = await run(["secrets", "versions", "add", secretId, project, "--data-file=-"], {
|
|
173
|
+
stdin: generate(),
|
|
174
|
+
});
|
|
175
|
+
if (!add.ok) {
|
|
176
|
+
return { ok: false, steps, error: `add a version to ${secretId}: ${add.stderr.trim()}` };
|
|
177
|
+
}
|
|
178
|
+
steps.push(`added a new version to ${secretId} (the previous key is version ${version})`);
|
|
179
|
+
|
|
180
|
+
const update = [
|
|
181
|
+
"run",
|
|
182
|
+
"services",
|
|
183
|
+
"update",
|
|
184
|
+
t.gatewayService,
|
|
185
|
+
"--region",
|
|
186
|
+
t.region,
|
|
187
|
+
project,
|
|
188
|
+
`--update-secrets=${SEAL_KEY_ENV}=${secretId}:latest,${SEAL_KEY_PREVIOUS_ENV}=${secretId}:${version}`,
|
|
189
|
+
`--update-labels=${SEAL_ROTATION_LABEL}=${now}`,
|
|
190
|
+
"--quiet",
|
|
191
|
+
];
|
|
192
|
+
const rolled = await run(update);
|
|
193
|
+
const flipped = rolled.ok ? await run(rollFlip(t)) : rolled;
|
|
194
|
+
if (!flipped.ok) {
|
|
195
|
+
return {
|
|
196
|
+
ok: false,
|
|
197
|
+
steps,
|
|
198
|
+
error: `The new key is stored but the gateway does not use it yet. Do not rotate again — roll the gateway:\n gcloud ${update.join(" ")}\n gcloud ${rollFlip(t).join(" ")}\n(${flipped.stderr.trim()})`,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
steps.push(
|
|
202
|
+
`rolled the gateway onto the new key; tokens sealed under version ${version} keep working`,
|
|
203
|
+
`finish after ${new Date(now + SEAL_KEY_ROTATION_WINDOW_DAYS * DAY_MS).toISOString()} with: cms-provision rotate-secret mcp-oauth-seal-key --finish`,
|
|
204
|
+
"a `cms-provision upgrade` before then drops the previous key early: clients sealed under it sign in again",
|
|
205
|
+
);
|
|
206
|
+
return { ok: true, steps };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Finish a rotation: drop the previous key once the window has passed. */
|
|
210
|
+
export async function finishSealKeyRotation(
|
|
211
|
+
t: SealRotationTarget,
|
|
212
|
+
deps: SealRotationDeps = {},
|
|
213
|
+
opts: { readonly now?: boolean } = {},
|
|
214
|
+
): Promise<SealRotationReport> {
|
|
215
|
+
const run = deps.run ?? defaultGcloud;
|
|
216
|
+
const now = (deps.nowMs ?? Date.now)();
|
|
217
|
+
const steps: string[] = [];
|
|
218
|
+
const live = await gatewayState(run, t);
|
|
219
|
+
if (!live.ok) return { ok: false, steps, error: live.error };
|
|
220
|
+
if (!live.state.previousPinned) {
|
|
221
|
+
return {
|
|
222
|
+
ok: false,
|
|
223
|
+
steps,
|
|
224
|
+
error: `No seal-key rotation is in progress on ${t.gatewayService} (${SEAL_KEY_PREVIOUS_ENV} is not set). Start one with \`cms-provision rotate-secret mcp-oauth-seal-key\`.`,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
const started = live.state.startedAtMs;
|
|
228
|
+
const due = started === null ? null : started + SEAL_KEY_ROTATION_WINDOW_DAYS * DAY_MS;
|
|
229
|
+
if (!opts.now && (due === null || now < due)) {
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
steps,
|
|
233
|
+
error:
|
|
234
|
+
due === null
|
|
235
|
+
? `The gateway has ${SEAL_KEY_PREVIOUS_ENV} but no ${SEAL_ROTATION_LABEL} label, so the rotation's start is unknown. Wait ${SEAL_KEY_ROTATION_WINDOW_DAYS} days from when it started, then run --finish --now.`
|
|
236
|
+
: `The previous key still opens refresh tokens until ${new Date(due).toISOString()} (${SEAL_KEY_ROTATION_WINDOW_DAYS}-day lifetime). Finishing now would sign those clients out; run --finish after that time, or --finish --now if the old key leaked.`,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
const update = [
|
|
240
|
+
"run",
|
|
241
|
+
"services",
|
|
242
|
+
"update",
|
|
243
|
+
t.gatewayService,
|
|
244
|
+
"--region",
|
|
245
|
+
t.region,
|
|
246
|
+
`--project=${t.projectId}`,
|
|
247
|
+
`--remove-secrets=${SEAL_KEY_PREVIOUS_ENV}`,
|
|
248
|
+
`--remove-labels=${SEAL_ROTATION_LABEL}`,
|
|
249
|
+
"--quiet",
|
|
250
|
+
];
|
|
251
|
+
const rolled = await run(update);
|
|
252
|
+
const flipped = rolled.ok ? await run(rollFlip(t)) : rolled;
|
|
253
|
+
if (!flipped.ok) {
|
|
254
|
+
return { ok: false, steps, error: `roll the gateway: ${flipped.stderr.trim()}` };
|
|
255
|
+
}
|
|
256
|
+
steps.push("removed the previous seal key from the gateway; the rotation is complete");
|
|
257
|
+
return { ok: true, steps };
|
|
258
|
+
}
|
|
@@ -146,11 +146,28 @@ describe.each(["gcp", "gcp-firebase"] as const)("%s stack ↔ upgrade parity", (
|
|
|
146
146
|
expect(src).not.toMatch(/serviceName: "gateway",[^}]*mediaVolume:/);
|
|
147
147
|
// No volume, mount or execution environment besides the contract's.
|
|
148
148
|
expect(src.match(/executionEnvironment:/g)).toHaveLength(1);
|
|
149
|
-
expect(src).toContain("executionEnvironment: args.mediaVolume.executionEnvironment
|
|
149
|
+
expect(src).toContain("? { executionEnvironment: args.mediaVolume.executionEnvironment }");
|
|
150
150
|
expect(src.match(/volumes:/g)).toHaveLength(1);
|
|
151
|
-
expect(src).
|
|
151
|
+
expect(src).toMatch(
|
|
152
|
+
/volumes: \[\s*\.\.\.\(args\.mediaVolume\?\.volumes \?\? \[\]\),\s*\.\.\.secretFileVolumesTemplate\(args\.serviceName, env\)\.volumes,\s*\]/,
|
|
153
|
+
);
|
|
152
154
|
expect(src.match(/volumeMounts:/g)).toHaveLength(1);
|
|
153
|
-
expect(src).
|
|
155
|
+
expect(src).toMatch(
|
|
156
|
+
/volumeMounts: \[\s*\.\.\.\(args\.mediaVolume\?\.volumeMounts \?\? \[\]\),\s*\.\.\.secretFileVolumesTemplate\(args\.serviceName, env\)\.volumeMounts,\s*\]/,
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("#806 — mounts each service's secret files from the contract upgrade applies", () => {
|
|
161
|
+
// secretFileVolumesTemplate(service) is SERVICE_SECRET_FILES — the same
|
|
162
|
+
// table stack-converge.ts planSecretFiles adds to existing installs.
|
|
163
|
+
expect(src).toContain("secretFileVolumesTemplate,");
|
|
164
|
+
expect(src.match(/secretFileVolumesTemplate\(args\.serviceName, env\)/g)).toHaveLength(2);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("#806 — gives the gateway the contract's request timeout upgrade applies", () => {
|
|
168
|
+
expect(src).toMatch(
|
|
169
|
+
/serviceName: "gateway",[\s\S]*?timeout: `\$\{GATEWAY_TIMEOUT_SECONDS\}s`,[\s\S]*?contractEnv: gatewayEnvContract\(/,
|
|
170
|
+
);
|
|
154
171
|
});
|
|
155
172
|
|
|
156
173
|
it("sets no env var outside the contract (C1: no secret value in a plain var)", () => {
|
|
@@ -71,9 +71,14 @@ describe("adminEnvContract", () => {
|
|
|
71
71
|
CAELO_GENERATOR_CLI: "/app/apps/static-generator/src/cli.ts",
|
|
72
72
|
MEDIA_ROOT_DIR: "/app/apps/admin/data/media",
|
|
73
73
|
CAELO_MCP_IAP_SERVICE_ACCOUNT: "caelo-mcp@acme.iam.gserviceaccount.com",
|
|
74
|
+
// #806 — where Settings → MCP → Remote writes the OAuth client.
|
|
75
|
+
CAELO_MCP_OAUTH_CLIENT_STORE: "projects/acme/secrets/caelo-production-mcp-oauth-client",
|
|
74
76
|
CAELO_OPERATOR_ACCESS_JOB:
|
|
75
77
|
"projects/acme/locations/europe-west1/jobs/caelo-production-operator-access-sync",
|
|
76
78
|
CAELO_UPGRADER_JOB: "projects/acme/locations/europe-west1/jobs/caelo-production-upgrader",
|
|
79
|
+
CAELO_CONVERGE_JOB: "projects/acme/locations/europe-west1/jobs/caelo-production-converge",
|
|
80
|
+
CAELO_AUDIT_JOB: "projects/acme/locations/europe-west1/jobs/caelo-production-audit",
|
|
81
|
+
CAELO_AUDIT_RESULTS_BUCKET: "acme-caelo-production-audit-results",
|
|
77
82
|
CAELO_STATIC_BUCKET: "acme-caelo-production-static",
|
|
78
83
|
CAELO_STAGING_BUCKET: "acme-caelo-production-staging",
|
|
79
84
|
...ADMIN_SECRETS,
|
|
@@ -101,9 +106,14 @@ describe("adminEnvContract", () => {
|
|
|
101
106
|
CAELO_GENERATOR_CLI: "/app/apps/static-generator/src/cli.ts",
|
|
102
107
|
MEDIA_ROOT_DIR: "/app/apps/admin/data/media",
|
|
103
108
|
CAELO_MCP_IAP_SERVICE_ACCOUNT: "caelo-mcp@acme.iam.gserviceaccount.com",
|
|
109
|
+
// #806 — where Settings → MCP → Remote writes the OAuth client.
|
|
110
|
+
CAELO_MCP_OAUTH_CLIENT_STORE: "projects/acme/secrets/caelo-production-mcp-oauth-client",
|
|
104
111
|
CAELO_OPERATOR_ACCESS_JOB:
|
|
105
112
|
"projects/acme/locations/europe-west1/jobs/caelo-production-operator-access-sync",
|
|
106
113
|
CAELO_UPGRADER_JOB: "projects/acme/locations/europe-west1/jobs/caelo-production-upgrader",
|
|
114
|
+
CAELO_CONVERGE_JOB: "projects/acme/locations/europe-west1/jobs/caelo-production-converge",
|
|
115
|
+
CAELO_AUDIT_JOB: "projects/acme/locations/europe-west1/jobs/caelo-production-audit",
|
|
116
|
+
CAELO_AUDIT_RESULTS_BUCKET: "acme-caelo-production-audit-results",
|
|
107
117
|
CAELO_FIREBASE_SITE: "caelo-production-site-abc123",
|
|
108
118
|
CAELO_GATEWAY_SERVICE: "caelo-production-gateway-1a2b3c4",
|
|
109
119
|
CAELO_GATEWAY_REGION: "europe-west1",
|
|
@@ -163,6 +173,9 @@ describe("gatewayEnvContract", () => {
|
|
|
163
173
|
PUBLIC_DATABASE_URL: "postgresql://public_role@10.20.0.3:5432/cms_public?sslmode=require",
|
|
164
174
|
PUBLIC_DATABASE_PASSWORD: "secret:caelo-production-public-role-password:latest",
|
|
165
175
|
CAELO_STAGING_GATE_SECRET: "secret:caelo-production-staging-gate-secret:latest",
|
|
176
|
+
// #806 — the remote-MCP broker: its client file and its seal key.
|
|
177
|
+
CAELO_MCP_OAUTH_CLIENT_FILE: "/secrets/mcp-oauth-client/client.json",
|
|
178
|
+
CAELO_MCP_OAUTH_SEAL_KEY: "secret:caelo-production-mcp-oauth-seal-key:latest",
|
|
166
179
|
});
|
|
167
180
|
});
|
|
168
181
|
|
|
@@ -194,7 +207,13 @@ describe("gatewayEnvContract", () => {
|
|
|
194
207
|
});
|
|
195
208
|
|
|
196
209
|
it("the gateway's run SA reads no admin-only secret (admin password, KEK, internal, tool approval)", () => {
|
|
197
|
-
expect(serviceSecrets("gateway")).toEqual([
|
|
210
|
+
expect(serviceSecrets("gateway")).toEqual([
|
|
211
|
+
"public-role-password",
|
|
212
|
+
"staging-gate-secret",
|
|
213
|
+
// #806 — the broker's seal key (env) and OAuth client (file).
|
|
214
|
+
"mcp-oauth-seal-key",
|
|
215
|
+
"mcp-oauth-client",
|
|
216
|
+
]);
|
|
198
217
|
for (const v of gateway) {
|
|
199
218
|
expect(v.name).not.toMatch(/KEK|INTERNAL|TOOL_APPROVAL|PUBLIC_ADMIN/);
|
|
200
219
|
}
|
|
@@ -291,6 +310,17 @@ describe("runtime secrets", () => {
|
|
|
291
310
|
secret: "staging-gate-secret",
|
|
292
311
|
stackResource: "gateway-staging-gate-secret-binding",
|
|
293
312
|
},
|
|
313
|
+
{
|
|
314
|
+
service: "gateway",
|
|
315
|
+
secret: "mcp-oauth-seal-key",
|
|
316
|
+
stackResource: "gateway-mcp-oauth-seal-key-binding",
|
|
317
|
+
},
|
|
318
|
+
// The secret file counts like an env secret: the SA reads it.
|
|
319
|
+
{
|
|
320
|
+
service: "gateway",
|
|
321
|
+
secret: "mcp-oauth-client",
|
|
322
|
+
stackResource: "gateway-mcp-oauth-client-binding",
|
|
323
|
+
},
|
|
294
324
|
]);
|
|
295
325
|
});
|
|
296
326
|
|
|
@@ -347,12 +377,14 @@ describe("stackIamInvariants", () => {
|
|
|
347
377
|
}
|
|
348
378
|
});
|
|
349
379
|
|
|
350
|
-
it("grants the gateway SA only its own role password, the staging gate key + telemetry roles", () => {
|
|
380
|
+
it("grants the gateway SA only its own role password, the staging gate key, remote MCP's two secrets + telemetry roles", () => {
|
|
351
381
|
for (const provider of ["gcp", "gcp-firebase"] as const) {
|
|
352
382
|
const gateway = stackIamInvariants(provider).filter((i) => i.member === "gateway-sa");
|
|
353
383
|
expect(gateway.map((i) => `${i.role} ${JSON.stringify(i.target)}`).sort()).toEqual([
|
|
354
384
|
'roles/logging.logWriter {"kind":"project"}',
|
|
355
385
|
'roles/monitoring.metricWriter {"kind":"project"}',
|
|
386
|
+
'roles/secretmanager.secretAccessor {"kind":"secret","name":"mcp-oauth-client"}',
|
|
387
|
+
'roles/secretmanager.secretAccessor {"kind":"secret","name":"mcp-oauth-seal-key"}',
|
|
356
388
|
'roles/secretmanager.secretAccessor {"kind":"secret","name":"public-role-password"}',
|
|
357
389
|
'roles/secretmanager.secretAccessor {"kind":"secret","name":"staging-gate-secret"}',
|
|
358
390
|
]);
|