@caelo-cms/provisioning 0.10.51 → 0.10.53

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.
Files changed (117) hide show
  1. package/caddy/Caddyfile.production +1 -1
  2. package/caddy/Caddyfile.staging +1 -1
  3. package/dist/agent/command.js +1 -0
  4. package/dist/agent/command.js.map +1 -1
  5. package/dist/agent/units.js +1 -1
  6. package/dist/agent/units.js.map +1 -1
  7. package/dist/agent-db.js +89 -45
  8. package/dist/aws-task-roles.d.ts +1 -1
  9. package/dist/aws-task-roles.d.ts.map +1 -1
  10. package/dist/aws-task-roles.js +0 -1
  11. package/dist/aws-task-roles.js.map +1 -1
  12. package/dist/cli-routing.d.ts +20 -0
  13. package/dist/cli-routing.d.ts.map +1 -1
  14. package/dist/cli-routing.js +106 -0
  15. package/dist/cli-routing.js.map +1 -1
  16. package/dist/cli.js +5 -6
  17. package/dist/cli.js.map +1 -1
  18. package/dist/compose.d.ts +4 -3
  19. package/dist/compose.d.ts.map +1 -1
  20. package/dist/compose.js +3 -2
  21. package/dist/compose.js.map +1 -1
  22. package/dist/gateway-credentials.d.ts +3 -5
  23. package/dist/gateway-credentials.d.ts.map +1 -1
  24. package/dist/gateway-credentials.js +8 -18
  25. package/dist/gateway-credentials.js.map +1 -1
  26. package/dist/infra-contract.d.ts +1 -1
  27. package/dist/infra-contract.d.ts.map +1 -1
  28. package/dist/infra-contract.js +14 -1
  29. package/dist/infra-contract.js.map +1 -1
  30. package/dist/ops-backend.js +1 -1
  31. package/dist/release-contract.d.ts.map +1 -1
  32. package/dist/release-contract.js +3 -0
  33. package/dist/release-contract.js.map +1 -1
  34. package/dist/runtime-secrets.d.ts +1 -3
  35. package/dist/runtime-secrets.d.ts.map +1 -1
  36. package/dist/runtime-secrets.js +2 -6
  37. package/dist/runtime-secrets.js.map +1 -1
  38. package/dist/self-hosted-roles.d.ts +9 -8
  39. package/dist/self-hosted-roles.d.ts.map +1 -1
  40. package/dist/self-hosted-roles.js +9 -9
  41. package/dist/self-hosted-roles.js.map +1 -1
  42. package/dist/self-hosted-upgrade.d.ts +7 -1
  43. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  44. package/dist/self-hosted-upgrade.js +17 -4
  45. package/dist/self-hosted-upgrade.js.map +1 -1
  46. package/dist/stack-contract.d.ts +52 -3
  47. package/dist/stack-contract.d.ts.map +1 -1
  48. package/dist/stack-contract.js +36 -36
  49. package/dist/stack-contract.js.map +1 -1
  50. package/dist/state-store.d.ts +8 -0
  51. package/dist/state-store.d.ts.map +1 -1
  52. package/dist/state-store.js +10 -2
  53. package/dist/state-store.js.map +1 -1
  54. package/dist/upgrade/converge.d.ts.map +1 -1
  55. package/dist/upgrade/converge.js +5 -13
  56. package/dist/upgrade/converge.js.map +1 -1
  57. package/dist/upgrader-job.d.ts.map +1 -1
  58. package/dist/upgrader-job.js +7 -1
  59. package/dist/upgrader-job.js.map +1 -1
  60. package/dist/upgrader-setup.d.ts +5 -0
  61. package/dist/upgrader-setup.d.ts.map +1 -1
  62. package/dist/upgrader-setup.js +40 -6
  63. package/dist/upgrader-setup.js.map +1 -1
  64. package/dist/wizards/gcp.d.ts.map +1 -1
  65. package/dist/wizards/gcp.js +2 -31
  66. package/dist/wizards/gcp.js.map +1 -1
  67. package/package.json +7 -5
  68. package/src/admin-memory.test.ts +1 -1
  69. package/src/agent/command.ts +1 -0
  70. package/src/agent/units.ts +1 -1
  71. package/src/aws-task-roles.ts +0 -2
  72. package/src/caddy-redirects.docker.test.ts +8 -1
  73. package/src/caddy-redirects.test.ts +1 -1
  74. package/src/checkpoint-secrets.test.ts +0 -2
  75. package/src/cli-routing.test.ts +27 -1
  76. package/src/cli-routing.ts +131 -0
  77. package/src/cli.ts +5 -8
  78. package/src/compose.ts +13 -5
  79. package/src/error-pages-stacks.test.ts +38 -0
  80. package/src/gateway-credentials.test.ts +0 -29
  81. package/src/gateway-credentials.ts +11 -25
  82. package/src/gateway-env-isolation.test.ts +7 -2
  83. package/src/index.test.ts +7 -6
  84. package/src/infra-contract.test.ts +5 -3
  85. package/src/infra-contract.ts +14 -1
  86. package/src/ops-backend.ts +1 -1
  87. package/src/release-contract.ts +3 -0
  88. package/src/release-manifest.test.ts +1 -0
  89. package/src/runtime-secrets.test.ts +9 -81
  90. package/src/runtime-secrets.ts +2 -6
  91. package/src/self-hosted-compose.test.ts +1 -1
  92. package/src/self-hosted-roles.integration.test.ts +23 -38
  93. package/src/self-hosted-roles.test.ts +11 -7
  94. package/src/self-hosted-roles.ts +17 -16
  95. package/src/self-hosted-upgrade.test.ts +28 -1
  96. package/src/self-hosted-upgrade.ts +29 -4
  97. package/src/stack-contract.test.ts +15 -11
  98. package/src/stack-contract.ts +40 -5
  99. package/src/stack-converge-secrets.test.ts +2 -0
  100. package/src/stack-converge.test.ts +2 -0
  101. package/src/state-store.ts +11 -2
  102. package/src/upgrade/converge.ts +5 -19
  103. package/src/upgrader-job.test.ts +17 -1
  104. package/src/upgrader-job.ts +13 -3
  105. package/src/upgrader-setup.test.ts +12 -1
  106. package/src/upgrader-setup.ts +41 -5
  107. package/src/welcome-page.test.ts +40 -0
  108. package/src/wizards/gcp.ts +2 -32
  109. package/stacks/aws/README.md +3 -1
  110. package/stacks/aws/index.ts +8 -6
  111. package/stacks/azure/README.md +3 -1
  112. package/stacks/azure/index.ts +9 -6
  113. package/stacks/gcp/README.md +1 -1
  114. package/stacks/gcp/index.ts +1 -4
  115. package/stacks/gcp-firebase/index.ts +1 -4
  116. package/stacks/self-hosted/index.ts +4 -11
  117. package/static/welcome.html +93 -111
package/src/compose.ts CHANGED
@@ -29,7 +29,13 @@
29
29
 
30
30
  import { CADDY_REDIRECTS_FILE } from "@caelo-cms/shared";
31
31
  import { caddyRedirectReloadCommand } from "./caddy-redirects.js";
32
- import { ADMIN_MEMORY_DEFAULT, memoryQuantityMiB, publicSiteUrl } from "./stack-contract.js";
32
+ import {
33
+ ADMIN_MEMORY_DEFAULT,
34
+ GATEWAY_TRUSTED_PROXY_HOPS,
35
+ memoryQuantityMiB,
36
+ publicSiteUrl,
37
+ TRUSTED_PROXY_HOPS_ENV,
38
+ } from "./stack-contract.js";
33
39
  import { ghcrRepository, isImageDigest, type ReleaseService } from "./upgrade/release-images.js";
34
40
 
35
41
  /** The admin + gateway release images, each `ghcr.io/caelo-cms/<svc>@sha256:…`. */
@@ -160,13 +166,14 @@ export function ghcrImageDigest(service: ReleaseService, ref: string): string |
160
166
 
161
167
  /**
162
168
  * The application roles' passwords (bootstrap.sh creates the roles with
163
- * them). One per role, so the gateway — which gets only the public and
164
- * gateway logins — never holds admin_role's (#613, CLAUDE.md §2).
169
+ * them). One per role, so the gateway — which gets only the public login —
170
+ * never holds admin_role's (#613, #635, CLAUDE.md §2). An install's
171
+ * config.json from before #635 still carries a `gateway` entry; it is
172
+ * ignored.
165
173
  */
166
174
  export interface RolePasswords {
167
175
  readonly admin: string;
168
176
  readonly public: string;
169
- readonly gateway: string;
170
177
  }
171
178
 
172
179
  export interface ComposeSpec {
@@ -254,7 +261,6 @@ services:
254
261
  POSTGRES_DB: caelo
255
262
  ADMIN_ROLE_PASSWORD: "${escapeYaml(spec.rolePasswords.admin)}"
256
263
  PUBLIC_ROLE_PASSWORD: "${escapeYaml(spec.rolePasswords.public)}"
257
- GATEWAY_ROLE_PASSWORD: "${escapeYaml(spec.rolePasswords.gateway)}"
258
264
  ${postgresAgent(spec.agent)} volumes:
259
265
  - caelo-pg:/var/lib/postgresql/data
260
266
  - ./bootstrap.sh:/docker-entrypoint-initdb.d/00_bootstrap.sh:ro
@@ -392,6 +398,8 @@ ${serviceRuntime(spec.images, "gateway", "bun run apps/api-gateway/src/server.ts
392
398
  # CLAUDE.md §2: the public side has no path to the authoring database).
393
399
  PUBLIC_DATABASE_URL: "${dbUrl("public_role", spec.rolePasswords.public, "cms_public")}"
394
400
  GATEWAY_PORT: "${COMPOSE_GATEWAY_PORT}"
401
+ # Caddy is the one proxy in front (client-ip.ts in the gateway).
402
+ ${TRUSTED_PROXY_HOPS_ENV}: "${GATEWAY_TRUSTED_PROXY_HOPS["self-hosted"]}"
395
403
  NODE_ENV: production`;
396
404
  }
397
405
 
@@ -0,0 +1,38 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #802 — every provider answers an unknown URL with the site's own
5
+ * `/404.html` (written by the static generator from the designated 404
6
+ * page) and status 404. Firebase Hosting does this by convention for a
7
+ * root `/404.html`; the other stacks must wire it. Source-level checks:
8
+ * the Pulumi programs cannot run in a unit test.
9
+ */
10
+
11
+ import { describe, expect, it } from "bun:test";
12
+ import { readFileSync } from "node:fs";
13
+ import { resolve } from "node:path";
14
+
15
+ const stack = (provider: string): string =>
16
+ readFileSync(resolve(import.meta.dir, "..", "stacks", provider, "index.ts"), "utf8");
17
+
18
+ describe("#802 site 404 page wiring per provider", () => {
19
+ it("gcp: the static bucket's website serves 404.html for unknown objects", () => {
20
+ expect(stack("gcp")).toContain('notFoundPage: "404.html"');
21
+ });
22
+
23
+ it("aws: CloudFront maps both 404 and the OAI origin's 403 to /404.html with status 404", () => {
24
+ const src = stack("aws");
25
+ expect(src).toContain(
26
+ '{ errorCode: 404, responseCode: 404, responsePagePath: "/404.html", errorCachingMinTtl: 60 }',
27
+ );
28
+ // S3 behind an OAI without s3:ListBucket answers a missing key with
29
+ // 403 — without this entry visitors get S3's AccessDenied XML.
30
+ expect(src).toContain(
31
+ '{ errorCode: 403, responseCode: 404, responsePagePath: "/404.html", errorCachingMinTtl: 60 }',
32
+ );
33
+ });
34
+
35
+ it("azure: the static website's error document is 404.html", () => {
36
+ expect(stack("azure")).toContain('error404Document: "404.html"');
37
+ });
38
+ });
@@ -2,7 +2,6 @@
2
2
 
3
3
  import { describe, expect, it } from "bun:test";
4
4
  import {
5
- ensureGatewayRolePassword,
6
5
  findSqlInstance,
7
6
  gatewayMovesPublicRole,
8
7
  planPublicRoleSwitch,
@@ -52,7 +51,6 @@ const target = { projectId: "acme", env: "production", sqlInstance: "caelo-produ
52
51
  const SECRETS = {
53
52
  "postgres-password": "admin-pw",
54
53
  "public-role-password": "public-pw",
55
- "gateway-role-password": "gateway-pw",
56
54
  };
57
55
  const secretRef = (secret: string): LiveEnvValue => ({
58
56
  kind: "secret",
@@ -66,7 +64,6 @@ const BEFORE_613 = new Map<string, LiveEnvValue>([
66
64
  ]);
67
65
  const AFTER_613 = new Map<string, LiveEnvValue>([
68
66
  ["PUBLIC_DATABASE_PASSWORD", secretRef("public-role-password")],
69
- ["GATEWAY_DATABASE_PASSWORD", secretRef("gateway-role-password")],
70
67
  ]);
71
68
 
72
69
  describe("gatewayMovesPublicRole", () => {
@@ -85,32 +82,6 @@ describe("gatewayMovesPublicRole", () => {
85
82
  });
86
83
  });
87
84
 
88
- describe("ensureGatewayRolePassword", () => {
89
- it("sets gateway_role from its secret through the API, never on argv", async () => {
90
- const { run, lines } = fakeGcloud(SECRETS);
91
- const sql = fakeSqlApi();
92
- const r = await ensureGatewayRolePassword(target, {
93
- run,
94
- http: sql.http,
95
- sleep: async () => {},
96
- });
97
- expect(r.status).toBe("applied");
98
- expect(sql.set).toEqual(["gateway_role gateway-pw"]);
99
- expect(lines.some((l) => l.includes("gateway-pw"))).toBe(false);
100
- });
101
-
102
- it("fails, naming the secret, when the secret can't be read", async () => {
103
- const { run } = fakeGcloud({});
104
- const r = await ensureGatewayRolePassword(target, {
105
- run,
106
- http: fakeSqlApi().http,
107
- sleep: async () => {},
108
- });
109
- expect(r.status).toBe("failed");
110
- expect(r.error).toContain("caelo-production-gateway-role-password");
111
- });
112
- });
113
-
114
85
  describe("planPublicRoleSwitch", () => {
115
86
  it("first upgrade: moves public_role onto its own password before the gateway rolls", async () => {
116
87
  const { run } = fakeGcloud(SECRETS);
@@ -2,28 +2,22 @@
2
2
 
3
3
  /**
4
4
  * Moving an existing install's API gateway off admin_role (issue #613),
5
- * as `cms-provision upgrade` does it. The env side — the per-role password
6
- * secrets in, ADMIN_DATABASE_* out (and, since #635, GATEWAY_DATABASE_* out
7
- * too: the gateway has no cms_admin login at all) — rides the gateway's
8
- * roll (stack-contract.ts). gateway_role keeps getting its password until
9
- * the contract release drops the role, because the previous release's
10
- * gateway logs in with it until it is rolled. The database side is ordered around
11
- * that roll so /api/* keeps working:
5
+ * as `cms-provision upgrade` does it. The env side — public_role's own
6
+ * password secret in, every cms_admin credential out (#613, #635) — rides
7
+ * the gateway's roll (stack-contract.ts). The database side is ordered
8
+ * around that roll so /api/* keeps working:
12
9
  *
13
- * 1. after migrations (which create `gateway_role` without a password):
14
- * `gateway_role` gets its password. Nothing uses the role yet, so this
15
- * is safe at any point before the gateway rolls.
16
- * 2. right before the gateway rolls: `public_role` moves off admin_role's
10
+ * 1. right before the gateway rolls: `public_role` moves off admin_role's
17
11
  * password onto its own. Only the gateway logs in as public_role, so
18
12
  * the window in which the old revision cannot open NEW public
19
13
  * connections is the roll itself (open connections stay up).
20
- * 3. if the gateway's roll fails and it is rolled back: `public_role`
14
+ * 2. if the gateway's roll fails and it is rolled back: `public_role`
21
15
  * goes back to the password the prior revision reads, so the rollback
22
16
  * leaves a working gateway, not a locked-out one.
23
17
  *
24
18
  * On every later upgrade the gateway already reads public_role's own
25
- * secret: step 2 then re-applies the same value (a no-op for clients) and
26
- * step 3 has nothing to undo.
19
+ * secret: step 1 then re-applies the same value (a no-op for clients) and
20
+ * step 2 has nothing to undo.
27
21
  */
28
22
 
29
23
  import { gcloud as defaultGcloud } from "./gcloud.js";
@@ -60,22 +54,14 @@ export interface GatewayCredentialDeps {
60
54
  readonly http?: HttpFetch;
61
55
  }
62
56
 
63
- /** Step 1: `gateway_role`'s password from its secret. */
64
- export function ensureGatewayRolePassword(
65
- target: DatabaseRoleTarget,
66
- deps: GatewayCredentialDeps = {},
67
- ): Promise<EnsureOutcome> {
68
- return ensureDatabaseRolePassword(target, "gateway_role", deps);
69
- }
70
-
71
- /** Steps 2 and 3 for one roll of the gateway. */
57
+ /** Steps 1 and 2 for one roll of the gateway. */
72
58
  export interface PublicRoleSwitch {
73
59
  /** Whether this roll moves public_role off admin_role's password. */
74
60
  readonly moves: boolean;
75
- /** Step 2 — run right before the gateway's `services update`. */
61
+ /** Step 1 — run right before the gateway's `services update`. */
76
62
  beforeGatewayRoll(): Promise<EnsureOutcome>;
77
63
  /**
78
- * Step 3 — run when the gateway's roll failed and it goes back to its
64
+ * Step 2 — run when the gateway's roll failed and it goes back to its
79
65
  * prior revision. Returns what happened, for the operator.
80
66
  */
81
67
  afterGatewayRollFailed(): Promise<string>;
@@ -126,7 +126,7 @@ describe("self-hosted: the generated compose gateway service (#635)", () => {
126
126
  const out = generateDockerCompose({
127
127
  domain: "example.com",
128
128
  postgresPassword: "superpw",
129
- rolePasswords: { admin: "adminpw", public: "publicpw", gateway: "gatewaypw" },
129
+ rolePasswords: { admin: "adminpw", public: "publicpw" },
130
130
  caeloSecretKek: "0".repeat(64),
131
131
  diskSize: "20Gi",
132
132
  images: {
@@ -148,7 +148,12 @@ describe("self-hosted: the generated compose gateway service (#635)", () => {
148
148
  expect(gateway).not.toMatch(/^ {4}(secrets|env_file|volumes):/m);
149
149
  const env = gateway.slice(gateway.indexOf(" environment:"));
150
150
  const keys = [...env.matchAll(/^ {6}([A-Z_][A-Z0-9_]*):/gm)].map((m) => m[1]);
151
- expect(keys.sort()).toEqual(["GATEWAY_PORT", "NODE_ENV", "PUBLIC_DATABASE_URL"]);
151
+ expect(keys.sort()).toEqual([
152
+ "CAELO_TRUSTED_PROXY_HOPS",
153
+ "GATEWAY_PORT",
154
+ "NODE_ENV",
155
+ "PUBLIC_DATABASE_URL",
156
+ ]);
152
157
  expect(out).not.toMatch(/^secrets:/m);
153
158
  });
154
159
  });
package/src/index.test.ts CHANGED
@@ -115,7 +115,7 @@ describe("docker-compose generator", () => {
115
115
  const out = generateDockerCompose({
116
116
  domain: "example.com",
117
117
  postgresPassword: "supersecret",
118
- rolePasswords: { admin: "adminpw", public: "publicpw", gateway: "gatewaypw" },
118
+ rolePasswords: { admin: "adminpw", public: "publicpw" },
119
119
  caeloSecretKek: "0".repeat(64),
120
120
  anthropicApiKey: "sk-ant-…",
121
121
  diskSize: "20Gi",
@@ -136,7 +136,7 @@ describe("docker-compose generator", () => {
136
136
  const out = generateDockerCompose({
137
137
  domain: "example.com",
138
138
  postgresPassword: "supersecret",
139
- rolePasswords: { admin: "adminpw", public: "publicpw", gateway: "gatewaypw" },
139
+ rolePasswords: { admin: "adminpw", public: "publicpw" },
140
140
  caeloSecretKek: "0".repeat(64),
141
141
  diskSize: "20Gi",
142
142
  images: RELEASE_IMAGES,
@@ -150,16 +150,17 @@ describe("docker-compose generator", () => {
150
150
  expect(gateway).not.toMatch(
151
151
  /ADMIN_DATABASE|GATEWAY_DATABASE|cms_admin|admin_role|gateway_role|adminpw|gatewaypw|supersecret|CAELO_SECRET_KEK/,
152
152
  );
153
- // bootstrap.sh still creates the three roles from these (gateway_role
154
- // until the contract release drops it).
155
- expect(out).toContain('GATEWAY_ROLE_PASSWORD: "gatewaypw"');
153
+ // bootstrap.sh creates the two application roles from these (#635: no
154
+ // gateway_role).
155
+ expect(out).toContain('PUBLIC_ROLE_PASSWORD: "publicpw"');
156
+ expect(out).not.toContain("GATEWAY_ROLE_PASSWORD");
156
157
  });
157
158
 
158
159
  it("escapes secrets containing quotes safely", () => {
159
160
  const out = generateDockerCompose({
160
161
  domain: "example.com",
161
162
  postgresPassword: 'pa"ss',
162
- rolePasswords: { admin: "a", public: "b", gateway: "g" },
163
+ rolePasswords: { admin: "a", public: "b" },
163
164
  caeloSecretKek: "0".repeat(64),
164
165
  diskSize: "1Gi",
165
166
  images: RELEASE_IMAGES,
@@ -20,11 +20,13 @@ describe("infrastructure contract steps", () => {
20
20
  );
21
21
  });
22
22
 
23
- it("is at 5: public_role revoke (3), URL-map redirects role (4), no gateway cms_admin login (5)", () => {
24
- expect(INFRA_CONTRACT_VERSION).toBe(5);
23
+ it("is at 7: public_role revoke (3), URL-map redirects role (4), no gateway cms_admin login (5), ops-bucket list (6), gateway proxy hops (7)", () => {
24
+ expect(INFRA_CONTRACT_VERSION).toBe(7);
25
25
  expect(INFRA_CONTRACT_STEPS[3]).toContain("#633");
26
26
  expect(INFRA_CONTRACT_STEPS[4]).toContain("#587");
27
27
  expect(INFRA_CONTRACT_STEPS[5]).toContain("#635");
28
+ expect(INFRA_CONTRACT_STEPS[6]).toContain("#799");
29
+ expect(INFRA_CONTRACT_STEPS[7]).toContain("#845");
28
30
  });
29
31
 
30
32
  it("lists the pending steps oldest first", () => {
@@ -35,6 +37,6 @@ describe("infrastructure contract steps", () => {
35
37
  });
36
38
 
37
39
  it("names a version a newer release added by number", () => {
38
- expect(infraStepsText(5, 6)).toBe("infrastructure step 6 (see that release's notes)");
40
+ expect(infraStepsText(7, 8)).toBe("infrastructure step 8 (see that release's notes)");
39
41
  });
40
42
  });
@@ -22,7 +22,7 @@
22
22
  * whose app-roll references infrastructure an install does not have yet fails
23
23
  * at roll time, after migrations ran.
24
24
  */
25
- export const INFRA_CONTRACT_VERSION = 5;
25
+ export const INFRA_CONTRACT_VERSION = 7;
26
26
 
27
27
  // History (why each bump happened — an install below a number needs the
28
28
  // converge that introduced it):
@@ -40,6 +40,17 @@ export const INFRA_CONTRACT_VERSION = 5;
40
40
  // GATEWAY_DATABASE_PASSWORD go, and its SA loses access to the
41
41
  // gateway-role-password secret (a retired IAM binding, removed after
42
42
  // the roll — the in-cloud upgrader cannot change IAM).
43
+ // 6 — #799: the upgrader's custom role `caeloUpgraderOpsList`
44
+ // (`storage.objects.list` on the ops bucket). Every `gcloud storage cp`
45
+ // into the bucket lists it first; without the binding the in-cloud
46
+ // upgrader fails at its state lock, so it must not run before the
47
+ // converge grants it.
48
+ // 7 — #845: the gateway refuses to boot without CAELO_TRUSTED_PROXY_HOPS
49
+ // (which X-Forwarded-For entry is the visitor). A plain env var, but
50
+ // the in-cloud upgrader rolls with the env contract of the release it
51
+ // runs from (the installed one), which does not set it — so the new
52
+ // gateway would not start. The operator-identity upgrade runs the new
53
+ // release's contract and sets it.
43
54
 
44
55
  /**
45
56
  * What each contract version adds, in operator terms — the upgrader's
@@ -52,6 +63,8 @@ export const INFRA_CONTRACT_STEPS: Readonly<Record<number, string>> = {
52
63
  3: "public_role out of the Cloud SQL superuser group (#633)",
53
64
  4: "the admin's permission to serve redirects from the load balancer (#587)",
54
65
  5: "the gateway's cms_admin login removed from its env and IAM (#635)",
66
+ 6: "the upgrader's permission to list its ops bucket (#799)",
67
+ 7: "the gateway's trusted proxy hop count, CAELO_TRUSTED_PROXY_HOPS (#845)",
55
68
  };
56
69
 
57
70
  /**
@@ -20,7 +20,7 @@
20
20
  * anything. The bucket's convenience bindings for project viewers and
21
21
  * editors are removed; public principals are refused outright. Project
22
22
  * Owners keep access through their project role (that cannot be taken
23
- * away at the bucket); docs-site/pages/install-gcp.md says so.
23
+ * away at the bucket); the GCP install docs say so.
24
24
  *
25
25
  * Every step reads before it writes, so a re-run is a no-op and an operator
26
26
  * without IAM-write permission can still run against an ops backend someone
@@ -46,6 +46,9 @@ export const INFRA_CONTRACT_PATHS: readonly string[] = [
46
46
  // #587 — the admin's URL-map redirects role and the URL map's shape.
47
47
  "packages/provisioning/src/url-map-redirects-access.ts",
48
48
  "packages/provisioning/src/gcp-url-map.ts",
49
+ // The in-cloud upgrader job's service account, custom roles and bucket
50
+ // grants (#799 added one without the checks noticing).
51
+ "packages/provisioning/src/upgrader-setup.ts",
49
52
  ];
50
53
 
51
54
  /**
@@ -296,6 +296,7 @@ describe("release-contract", () => {
296
296
  expect(isInfraContractPath("packages/provisioning/stacks/gcp/index.ts")).toBe(true);
297
297
  expect(isInfraContractPath("packages/provisioning/src/stack-converge.ts")).toBe(true);
298
298
  expect(isInfraContractPath("packages/provisioning/src/upgrade/converge.ts")).toBe(true);
299
+ expect(isInfraContractPath("packages/provisioning/src/upgrader-setup.ts")).toBe(true);
299
300
  expect(isInfraContractPath("packages/provisioning/src/stack-converge.test.ts")).toBe(false);
300
301
  expect(isInfraContractPath("packages/provisioning/stacks/gcp/README.md")).toBe(false);
301
302
  expect(isInfraContractPath("packages/provisioning/src/cli.ts")).toBe(false);
@@ -115,13 +115,7 @@ describe("ensureGeneratedSecrets", () => {
115
115
  { ...secretsInstall, replication: regional },
116
116
  { run },
117
117
  );
118
- expect(outcomes.map((o) => o.status)).toEqual([
119
- "present",
120
- "present",
121
- "present",
122
- "present",
123
- "present",
124
- ]);
118
+ expect(outcomes.map((o) => o.status)).toEqual(["present", "present", "present", "present"]);
125
119
  expect(lines().some((l) => l.includes(" create ") || l.includes(" add "))).toBe(false);
126
120
  });
127
121
 
@@ -132,13 +126,7 @@ describe("ensureGeneratedSecrets", () => {
132
126
  { ...secretsInstall, replication: regional },
133
127
  { run, generate: () => `generated-value-${++n}` },
134
128
  );
135
- expect(outcomes.map((o) => o.status)).toEqual([
136
- "applied",
137
- "applied",
138
- "applied",
139
- "applied",
140
- "applied",
141
- ]);
129
+ expect(outcomes.map((o) => o.status)).toEqual(["applied", "applied", "applied", "applied"]);
142
130
  const creates = calls.filter((c) => c.line.startsWith("secrets create"));
143
131
  expect(creates).toEqual([
144
132
  {
@@ -149,19 +137,15 @@ describe("ensureGeneratedSecrets", () => {
149
137
  line: "secrets create caelo-production-tool-approval-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
150
138
  stdin: "generated-value-2",
151
139
  },
152
- // #613 — the gateway's role passwords: fresh values, never admin_role's.
140
+ // #613 — the gateway's role password: a fresh value, never admin_role's.
153
141
  {
154
142
  line: "secrets create caelo-production-public-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
155
143
  stdin: "generated-value-3",
156
144
  },
157
- {
158
- line: "secrets create caelo-production-gateway-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
159
- stdin: "generated-value-4",
160
- },
161
145
  // #650 — the staging access gate's key.
162
146
  {
163
147
  line: "secrets create caelo-production-staging-gate-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
164
- stdin: "generated-value-5",
148
+ stdin: "generated-value-4",
165
149
  },
166
150
  ]);
167
151
  for (const c of calls) expect(c.line).not.toContain("generated-value");
@@ -186,7 +170,6 @@ describe("ensureGeneratedSecrets", () => {
186
170
  "generated",
187
171
  "generated",
188
172
  "generated",
189
- "generated",
190
173
  ]);
191
174
  for (const c of calls) expect(c.line).not.toContain("operator-set");
192
175
  });
@@ -197,20 +180,13 @@ describe("ensureGeneratedSecrets", () => {
197
180
  "secrets versions list caelo-production-internal-secret": [ok("")],
198
181
  "secrets versions list caelo-production-tool-approval-secret": [ok("v/1")],
199
182
  "secrets versions list caelo-production-public-role-password": [ok("v/1")],
200
- "secrets versions list caelo-production-gateway-role-password": [ok("v/1")],
201
183
  "secrets versions list caelo-production-staging-gate-secret": [ok("v/1")],
202
184
  });
203
185
  const outcomes = await ensureGeneratedSecrets(
204
186
  { ...secretsInstall, replication: { kind: "automatic" } },
205
187
  { run, generate: () => "v" },
206
188
  );
207
- expect(outcomes.map((o) => o.status)).toEqual([
208
- "applied",
209
- "present",
210
- "present",
211
- "present",
212
- "present",
213
- ]);
189
+ expect(outcomes.map((o) => o.status)).toEqual(["applied", "present", "present", "present"]);
214
190
  expect(calls.filter((c) => c.line.startsWith("secrets versions add"))).toEqual([
215
191
  {
216
192
  line: "secrets versions add caelo-production-internal-secret --project=acme --data-file=-",
@@ -244,13 +220,7 @@ describe("ensureGeneratedSecrets", () => {
244
220
  { ...secretsInstall, replication: { kind: "automatic" } },
245
221
  { run, generate: () => "v" },
246
222
  );
247
- expect(outcomes.map((o) => o.status)).toEqual([
248
- "applied",
249
- "applied",
250
- "applied",
251
- "applied",
252
- "applied",
253
- ]);
223
+ expect(outcomes.map((o) => o.status)).toEqual(["applied", "applied", "applied", "applied"]);
254
224
  expect(outcomes.every((o) => o.detail?.includes("gcp.resourceLocations"))).toBe(true);
255
225
  const creates = lines().filter((l) => l.startsWith("secrets create"));
256
226
  // One refused automatic attempt; then every secret — the refused one
@@ -260,7 +230,6 @@ describe("ensureGeneratedSecrets", () => {
260
230
  "secrets create caelo-production-internal-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
261
231
  "secrets create caelo-production-tool-approval-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
262
232
  "secrets create caelo-production-public-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
263
- "secrets create caelo-production-gateway-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
264
233
  // #652 — the staging gate's key follows the same path.
265
234
  "secrets create caelo-production-staging-gate-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
266
235
  ]);
@@ -290,7 +259,7 @@ describe("ensureGeneratedSecrets", () => {
290
259
  { run, generate: () => "v" },
291
260
  );
292
261
  expect(outcomes.every((o) => o.status === "failed")).toBe(true);
293
- expect(lines().filter((l) => l.startsWith("secrets create"))).toHaveLength(5);
262
+ expect(lines().filter((l) => l.startsWith("secrets create"))).toHaveLength(4);
294
263
  });
295
264
  });
296
265
 
@@ -299,7 +268,6 @@ describe("rotationRefusal", () => {
299
268
  for (const s of [
300
269
  "postgres-password",
301
270
  "public-role-password",
302
- "gateway-role-password",
303
271
  "internal-secret",
304
272
  "tool-approval-secret",
305
273
  "staging-gate-secret",
@@ -311,6 +279,8 @@ describe("rotationRefusal", () => {
311
279
  it("refuses the KEK (re-encryption needed) and secrets nothing reads", () => {
312
280
  expect(rotationRefusal("secret-kek")).toContain("re-encryption");
313
281
  expect(rotationRefusal("csrf-secret")).toContain("Rotatable");
282
+ // #635 — the former gateway_role is gone; there is nothing to rotate.
283
+ expect(rotationRefusal("gateway-role-password")).toContain("Rotatable");
314
284
  });
315
285
  });
316
286
 
@@ -411,28 +381,6 @@ describe("rotateRuntimeSecret", () => {
411
381
  ).toBe(true);
412
382
  });
413
383
 
414
- it("gateway-role-password: changes gateway_role only and rolls no service (#613, #635)", async () => {
415
- const { run, lines } = fakeGcloud({
416
- ...converged(),
417
- "secrets versions access": [ok("old-pw")],
418
- });
419
- const sql = fakeSqlApi();
420
- const report = await rotateRuntimeSecret(target, "gateway-role-password", {
421
- run,
422
- http: sql.http,
423
- sleep: async () => {},
424
- generate: () => "new-pw",
425
- });
426
- expect(report.ok).toBe(true);
427
- expect(sql.passwords()).toEqual(["gateway_role new-pw"]);
428
- // No service logs in as gateway_role any more: the gateway reads
429
- // cms_public only (#635).
430
- expect(lines().filter((l) => l.startsWith("run services update "))).toEqual([]);
431
- expect(lines()).toContain(
432
- "secrets versions access latest --secret=caelo-production-gateway-role-password --project=acme",
433
- );
434
- });
435
-
436
384
  it("public-role-password: stores nothing and rolls nothing when public_role can't be changed", async () => {
437
385
  const { run, lines } = fakeGcloud({
438
386
  ...converged(),
@@ -474,26 +422,6 @@ describe("rotateRuntimeSecret", () => {
474
422
  expect(lines().every((l) => l.startsWith("run services describe"))).toBe(true);
475
423
  });
476
424
 
477
- it("refuses gateway-role-password while the gateway still logs in with it (before #635)", async () => {
478
- const answers = converged();
479
- answers["run services describe gw-svc"] = [
480
- service([
481
- ref("GATEWAY_DATABASE_PASSWORD", "gateway-role-password"),
482
- ref("PUBLIC_DATABASE_PASSWORD", "public-role-password"),
483
- ]),
484
- ];
485
- const { run } = fakeGcloud(answers);
486
- const sql = fakeSqlApi();
487
- const report = await rotateRuntimeSecret(target, "gateway-role-password", {
488
- run,
489
- http: sql.http,
490
- generate: () => "new-pw",
491
- });
492
- expect(report.ok).toBe(false);
493
- expect(report.error).toContain("GATEWAY_DATABASE_PASSWORD");
494
- expect(sql.requests).toHaveLength(0);
495
- });
496
-
497
425
  it("refuses before changing anything on an install upgrade hasn't moved over", async () => {
498
426
  const { run, lines } = fakeGcloud({
499
427
  "run services describe adm-svc": [
@@ -13,9 +13,8 @@
13
13
  * Secret Manager on stdin — never on argv, never to disk.
14
14
  * - {@link ensureDatabaseRolePassword}: a database role's password set
15
15
  * from its secret (`DATABASE_ROLE_SECRET`), through the Cloud SQL Admin
16
- * API — how `gateway_role` (created by migration 0248, without a
17
- * password) and `public_role` (moved off admin_role's password, #613)
18
- * get theirs.
16
+ * API — how `public_role` (moved off admin_role's password, #613) gets
17
+ * its own.
19
18
  * - {@link rotateRuntimeSecret}: `cms-provision rotate-secret`.
20
19
  *
21
20
  * Every function reports instead of throwing on a gcloud failure.
@@ -368,7 +367,6 @@ async function addVersion(
368
367
  export const ROTATABLE_SECRETS = [
369
368
  "postgres-password",
370
369
  "public-role-password",
371
- "gateway-role-password",
372
370
  "internal-secret",
373
371
  "tool-approval-secret",
374
372
  // #650 — rotating it signs every staging session out and replaces the
@@ -726,8 +724,6 @@ export async function readSecretValue(
726
724
  * the role already has is a no-op for every client, so upgrade runs this on
727
725
  * every roll and an install converges whatever state it was left in.
728
726
  *
729
- * The role must exist: `gateway_role` is created by migration 0248, so this
730
- * runs after migrations.
731
727
  */
732
728
  export async function ensureDatabaseRolePassword(
733
729
  target: DatabaseRoleTarget,
@@ -19,7 +19,7 @@ const GATEWAY = `ghcr.io/caelo-cms/gateway@sha256:${"b".repeat(64)}`;
19
19
  const base: Omit<ComposeSpec, "images"> = {
20
20
  domain: "example.com",
21
21
  postgresPassword: "pgpw",
22
- rolePasswords: { admin: "adminpw", public: "publicpw", gateway: "gatewaypw" },
22
+ rolePasswords: { admin: "adminpw", public: "publicpw" },
23
23
  caeloSecretKek: "0".repeat(64),
24
24
  anthropicApiKey: "sk-ant-x",
25
25
  diskSize: "20Gi",