@caelo-cms/provisioning 0.11.9 → 0.11.11
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/adapter.d.ts +6 -0
- package/dist/adapter.d.ts.map +1 -1
- package/dist/admin-domain.d.ts +10 -1
- package/dist/admin-domain.d.ts.map +1 -1
- package/dist/admin-domain.js +13 -3
- package/dist/admin-domain.js.map +1 -1
- package/dist/adopt-existing.d.ts +12 -3
- package/dist/adopt-existing.d.ts.map +1 -1
- package/dist/adopt-existing.js +143 -0
- package/dist/adopt-existing.js.map +1 -1
- package/dist/agent/command.d.ts +23 -0
- package/dist/agent/command.d.ts.map +1 -1
- package/dist/agent/command.js +32 -9
- package/dist/agent/command.js.map +1 -1
- package/dist/agent/run.d.ts +2 -0
- package/dist/agent/run.d.ts.map +1 -1
- package/dist/agent/run.js +5 -1
- package/dist/agent/run.js.map +1 -1
- package/dist/agent-db.js +69 -10
- package/dist/aws-task-roles.d.ts +4 -2
- package/dist/aws-task-roles.d.ts.map +1 -1
- package/dist/aws-task-roles.js +4 -1
- package/dist/aws-task-roles.js.map +1 -1
- package/dist/caddy.d.ts +21 -4
- package/dist/caddy.d.ts.map +1 -1
- package/dist/caddy.js +27 -3
- 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 +68 -7
- package/dist/cli.js.map +1 -1
- package/dist/cloudsql-tls.d.ts +93 -0
- package/dist/cloudsql-tls.d.ts.map +1 -0
- package/dist/cloudsql-tls.js +295 -0
- package/dist/cloudsql-tls.js.map +1 -0
- package/dist/compose.d.ts +43 -6
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +148 -23
- package/dist/compose.js.map +1 -1
- package/dist/converge-checkpoint.d.ts +1 -0
- package/dist/converge-checkpoint.d.ts.map +1 -1
- package/dist/converge-checkpoint.js +8 -1
- package/dist/converge-checkpoint.js.map +1 -1
- package/dist/converge-db-trust.d.ts +44 -0
- package/dist/converge-db-trust.d.ts.map +1 -0
- package/dist/converge-db-trust.js +158 -0
- package/dist/converge-db-trust.js.map +1 -0
- package/dist/converge-job-gcp.d.ts +5 -2
- package/dist/converge-job-gcp.d.ts.map +1 -1
- package/dist/converge-job-gcp.js +11 -13
- package/dist/converge-job-gcp.js.map +1 -1
- package/dist/converge-job-preflight.d.ts.map +1 -1
- package/dist/converge-job-preflight.js +8 -0
- package/dist/converge-job-preflight.js.map +1 -1
- package/dist/converge-job.d.ts +17 -0
- package/dist/converge-job.d.ts.map +1 -1
- package/dist/converge-job.js +12 -0
- package/dist/converge-job.js.map +1 -1
- package/dist/deployed-release.d.ts +4 -1
- package/dist/deployed-release.d.ts.map +1 -1
- package/dist/deployed-release.js +18 -8
- package/dist/deployed-release.js.map +1 -1
- package/dist/gcp-names.d.ts +34 -0
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +45 -0
- package/dist/gcp-names.js.map +1 -1
- package/dist/gcp-url-map.d.ts.map +1 -1
- package/dist/gcp-url-map.js +6 -2
- package/dist/gcp-url-map.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- 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 +45 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/install-job.d.ts +4 -2
- package/dist/install-job.d.ts.map +1 -1
- package/dist/install-job.js +4 -1
- package/dist/install-job.js.map +1 -1
- package/dist/install-state.d.ts +13 -0
- package/dist/install-state.d.ts.map +1 -1
- package/dist/install-state.js +17 -4
- package/dist/install-state.js.map +1 -1
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +20 -8
- package/dist/lifecycle.js.map +1 -1
- package/dist/mcp-auth-proxy-setup.d.ts +48 -0
- package/dist/mcp-auth-proxy-setup.d.ts.map +1 -0
- package/dist/mcp-auth-proxy-setup.js +280 -0
- package/dist/mcp-auth-proxy-setup.js.map +1 -0
- package/dist/migration-runner.d.ts.map +1 -1
- package/dist/migration-runner.js +8 -3
- package/dist/migration-runner.js.map +1 -1
- package/dist/migration-targets.d.ts +11 -0
- package/dist/migration-targets.d.ts.map +1 -0
- package/dist/migration-targets.js +12 -0
- package/dist/migration-targets.js.map +1 -0
- package/dist/operator-access.d.ts +10 -0
- package/dist/operator-access.d.ts.map +1 -1
- package/dist/operator-access.js +5 -0
- package/dist/operator-access.js.map +1 -1
- package/dist/pool-budget-stack.d.ts +13 -1
- package/dist/pool-budget-stack.d.ts.map +1 -1
- package/dist/pool-budget-stack.js +11 -1
- package/dist/pool-budget-stack.js.map +1 -1
- package/dist/public-role-isolation.d.ts +7 -3
- package/dist/public-role-isolation.d.ts.map +1 -1
- package/dist/public-role-isolation.js +25 -13
- package/dist/public-role-isolation.js.map +1 -1
- package/dist/release-contract.d.ts.map +1 -1
- package/dist/release-contract.js +3 -0
- package/dist/release-contract.js.map +1 -1
- package/dist/release-manifest.d.ts +25 -1
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js +36 -3
- package/dist/release-manifest.js.map +1 -1
- package/dist/remote-mcp.d.ts +55 -18
- package/dist/remote-mcp.d.ts.map +1 -1
- package/dist/remote-mcp.js +307 -77
- package/dist/remote-mcp.js.map +1 -1
- package/dist/runtime-secrets.d.ts +11 -0
- package/dist/runtime-secrets.d.ts.map +1 -1
- package/dist/runtime-secrets.js +40 -25
- package/dist/runtime-secrets.js.map +1 -1
- package/dist/scheduler-setup.d.ts +75 -0
- package/dist/scheduler-setup.d.ts.map +1 -0
- package/dist/scheduler-setup.js +331 -0
- package/dist/scheduler-setup.js.map +1 -0
- package/dist/seal-key-rotation.d.ts +7 -7
- package/dist/seal-key-rotation.d.ts.map +1 -1
- package/dist/seal-key-rotation.js +22 -22
- package/dist/seal-key-rotation.js.map +1 -1
- package/dist/self-hosted-db-tls.d.ts +37 -0
- package/dist/self-hosted-db-tls.d.ts.map +1 -0
- package/dist/self-hosted-db-tls.js +220 -0
- package/dist/self-hosted-db-tls.js.map +1 -0
- package/dist/self-hosted-release.d.ts +3 -1
- package/dist/self-hosted-release.d.ts.map +1 -1
- package/dist/self-hosted-release.js +33 -9
- package/dist/self-hosted-release.js.map +1 -1
- package/dist/self-hosted-roles.d.ts +43 -2
- package/dist/self-hosted-roles.d.ts.map +1 -1
- package/dist/self-hosted-roles.js +87 -1
- package/dist/self-hosted-roles.js.map +1 -1
- package/dist/self-hosted-scheduler-interval.d.ts +21 -0
- package/dist/self-hosted-scheduler-interval.d.ts.map +1 -0
- package/dist/self-hosted-scheduler-interval.js +68 -0
- package/dist/self-hosted-scheduler-interval.js.map +1 -0
- package/dist/self-hosted-start.d.ts +0 -21
- package/dist/self-hosted-start.d.ts.map +1 -1
- package/dist/self-hosted-start.js +39 -1
- package/dist/self-hosted-start.js.map +1 -1
- package/dist/self-hosted-upgrade.d.ts +19 -7
- package/dist/self-hosted-upgrade.d.ts.map +1 -1
- package/dist/self-hosted-upgrade.js +78 -25
- package/dist/self-hosted-upgrade.js.map +1 -1
- package/dist/stack-contract.d.ts +234 -28
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +397 -39
- package/dist/stack-contract.js.map +1 -1
- package/dist/stack-converge.d.ts +31 -0
- package/dist/stack-converge.d.ts.map +1 -1
- package/dist/stack-converge.js +71 -30
- package/dist/stack-converge.js.map +1 -1
- package/dist/staging-database.d.ts +22 -0
- package/dist/staging-database.d.ts.map +1 -0
- package/dist/staging-database.js +117 -0
- package/dist/staging-database.js.map +1 -0
- package/dist/upgrade/app-roll.d.ts +25 -4
- package/dist/upgrade/app-roll.d.ts.map +1 -1
- package/dist/upgrade/app-roll.js +63 -17
- package/dist/upgrade/app-roll.js.map +1 -1
- package/dist/upgrade/converge.d.ts +5 -0
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +207 -20
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrade/health.d.ts +31 -7
- package/dist/upgrade/health.d.ts.map +1 -1
- package/dist/upgrade/health.js +41 -17
- package/dist/upgrade/health.js.map +1 -1
- package/dist/upgrade/image-forms.d.ts +1 -1
- package/dist/upgrade/image-forms.d.ts.map +1 -1
- package/dist/upgrade/image-forms.js +24 -7
- package/dist/upgrade/image-forms.js.map +1 -1
- package/dist/upgrade/release-images.d.ts +12 -3
- package/dist/upgrade/release-images.d.ts.map +1 -1
- package/dist/upgrade/release-images.js +13 -4
- package/dist/upgrade/release-images.js.map +1 -1
- package/dist/upgrade/upgrade.d.ts +19 -3
- package/dist/upgrade/upgrade.d.ts.map +1 -1
- package/dist/upgrade/upgrade.js +37 -2
- package/dist/upgrade/upgrade.js.map +1 -1
- package/dist/upgrader-gcp.d.ts +13 -0
- package/dist/upgrader-gcp.d.ts.map +1 -1
- package/dist/upgrader-gcp.js +50 -1
- package/dist/upgrader-gcp.js.map +1 -1
- package/dist/upgrader-iam-preflight.d.ts.map +1 -1
- package/dist/upgrader-iam-preflight.js +6 -1
- package/dist/upgrader-iam-preflight.js.map +1 -1
- package/dist/upgrader-job.d.ts +1 -1
- package/dist/upgrader-job.d.ts.map +1 -1
- package/dist/upgrader-job.js +20 -14
- package/dist/upgrader-job.js.map +1 -1
- package/dist/upgrader-records.d.ts +1 -1
- package/dist/upgrader-records.d.ts.map +1 -1
- package/dist/upgrader-records.js +26 -7
- package/dist/upgrader-records.js.map +1 -1
- package/dist/upgrader-setup.d.ts.map +1 -1
- package/dist/upgrader-setup.js +10 -5
- package/dist/upgrader-setup.js.map +1 -1
- package/dist/wizard.d.ts +7 -0
- package/dist/wizard.d.ts.map +1 -1
- package/dist/wizard.js +11 -0
- package/dist/wizard.js.map +1 -1
- package/dist/wizards/gcp-cost.d.ts +31 -0
- package/dist/wizards/gcp-cost.d.ts.map +1 -1
- package/dist/wizards/gcp-cost.js +103 -18
- package/dist/wizards/gcp-cost.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +129 -27
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +2 -2
- package/src/adapter.ts +6 -0
- package/src/admin-domain.test.ts +9 -8
- package/src/admin-domain.ts +13 -3
- package/src/admin-memory.test.ts +2 -1
- package/src/adopt-existing.test.ts +85 -0
- package/src/adopt-existing.ts +141 -3
- package/src/agent/command.test.ts +49 -1
- package/src/agent/command.ts +39 -9
- package/src/agent/run.test.ts +19 -0
- package/src/agent/run.ts +11 -2
- package/src/aws-task-roles.ts +5 -2
- package/src/caddy-redirects.docker.test.ts +2 -0
- package/src/caddy-redirects.test.ts +4 -1
- package/src/caddy-validate.test.ts +7 -1
- package/src/caddy.test.ts +39 -1
- package/src/caddy.ts +36 -4
- package/src/checkpoint-secrets.test.ts +14 -6
- package/src/cli-routing.test.ts +1 -1
- package/src/cli-routing.ts +1 -1
- package/src/cli.ts +71 -5
- package/src/cloudsql-tls.test.ts +255 -0
- package/src/cloudsql-tls.ts +359 -0
- package/src/compose.ts +193 -24
- package/src/converge-checkpoint.ts +8 -1
- package/src/converge-db-trust.test.ts +237 -0
- package/src/converge-db-trust.ts +212 -0
- package/src/converge-job-gcp.ts +11 -12
- package/src/converge-job-preflight.test.ts +5 -0
- package/src/converge-job-preflight.ts +8 -0
- package/src/converge-job.test.ts +70 -0
- package/src/converge-job.ts +32 -0
- package/src/database-tls-builders.test.ts +56 -0
- package/src/deployed-release.test.ts +107 -40
- package/src/deployed-release.ts +30 -9
- package/src/gateway-env-isolation.test.ts +50 -12
- package/src/gcp-names.ts +56 -0
- package/src/gcp-url-map.test.ts +6 -8
- package/src/gcp-url-map.ts +6 -2
- package/src/index.test.ts +10 -4
- package/src/index.ts +7 -0
- package/src/infra-contract.test.ts +7 -3
- package/src/infra-contract.ts +45 -1
- package/src/install-job.ts +4 -1
- package/src/install-state.ts +33 -4
- package/src/lifecycle.ts +23 -11
- package/src/mcp-auth-proxy-setup.test.ts +305 -0
- package/src/mcp-auth-proxy-setup.ts +374 -0
- package/src/mcp-iap-stacks.test.ts +1 -1
- package/src/media-volume.test.ts +1 -1
- package/src/migration-runner.ts +14 -9
- package/src/migration-targets.ts +12 -0
- package/src/operator-access-stacks.test.ts +1 -1
- package/src/operator-access.test.ts +20 -4
- package/src/operator-access.ts +14 -0
- package/src/pool-budget-stack.ts +15 -0
- package/src/pool-budget-stacks.test.ts +39 -14
- package/src/public-role-isolation.test.ts +18 -8
- package/src/public-role-isolation.ts +31 -17
- package/src/release-contract.ts +3 -0
- package/src/release-manifest.test.ts +25 -0
- package/src/release-manifest.ts +59 -4
- package/src/remote-mcp-stack-config.test.ts +19 -9
- package/src/remote-mcp.test.ts +252 -57
- package/src/remote-mcp.ts +336 -77
- package/src/runtime-secrets.test.ts +37 -14
- package/src/runtime-secrets.ts +87 -27
- package/src/scheduler-setup.test.ts +201 -0
- package/src/scheduler-setup.ts +375 -0
- package/src/seal-key-rotation.test.ts +2 -2
- package/src/seal-key-rotation.ts +25 -25
- package/src/self-hosted-backup.test.ts +1 -0
- package/src/self-hosted-compose.test.ts +180 -6
- package/src/self-hosted-db-tls.test.ts +111 -0
- package/src/self-hosted-db-tls.ts +287 -0
- package/src/self-hosted-release-resolve.test.ts +71 -0
- package/src/self-hosted-release.ts +41 -11
- package/src/self-hosted-roles.integration.test.ts +2 -0
- package/src/self-hosted-roles.test.ts +87 -3
- package/src/self-hosted-roles.ts +101 -3
- package/src/self-hosted-scheduler-interval.test.ts +66 -0
- package/src/self-hosted-scheduler-interval.ts +79 -0
- package/src/self-hosted-start.test.ts +35 -2
- package/src/self-hosted-start.ts +20 -2
- package/src/self-hosted-upgrade.test.ts +104 -14
- package/src/self-hosted-upgrade.ts +123 -31
- package/src/stack-contract-parity.test.ts +79 -12
- package/src/stack-contract.test.ts +199 -31
- package/src/stack-contract.ts +472 -45
- package/src/stack-converge-secrets.test.ts +28 -13
- package/src/stack-converge.test.ts +203 -28
- package/src/stack-converge.ts +92 -30
- package/src/staging-database.test.ts +114 -0
- package/src/staging-database.ts +148 -0
- package/src/state-store.test.ts +2 -1
- package/src/upgrade/app-roll.ts +97 -19
- package/src/upgrade/converge.ts +249 -19
- package/src/upgrade/health.test.ts +63 -25
- package/src/upgrade/health.ts +54 -22
- package/src/upgrade/image-forms.test.ts +34 -0
- package/src/upgrade/image-forms.ts +23 -8
- package/src/upgrade/release-images.test.ts +5 -1
- package/src/upgrade/release-images.ts +22 -6
- package/src/upgrade/upgrade.test.ts +438 -46
- package/src/upgrade/upgrade.ts +64 -6
- package/src/upgrader-gcp.ts +66 -3
- package/src/upgrader-iam-preflight.test.ts +19 -0
- package/src/upgrader-iam-preflight.ts +6 -0
- package/src/upgrader-job.test.ts +101 -27
- package/src/upgrader-job.ts +27 -14
- package/src/upgrader-records.test.ts +97 -12
- package/src/upgrader-records.ts +28 -11
- package/src/upgrader-setup.test.ts +2 -0
- package/src/upgrader-setup.ts +10 -4
- package/src/wizard.ts +19 -0
- package/src/wizards/gcp-cost.test.ts +103 -0
- package/src/wizards/gcp-cost.ts +125 -18
- package/src/wizards/gcp.ts +164 -22
- package/stacks/aws/Pulumi.yaml +1 -1
- package/stacks/aws/index.ts +72 -14
- package/stacks/azure/index.ts +108 -21
- package/stacks/gcp/Pulumi.yaml +1 -1
- package/stacks/gcp/README.md +1 -0
- package/stacks/gcp/index.ts +133 -24
- package/stacks/gcp-firebase/Pulumi.yaml +1 -1
- package/stacks/gcp-firebase/index.ts +212 -34
- package/stacks/self-hosted/Pulumi.yaml +3 -0
- package/stacks/self-hosted/README.md +6 -1
- package/stacks/self-hosted/index.ts +40 -5
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #1140 — set up (and converge) the scheduler tick on a Google Cloud install
|
|
5
|
+
* (`gcp`, `gcp-firebase`): the ONE Cloud Scheduler job that calls the
|
|
6
|
+
* admin's `/api/internal/scheduler/tick` every `schedulerIntervalMinutes`
|
|
7
|
+
* (1–15, default 5). Each task's own cadence lives in the admin's
|
|
8
|
+
* due-table; the tick only says "run what is due now". Run by the wizard
|
|
9
|
+
* after migrations and by `cms-provision upgrade`'s converge (and the
|
|
10
|
+
* converge job), as the operator's or the provisioner's identity.
|
|
11
|
+
*
|
|
12
|
+
* Why not Cloud Scheduler → admin directly. The admin sits behind IAP, and
|
|
13
|
+
* IAP accepts a service account's OIDC ID token only when its `aud` is an
|
|
14
|
+
* OAuth client allowlisted for programmatic access; an install on the
|
|
15
|
+
* Google-managed IAP client has none (cloud.google.com/iap/docs/
|
|
16
|
+
* authentication-howto). Cloud Scheduler's other credential, an OAuth
|
|
17
|
+
* access token, it sends only to `*.googleapis.com` (cloud.google.com/
|
|
18
|
+
* scheduler/docs/http-target-auth). What passes IAP for a service account
|
|
19
|
+
* on every install is a self-signed service-account JWT with `aud` =
|
|
20
|
+
* `<admin origin>/*` — the path caelo-mcp and admin-internal-google.ts
|
|
21
|
+
* take. Cloud Scheduler cannot sign one, so the job starts a Cloud
|
|
22
|
+
* Workflows execution (a googleapis.com target, OAuth), and the workflow
|
|
23
|
+
* signs the JWT as the scheduler account through the IAM Credentials API
|
|
24
|
+
* and POSTs the tick. No container per tick and no image to keep in step
|
|
25
|
+
* with the app-roll; cents a month (wizards/gcp-cost.ts).
|
|
26
|
+
*
|
|
27
|
+
* Why the CLI owns it and not the stacks: the same as audit-job-setup.ts —
|
|
28
|
+
* `upgrade` never runs Pulumi, and existing installs get the tick from here.
|
|
29
|
+
*
|
|
30
|
+
* Trust boundary. The scheduler's identity, `caelo-<env>-scheduler`, holds:
|
|
31
|
+
*
|
|
32
|
+
* - `roles/iam.serviceAccountTokenCreator` on ITSELF only (signJwt);
|
|
33
|
+
* - `roles/iap.httpsResourceAccessor` on the admin's IAP resource (the
|
|
34
|
+
* operator-access sync job keeps it there, operator-access.ts);
|
|
35
|
+
* - `roles/workflows.invoker` on the project (Cloud Scheduler starts the
|
|
36
|
+
* workflow as it; Workflows has no per-workflow IAM policy to bind to);
|
|
37
|
+
* - `roles/logging.logWriter` (the workflow's error call logs).
|
|
38
|
+
*
|
|
39
|
+
* Nothing else: no Secret Manager, no Cloud SQL, no Cloud Run rights. What
|
|
40
|
+
* it can do with the admin is the admin's to decide: the tick endpoint
|
|
41
|
+
* accepts exactly this account's IAP identity (`CAELO_SCHEDULER_SERVICE_
|
|
42
|
+
* ACCOUNT` on the admin) and only ever runs the due-table, never a build
|
|
43
|
+
* or a publish (scripts/no-automatic-production-deploy.test.ts).
|
|
44
|
+
* {@link SCHEDULER_FORBIDDEN_ROLES} names that line; the tests assert no
|
|
45
|
+
* grant crosses it.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
49
|
+
import { tmpdir } from "node:os";
|
|
50
|
+
import { join } from "node:path";
|
|
51
|
+
import { gcloud as defaultGcloud, type GcloudResult } from "./gcloud.js";
|
|
52
|
+
import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
|
|
53
|
+
import {
|
|
54
|
+
schedulerJobName,
|
|
55
|
+
schedulerServiceAccountEmail,
|
|
56
|
+
schedulerServiceAccountId,
|
|
57
|
+
schedulerWorkflowName,
|
|
58
|
+
} from "./gcp-names.js";
|
|
59
|
+
import { type IapResource, iapResourceArgs } from "./mcp-iap.js";
|
|
60
|
+
import { schedulerCron } from "./stack-contract.js";
|
|
61
|
+
|
|
62
|
+
/** The admin path the workflow calls (apps/admin `api/internal/scheduler/tick`). */
|
|
63
|
+
export const SCHEDULER_TICK_PATH = "/api/internal/scheduler/tick";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* How long the workflow waits for the admin's answer, in seconds: Workflows'
|
|
67
|
+
* maximum (cloud.google.com/workflows/docs/reference/stdlib/http/post). The
|
|
68
|
+
* admin holds the request until the tick's work is done: it claims tasks for
|
|
69
|
+
* at most its budget (4 minutes, admin-core scheduler/tick.ts) and then waits
|
|
70
|
+
* at most one task's lease (10 minutes) for the last one, so 14 minutes in
|
|
71
|
+
* the worst case, inside the admin's own 3600 s request timeout.
|
|
72
|
+
*/
|
|
73
|
+
export const SCHEDULER_TICK_TIMEOUT_SECONDS = 1800;
|
|
74
|
+
|
|
75
|
+
/** Lifetime of the IAP credential the workflow signs per tick. */
|
|
76
|
+
const JWT_LIFETIME_SECONDS = 600;
|
|
77
|
+
|
|
78
|
+
/** APIs the tick needs on the project. */
|
|
79
|
+
export const SCHEDULER_APIS = [
|
|
80
|
+
"cloudscheduler.googleapis.com",
|
|
81
|
+
"workflows.googleapis.com",
|
|
82
|
+
"iamcredentials.googleapis.com",
|
|
83
|
+
] as const;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Roles the scheduler's identity must never hold anywhere. Prefix matches
|
|
87
|
+
* (`roles/secretmanager.` = every Secret Manager role).
|
|
88
|
+
*/
|
|
89
|
+
export const SCHEDULER_FORBIDDEN_ROLES = [
|
|
90
|
+
"roles/owner",
|
|
91
|
+
"roles/editor",
|
|
92
|
+
"roles/viewer",
|
|
93
|
+
"roles/run.",
|
|
94
|
+
"roles/iam.serviceAccountUser",
|
|
95
|
+
"roles/iam.serviceAccountAdmin",
|
|
96
|
+
"roles/iam.securityAdmin",
|
|
97
|
+
"roles/iap.admin",
|
|
98
|
+
"roles/iap.settingsAdmin",
|
|
99
|
+
"roles/secretmanager.",
|
|
100
|
+
"roles/cloudsql.",
|
|
101
|
+
"roles/compute.",
|
|
102
|
+
"roles/storage.",
|
|
103
|
+
"roles/resourcemanager.",
|
|
104
|
+
"roles/workflows.admin",
|
|
105
|
+
"roles/workflows.editor",
|
|
106
|
+
"roles/cloudscheduler.",
|
|
107
|
+
] as const;
|
|
108
|
+
|
|
109
|
+
export interface SchedulerTickTarget {
|
|
110
|
+
readonly projectId: string;
|
|
111
|
+
readonly region: string;
|
|
112
|
+
/** Pulumi stack name (`production`). */
|
|
113
|
+
readonly env: string;
|
|
114
|
+
/**
|
|
115
|
+
* Where the admin answers behind IAP, an origin: `https://admin.<domain>`
|
|
116
|
+
* on gcp, the admin service's URL on gcp-firebase (remote-mcp.ts
|
|
117
|
+
* RemoteMcpIdentity.adminUrl, the same value as the admin's
|
|
118
|
+
* CAELO_ADMIN_URL).
|
|
119
|
+
*/
|
|
120
|
+
readonly adminUrl: string;
|
|
121
|
+
/** The admin's IAP resource (the service on gcp-firebase, the LB backend on gcp). */
|
|
122
|
+
readonly resource: IapResource;
|
|
123
|
+
/** The `schedulerIntervalMinutes` knob (1–15). */
|
|
124
|
+
readonly intervalMinutes: number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export type SchedulerTickResult =
|
|
128
|
+
| { readonly ok: true; readonly done: string[]; readonly serviceAccount: string }
|
|
129
|
+
| { readonly ok: false; readonly done: string[]; readonly error: string };
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The tick's admin origin, or an error naming why it is not one: an https
|
|
133
|
+
* origin without path, query or credentials (it is written into the
|
|
134
|
+
* workflow's source, so nothing but an origin may get there).
|
|
135
|
+
*/
|
|
136
|
+
export function tickOrigin(
|
|
137
|
+
adminUrl: string,
|
|
138
|
+
): { ok: true; origin: string } | { ok: false; error: string } {
|
|
139
|
+
let u: URL;
|
|
140
|
+
try {
|
|
141
|
+
u = new URL(adminUrl);
|
|
142
|
+
} catch {
|
|
143
|
+
return { ok: false, error: `the admin URL ${JSON.stringify(adminUrl)} is not a URL` };
|
|
144
|
+
}
|
|
145
|
+
if (
|
|
146
|
+
u.protocol !== "https:" ||
|
|
147
|
+
u.username ||
|
|
148
|
+
u.password ||
|
|
149
|
+
(u.pathname !== "/" && u.pathname !== "") ||
|
|
150
|
+
u.search ||
|
|
151
|
+
u.hash ||
|
|
152
|
+
!/^[a-z0-9.-]+$/.test(u.hostname)
|
|
153
|
+
) {
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
error: `the admin URL ${JSON.stringify(adminUrl)} is not a plain https origin`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
return { ok: true, origin: u.origin };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The workflow one tick runs (Cloud Workflows YAML): sign a service-account
|
|
164
|
+
* JWT for the admin's IAP as the scheduler account itself (the IAM
|
|
165
|
+
* Credentials API, authenticated with the workflow's own OAuth token), then
|
|
166
|
+
* POST the tick with it. A refused or failed tick (HTTP ≥ 400) fails the
|
|
167
|
+
* execution, so it shows in the workflow's execution history and logs.
|
|
168
|
+
*/
|
|
169
|
+
export function schedulerWorkflowSource(opts: {
|
|
170
|
+
readonly serviceAccount: string;
|
|
171
|
+
readonly origin: string;
|
|
172
|
+
}): string {
|
|
173
|
+
const { serviceAccount: sa, origin } = opts;
|
|
174
|
+
return `# Caelo scheduler tick (#1140) — generated by cms-provision, do not edit.
|
|
175
|
+
main:
|
|
176
|
+
steps:
|
|
177
|
+
- claims:
|
|
178
|
+
assign:
|
|
179
|
+
- now: \${int(sys.now())}
|
|
180
|
+
- jwtClaims:
|
|
181
|
+
iss: "${sa}"
|
|
182
|
+
sub: "${sa}"
|
|
183
|
+
aud: "${origin}/*"
|
|
184
|
+
iat: \${now}
|
|
185
|
+
exp: \${now + ${JWT_LIFETIME_SECONDS}}
|
|
186
|
+
- sign:
|
|
187
|
+
call: http.post
|
|
188
|
+
args:
|
|
189
|
+
url: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${sa}:signJwt"
|
|
190
|
+
auth:
|
|
191
|
+
type: OAuth2
|
|
192
|
+
body:
|
|
193
|
+
payload: \${json.encode_to_string(jwtClaims)}
|
|
194
|
+
result: signed
|
|
195
|
+
- tick:
|
|
196
|
+
call: http.post
|
|
197
|
+
args:
|
|
198
|
+
url: "${origin}${SCHEDULER_TICK_PATH}"
|
|
199
|
+
headers:
|
|
200
|
+
Authorization: \${"Bearer " + signed.body.signedJwt}
|
|
201
|
+
timeout: ${SCHEDULER_TICK_TIMEOUT_SECONDS}
|
|
202
|
+
result: answer
|
|
203
|
+
- done:
|
|
204
|
+
return: \${answer.code}
|
|
205
|
+
`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Ensure the scheduler tick and everything it needs. Never throws for a gcloud failure. */
|
|
209
|
+
export async function ensureSchedulerTick(
|
|
210
|
+
target: SchedulerTickTarget,
|
|
211
|
+
deps: { run?: GcloudRunner; sleep?: Sleep } = {},
|
|
212
|
+
): Promise<SchedulerTickResult> {
|
|
213
|
+
const run = deps.run ?? defaultGcloud;
|
|
214
|
+
const sleep = deps.sleep ?? realSleep;
|
|
215
|
+
const retry = (args: string[]) => runWithRetry(run, sleep, args);
|
|
216
|
+
const { projectId, region, env } = target;
|
|
217
|
+
const project = `--project=${projectId}`;
|
|
218
|
+
const sa = schedulerServiceAccountEmail(projectId, env);
|
|
219
|
+
const member = `serviceAccount:${sa}`;
|
|
220
|
+
const workflow = schedulerWorkflowName(env);
|
|
221
|
+
const job = schedulerJobName(env);
|
|
222
|
+
const done: string[] = [];
|
|
223
|
+
const fail = (step: string, r: GcloudResult): SchedulerTickResult => ({
|
|
224
|
+
ok: false,
|
|
225
|
+
done,
|
|
226
|
+
error: `${step}: ${r.stderr.trim()}`,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const origin = tickOrigin(target.adminUrl);
|
|
230
|
+
if (!origin.ok) return { ok: false, done, error: origin.error };
|
|
231
|
+
let cron: string;
|
|
232
|
+
try {
|
|
233
|
+
cron = schedulerCron(target.intervalMinutes);
|
|
234
|
+
} catch (e) {
|
|
235
|
+
return { ok: false, done, error: (e as Error).message };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 1. APIs.
|
|
239
|
+
const api = await run(["services", "enable", ...SCHEDULER_APIS, project, "--quiet"]);
|
|
240
|
+
if (!api.ok) return fail(`enable ${SCHEDULER_APIS.join(", ")}`, api);
|
|
241
|
+
done.push(`APIs enabled (${SCHEDULER_APIS.join(", ")})`);
|
|
242
|
+
|
|
243
|
+
// 2. The scheduler's own service account.
|
|
244
|
+
const describe = await run([
|
|
245
|
+
"iam",
|
|
246
|
+
"service-accounts",
|
|
247
|
+
"describe",
|
|
248
|
+
sa,
|
|
249
|
+
project,
|
|
250
|
+
"--format=value(email)",
|
|
251
|
+
]);
|
|
252
|
+
if (!describe.ok) {
|
|
253
|
+
const create = await run([
|
|
254
|
+
"iam",
|
|
255
|
+
"service-accounts",
|
|
256
|
+
"create",
|
|
257
|
+
schedulerServiceAccountId(env),
|
|
258
|
+
"--display-name=Caelo scheduler tick",
|
|
259
|
+
"--description=Calls the admin's scheduler tick through IAP every few minutes; signs its own IAP credential, nothing else.",
|
|
260
|
+
project,
|
|
261
|
+
]);
|
|
262
|
+
if (!create.ok && !/already exists/i.test(create.stderr)) return fail(`create ${sa}`, create);
|
|
263
|
+
}
|
|
264
|
+
done.push(`service account ${sa}`);
|
|
265
|
+
|
|
266
|
+
// 3. Its grants (see the file comment).
|
|
267
|
+
const self = await retry([
|
|
268
|
+
"iam",
|
|
269
|
+
"service-accounts",
|
|
270
|
+
"add-iam-policy-binding",
|
|
271
|
+
sa,
|
|
272
|
+
`--member=${member}`,
|
|
273
|
+
"--role=roles/iam.serviceAccountTokenCreator",
|
|
274
|
+
"--condition=None",
|
|
275
|
+
project,
|
|
276
|
+
"--quiet",
|
|
277
|
+
"--format=none",
|
|
278
|
+
]);
|
|
279
|
+
if (!self.ok) return fail("let the scheduler sign its IAP credential as itself", self);
|
|
280
|
+
done.push(`scheduler: roles/iam.serviceAccountTokenCreator on ${sa} (itself, signJwt)`);
|
|
281
|
+
|
|
282
|
+
for (const g of [
|
|
283
|
+
{ role: "roles/workflows.invoker", why: "Cloud Scheduler starts the tick workflow as it" },
|
|
284
|
+
{ role: "roles/logging.logWriter", why: "the workflow logs failed calls" },
|
|
285
|
+
]) {
|
|
286
|
+
const r = await retry([
|
|
287
|
+
"projects",
|
|
288
|
+
"add-iam-policy-binding",
|
|
289
|
+
projectId,
|
|
290
|
+
`--member=${member}`,
|
|
291
|
+
`--role=${g.role}`,
|
|
292
|
+
"--condition=None",
|
|
293
|
+
"--quiet",
|
|
294
|
+
"--format=none",
|
|
295
|
+
]);
|
|
296
|
+
if (!r.ok) return fail(`grant the scheduler ${g.role} (${g.why})`, r);
|
|
297
|
+
done.push(`scheduler: ${g.role} on the project`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const iap = await retry([
|
|
301
|
+
"iap",
|
|
302
|
+
"web",
|
|
303
|
+
"add-iam-policy-binding",
|
|
304
|
+
...iapResourceArgs(target.resource),
|
|
305
|
+
`--member=${member}`,
|
|
306
|
+
"--role=roles/iap.httpsResourceAccessor",
|
|
307
|
+
"--condition=None",
|
|
308
|
+
project,
|
|
309
|
+
"--quiet",
|
|
310
|
+
]);
|
|
311
|
+
if (!iap.ok) return fail("let the scheduler pass the admin's IAP", iap);
|
|
312
|
+
done.push(`scheduler: roles/iap.httpsResourceAccessor on the admin (${target.resource.service})`);
|
|
313
|
+
|
|
314
|
+
// 4. The workflow. A private directory (mkdtemp, mode 700): no other local
|
|
315
|
+
// user can swap the file between write and read.
|
|
316
|
+
const dir = mkdtempSync(join(tmpdir(), "caelo-scheduler-"));
|
|
317
|
+
try {
|
|
318
|
+
const file = join(dir, "tick.yaml");
|
|
319
|
+
writeFileSync(file, schedulerWorkflowSource({ serviceAccount: sa, origin: origin.origin }), {
|
|
320
|
+
mode: 0o600,
|
|
321
|
+
});
|
|
322
|
+
const deploy = await retry([
|
|
323
|
+
"workflows",
|
|
324
|
+
"deploy",
|
|
325
|
+
workflow,
|
|
326
|
+
`--source=${file}`,
|
|
327
|
+
`--location=${region}`,
|
|
328
|
+
`--service-account=${sa}`,
|
|
329
|
+
"--call-log-level=log-errors-only",
|
|
330
|
+
`--description=Caelo scheduler tick (#1140): calls ${origin.origin}${SCHEDULER_TICK_PATH}`,
|
|
331
|
+
project,
|
|
332
|
+
"--quiet",
|
|
333
|
+
]);
|
|
334
|
+
if (!deploy.ok) return fail(`deploy workflow ${workflow}`, deploy);
|
|
335
|
+
} finally {
|
|
336
|
+
rmSync(dir, { recursive: true, force: true });
|
|
337
|
+
}
|
|
338
|
+
done.push(`workflow ${workflow} (calls ${origin.origin}${SCHEDULER_TICK_PATH})`);
|
|
339
|
+
|
|
340
|
+
// 5. The one Cloud Scheduler job.
|
|
341
|
+
const scheduleArgs = [
|
|
342
|
+
job,
|
|
343
|
+
`--location=${region}`,
|
|
344
|
+
project,
|
|
345
|
+
`--schedule=${cron}`,
|
|
346
|
+
"--time-zone=Etc/UTC",
|
|
347
|
+
`--uri=https://workflowexecutions.googleapis.com/v1/projects/${projectId}/locations/${region}/workflows/${workflow}/executions`,
|
|
348
|
+
"--http-method=POST",
|
|
349
|
+
`--oauth-service-account-email=${sa}`,
|
|
350
|
+
// A tick that has not answered when the next one is due is the admin's
|
|
351
|
+
// lease to sort out (its due-table), not a reason to retry this one.
|
|
352
|
+
"--max-retry-attempts=0",
|
|
353
|
+
"--quiet",
|
|
354
|
+
];
|
|
355
|
+
const existing = await run([
|
|
356
|
+
"scheduler",
|
|
357
|
+
"jobs",
|
|
358
|
+
"describe",
|
|
359
|
+
job,
|
|
360
|
+
`--location=${region}`,
|
|
361
|
+
project,
|
|
362
|
+
"--format=value(name)",
|
|
363
|
+
]);
|
|
364
|
+
const sched = await retry([
|
|
365
|
+
"scheduler",
|
|
366
|
+
"jobs",
|
|
367
|
+
existing.ok ? "update" : "create",
|
|
368
|
+
"http",
|
|
369
|
+
...scheduleArgs,
|
|
370
|
+
]);
|
|
371
|
+
if (!sched.ok) return fail(`schedule ${job}`, sched);
|
|
372
|
+
done.push(`Cloud Scheduler job ${job} (${cron}, UTC)`);
|
|
373
|
+
|
|
374
|
+
return { ok: true, done, serviceAccount: sa };
|
|
375
|
+
}
|
|
@@ -15,7 +15,7 @@ const T = {
|
|
|
15
15
|
projectId: "acme",
|
|
16
16
|
region: "europe-west1",
|
|
17
17
|
env: "production",
|
|
18
|
-
|
|
18
|
+
proxyService: "caelo-production-gateway-abc",
|
|
19
19
|
};
|
|
20
20
|
const DAY = 24 * 60 * 60 * 1000;
|
|
21
21
|
const NOW = Date.UTC(2026, 9, 20);
|
|
@@ -122,7 +122,7 @@ describe("seal-key rotation (#806)", () => {
|
|
|
122
122
|
|
|
123
123
|
it("keeps the window equal to the broker's refresh-token lifetime", async () => {
|
|
124
124
|
const src = await Bun.file(
|
|
125
|
-
new URL("../../../apps/
|
|
125
|
+
new URL("../../../apps/mcp-auth-proxy/src/token.ts", import.meta.url),
|
|
126
126
|
).text();
|
|
127
127
|
expect(src).toContain(
|
|
128
128
|
`REFRESH_MAX_LIFETIME_SECONDS = ${SEAL_KEY_ROTATION_WINDOW_DAYS} * 24 * 60 * 60`,
|
package/src/seal-key-rotation.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* every connected MCP client out.
|
|
6
6
|
*
|
|
7
7
|
* The broker seals its OAuth state, codes and refresh tokens under
|
|
8
|
-
* `CAELO_MCP_OAUTH_SEAL_KEY` (
|
|
8
|
+
* `CAELO_MCP_OAUTH_SEAL_KEY` (apps/mcp-auth-proxy seal.ts, envelope v2 with a
|
|
9
9
|
* key id). While `CAELO_MCP_OAUTH_SEAL_KEY_PREVIOUS` is set it also opens
|
|
10
10
|
* values sealed under that key, and seals everything new under the current
|
|
11
11
|
* one. Refresh tokens live at most {@link SEAL_KEY_ROTATION_WINDOW_DAYS}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Two steps, both `cms-provision rotate-secret mcp-oauth-seal-key`:
|
|
16
16
|
*
|
|
17
|
-
* start — add a new version to the secret, then roll the
|
|
17
|
+
* start — add a new version to the secret, then roll the MCP-Auth-Proxy with
|
|
18
18
|
* the current key at `latest` and the previous key PINNED to
|
|
19
19
|
* the version that was latest before (a pinned version never
|
|
20
20
|
* follows the new one), labelled with the start time.
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* progress (a second start would pin the CURRENT key as "previous" and drop
|
|
28
28
|
* the real previous one early), and finishing before the window ends.
|
|
29
29
|
*
|
|
30
|
-
* A `cms-provision upgrade` whose `pulumi up` rewrites the
|
|
30
|
+
* A `cms-provision upgrade` whose `pulumi up` rewrites the MCP-Auth-Proxy template
|
|
31
31
|
* during the window drops the previous key (the stack does not declare it):
|
|
32
32
|
* clients sealed under it sign in again. That fails safe — no token lives
|
|
33
33
|
* longer than intended — and is documented in the rotate output.
|
|
@@ -39,27 +39,27 @@ import { gcpSecretId } from "./gcp-names.js";
|
|
|
39
39
|
import { initialSecretValue } from "./runtime-secrets.js";
|
|
40
40
|
import { liveContainerEnv } from "./stack-converge.js";
|
|
41
41
|
|
|
42
|
-
/** The
|
|
42
|
+
/** The MCP-Auth-Proxy env var holding the previous key during a rotation. */
|
|
43
43
|
export const SEAL_KEY_PREVIOUS_ENV = "CAELO_MCP_OAUTH_SEAL_KEY_PREVIOUS";
|
|
44
|
-
/** The
|
|
44
|
+
/** The MCP-Auth-Proxy env var holding the current key (stack-contract SERVICE_SECRET_ENV). */
|
|
45
45
|
export const SEAL_KEY_ENV = "CAELO_MCP_OAUTH_SEAL_KEY";
|
|
46
|
-
/** Label on the
|
|
46
|
+
/** Label on the MCP-Auth-Proxy service: when the rotation started (epoch ms). */
|
|
47
47
|
export const SEAL_ROTATION_LABEL = "caelo-seal-rotation-started";
|
|
48
48
|
/**
|
|
49
49
|
* Days the previous key must stay: the broker's refresh-token lifetime
|
|
50
|
-
* (
|
|
50
|
+
* (apps/mcp-auth-proxy token.ts REFRESH_MAX_LIFETIME_SECONDS, 30 days).
|
|
51
51
|
*/
|
|
52
52
|
export const SEAL_KEY_ROTATION_WINDOW_DAYS = 30;
|
|
53
53
|
|
|
54
54
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
55
55
|
|
|
56
|
-
/** The deployed
|
|
56
|
+
/** The deployed MCP-Auth-Proxy a rotation works on. */
|
|
57
57
|
export interface SealRotationTarget {
|
|
58
58
|
readonly projectId: string;
|
|
59
59
|
readonly region: string;
|
|
60
60
|
readonly env: string;
|
|
61
|
-
/** Deployed (Pulumi-suffixed)
|
|
62
|
-
readonly
|
|
61
|
+
/** Deployed (Pulumi-suffixed or fixed) MCP-Auth-Proxy Cloud Run service name. */
|
|
62
|
+
readonly proxyService: string;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
export interface SealRotationReport {
|
|
@@ -80,7 +80,7 @@ interface GatewayState {
|
|
|
80
80
|
readonly startedAtMs: number | null;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
async function
|
|
83
|
+
async function proxyState(
|
|
84
84
|
run: GcloudRunner,
|
|
85
85
|
t: SealRotationTarget,
|
|
86
86
|
): Promise<{ ok: true; state: GatewayState } | { ok: false; error: string }> {
|
|
@@ -88,13 +88,13 @@ async function gatewayState(
|
|
|
88
88
|
"run",
|
|
89
89
|
"services",
|
|
90
90
|
"describe",
|
|
91
|
-
t.
|
|
91
|
+
t.proxyService,
|
|
92
92
|
"--region",
|
|
93
93
|
t.region,
|
|
94
94
|
`--project=${t.projectId}`,
|
|
95
95
|
"--format=json",
|
|
96
96
|
]);
|
|
97
|
-
if (!r.ok) return { ok: false, error: `read the
|
|
97
|
+
if (!r.ok) return { ok: false, error: `read the MCP-Auth-Proxy service: ${r.stderr.trim()}` };
|
|
98
98
|
const env = liveContainerEnv(r.stdout);
|
|
99
99
|
const labels =
|
|
100
100
|
(JSON.parse(r.stdout) as { metadata?: { labels?: Record<string, string> } }).metadata?.labels ??
|
|
@@ -105,7 +105,7 @@ async function gatewayState(
|
|
|
105
105
|
if (current?.kind !== "secret") {
|
|
106
106
|
return {
|
|
107
107
|
ok: false,
|
|
108
|
-
error: `${SEAL_KEY_ENV} on ${t.
|
|
108
|
+
error: `${SEAL_KEY_ENV} on ${t.proxyService} is not a Secret Manager reference — run \`cms-provision upgrade\` first (it installs remote MCP's secrets).`,
|
|
109
109
|
};
|
|
110
110
|
}
|
|
111
111
|
return {
|
|
@@ -119,7 +119,7 @@ function rollFlip(t: SealRotationTarget): string[] {
|
|
|
119
119
|
"run",
|
|
120
120
|
"services",
|
|
121
121
|
"update-traffic",
|
|
122
|
-
t.
|
|
122
|
+
t.proxyService,
|
|
123
123
|
"--region",
|
|
124
124
|
t.region,
|
|
125
125
|
`--project=${t.projectId}`,
|
|
@@ -140,7 +140,7 @@ export async function startSealKeyRotation(
|
|
|
140
140
|
const project = `--project=${t.projectId}`;
|
|
141
141
|
const steps: string[] = [];
|
|
142
142
|
|
|
143
|
-
const live = await
|
|
143
|
+
const live = await proxyState(run, t);
|
|
144
144
|
if (!live.ok) return { ok: false, steps, error: live.error };
|
|
145
145
|
if (live.state.previousPinned) {
|
|
146
146
|
const since = live.state.startedAtMs;
|
|
@@ -181,7 +181,7 @@ export async function startSealKeyRotation(
|
|
|
181
181
|
"run",
|
|
182
182
|
"services",
|
|
183
183
|
"update",
|
|
184
|
-
t.
|
|
184
|
+
t.proxyService,
|
|
185
185
|
"--region",
|
|
186
186
|
t.region,
|
|
187
187
|
project,
|
|
@@ -195,11 +195,11 @@ export async function startSealKeyRotation(
|
|
|
195
195
|
return {
|
|
196
196
|
ok: false,
|
|
197
197
|
steps,
|
|
198
|
-
error: `The new key is stored but the
|
|
198
|
+
error: `The new key is stored but the MCP-Auth-Proxy does not use it yet. Do not rotate again — roll the MCP-Auth-Proxy:\n gcloud ${update.join(" ")}\n gcloud ${rollFlip(t).join(" ")}\n(${flipped.stderr.trim()})`,
|
|
199
199
|
};
|
|
200
200
|
}
|
|
201
201
|
steps.push(
|
|
202
|
-
`rolled the
|
|
202
|
+
`rolled the MCP-Auth-Proxy onto the new key; tokens sealed under version ${version} keep working`,
|
|
203
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
204
|
"a `cms-provision upgrade` before then drops the previous key early: clients sealed under it sign in again",
|
|
205
205
|
);
|
|
@@ -215,13 +215,13 @@ export async function finishSealKeyRotation(
|
|
|
215
215
|
const run = deps.run ?? defaultGcloud;
|
|
216
216
|
const now = (deps.nowMs ?? Date.now)();
|
|
217
217
|
const steps: string[] = [];
|
|
218
|
-
const live = await
|
|
218
|
+
const live = await proxyState(run, t);
|
|
219
219
|
if (!live.ok) return { ok: false, steps, error: live.error };
|
|
220
220
|
if (!live.state.previousPinned) {
|
|
221
221
|
return {
|
|
222
222
|
ok: false,
|
|
223
223
|
steps,
|
|
224
|
-
error: `No seal-key rotation is in progress on ${t.
|
|
224
|
+
error: `No seal-key rotation is in progress on ${t.proxyService} (${SEAL_KEY_PREVIOUS_ENV} is not set). Start one with \`cms-provision rotate-secret mcp-oauth-seal-key\`.`,
|
|
225
225
|
};
|
|
226
226
|
}
|
|
227
227
|
const started = live.state.startedAtMs;
|
|
@@ -232,7 +232,7 @@ export async function finishSealKeyRotation(
|
|
|
232
232
|
steps,
|
|
233
233
|
error:
|
|
234
234
|
due === null
|
|
235
|
-
? `The
|
|
235
|
+
? `The MCP-Auth-Proxy 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
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
237
|
};
|
|
238
238
|
}
|
|
@@ -240,7 +240,7 @@ export async function finishSealKeyRotation(
|
|
|
240
240
|
"run",
|
|
241
241
|
"services",
|
|
242
242
|
"update",
|
|
243
|
-
t.
|
|
243
|
+
t.proxyService,
|
|
244
244
|
"--region",
|
|
245
245
|
t.region,
|
|
246
246
|
`--project=${t.projectId}`,
|
|
@@ -251,8 +251,8 @@ export async function finishSealKeyRotation(
|
|
|
251
251
|
const rolled = await run(update);
|
|
252
252
|
const flipped = rolled.ok ? await run(rollFlip(t)) : rolled;
|
|
253
253
|
if (!flipped.ok) {
|
|
254
|
-
return { ok: false, steps, error: `roll the
|
|
254
|
+
return { ok: false, steps, error: `roll the MCP-Auth-Proxy: ${flipped.stderr.trim()}` };
|
|
255
255
|
}
|
|
256
|
-
steps.push("removed the previous seal key from the
|
|
256
|
+
steps.push("removed the previous seal key from the MCP-Auth-Proxy; the rotation is complete");
|
|
257
257
|
return { ok: true, steps };
|
|
258
258
|
}
|
|
@@ -203,6 +203,7 @@ describe("restoreSelfHosted", () => {
|
|
|
203
203
|
"<converge roles>",
|
|
204
204
|
`${MIGRATE} admin`,
|
|
205
205
|
`${MIGRATE} public`,
|
|
206
|
+
`${MIGRATE} public-staging`,
|
|
206
207
|
"run --rm --no-deps -T --entrypoint sh caelo-admin -c set -e; find /srv/caelo /data/media -mindepth 1 -delete; tar -C / -xf -",
|
|
207
208
|
"up -d --remove-orphans",
|
|
208
209
|
"exec -T caelo-admin bun -e <probe>",
|