@caelo-cms/provisioning 0.10.47 → 0.10.49
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/caddy/Caddyfile.production +1 -1
- package/caddy/Caddyfile.staging +1 -1
- package/dist/agent/apply.d.ts +45 -0
- package/dist/agent/apply.d.ts.map +1 -0
- package/dist/agent/apply.js +83 -0
- package/dist/agent/apply.js.map +1 -0
- package/dist/agent/cli-provenance.d.ts +42 -0
- package/dist/agent/cli-provenance.d.ts.map +1 -0
- package/dist/agent/cli-provenance.js +138 -0
- package/dist/agent/cli-provenance.js.map +1 -0
- package/dist/agent/command.d.ts +79 -0
- package/dist/agent/command.d.ts.map +1 -0
- package/dist/agent/command.js +454 -0
- package/dist/agent/command.js.map +1 -0
- package/dist/agent/config.d.ts +30 -0
- package/dist/agent/config.d.ts.map +1 -0
- package/dist/agent/config.js +60 -0
- package/dist/agent/config.js.map +1 -0
- package/dist/agent/lock.d.ts +13 -0
- package/dist/agent/lock.d.ts.map +1 -0
- package/dist/agent/lock.js +100 -0
- package/dist/agent/lock.js.map +1 -0
- package/dist/agent/queue.d.ts +35 -0
- package/dist/agent/queue.d.ts.map +1 -0
- package/dist/agent/queue.js +50 -0
- package/dist/agent/queue.js.map +1 -0
- package/dist/agent/run.d.ts +97 -0
- package/dist/agent/run.d.ts.map +1 -0
- package/dist/agent/run.js +198 -0
- package/dist/agent/run.js.map +1 -0
- package/dist/agent/systemd.d.ts +55 -0
- package/dist/agent/systemd.d.ts.map +1 -0
- package/dist/agent/systemd.js +171 -0
- package/dist/agent/systemd.js.map +1 -0
- package/dist/agent/units.d.ts +44 -0
- package/dist/agent/units.d.ts.map +1 -0
- package/dist/agent/units.js +127 -0
- package/dist/agent/units.js.map +1 -0
- package/dist/agent-db.js +18402 -0
- package/dist/cli.js +119 -16
- package/dist/cli.js.map +1 -1
- package/dist/cloudsql-iam-user.d.ts +39 -0
- package/dist/cloudsql-iam-user.d.ts.map +1 -0
- package/dist/cloudsql-iam-user.js +119 -0
- package/dist/cloudsql-iam-user.js.map +1 -0
- package/dist/compose.d.ts +16 -0
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +26 -1
- package/dist/compose.js.map +1 -1
- package/dist/dns/index.d.ts +2 -0
- package/dist/dns/index.d.ts.map +1 -1
- package/dist/dns/index.js +3 -2
- package/dist/dns/index.js.map +1 -1
- package/dist/dns/manual.d.ts +5 -0
- package/dist/dns/manual.d.ts.map +1 -1
- package/dist/dns/manual.js +28 -12
- package/dist/dns/manual.js.map +1 -1
- package/dist/gcloud.d.ts +2 -0
- package/dist/gcloud.d.ts.map +1 -1
- package/dist/gcloud.js +1 -1
- package/dist/gcloud.js.map +1 -1
- package/dist/gcp-names.d.ts +15 -0
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +23 -0
- package/dist/gcp-names.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/infra-contract.d.ts +1 -1
- package/dist/infra-contract.js +7 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/install-job-runner.d.ts +13 -0
- package/dist/install-job-runner.d.ts.map +1 -0
- package/dist/install-job-runner.js +155 -0
- package/dist/install-job-runner.js.map +1 -0
- package/dist/install-job.d.ts +210 -0
- package/dist/install-job.d.ts.map +1 -0
- package/dist/install-job.js +449 -0
- package/dist/install-job.js.map +1 -0
- package/dist/install-state.d.ts +10 -1
- package/dist/install-state.d.ts.map +1 -1
- package/dist/install-state.js +10 -2
- package/dist/install-state.js.map +1 -1
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +19 -4
- package/dist/lifecycle.js.map +1 -1
- package/dist/migration-runner.d.ts +5 -0
- package/dist/migration-runner.d.ts.map +1 -1
- package/dist/migration-runner.js +1 -1
- package/dist/migration-runner.js.map +1 -1
- package/dist/operator-access.d.ts +14 -8
- package/dist/operator-access.d.ts.map +1 -1
- package/dist/operator-access.js +16 -102
- package/dist/operator-access.js.map +1 -1
- package/dist/release-manifest.d.ts +2 -41
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js +18 -121
- package/dist/release-manifest.js.map +1 -1
- package/dist/run-mode.d.ts +27 -0
- package/dist/run-mode.d.ts.map +1 -0
- package/dist/run-mode.js +22 -0
- package/dist/run-mode.js.map +1 -0
- package/dist/runtime-secrets.d.ts +19 -0
- package/dist/runtime-secrets.d.ts.map +1 -1
- package/dist/runtime-secrets.js +44 -0
- package/dist/runtime-secrets.js.map +1 -1
- package/dist/self-hosted-roles.d.ts +22 -1
- package/dist/self-hosted-roles.d.ts.map +1 -1
- package/dist/self-hosted-roles.js +81 -12
- package/dist/self-hosted-roles.js.map +1 -1
- package/dist/self-hosted-upgrade.d.ts +17 -0
- package/dist/self-hosted-upgrade.d.ts.map +1 -1
- package/dist/self-hosted-upgrade.js +16 -1
- package/dist/self-hosted-upgrade.js.map +1 -1
- package/dist/stack-contract.d.ts +6 -0
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +10 -1
- package/dist/stack-contract.js.map +1 -1
- package/dist/upgrade/converge.d.ts +6 -0
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +59 -2
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrade/gcp-lookup.d.ts +6 -0
- package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
- package/dist/upgrade/gcp-lookup.js +25 -0
- package/dist/upgrade/gcp-lookup.js.map +1 -1
- package/dist/upgrade/release-images.d.ts +26 -2
- package/dist/upgrade/release-images.d.ts.map +1 -1
- package/dist/upgrade/release-images.js +35 -2
- package/dist/upgrade/release-images.js.map +1 -1
- package/dist/upgrade/upgrade.d.ts +5 -0
- package/dist/upgrade/upgrade.d.ts.map +1 -1
- package/dist/upgrade/upgrade.js +28 -1
- package/dist/upgrade/upgrade.js.map +1 -1
- package/dist/upgrader-assess.d.ts +85 -0
- package/dist/upgrader-assess.d.ts.map +1 -0
- package/dist/upgrader-assess.js +122 -0
- package/dist/upgrader-assess.js.map +1 -0
- package/dist/upgrader-gcp.d.ts +17 -0
- package/dist/upgrader-gcp.d.ts.map +1 -0
- package/dist/upgrader-gcp.js +87 -0
- package/dist/upgrader-gcp.js.map +1 -0
- package/dist/upgrader-job.d.ts +133 -0
- package/dist/upgrader-job.d.ts.map +1 -0
- package/dist/upgrader-job.js +259 -0
- package/dist/upgrader-job.js.map +1 -0
- package/dist/upgrader-reporter.d.ts +35 -0
- package/dist/upgrader-reporter.d.ts.map +1 -0
- package/dist/upgrader-reporter.js +79 -0
- package/dist/upgrader-reporter.js.map +1 -0
- package/dist/upgrader-setup.d.ts +59 -0
- package/dist/upgrader-setup.d.ts.map +1 -0
- package/dist/upgrader-setup.js +308 -0
- package/dist/upgrader-setup.js.map +1 -0
- package/dist/wizard.d.ts +6 -0
- package/dist/wizard.d.ts.map +1 -1
- package/dist/wizard.js +32 -4
- package/dist/wizard.js.map +1 -1
- package/dist/wizards/gcp-firebase.d.ts +3 -0
- package/dist/wizards/gcp-firebase.d.ts.map +1 -1
- package/dist/wizards/gcp-firebase.js +1 -0
- package/dist/wizards/gcp-firebase.js.map +1 -1
- package/dist/wizards/gcp-install-job.d.ts +74 -0
- package/dist/wizards/gcp-install-job.d.ts.map +1 -0
- package/dist/wizards/gcp-install-job.js +398 -0
- package/dist/wizards/gcp-install-job.js.map +1 -0
- package/dist/wizards/gcp-prompts.d.ts +3 -0
- package/dist/wizards/gcp-prompts.d.ts.map +1 -0
- package/dist/wizards/gcp-prompts.js +25 -0
- package/dist/wizards/gcp-prompts.js.map +1 -0
- package/dist/wizards/gcp-pulumi.d.ts +4 -2
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +1 -1
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts +40 -0
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +223 -59
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +6 -4
- package/src/adapter.ts +97 -0
- package/src/adc-impersonation.ts +176 -0
- package/src/admin-domain.test.ts +254 -0
- package/src/admin-domain.ts +245 -0
- package/src/admin-memory.test.ts +151 -0
- package/src/agent/apply.test.ts +185 -0
- package/src/agent/apply.ts +124 -0
- package/src/agent/cli-provenance.test.ts +159 -0
- package/src/agent/cli-provenance.ts +191 -0
- package/src/agent/command.test.ts +77 -0
- package/src/agent/command.ts +564 -0
- package/src/agent/config.ts +86 -0
- package/src/agent/lock-config.test.ts +97 -0
- package/src/agent/lock.ts +110 -0
- package/src/agent/queue.test.ts +10 -0
- package/src/agent/queue.ts +91 -0
- package/src/agent/run.test.ts +437 -0
- package/src/agent/run.ts +295 -0
- package/src/agent/systemd.ts +209 -0
- package/src/agent/units.test.ts +223 -0
- package/src/agent/units.ts +163 -0
- package/src/azure-front-door.test.ts +119 -0
- package/src/azure-front-door.ts +56 -0
- package/src/bootstrap-token.ts +20 -0
- package/src/caddy.ts +112 -0
- package/src/cdn-copy.ts +91 -0
- package/src/cert-wait.test.ts +194 -0
- package/src/cert-wait.ts +163 -0
- package/src/checkpoint-secrets.test.ts +380 -0
- package/src/checkpoint-secrets.ts +283 -0
- package/src/cli-routing.test.ts +45 -0
- package/src/cli-routing.ts +43 -0
- package/src/cli.ts +1491 -0
- package/src/cloudsql-iam-user.ts +147 -0
- package/src/compose.ts +334 -0
- package/src/default-firewall.test.ts +411 -0
- package/src/default-firewall.ts +342 -0
- package/src/deployed-release.test.ts +231 -0
- package/src/deployed-release.ts +220 -0
- package/src/dns/cloudflare.ts +182 -0
- package/src/dns/groups.test.ts +76 -0
- package/src/dns/groups.ts +86 -0
- package/src/dns/index.ts +56 -0
- package/src/dns/manual.ts +130 -0
- package/src/dns/types.ts +27 -0
- package/src/gateway-credentials.test.ts +180 -0
- package/src/gateway-credentials.ts +144 -0
- package/src/gcloud-components.test.ts +76 -0
- package/src/gcloud-components.ts +85 -0
- package/src/gcloud-retry.ts +41 -0
- package/src/gcloud-runner.test.ts +61 -0
- package/src/gcloud.test.ts +99 -0
- package/src/gcloud.ts +322 -0
- package/src/gcp-names.ts +135 -0
- package/src/index.test.ts +332 -0
- package/src/index.ts +114 -0
- package/src/infra-contract.ts +32 -0
- package/src/install-job-runner.ts +166 -0
- package/src/install-job.test.ts +566 -0
- package/src/install-job.ts +542 -0
- package/src/install-state.test.ts +239 -0
- package/src/install-state.ts +583 -0
- package/src/internal-token.ts +35 -0
- package/src/lifecycle.ts +900 -0
- package/src/mcp-iap-stacks.test.ts +58 -0
- package/src/mcp-iap.test.ts +149 -0
- package/src/mcp-iap.ts +131 -0
- package/src/media-volume.test.ts +296 -0
- package/src/migration-runner.ts +365 -0
- package/src/operator-access-stacks.test.ts +55 -0
- package/src/operator-access.test.ts +332 -0
- package/src/operator-access.ts +554 -0
- package/src/ops-backend.test.ts +320 -0
- package/src/ops-backend.ts +414 -0
- package/src/provisioner-identity.test.ts +482 -0
- package/src/provisioner-identity.ts +407 -0
- package/src/redirects-emit.ts +175 -0
- package/src/region-data.ts +428 -0
- package/src/regions.test.ts +277 -0
- package/src/regions.ts +370 -0
- package/src/release-contract.ts +57 -0
- package/src/release-manifest.test.ts +300 -0
- package/src/release-manifest.ts +310 -0
- package/src/run-mode.ts +40 -0
- package/src/runtime-secrets.test.ts +627 -0
- package/src/runtime-secrets.ts +754 -0
- package/src/self-command.test.ts +14 -0
- package/src/self-command.ts +21 -0
- package/src/self-hosted-compose.test.ts +177 -0
- package/src/self-hosted-release.ts +240 -0
- package/src/self-hosted-roles.integration.test.ts +161 -0
- package/src/self-hosted-roles.test.ts +124 -0
- package/src/self-hosted-roles.ts +203 -0
- package/src/self-hosted-upgrade.test.ts +394 -0
- package/src/self-hosted-upgrade.ts +548 -0
- package/src/stack-contract-parity.test.ts +199 -0
- package/src/stack-contract.test.ts +391 -0
- package/src/stack-contract.ts +888 -0
- package/src/stack-converge-secrets.test.ts +421 -0
- package/src/stack-converge.test.ts +833 -0
- package/src/stack-converge.ts +909 -0
- package/src/staging-admin.test.ts +246 -0
- package/src/staging-admin.ts +324 -0
- package/src/state-attach.test.ts +211 -0
- package/src/state-attach.ts +132 -0
- package/src/state-backend.test.ts +145 -0
- package/src/state-backend.ts +262 -0
- package/src/state-command.ts +293 -0
- package/src/state-migrate.test.ts +479 -0
- package/src/state-migrate.ts +434 -0
- package/src/state-pulumi.ts +289 -0
- package/src/state-store.test.ts +212 -0
- package/src/state-store.ts +243 -0
- package/src/upgrade/app-roll.ts +486 -0
- package/src/upgrade/backup.ts +109 -0
- package/src/upgrade/converge.ts +437 -0
- package/src/upgrade/gcp-lookup.ts +167 -0
- package/src/upgrade/health.test.ts +279 -0
- package/src/upgrade/health.ts +305 -0
- package/src/upgrade/release-images.test.ts +119 -0
- package/src/upgrade/release-images.ts +393 -0
- package/src/upgrade/reporter.ts +46 -0
- package/src/upgrade/upgrade.test.ts +626 -0
- package/src/upgrade/upgrade.ts +249 -0
- package/src/upgrader-assess.test.ts +306 -0
- package/src/upgrader-assess.ts +250 -0
- package/src/upgrader-gcp.ts +123 -0
- package/src/upgrader-job.test.ts +411 -0
- package/src/upgrader-job.ts +502 -0
- package/src/upgrader-reporter.test.ts +46 -0
- package/src/upgrader-reporter.ts +105 -0
- package/src/upgrader-setup.test.ts +169 -0
- package/src/upgrader-setup.ts +355 -0
- package/src/wizard.ts +425 -0
- package/src/wizards/gcp-cost.ts +188 -0
- package/src/wizards/gcp-firebase.ts +72 -0
- package/src/wizards/gcp-install-job.ts +506 -0
- package/src/wizards/gcp-prompts.ts +26 -0
- package/src/wizards/gcp-pulumi.test.ts +246 -0
- package/src/wizards/gcp-pulumi.ts +516 -0
- package/src/wizards/gcp.ts +1935 -0
|
@@ -0,0 +1,888 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a GCP install looks like, declared once for both ways of producing
|
|
5
|
+
* one: the Pulumi stacks (`stacks/gcp`, `stacks/gcp-firebase`) for new
|
|
6
|
+
* installs and `pulumi up`, and `cms-provision upgrade` (stack-converge.ts)
|
|
7
|
+
* for existing installs, which rolls images through gcloud without running
|
|
8
|
+
* Pulumi.
|
|
9
|
+
*
|
|
10
|
+
* Before this contract, everything added to the stacks after an install was
|
|
11
|
+
* created never reached that install: `CAELO_SITE_URL` (so `site_base_url`
|
|
12
|
+
* stayed unset and Stage failed), the run SA's `run.viewer` on the gateway
|
|
13
|
+
* (Firebase Hosting deploys 403'd), the telemetry roles, the one-year CDN
|
|
14
|
+
* TTLs. Three parts close that gap:
|
|
15
|
+
*
|
|
16
|
+
* - {@link adminEnvContract} / {@link gatewayEnvContract}: the complete
|
|
17
|
+
* env of the two Cloud Run services — plain values and Secret Manager
|
|
18
|
+
* references. The stacks deploy them as the Cloud Run `envs`; upgrade
|
|
19
|
+
* applies them (`--update-env-vars` / `--update-secrets`) in the same
|
|
20
|
+
* `services update` that rolls the image.
|
|
21
|
+
* - {@link stackIamInvariants} + {@link STATIC_CDN_POLICY}: the IAM
|
|
22
|
+
* bindings and CDN settings upgrade ensures with gcloud.
|
|
23
|
+
* - {@link STACK_IAM_NOT_ENSURED}: bindings upgrade deliberately leaves to
|
|
24
|
+
* Pulumi, each with the reason. The parity test
|
|
25
|
+
* (stack-contract-parity.test.ts) fails when a stack declares an IAM
|
|
26
|
+
* binding that is in neither list, so a new binding cannot ship without
|
|
27
|
+
* a decision about existing installs.
|
|
28
|
+
*
|
|
29
|
+
* No secret value is ever a plain env var: database URLs carry no password
|
|
30
|
+
* (the apps add it from the `<NAME>_PASSWORD` var, @caelo-cms/shared
|
|
31
|
+
* `databaseUrlFromEnv`), and every secret is a `secretKeyRef` to Secret
|
|
32
|
+
* Manager. Anyone with `run.services.get` sees secret names, never values.
|
|
33
|
+
*
|
|
34
|
+
* Pure module — the stacks import it from `dist/`.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import {
|
|
38
|
+
gatewayServiceAccountEmail,
|
|
39
|
+
gcpBucketName,
|
|
40
|
+
gcpNamePrefix,
|
|
41
|
+
gcpSecretId,
|
|
42
|
+
mcpIapServiceAccountEmail,
|
|
43
|
+
operatorAccessJobResource,
|
|
44
|
+
runServiceAccountEmail,
|
|
45
|
+
staticPublisherServiceAccountId,
|
|
46
|
+
upgraderJobResource,
|
|
47
|
+
} from "./gcp-names.js";
|
|
48
|
+
|
|
49
|
+
export type GcpProvider = "gcp" | "gcp-firebase";
|
|
50
|
+
export type CloudRunSlug = "admin" | "gateway";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* One container env var, in the shape Pulumi's `gcp.cloudrunv2.Service`
|
|
54
|
+
* `envs` accepts. `V` is the value type: `pulumi.Input<string>` in the
|
|
55
|
+
* stacks, `string` in upgrade.
|
|
56
|
+
*/
|
|
57
|
+
export type CloudRunEnvVar<V = string> =
|
|
58
|
+
| { readonly name: string; readonly value: V }
|
|
59
|
+
| {
|
|
60
|
+
readonly name: string;
|
|
61
|
+
readonly valueSource: {
|
|
62
|
+
readonly secretKeyRef: { readonly secret: V; readonly version: string };
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Install metadata every service's env derives from. */
|
|
67
|
+
export interface ServiceEnvInputs {
|
|
68
|
+
readonly provider: GcpProvider;
|
|
69
|
+
readonly projectId: string;
|
|
70
|
+
/** Pulumi stack name (`production`). */
|
|
71
|
+
readonly env: string;
|
|
72
|
+
/** Install domain; the public site is served at its apex. */
|
|
73
|
+
readonly domain: string;
|
|
74
|
+
readonly region: string;
|
|
75
|
+
/**
|
|
76
|
+
* #650 — the protected staging site's host: the install's setting (the
|
|
77
|
+
* wizard's answer / `--staging-domain`, {@link defaultStagingHost} when
|
|
78
|
+
* the install predates it). Seeds the admin's staging host setting.
|
|
79
|
+
*/
|
|
80
|
+
readonly stagingHost: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The password-less connection URLs of the install's Cloud SQL databases. */
|
|
84
|
+
export interface DatabaseUrls<V = string> {
|
|
85
|
+
/** admin_role on cms_admin — the admin's primary pool. */
|
|
86
|
+
readonly admin: V;
|
|
87
|
+
/** admin_role on cms_public — the admin's second pool (DDL, migrations). */
|
|
88
|
+
readonly publicAdmin: V;
|
|
89
|
+
/** public_role on cms_public — the gateway's visitor-write pool. */
|
|
90
|
+
readonly public: V;
|
|
91
|
+
/**
|
|
92
|
+
* gateway_role on cms_admin — the gateway's narrow pool (#613): its
|
|
93
|
+
* settings, rate limits, captcha, request log, plugin op audit and the
|
|
94
|
+
* plugin registry, read-mostly (migration 0248).
|
|
95
|
+
*/
|
|
96
|
+
readonly gateway: V;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Port Cloud SQL Postgres listens on (private IP). */
|
|
100
|
+
const DATABASE_PORT = 5432;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Password-less connection URLs for a Cloud SQL instance at `host`. The
|
|
104
|
+
* password reaches the apps separately, as a Secret Manager reference
|
|
105
|
+
* ({@link SERVICE_SECRET_ENV}).
|
|
106
|
+
*/
|
|
107
|
+
export function databaseUrls(host: string): DatabaseUrls {
|
|
108
|
+
const url = (role: string, db: string) =>
|
|
109
|
+
`postgresql://${role}@${host}:${DATABASE_PORT}/${db}?sslmode=require`;
|
|
110
|
+
return {
|
|
111
|
+
admin: url("admin_role", "cms_admin"),
|
|
112
|
+
publicAdmin: url("admin_role", "cms_public"),
|
|
113
|
+
public: url("public_role", "cms_public"),
|
|
114
|
+
gateway: url("gateway_role", "cms_admin"),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** What a running service's env derives from: install metadata + the database. */
|
|
119
|
+
export interface RuntimeEnvInputs<V = string> extends ServiceEnvInputs {
|
|
120
|
+
/** From the Cloud SQL private IP — an Output in the stacks. */
|
|
121
|
+
readonly databaseUrls: DatabaseUrls<V>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* What the admin's env derives from. Most values come from install metadata;
|
|
126
|
+
* the few Pulumi generates (database host, Firebase site id, the gateway's
|
|
127
|
+
* auto-named Cloud Run service) are passed in — as Outputs by the stack, as
|
|
128
|
+
* discovered strings by upgrade.
|
|
129
|
+
*/
|
|
130
|
+
export type AdminEnvInputs<V = string> =
|
|
131
|
+
| (RuntimeEnvInputs<V> & { readonly provider: "gcp" })
|
|
132
|
+
| (RuntimeEnvInputs<V> & {
|
|
133
|
+
readonly provider: "gcp-firebase";
|
|
134
|
+
readonly firebaseSiteId: V;
|
|
135
|
+
readonly gatewayService: V;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
/** The public site's base URL, as the admin seeds `site_base_url` from it (#551). */
|
|
139
|
+
export function publicSiteUrl(domain: string): string {
|
|
140
|
+
return `https://${domain}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* #650 — the staging host an install gets when the operator names none.
|
|
145
|
+
* Same rule as @caelo-cms/shared `defaultStagingHost` (the stacks load this
|
|
146
|
+
* module from `dist/` without the shared package, hence the local copy).
|
|
147
|
+
*/
|
|
148
|
+
export function defaultStagingHost(domain: string): string {
|
|
149
|
+
return `staging.${domain.trim().toLowerCase().replace(/\.$/, "")}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Env var the admin seeds its staging host setting from. */
|
|
153
|
+
export const STAGING_HOST_ENV = "CAELO_STAGING_HOST";
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The admin env var naming the MCP service account (issue #37). `upgrade`
|
|
157
|
+
* leaves it untouched when it could not set that account up.
|
|
158
|
+
*/
|
|
159
|
+
export const MCP_ENV_VAR = "CAELO_MCP_IAP_SERVICE_ACCOUNT";
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The admin env var naming the operator-access sync job. `upgrade` leaves it
|
|
163
|
+
* untouched when it could not set the job up.
|
|
164
|
+
*/
|
|
165
|
+
export const OPERATOR_ACCESS_JOB_ENV_VAR = "CAELO_OPERATOR_ACCESS_JOB";
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The admin env var naming the upgrader job (#696) — what Settings →
|
|
169
|
+
* Updates' "Install update" starts. `upgrade` leaves it untouched when it
|
|
170
|
+
* could not set the job up.
|
|
171
|
+
*/
|
|
172
|
+
export const UPGRADER_JOB_ENV_VAR = "CAELO_UPGRADER_JOB";
|
|
173
|
+
|
|
174
|
+
/** Where the admin image ships the static-generator CLI. */
|
|
175
|
+
const GENERATOR_CLI = "/app/apps/static-generator/src/cli.ts";
|
|
176
|
+
|
|
177
|
+
// ===========================================================================
|
|
178
|
+
// Media volume
|
|
179
|
+
// ===========================================================================
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Where the admin keeps media: the image's default media root
|
|
183
|
+
* (`/app/apps/admin` + `data/media`), now the mount point of the media
|
|
184
|
+
* bucket. The admin's `MEDIA_ROOT_DIR` and the static generator (a child
|
|
185
|
+
* process of the admin, same env) both read this path.
|
|
186
|
+
*/
|
|
187
|
+
export const MEDIA_MOUNT_PATH = "/app/apps/admin/data/media";
|
|
188
|
+
|
|
189
|
+
/** Name of the admin's Cloud Storage volume. */
|
|
190
|
+
export const MEDIA_VOLUME_NAME = "media";
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The admin's media storage on GCP: the install's media bucket, mounted
|
|
194
|
+
* read-write with Cloud Run's Cloud Storage (gcsfuse) volume at
|
|
195
|
+
* {@link MEDIA_MOUNT_PATH}. Before this, media lived on the container's
|
|
196
|
+
* in-memory filesystem: lost on every new revision and every scale-to-zero,
|
|
197
|
+
* and different on every instance.
|
|
198
|
+
*
|
|
199
|
+
* Cloud Storage volumes need the second-generation execution environment.
|
|
200
|
+
* The stacks deploy this shape ({@link adminMediaVolumeTemplate}); upgrade
|
|
201
|
+
* adds whatever part of it a service is missing (stack-converge.ts
|
|
202
|
+
* `planMediaVolume`).
|
|
203
|
+
*/
|
|
204
|
+
export interface MediaVolumeContract {
|
|
205
|
+
readonly volumeName: string;
|
|
206
|
+
readonly bucket: string;
|
|
207
|
+
readonly mountPath: string;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The admin's media volume on an install. */
|
|
211
|
+
export function adminMediaVolume(projectId: string, env: string): MediaVolumeContract {
|
|
212
|
+
return {
|
|
213
|
+
volumeName: MEDIA_VOLUME_NAME,
|
|
214
|
+
bucket: gcpBucketName(projectId, env, "media"),
|
|
215
|
+
mountPath: MEDIA_MOUNT_PATH,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* {@link adminMediaVolume} in the shape `gcp.cloudrunv2.Service` takes:
|
|
221
|
+
* `executionEnvironment` + `volumes` on the template, `volumeMounts` on the
|
|
222
|
+
* container.
|
|
223
|
+
*/
|
|
224
|
+
export function adminMediaVolumeTemplate(volume: MediaVolumeContract) {
|
|
225
|
+
return {
|
|
226
|
+
executionEnvironment: "EXECUTION_ENVIRONMENT_GEN2",
|
|
227
|
+
volumes: [{ name: volume.volumeName, gcs: { bucket: volume.bucket, readOnly: false } }],
|
|
228
|
+
volumeMounts: [{ name: volume.volumeName, mountPath: volume.mountPath }],
|
|
229
|
+
} as const;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ===========================================================================
|
|
233
|
+
// Runtime secrets
|
|
234
|
+
// ===========================================================================
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The Secret Manager secrets admin + gateway read at runtime, by logical
|
|
238
|
+
* name ({@link gcpSecretId} gives the secret id).
|
|
239
|
+
*/
|
|
240
|
+
export type RuntimeSecret =
|
|
241
|
+
| "postgres-password"
|
|
242
|
+
| "public-role-password"
|
|
243
|
+
| "gateway-role-password"
|
|
244
|
+
| "secret-kek"
|
|
245
|
+
| "internal-secret"
|
|
246
|
+
| "tool-approval-secret"
|
|
247
|
+
| "staging-gate-secret";
|
|
248
|
+
|
|
249
|
+
/** The Postgres roles Caelo's services log in as, with their passwords. */
|
|
250
|
+
export type DatabaseRole = "admin_role" | "public_role" | "gateway_role";
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Each database role's password secret — one per role (#613), so the
|
|
254
|
+
* gateway's SA, which reads only the public_role and gateway_role
|
|
255
|
+
* passwords, can never read admin_role's. `postgres-password` keeps its
|
|
256
|
+
* name: it has always been admin_role's (and, before #613, shared with
|
|
257
|
+
* public_role). `rotate-secret` changes exactly the role(s) listed here.
|
|
258
|
+
*/
|
|
259
|
+
export const DATABASE_ROLE_SECRET: Readonly<Record<DatabaseRole, RuntimeSecret>> = {
|
|
260
|
+
admin_role: "postgres-password",
|
|
261
|
+
public_role: "public-role-password",
|
|
262
|
+
gateway_role: "gateway-role-password",
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Secrets the CLI generates and stores (runtime-secrets.ts
|
|
267
|
+
* `ensureGeneratedSecrets`) rather than Pulumi: the wizard creates them
|
|
268
|
+
* before `pulumi up`, upgrade creates them on installs that predate them.
|
|
269
|
+
* Pulumi only references them, so a `pulumi up` after an upgrade never
|
|
270
|
+
* collides with a secret upgrade created.
|
|
271
|
+
*/
|
|
272
|
+
export const CLI_GENERATED_SECRETS = [
|
|
273
|
+
"internal-secret",
|
|
274
|
+
"tool-approval-secret",
|
|
275
|
+
// #613 — the gateway's own role passwords. Generated (never seeded from
|
|
276
|
+
// `postgres-password`), so the gateway never learns admin_role's.
|
|
277
|
+
"public-role-password",
|
|
278
|
+
"gateway-role-password",
|
|
279
|
+
// #650 — the staging access gate's key: signs "Open staging" links and
|
|
280
|
+
// session cookies, derives the crawler credentials. Read by the admin
|
|
281
|
+
// (issues) and the gateway (verifies).
|
|
282
|
+
"staging-gate-secret",
|
|
283
|
+
] as const satisfies readonly RuntimeSecret[];
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Each service's Secret Manager-backed env vars and the secret each reads.
|
|
287
|
+
* The services' run SAs get `secretAccessor` on exactly these secrets
|
|
288
|
+
* ({@link runtimeSecretBindings}), so this table is also the least-privilege
|
|
289
|
+
* boundary between the two services.
|
|
290
|
+
*/
|
|
291
|
+
export const SERVICE_SECRET_ENV: Readonly<
|
|
292
|
+
Record<CloudRunSlug, Readonly<Record<string, RuntimeSecret>>>
|
|
293
|
+
> = {
|
|
294
|
+
admin: {
|
|
295
|
+
ADMIN_DATABASE_PASSWORD: "postgres-password",
|
|
296
|
+
PUBLIC_ADMIN_DATABASE_PASSWORD: "postgres-password",
|
|
297
|
+
// P18 — encrypts AI provider keys and other at-rest secrets in cms_admin.
|
|
298
|
+
CAELO_SECRET_KEK: "secret-kek",
|
|
299
|
+
// P15.1 — HMAC key of the /api/internal/* bearer tokens (internal-jwt.ts).
|
|
300
|
+
CAELO_INTERNAL_SECRET: "internal-secret",
|
|
301
|
+
// Binds an AI tool-approval to the exact call the operator approved
|
|
302
|
+
// (admin-core ai/providers/_sdk-shared.ts).
|
|
303
|
+
CAELO_TOOL_APPROVAL_SECRET: "tool-approval-secret",
|
|
304
|
+
// #650 — issues "Open staging" links, shows the Owner the crawler
|
|
305
|
+
// credentials (admin-core ops/staging_access.ts).
|
|
306
|
+
CAELO_STAGING_GATE_SECRET: "staging-gate-secret",
|
|
307
|
+
},
|
|
308
|
+
gateway: {
|
|
309
|
+
// CLAUDE.md §2: never an admin_role credential (#613). gateway_role on
|
|
310
|
+
// cms_admin + public_role on cms_public, each with its own password.
|
|
311
|
+
GATEWAY_DATABASE_PASSWORD: "gateway-role-password",
|
|
312
|
+
PUBLIC_DATABASE_PASSWORD: "public-role-password",
|
|
313
|
+
// #650 — verifies staging-host requests (api-gateway staging-proxy.ts).
|
|
314
|
+
CAELO_STAGING_GATE_SECRET: "staging-gate-secret",
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
/** The secrets `service` reads (deduplicated, table order). */
|
|
319
|
+
export function serviceSecrets(service: CloudRunSlug): RuntimeSecret[] {
|
|
320
|
+
return [...new Set(Object.values(SERVICE_SECRET_ENV[service]))];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Env vars an older stack set that the contract no longer gives a service;
|
|
325
|
+
* upgrade removes them. The gateway never used the KEK, and its run SA
|
|
326
|
+
* cannot read it. Its admin_role pool went with #613 — the gateway refuses
|
|
327
|
+
* to boot while either var is set (apps/api-gateway `gatewayDatabaseUrls`).
|
|
328
|
+
*/
|
|
329
|
+
export const RETIRED_SERVICE_ENV: Readonly<Record<CloudRunSlug, readonly string[]>> = {
|
|
330
|
+
admin: [],
|
|
331
|
+
gateway: ["CAELO_SECRET_KEK", "ADMIN_DATABASE_URL", "ADMIN_DATABASE_PASSWORD"],
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
function secretEnv(service: CloudRunSlug, env: string): CloudRunEnvVar[] {
|
|
335
|
+
return Object.entries(SERVICE_SECRET_ENV[service]).map(([name, secret]) => ({
|
|
336
|
+
name,
|
|
337
|
+
valueSource: { secretKeyRef: { secret: gcpSecretId(env, secret), version: "latest" } },
|
|
338
|
+
}));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ===========================================================================
|
|
342
|
+
// Env contracts
|
|
343
|
+
// ===========================================================================
|
|
344
|
+
|
|
345
|
+
/** Env vars both services carry. */
|
|
346
|
+
function commonEnv(inputs: ServiceEnvInputs): CloudRunEnvVar[] {
|
|
347
|
+
return [
|
|
348
|
+
{ name: "CAELO_PROVIDER", value: inputs.provider },
|
|
349
|
+
{ name: "CAELO_ENV", value: inputs.env },
|
|
350
|
+
{
|
|
351
|
+
name: "MEDIA_STORAGE_URL",
|
|
352
|
+
value: `gs://${gcpBucketName(inputs.projectId, inputs.env, "media")}`,
|
|
353
|
+
},
|
|
354
|
+
];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* The env of the gateway service. Order is stable so the stacks see no
|
|
359
|
+
* spurious diffs.
|
|
360
|
+
*/
|
|
361
|
+
export function gatewayEnvContract<V>(inputs: RuntimeEnvInputs<V>): CloudRunEnvVar<V | string>[] {
|
|
362
|
+
return [
|
|
363
|
+
...commonEnv(inputs),
|
|
364
|
+
{ name: "PUBLIC_DATABASE_URL", value: inputs.databaseUrls.public },
|
|
365
|
+
{ name: "GATEWAY_DATABASE_URL", value: inputs.databaseUrls.gateway },
|
|
366
|
+
...secretEnv("gateway", inputs.env),
|
|
367
|
+
];
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* The env of the admin service.
|
|
372
|
+
*
|
|
373
|
+
* @example
|
|
374
|
+
* adminEnvContract({ provider: "gcp", projectId: "p", env: "production",
|
|
375
|
+
* domain: "example.com", region: "europe-west1",
|
|
376
|
+
* databaseUrls: databaseUrls("10.20.0.3") })
|
|
377
|
+
* // → [{ name: "CAELO_PROVIDER", value: "gcp" }, …,
|
|
378
|
+
* // { name: "CAELO_SITE_URL", value: "https://example.com" }, …,
|
|
379
|
+
* // { name: "CAELO_SECRET_KEK", valueSource: { secretKeyRef: … } }, …]
|
|
380
|
+
*/
|
|
381
|
+
export function adminEnvContract<V>(inputs: AdminEnvInputs<V>): CloudRunEnvVar<V | string>[] {
|
|
382
|
+
const { projectId, env, domain } = inputs;
|
|
383
|
+
const admin: CloudRunEnvVar<V | string>[] = [
|
|
384
|
+
...commonEnv(inputs),
|
|
385
|
+
{ name: "ADMIN_DATABASE_URL", value: inputs.databaseUrls.admin },
|
|
386
|
+
// admin_role on cms_public: cross-DB reads, plugin DDL, migrations.
|
|
387
|
+
// Not public_role (write-limited).
|
|
388
|
+
{ name: "PUBLIC_ADMIN_DATABASE_URL", value: inputs.databaseUrls.publicAdmin },
|
|
389
|
+
// #551 — the admin seeds site_defaults.site_base_url from it (canonical,
|
|
390
|
+
// og:url, sitemap) when it is not configured yet.
|
|
391
|
+
{ name: "CAELO_SITE_URL", value: publicSiteUrl(domain) },
|
|
392
|
+
{ name: "CAELO_GENERATOR_CLI", value: GENERATOR_CLI },
|
|
393
|
+
// The mounted media bucket (adminMediaVolume). Explicit so the admin's
|
|
394
|
+
// boot check and the static generator name the same path.
|
|
395
|
+
{ name: "MEDIA_ROOT_DIR", value: MEDIA_MOUNT_PATH },
|
|
396
|
+
// Issue #37 — shown in the /settings/mcp `claude mcp add` command.
|
|
397
|
+
{ name: MCP_ENV_VAR, value: mcpIapServiceAccountEmail(projectId) },
|
|
398
|
+
// The operator-access sync job the admin starts after user/role changes
|
|
399
|
+
// (admin-core security/operator-access/gcp-job-trigger.ts).
|
|
400
|
+
{
|
|
401
|
+
name: OPERATOR_ACCESS_JOB_ENV_VAR,
|
|
402
|
+
value: operatorAccessJobResource(projectId, inputs.region, env),
|
|
403
|
+
},
|
|
404
|
+
// #696 — the job "Install update" starts (upgrader-setup.ts); the admin
|
|
405
|
+
// may only run it, never override it.
|
|
406
|
+
{ name: UPGRADER_JOB_ENV_VAR, value: upgraderJobResource(projectId, inputs.region, env) },
|
|
407
|
+
];
|
|
408
|
+
if (inputs.provider === "gcp") {
|
|
409
|
+
// v0.2.78 — the GCS StaticPublisher: Stage uploads to staging,
|
|
410
|
+
// Confirm-publish copies to static.
|
|
411
|
+
admin.push(
|
|
412
|
+
{ name: "CAELO_STATIC_BUCKET", value: gcpBucketName(projectId, env, "static") },
|
|
413
|
+
{ name: "CAELO_STAGING_BUCKET", value: gcpBucketName(projectId, env, "staging") },
|
|
414
|
+
);
|
|
415
|
+
} else {
|
|
416
|
+
// v0.3.1 — the Firebase publisher deploys to this site and declares
|
|
417
|
+
// the /api/** rewrite to the gateway service.
|
|
418
|
+
admin.push(
|
|
419
|
+
{ name: "CAELO_FIREBASE_SITE", value: inputs.firebaseSiteId },
|
|
420
|
+
{ name: "CAELO_GATEWAY_SERVICE", value: inputs.gatewayService },
|
|
421
|
+
{ name: "CAELO_GATEWAY_REGION", value: inputs.region },
|
|
422
|
+
// #650 — seeds the admin's staging host setting (staging builds render
|
|
423
|
+
// it, the gateway's gate answers on it); the admin DB is the truth
|
|
424
|
+
// afterwards, changed under Settings → Domains.
|
|
425
|
+
{ name: STAGING_HOST_ENV, value: inputs.stagingHost },
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
admin.push(...secretEnv("admin", env));
|
|
429
|
+
return admin;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ===========================================================================
|
|
433
|
+
// IAM + CDN invariants
|
|
434
|
+
// ===========================================================================
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Cloud CDN policy of the `gcp` stack's static backend bucket. The GCS
|
|
438
|
+
* publisher sets an explicit Cache-Control on every object: content-hashed
|
|
439
|
+
* assets `max-age=31536000, immutable`, pages `max-age=60` + SWR. In
|
|
440
|
+
* CACHE_ALL_STATIC mode `clientTtl` clamps the max-age browsers see and
|
|
441
|
+
* `maxTtl` caps the edge TTL, so both must allow a year or the immutable
|
|
442
|
+
* policy is cut back to 1h (#555). `defaultTtl` applies only to responses
|
|
443
|
+
* without a max-age.
|
|
444
|
+
*/
|
|
445
|
+
export const STATIC_CDN_POLICY = {
|
|
446
|
+
cacheMode: "CACHE_ALL_STATIC",
|
|
447
|
+
defaultTtl: 3600,
|
|
448
|
+
maxTtl: 31536000,
|
|
449
|
+
clientTtl: 31536000,
|
|
450
|
+
} as const;
|
|
451
|
+
|
|
452
|
+
/** Who an IAM invariant grants a role to. */
|
|
453
|
+
export type IamPrincipal =
|
|
454
|
+
/** The runtime SA the admin (and the migration jobs) run as. */
|
|
455
|
+
| "run-sa"
|
|
456
|
+
/** The runtime SA the gateway runs as. */
|
|
457
|
+
| "gateway-sa"
|
|
458
|
+
/** The `gcp` stack's static-publisher SA. */
|
|
459
|
+
| "static-publisher-sa"
|
|
460
|
+
/** IAP's Google-managed service agent (forwards IAP traffic to Cloud Run). */
|
|
461
|
+
| "iap-service-agent"
|
|
462
|
+
| "allUsers";
|
|
463
|
+
|
|
464
|
+
/** The resource an IAM invariant's binding lives on. */
|
|
465
|
+
export type IamTarget =
|
|
466
|
+
| { readonly kind: "project" }
|
|
467
|
+
| { readonly kind: "secret"; readonly name: string }
|
|
468
|
+
| { readonly kind: "bucket"; readonly bucket: "media" | "static" | "staging" }
|
|
469
|
+
| { readonly kind: "run-service"; readonly service: CloudRunSlug };
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* One IAM binding a stack declares and upgrade ensures.
|
|
473
|
+
*
|
|
474
|
+
* `onFailure` decides what upgrade does when it can neither find nor add the
|
|
475
|
+
* binding: `abort` (before any traffic shifts) when the install cannot work
|
|
476
|
+
* without it, `warn` when only something non-essential degrades.
|
|
477
|
+
*/
|
|
478
|
+
export interface IamInvariant {
|
|
479
|
+
/**
|
|
480
|
+
* Logical name of the stack resource that declares the binding, without
|
|
481
|
+
* the `${namePrefix}-` prefix — the parity test finds it in the stack by
|
|
482
|
+
* this name.
|
|
483
|
+
*/
|
|
484
|
+
readonly stackResource: string;
|
|
485
|
+
readonly role: string;
|
|
486
|
+
readonly member: IamPrincipal;
|
|
487
|
+
readonly target: IamTarget;
|
|
488
|
+
readonly onFailure: "abort" | "warn";
|
|
489
|
+
readonly why: string;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const SECRET_ACCESSOR = "roles/secretmanager.secretAccessor";
|
|
493
|
+
|
|
494
|
+
/** One `secretAccessor` binding a stack declares for a service's run SA. */
|
|
495
|
+
export interface RuntimeSecretBinding {
|
|
496
|
+
readonly service: CloudRunSlug;
|
|
497
|
+
readonly secret: RuntimeSecret;
|
|
498
|
+
/** Logical stack resource name, without the `${namePrefix}-` prefix. */
|
|
499
|
+
readonly stackResource: string;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* The `secretAccessor` bindings both stacks declare: each service's run SA
|
|
504
|
+
* on exactly the secrets its env reads ({@link SERVICE_SECRET_ENV}).
|
|
505
|
+
*/
|
|
506
|
+
export function runtimeSecretBindings(): RuntimeSecretBinding[] {
|
|
507
|
+
return (["admin", "gateway"] as const).flatMap((service) =>
|
|
508
|
+
serviceSecrets(service).map((secret) => ({
|
|
509
|
+
service,
|
|
510
|
+
secret,
|
|
511
|
+
// The admin's keep the names they had when one SA served both.
|
|
512
|
+
stackResource: service === "admin" ? `${secret}-binding` : `gateway-${secret}-binding`,
|
|
513
|
+
})),
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function secretAccessors(): IamInvariant[] {
|
|
518
|
+
return runtimeSecretBindings().map((b) => ({
|
|
519
|
+
stackResource: b.stackResource,
|
|
520
|
+
role: SECRET_ACCESSOR,
|
|
521
|
+
member: b.service === "admin" ? "run-sa" : "gateway-sa",
|
|
522
|
+
target: { kind: "secret", name: b.secret },
|
|
523
|
+
onFailure: "abort",
|
|
524
|
+
why: `the ${b.service} reads ${b.secret} from Secret Manager at boot`,
|
|
525
|
+
}));
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** v0.6.6 — a custom runtime SA gets no telemetry roles implicitly. */
|
|
529
|
+
const TELEMETRY: readonly IamInvariant[] = [
|
|
530
|
+
{
|
|
531
|
+
stackResource: "run-log-writer",
|
|
532
|
+
role: "roles/logging.logWriter",
|
|
533
|
+
member: "run-sa",
|
|
534
|
+
target: { kind: "project" },
|
|
535
|
+
onFailure: "warn",
|
|
536
|
+
why: "container logs reach Cloud Logging",
|
|
537
|
+
},
|
|
538
|
+
{
|
|
539
|
+
stackResource: "run-metric-writer",
|
|
540
|
+
role: "roles/monitoring.metricWriter",
|
|
541
|
+
member: "run-sa",
|
|
542
|
+
target: { kind: "project" },
|
|
543
|
+
onFailure: "warn",
|
|
544
|
+
why: "CPU/memory metrics reach Cloud Monitoring (autoscaling signals)",
|
|
545
|
+
},
|
|
546
|
+
{
|
|
547
|
+
stackResource: "gateway-log-writer",
|
|
548
|
+
role: "roles/logging.logWriter",
|
|
549
|
+
member: "gateway-sa",
|
|
550
|
+
target: { kind: "project" },
|
|
551
|
+
onFailure: "warn",
|
|
552
|
+
why: "gateway container logs reach Cloud Logging",
|
|
553
|
+
},
|
|
554
|
+
{
|
|
555
|
+
stackResource: "gateway-metric-writer",
|
|
556
|
+
role: "roles/monitoring.metricWriter",
|
|
557
|
+
member: "gateway-sa",
|
|
558
|
+
target: { kind: "project" },
|
|
559
|
+
onFailure: "warn",
|
|
560
|
+
why: "gateway CPU/memory metrics reach Cloud Monitoring (autoscaling signals)",
|
|
561
|
+
},
|
|
562
|
+
];
|
|
563
|
+
|
|
564
|
+
const GCP_ONLY: readonly IamInvariant[] = [
|
|
565
|
+
{
|
|
566
|
+
stackResource: "static-public-read",
|
|
567
|
+
role: "roles/storage.objectViewer",
|
|
568
|
+
member: "allUsers",
|
|
569
|
+
target: { kind: "bucket", bucket: "static" },
|
|
570
|
+
onFailure: "abort",
|
|
571
|
+
why: "Cloud CDN fetches the public site from the static bucket",
|
|
572
|
+
},
|
|
573
|
+
{
|
|
574
|
+
stackResource: "media-rw",
|
|
575
|
+
role: "roles/storage.objectAdmin",
|
|
576
|
+
member: "run-sa",
|
|
577
|
+
target: { kind: "bucket", bucket: "media" },
|
|
578
|
+
onFailure: "abort",
|
|
579
|
+
why: "the admin stores and serves media",
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
stackResource: "admin-static-rw",
|
|
583
|
+
role: "roles/storage.objectAdmin",
|
|
584
|
+
member: "run-sa",
|
|
585
|
+
target: { kind: "bucket", bucket: "static" },
|
|
586
|
+
onFailure: "abort",
|
|
587
|
+
why: "Confirm-publish copies the staged build into the static bucket",
|
|
588
|
+
},
|
|
589
|
+
{
|
|
590
|
+
stackResource: "admin-staging-rw",
|
|
591
|
+
role: "roles/storage.objectAdmin",
|
|
592
|
+
member: "run-sa",
|
|
593
|
+
target: { kind: "bucket", bucket: "staging" },
|
|
594
|
+
onFailure: "abort",
|
|
595
|
+
why: "Stage uploads builds to the private staging bucket (v0.2.78)",
|
|
596
|
+
},
|
|
597
|
+
{
|
|
598
|
+
stackResource: "static-publisher-rw",
|
|
599
|
+
role: "roles/storage.objectAdmin",
|
|
600
|
+
member: "static-publisher-sa",
|
|
601
|
+
target: { kind: "bucket", bucket: "static" },
|
|
602
|
+
onFailure: "warn",
|
|
603
|
+
why: "manual `deploy` uploads as the static-publisher SA (the admin publishes without it)",
|
|
604
|
+
},
|
|
605
|
+
{
|
|
606
|
+
stackResource: "iap-invoke-admin",
|
|
607
|
+
role: "roles/run.invoker",
|
|
608
|
+
member: "iap-service-agent",
|
|
609
|
+
target: { kind: "run-service", service: "admin" },
|
|
610
|
+
onFailure: "abort",
|
|
611
|
+
why: "IAP forwards admin traffic to Cloud Run as its service agent",
|
|
612
|
+
},
|
|
613
|
+
];
|
|
614
|
+
|
|
615
|
+
const GCP_FIREBASE_ONLY: readonly IamInvariant[] = [
|
|
616
|
+
{
|
|
617
|
+
stackResource: "media-rw",
|
|
618
|
+
role: "roles/storage.objectAdmin",
|
|
619
|
+
member: "run-sa",
|
|
620
|
+
target: { kind: "bucket", bucket: "media" },
|
|
621
|
+
onFailure: "abort",
|
|
622
|
+
why: "the admin stores and serves media",
|
|
623
|
+
},
|
|
624
|
+
{
|
|
625
|
+
stackResource: "run-firebase-hosting",
|
|
626
|
+
role: "roles/firebasehosting.admin",
|
|
627
|
+
member: "run-sa",
|
|
628
|
+
target: { kind: "project" },
|
|
629
|
+
onFailure: "abort",
|
|
630
|
+
why: "the admin publishes the site to Firebase Hosting",
|
|
631
|
+
},
|
|
632
|
+
{
|
|
633
|
+
stackResource: "gateway-public-invoker",
|
|
634
|
+
role: "roles/run.invoker",
|
|
635
|
+
member: "allUsers",
|
|
636
|
+
target: { kind: "run-service", service: "gateway" },
|
|
637
|
+
onFailure: "abort",
|
|
638
|
+
why: "Firebase Hosting rewrites /api/** to the public gateway",
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
stackResource: "gateway-viewer-for-admin-sa",
|
|
642
|
+
role: "roles/run.viewer",
|
|
643
|
+
member: "run-sa",
|
|
644
|
+
target: { kind: "run-service", service: "gateway" },
|
|
645
|
+
onFailure: "abort",
|
|
646
|
+
why: "Firebase Hosting checks run.services.get on the rewrite target at deploy time (v0.6.5; without it every publish 403s)",
|
|
647
|
+
},
|
|
648
|
+
{
|
|
649
|
+
stackResource: "admin-iap-sa-invoker",
|
|
650
|
+
role: "roles/run.invoker",
|
|
651
|
+
member: "iap-service-agent",
|
|
652
|
+
target: { kind: "run-service", service: "admin" },
|
|
653
|
+
onFailure: "abort",
|
|
654
|
+
why: "IAP forwards admin traffic to Cloud Run as its service agent",
|
|
655
|
+
},
|
|
656
|
+
];
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* A binding an older stack declared that upgrade removes once nothing reads
|
|
660
|
+
* through it any more (after the rolls). Pulumi drops it on its own: the
|
|
661
|
+
* stacks no longer declare it.
|
|
662
|
+
*/
|
|
663
|
+
export interface RetiredIamBinding {
|
|
664
|
+
readonly role: string;
|
|
665
|
+
readonly member: IamPrincipal;
|
|
666
|
+
readonly target: IamTarget;
|
|
667
|
+
readonly why: string;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* #613 — the gateway SA's read access to admin_role's password. Removed
|
|
672
|
+
* only after the gateway rolled onto its own role passwords: a revision
|
|
673
|
+
* that still references the secret would fail to start without it.
|
|
674
|
+
*/
|
|
675
|
+
export const RETIRED_IAM_BINDINGS: readonly RetiredIamBinding[] = [
|
|
676
|
+
{
|
|
677
|
+
role: SECRET_ACCESSOR,
|
|
678
|
+
member: "gateway-sa",
|
|
679
|
+
target: { kind: "secret", name: "postgres-password" },
|
|
680
|
+
why: "the gateway no longer logs in as admin_role and must not be able to read its password (CLAUDE.md §2)",
|
|
681
|
+
},
|
|
682
|
+
];
|
|
683
|
+
|
|
684
|
+
/** Every IAM binding upgrade ensures on an install of `provider`. */
|
|
685
|
+
export function stackIamInvariants(provider: GcpProvider): IamInvariant[] {
|
|
686
|
+
return [
|
|
687
|
+
...secretAccessors(),
|
|
688
|
+
...(provider === "gcp" ? GCP_ONLY : GCP_FIREBASE_ONLY),
|
|
689
|
+
...TELEMETRY,
|
|
690
|
+
];
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* IAM bindings the stacks declare that upgrade deliberately does not ensure,
|
|
695
|
+
* keyed by provider and the stack resource's logical name as written in the
|
|
696
|
+
* stack source, with each `${…}` placeholder written as `*`.
|
|
697
|
+
*/
|
|
698
|
+
export const STACK_IAM_NOT_ENSURED: Record<GcpProvider, Readonly<Record<string, string>>> = {
|
|
699
|
+
gcp: {
|
|
700
|
+
"admin-iap-allow-*":
|
|
701
|
+
"The IAP allowlist is operator-managed membership from stack config; upgrade must not re-add principals an Owner removed.",
|
|
702
|
+
"mcp-token-creator-*": "Ensured by ensureMcpIapAccess (mcp-iap.ts).",
|
|
703
|
+
"admin-iap-mcp": "Ensured by ensureMcpIapAccess (mcp-iap.ts).",
|
|
704
|
+
"edge-log-sink-bq-perms":
|
|
705
|
+
"The member is the sink's generated writer identity, which only Pulumi knows; analytics only.",
|
|
706
|
+
},
|
|
707
|
+
"gcp-firebase": {
|
|
708
|
+
"admin-iap-*":
|
|
709
|
+
"The IAP allowlist is operator-managed membership from stack config; upgrade must not re-add principals an Owner removed.",
|
|
710
|
+
"admin-invoker-*":
|
|
711
|
+
"Per-principal invoker for the IAP allowlist (operator-managed, see admin-iap-*).",
|
|
712
|
+
"mcp-token-creator-*": "Ensured by ensureMcpIapAccess (mcp-iap.ts).",
|
|
713
|
+
"admin-iap-mcp": "Ensured by ensureMcpIapAccess (mcp-iap.ts).",
|
|
714
|
+
},
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
/** The IAM member string for a principal on a concrete install. */
|
|
718
|
+
export function iamMember(
|
|
719
|
+
principal: IamPrincipal,
|
|
720
|
+
install: { readonly projectId: string; readonly projectNumber: string; readonly env: string },
|
|
721
|
+
): string {
|
|
722
|
+
switch (principal) {
|
|
723
|
+
case "run-sa":
|
|
724
|
+
return `serviceAccount:${runServiceAccountEmail(install.projectId, install.env)}`;
|
|
725
|
+
case "gateway-sa":
|
|
726
|
+
return `serviceAccount:${gatewayServiceAccountEmail(install.projectId, install.env)}`;
|
|
727
|
+
case "static-publisher-sa":
|
|
728
|
+
return `serviceAccount:${staticPublisherServiceAccountId(install.env)}@${install.projectId}.iam.gserviceaccount.com`;
|
|
729
|
+
case "iap-service-agent":
|
|
730
|
+
return `serviceAccount:service-${install.projectNumber}@gcp-sa-iap.iam.gserviceaccount.com`;
|
|
731
|
+
case "allUsers":
|
|
732
|
+
return "allUsers";
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// ===========================================================================
|
|
737
|
+
// Firewall rules that must not exist
|
|
738
|
+
// ===========================================================================
|
|
739
|
+
|
|
740
|
+
/** One `allowed` entry of a firewall rule, as the Compute API returns it. */
|
|
741
|
+
export interface FirewallAllow {
|
|
742
|
+
readonly IPProtocol: "tcp" | "udp" | "icmp";
|
|
743
|
+
readonly ports?: readonly string[];
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* One firewall rule GCP auto-creates on the project's `default` network that
|
|
748
|
+
* a Caelo install must not have, in the exact shape GCP creates it. Only a
|
|
749
|
+
* rule matching this shape is deleted; a rule with the same name in any other
|
|
750
|
+
* shape is someone's deliberate change and is reported instead
|
|
751
|
+
* (default-firewall.ts).
|
|
752
|
+
*/
|
|
753
|
+
export interface AbsentDefaultFirewallRule {
|
|
754
|
+
readonly name: string;
|
|
755
|
+
/** The rule's exact source ranges as GCP creates it. */
|
|
756
|
+
readonly sourceRanges: readonly string[];
|
|
757
|
+
/** The rule's exact `allowed` entries as GCP creates it (order-insensitive). */
|
|
758
|
+
readonly allowed: readonly FirewallAllow[];
|
|
759
|
+
/**
|
|
760
|
+
* The rule carries traffic between VMs on the `default` network. Deleting
|
|
761
|
+
* it cuts that traffic, so it is only deleted when no VM instance in the
|
|
762
|
+
* project sits on the `default` network (default-firewall.ts checks).
|
|
763
|
+
*/
|
|
764
|
+
readonly guardsInstanceTraffic: boolean;
|
|
765
|
+
readonly why: string;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** The auto-created network every new GCP project gets with Compute Engine. */
|
|
769
|
+
export const GCP_DEFAULT_NETWORK = "default";
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Enabling Compute Engine gives a new project the `default` VPC network with
|
|
773
|
+
* four pre-populated ingress firewall rules (priority 65534, documented at
|
|
774
|
+
* https://cloud.google.com/firewall/docs/firewalls, "Pre-populated rules in
|
|
775
|
+
* the default network").
|
|
776
|
+
* Caelo runs no VMs and does not use the `default` network at all: the
|
|
777
|
+
* stacks create their own `${namePrefix}-vpc`, with no firewall rules, for
|
|
778
|
+
* Cloud SQL private IP and Cloud Run direct VPC egress
|
|
779
|
+
* (`vpcAccess.networkInterfaces` points at that network;
|
|
780
|
+
* default-firewall.test.ts pins it). So the rules grant nothing today, but
|
|
781
|
+
* every ISO 27001-style audit flags them, and the first VM anyone adds to the
|
|
782
|
+
* project would inherit them. The wizard and `upgrade` delete all four.
|
|
783
|
+
*
|
|
784
|
+
* The shapes are the documented ones. GCP documents no other variant (older
|
|
785
|
+
* legacy-mode default networks are not described), so a rule in any other
|
|
786
|
+
* shape is reported with the delete command instead of deleted. The network
|
|
787
|
+
* itself stays: deleting it is not ours to decide.
|
|
788
|
+
*/
|
|
789
|
+
export const ABSENT_DEFAULT_FIREWALL_RULES: readonly AbsentDefaultFirewallRule[] = [
|
|
790
|
+
{
|
|
791
|
+
name: "default-allow-ssh",
|
|
792
|
+
sourceRanges: ["0.0.0.0/0"],
|
|
793
|
+
allowed: [{ IPProtocol: "tcp", ports: ["22"] }],
|
|
794
|
+
guardsInstanceTraffic: false,
|
|
795
|
+
why: "SSH open to 0.0.0.0/0 on the default network; Caelo runs no VMs",
|
|
796
|
+
},
|
|
797
|
+
{
|
|
798
|
+
name: "default-allow-rdp",
|
|
799
|
+
sourceRanges: ["0.0.0.0/0"],
|
|
800
|
+
allowed: [{ IPProtocol: "tcp", ports: ["3389"] }],
|
|
801
|
+
guardsInstanceTraffic: false,
|
|
802
|
+
why: "RDP open to 0.0.0.0/0 on the default network; Caelo runs no VMs",
|
|
803
|
+
},
|
|
804
|
+
{
|
|
805
|
+
name: "default-allow-icmp",
|
|
806
|
+
sourceRanges: ["0.0.0.0/0"],
|
|
807
|
+
allowed: [{ IPProtocol: "icmp" }],
|
|
808
|
+
guardsInstanceTraffic: false,
|
|
809
|
+
why: "ICMP open to 0.0.0.0/0 on the default network; Caelo runs no VMs",
|
|
810
|
+
},
|
|
811
|
+
{
|
|
812
|
+
name: "default-allow-internal",
|
|
813
|
+
sourceRanges: ["10.128.0.0/9"],
|
|
814
|
+
allowed: [
|
|
815
|
+
{ IPProtocol: "tcp", ports: ["0-65535"] },
|
|
816
|
+
{ IPProtocol: "udp", ports: ["0-65535"] },
|
|
817
|
+
{ IPProtocol: "icmp" },
|
|
818
|
+
],
|
|
819
|
+
guardsInstanceTraffic: true,
|
|
820
|
+
why: "all ports between instances on the default network; Caelo does not use that network",
|
|
821
|
+
},
|
|
822
|
+
];
|
|
823
|
+
|
|
824
|
+
/** `tcp:22 from 0.0.0.0/0` — a rule's shape in one line, for reports. */
|
|
825
|
+
export function describeFirewallShape(
|
|
826
|
+
allowed: readonly { readonly IPProtocol: string; readonly ports?: readonly string[] }[],
|
|
827
|
+
sourceRanges: readonly string[],
|
|
828
|
+
): string {
|
|
829
|
+
const allow = allowed
|
|
830
|
+
.map((a) => `${a.IPProtocol}${a.ports?.length ? `:${a.ports.join(",")}` : ""}`)
|
|
831
|
+
.join(" ");
|
|
832
|
+
return `${allow || "(none)"} from ${sourceRanges.join(",") || "(no ranges)"}`;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/** Name prefix of the `gcp` stack's static backend bucket (Pulumi auto-suffixed). */
|
|
836
|
+
export function staticBackendBucketPrefix(env: string): string {
|
|
837
|
+
return `${gcpNamePrefix(env)}-static-backend`;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* #553 — the admin's memory, in Cloud Run / Kubernetes quantity notation.
|
|
842
|
+
* The admin runs the quality audit (Lighthouse driving the bundled
|
|
843
|
+
* Chromium in a child process) next to the editor's requests; 1 GiB left
|
|
844
|
+
* no headroom for both, 2 GiB does. Every adapter exposes the same
|
|
845
|
+
* `adminMemory` knob with this default (CLAUDE.md §11.B), and `upgrade`
|
|
846
|
+
* raises an existing install's admin to it (never lowers a larger value).
|
|
847
|
+
*/
|
|
848
|
+
export const ADMIN_MEMORY_DEFAULT = "2Gi";
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Parse a memory quantity (`512Mi`, `2Gi`, `2G`, `1.5Gi`) to MiB; null when
|
|
852
|
+
* it is not a quantity this contract understands.
|
|
853
|
+
*/
|
|
854
|
+
/**
|
|
855
|
+
* #553 — Azure Container Apps (Consumption) only accepts fixed CPU/memory
|
|
856
|
+
* pairs: 0.25 vCPU / 0.5Gi up to 4 vCPU / 8Gi in 0.25 vCPU steps, memory
|
|
857
|
+
* always 2 GiB per vCPU. Map `adminMemory` onto that pair, or throw with
|
|
858
|
+
* the accepted values — an unsupported size must fail before `pulumi up`
|
|
859
|
+
* does, not deploy something else.
|
|
860
|
+
*/
|
|
861
|
+
export function azureContainerAppResources(quantity: string): {
|
|
862
|
+
readonly cpu: number;
|
|
863
|
+
readonly memory: string;
|
|
864
|
+
} {
|
|
865
|
+
const mib = memoryQuantityMiB(quantity);
|
|
866
|
+
if (mib === null || mib % 512 !== 0 || mib < 512 || mib > 8192) {
|
|
867
|
+
throw new Error(
|
|
868
|
+
`caelo-azure:adminMemory "${quantity}" is not a Container Apps size; use 0.5Gi to 8Gi in 0.5Gi steps (e.g. 2Gi = 1 vCPU)`,
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
return { cpu: mib / 2048, memory: `${(mib / 1024).toFixed(1)}Gi` };
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
export function memoryQuantityMiB(quantity: string): number | null {
|
|
875
|
+
const m = /^(\d+(?:\.\d+)?)\s*(Mi|Gi|M|G)$/.exec(quantity.trim());
|
|
876
|
+
if (!m) return null;
|
|
877
|
+
const n = Number(m[1]);
|
|
878
|
+
switch (m[2]) {
|
|
879
|
+
case "Mi":
|
|
880
|
+
return Math.round(n);
|
|
881
|
+
case "Gi":
|
|
882
|
+
return Math.round(n * 1024);
|
|
883
|
+
case "M":
|
|
884
|
+
return Math.round((n * 1000 * 1000) / (1024 * 1024));
|
|
885
|
+
default:
|
|
886
|
+
return Math.round((n * 1000 * 1000 * 1000) / (1024 * 1024));
|
|
887
|
+
}
|
|
888
|
+
}
|