@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
@@ -1,11 +1,12 @@
1
1
  // SPDX-License-Identifier: MPL-2.0
2
2
 
3
3
  /**
4
- * #613 review — `cms-provision up` on a self-hosted volume created before
5
- * the per-role passwords: no `gateway_role`, `public_role` on another
6
- * password. The convergence script, run as the superuser exactly as `up`
7
- * runs it (psql, script on stdin), must leave the roles on the configured
8
- * passwords with the gateway's grants in place — and be safe to re-run.
4
+ * #613 review, #635 — `cms-provision up` on a self-hosted volume from
5
+ * before: `public_role` on another password, and the gateway's former
6
+ * `gateway_role` login still there with its grants. The convergence
7
+ * script, run as the superuser exactly as `up` runs it (psql, script on
8
+ * stdin), must leave the roles on the configured passwords and the former
9
+ * gateway_role gone — and be safe to re-run.
9
10
  *
10
11
  * Runs against the test database with the passwords the environment already
11
12
  * uses, so the other suites' connection URLs keep working afterwards.
@@ -25,9 +26,8 @@ const required = [
25
26
  "POSTGRES_PORT",
26
27
  "ADMIN_ROLE_PASSWORD",
27
28
  "PUBLIC_ROLE_PASSWORD",
28
- "GATEWAY_ROLE_PASSWORD",
29
29
  "ADMIN_DATABASE_URL",
30
- "GATEWAY_DATABASE_URL",
30
+ "PUBLIC_DATABASE_URL",
31
31
  ] as const;
32
32
  const missing = required.filter((name) => !env[name]);
33
33
  if (missing.length > 0) throw new Error(`${missing.join(", ")} required (see .env.example)`);
@@ -63,19 +63,20 @@ const psql: SuperuserPsql = async (script) => {
63
63
  const passwords = {
64
64
  admin: env.ADMIN_ROLE_PASSWORD as string,
65
65
  public: env.PUBLIC_ROLE_PASSWORD as string,
66
- gateway: env.GATEWAY_ROLE_PASSWORD as string,
67
66
  };
68
67
 
69
- describe("self-hosted role convergence against Postgres (#613 review)", () => {
70
- it("brings a pre-#613 volume to the configured roles, grants and passwords — twice", async () => {
71
- // The pre-#613 volume: no gateway_role, public_role on another password.
68
+ describe("self-hosted role convergence against Postgres (#613 review, #635)", () => {
69
+ it("brings an old volume to the configured roles and passwords, without gateway_role — twice", async () => {
70
+ // The old volume: the former gateway_role with a cms_admin grant, and
71
+ // public_role on another password.
72
72
  const reset = await psql(
73
73
  [
74
74
  "\\set ON_ERROR_STOP on",
75
+ "SELECT 'CREATE ROLE gateway_role NOINHERIT LOGIN' WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role') \\gexec",
76
+ "GRANT CONNECT ON DATABASE cms_admin TO gateway_role;",
75
77
  "\\connect cms_admin",
76
- "DROP OWNED BY gateway_role;",
78
+ "GRANT SELECT (gateway_cookie_secret) ON site_settings TO gateway_role;",
77
79
  "\\connect postgres",
78
- "DROP ROLE gateway_role;",
79
80
  "ALTER ROLE public_role WITH PASSWORD 'the-old-shared-password';",
80
81
  "",
81
82
  ].join("\n"),
@@ -87,36 +88,20 @@ describe("self-hosted role convergence against Postgres (#613 review)", () => {
87
88
  expect(r).toEqual({ ok: true });
88
89
  }
89
90
 
90
- // The gateway logs in with its configured password and reads what 0248 grants.
91
- const gateway = new SQL(env.GATEWAY_DATABASE_URL as string);
91
+ // public_role logs in with its configured password again.
92
+ const pub = new SQL(env.PUBLIC_DATABASE_URL as string);
92
93
  try {
93
- const rows = (await gateway.begin(async (tx) => {
94
- await tx.unsafe("SET LOCAL caelo.actor_kind = 'system'");
95
- return tx`SELECT current_user::text AS u, length(gateway_cookie_secret) > 0 AS s FROM site_settings`;
96
- })) as { u: string; s: boolean }[];
97
- expect(rows[0]).toEqual({ u: "gateway_role", s: true });
94
+ const [who] = (await pub`SELECT current_user::text AS u`) as { u: string }[];
95
+ expect(who?.u).toBe("public_role");
98
96
  } finally {
99
- await gateway.end();
97
+ await pub.end();
100
98
  }
99
+ // The former gateway_role is gone, with its grants (#635).
101
100
  const admin = new SQL(env.ADMIN_DATABASE_URL as string);
102
101
  try {
103
- const [attrs] = (await admin`
104
- SELECT rolcanlogin, rolinherit FROM pg_roles WHERE rolname = 'gateway_role'`) as {
105
- rolcanlogin: boolean;
106
- rolinherit: boolean;
107
- }[];
108
- expect(attrs).toEqual({ rolcanlogin: true, rolinherit: false });
109
- const policies = (await admin`
110
- SELECT policyname::text AS p FROM pg_policies WHERE 'gateway_role' = ANY (roles) ORDER BY 1`) as {
111
- p: string;
112
- }[];
113
- expect(policies.map((p) => p.p)).toEqual([
114
- "audit_events_gateway_plugin_only",
115
- // #634 (migration 0252): the visitor paths' row limits. The A/B
116
- // ones went with the experiment tables (#676, migration 0259).
117
- "comment_archive_gateway_approved_only",
118
- "rate_limit_buckets_gateway_keys",
119
- ]);
102
+ const roles =
103
+ (await admin`SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role'`) as unknown[];
104
+ expect(roles).toEqual([]);
120
105
  } finally {
121
106
  await admin.end();
122
107
  }
@@ -8,26 +8,30 @@ import {
8
8
  selfHostedBootstrapScript,
9
9
  } from "./self-hosted-roles.js";
10
10
 
11
- const PASSWORDS = { admin: "a'pw", public: "p-pw", gateway: "g-pw" };
11
+ const PASSWORDS = { admin: "a'pw", public: "p-pw" };
12
12
 
13
13
  describe("roleConvergenceScript (#613 review)", () => {
14
14
  const script = roleConvergenceScript(PASSWORDS);
15
15
 
16
16
  it("creates missing roles and (re)sets every role's password, quoted", () => {
17
- for (const role of ["admin_role", "public_role", "gateway_role"]) {
17
+ for (const role of ["admin_role", "public_role"]) {
18
18
  expect(script).toContain(
19
19
  `WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${role}')`,
20
20
  );
21
21
  }
22
22
  expect(script).toContain("ALTER ROLE admin_role WITH LOGIN PASSWORD 'a''pw';");
23
- expect(script).toContain("ALTER ROLE gateway_role WITH LOGIN PASSWORD 'g-pw';");
23
+ expect(script).not.toContain("CREATE ROLE gateway_role");
24
24
  });
25
25
 
26
- it("re-applies migration 0248's gateway grants in cms_admin, and stops on the first error", () => {
26
+ it("drops the former gateway_role with what it holds in both databases, and stops on the first error (#635)", () => {
27
27
  expect(script.startsWith("\\set ON_ERROR_STOP on")).toBe(true);
28
- expect(script.indexOf("\\connect cms_admin")).toBeLessThan(
29
- script.indexOf("PERFORM caelo_grant_gateway_role()"),
30
- );
28
+ const publicDrop = script.indexOf("\\connect cms_public");
29
+ const adminDrop = script.indexOf("\\connect cms_admin");
30
+ expect(publicDrop).toBeGreaterThan(-1);
31
+ expect(adminDrop).toBeGreaterThan(publicDrop);
32
+ expect(script.match(/DROP OWNED BY gateway_role/g)).toHaveLength(2);
33
+ expect(script.indexOf("THEN DROP ROLE gateway_role;")).toBeGreaterThan(adminDrop);
34
+ expect(script).not.toContain("caelo_grant_gateway_role");
31
35
  });
32
36
  });
33
37
 
@@ -7,13 +7,14 @@
7
7
  * bootstrap.sh creates the application roles, but only on the Postgres
8
8
  * container's FIRST start (docker-entrypoint-initdb.d). An install created
9
9
  * before the per-role passwords has a data volume whose roles have other
10
- * passwords — and no `gateway_role` at all — so emitting Compose URLs with
11
- * the new passwords would lock the admin and the gateway out on the next
12
- * restart. `cms-provision up` therefore runs {@link roleConvergenceScript}
13
- * through the running container as the Postgres superuser BEFORE it writes
14
- * the new Compose file: it creates missing roles and databases, sets each
15
- * role's password to the configured one, and re-applies migration 0248's
16
- * gateway grants. Every statement is idempotent.
10
+ * passwords, so emitting Compose URLs with the new passwords would lock the
11
+ * admin and the gateway out on the next restart. `cms-provision up`
12
+ * therefore runs {@link roleConvergenceScript} through the running container
13
+ * as the Postgres superuser BEFORE it writes the new Compose file: it
14
+ * creates missing roles and databases and sets each role's password to the
15
+ * configured one. It also drops the former `gateway_role` (#635: the
16
+ * gateway has no cms_admin login), with whatever it still holds. Every
17
+ * statement is idempotent.
17
18
  *
18
19
  * Passwords travel on psql's stdin, never on argv.
19
20
  */
@@ -34,7 +35,7 @@ function literal(s: string): string {
34
35
  export const AGENT_DB_ROLE = "caelo_agent";
35
36
 
36
37
  /** The application roles bootstrap.sh and the convergence create. */
37
- const APP_ROLES = ["admin_role", "public_role", "gateway_role"] as const;
38
+ const APP_ROLES = ["admin_role", "public_role"] as const;
38
39
  type AppRole = (typeof APP_ROLES)[number];
39
40
 
40
41
  /** How a role's password appears in the SQL: a literal, or a psql variable. */
@@ -69,12 +70,14 @@ function convergenceSql(password: PasswordSql, agent: boolean): string {
69
70
  "REVOKE ALL ON DATABASE cms_public FROM PUBLIC;",
70
71
  "GRANT CONNECT ON DATABASE cms_admin TO admin_role;",
71
72
  "GRANT CONNECT ON DATABASE cms_public TO public_role, admin_role;",
73
+ // #635 — the gateway's former cms_admin login goes, with whatever it
74
+ // still holds in either database (the superuser may drop what
75
+ // migration 0285 could only revoke).
76
+ "\\connect cms_public",
77
+ "DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role') THEN DROP OWNED BY gateway_role; END IF; END $$;",
72
78
  "\\connect cms_admin",
73
- // A database that ran migration 0248 before gateway_role existed has no
74
- // grants for it yet; one that has not run it yet gets them from it.
75
- "DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'caelo_grant_gateway_role') THEN PERFORM caelo_grant_gateway_role(); END IF; END $$;",
76
- // Migration 0251's staging-gate grants (issue #650), same convergence.
77
- "DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'caelo_grant_gateway_staging_access') THEN PERFORM caelo_grant_gateway_staging_access(); END IF; END $$;",
79
+ "DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role') THEN DROP OWNED BY gateway_role; END IF; END $$;",
80
+ "DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role') THEN DROP ROLE gateway_role; END IF; END $$;",
78
81
  // Migrations 0267 + 0270 (#696): the upgrader role's grants, applied
79
82
  // now that the role exists.
80
83
  "DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'caelo_grant_platform_updater') THEN PERFORM caelo_grant_platform_updater(); END IF; END $$;",
@@ -84,7 +87,7 @@ function convergenceSql(password: PasswordSql, agent: boolean): string {
84
87
 
85
88
  /**
86
89
  * The psql script (run as the superuser, connected to any database) that
87
- * brings roles, databases and the gateway's grants to the configured state.
90
+ * brings roles and databases to the configured state.
88
91
  * Mirrors bootstrap.sh, plus the password reset bootstrap.sh never does.
89
92
  * With `agentPassword`, also the host agent's login (#696 Phase 4c).
90
93
  */
@@ -92,7 +95,6 @@ export function roleConvergenceScript(passwords: RolePasswords, agentPassword?:
92
95
  const byRole: Record<AppRole, string> = {
93
96
  admin_role: passwords.admin,
94
97
  public_role: passwords.public,
95
- gateway_role: passwords.gateway,
96
98
  };
97
99
  return convergenceSql(
98
100
  (role) => literal(role === AGENT_DB_ROLE ? (agentPassword ?? "") : byRole[role]),
@@ -104,7 +106,6 @@ export function roleConvergenceScript(passwords: RolePasswords, agentPassword?:
104
106
  const PASSWORD_ENV: Record<AppRole | typeof AGENT_DB_ROLE, string> = {
105
107
  admin_role: "ADMIN_ROLE_PASSWORD",
106
108
  public_role: "PUBLIC_ROLE_PASSWORD",
107
- gateway_role: "GATEWAY_ROLE_PASSWORD",
108
109
  [AGENT_DB_ROLE]: "AGENT_ROLE_PASSWORD",
109
110
  };
110
111
 
@@ -19,6 +19,7 @@ import {
19
19
  readRelease,
20
20
  recordRelease,
21
21
  } from "./self-hosted-release.js";
22
+ import type { ConvergeOutcome } from "./self-hosted-roles.js";
22
23
  import {
23
24
  composePath,
24
25
  legacyComposePath,
@@ -121,11 +122,15 @@ beforeEach(() => {
121
122
  });
122
123
  afterEach(() => rmSync(dir, { recursive: true, force: true }));
123
124
 
124
- function deps(runner: HostRunner) {
125
+ function deps(runner: HostRunner & { calls: string[] }, roles: ConvergeOutcome = { ok: true }) {
125
126
  return {
126
127
  caeloDir: dir,
127
128
  runner,
128
129
  renderCompose: render,
130
+ convergeRoles: async () => {
131
+ runner.calls.push("converge roles");
132
+ return roles;
133
+ },
129
134
  log,
130
135
  fetchImpl: registry,
131
136
  sleep: async () => {},
@@ -151,6 +156,7 @@ describe("upgradeSelfHosted", () => {
151
156
  "pull",
152
157
  "migrate admin",
153
158
  "migrate public",
159
+ "converge roles",
154
160
  "up caelo-admin",
155
161
  "health admin",
156
162
  "up caelo-gateway",
@@ -269,6 +275,27 @@ describe("upgradeSelfHosted", () => {
269
275
  expect(readFileSync(composePath(dir), "utf8")).toBe(render(OLD));
270
276
  });
271
277
 
278
+ it("converges the database roles on every upgrade, after the migrations (#635: drops gateway_role)", async () => {
279
+ const runner = fakeRunner();
280
+ expect((await upgradeSelfHosted({ version: "0.11.0" }, deps(runner))).ok).toBe(true);
281
+ const s = steps(runner.calls);
282
+ expect(s.indexOf("converge roles")).toBeGreaterThan(s.indexOf("migrate public"));
283
+ expect(s.indexOf("converge roles")).toBeLessThan(s.indexOf("up caelo-admin"));
284
+ });
285
+
286
+ it("restores the Compose file and restarts nothing when the role convergence fails", async () => {
287
+ const runner = fakeRunner();
288
+ const r = await upgradeSelfHosted(
289
+ { version: "0.11.0" },
290
+ deps(runner, { ok: false, reachable: true, error: "role is busy" }),
291
+ );
292
+ expect(r).toMatchObject({ ok: false, stage: "roles", previousRunning: true });
293
+ if (!r.ok) expect(r.error).toContain("role is busy");
294
+ expect(steps(runner.calls).some((x) => x.startsWith("up "))).toBe(false);
295
+ expect(readFileSync(composePath(dir), "utf8")).toBe(render(OLD));
296
+ expect(readRelease(dir)?.version).toBe("0.10.0");
297
+ });
298
+
272
299
  it("rolls back to the previous images when the gateway fails its health check", async () => {
273
300
  let compose = "";
274
301
  const runner = fakeRunner([
@@ -13,9 +13,15 @@
13
13
  * the same `migrate.ts` entrypoint the GCP migrate job runs) while the
14
14
  * old containers keep serving — migrations are expand-only, so the
15
15
  * running release works on the new schema;
16
- * 5. recreate the admin, health-check it (`/_caelo/health`, which proves
16
+ * 5. converge the database roles (the same script `cms-provision up`
17
+ * runs): bootstrap.sh only runs on the data volume's first start, so a
18
+ * role a release retires — #635's gateway_role — is dropped here, on
19
+ * every existing install. After the migrations, because the runner
20
+ * refuses an install that skipped the release the old role's last user
21
+ * was retired in (run-order.ts): past it, nothing running uses it;
22
+ * 6. recreate the admin, health-check it (`/_caelo/health`, which proves
17
23
  * the DB pool and roles); then the gateway (`/healthz`);
18
- * 6. record the release in `.caelo/release.json`.
24
+ * 7. record the release in `.caelo/release.json`.
19
25
  * Any failure after step 3 puts the previous Compose file back; once a
20
26
  * container was recreated the previous images are started again and
21
27
  * health-checked (rollback). The database is never restored automatically:
@@ -52,6 +58,7 @@ import {
52
58
  releaseImages,
53
59
  resolveVerifiedRelease,
54
60
  } from "./self-hosted-release.js";
61
+ import type { ConvergeOutcome } from "./self-hosted-roles.js";
55
62
  import { fetchLatestReleaseTag, RELEASE_SERVICES, releaseTag } from "./upgrade/release-images.js";
56
63
 
57
64
  /** The Compose service names of the two Caelo services. */
@@ -78,6 +85,11 @@ export interface SelfHostedUpgradeDeps {
78
85
  /** Renders the Compose file for `images` from the install's config (secrets included). */
79
86
  readonly renderCompose: (images: ReleaseImages) => string;
80
87
  readonly log: UpgradeLog;
88
+ /**
89
+ * Converges the database roles as the Postgres superuser
90
+ * (`convergeSelfHostedRoles`); drops roles a release retired (#635).
91
+ */
92
+ readonly convergeRoles: () => Promise<ConvergeOutcome>;
81
93
  readonly fetchImpl?: typeof fetch;
82
94
  readonly sleep?: (ms: number) => Promise<void>;
83
95
  readonly now?: () => Date;
@@ -116,6 +128,7 @@ type UpgradeStage =
116
128
  | "backup"
117
129
  | "pull"
118
130
  | "migrate"
131
+ | "roles"
119
132
  | "roll-admin"
120
133
  | "roll-gateway";
121
134
 
@@ -321,7 +334,19 @@ export async function upgradeSelfHosted(
321
334
  );
322
335
  }
323
336
 
324
- // 5. Roll admin, then gateway, each health-checked.
337
+ // 5. Database roles: drops what this release retired (#635 gateway_role).
338
+ log.step("Converging the database roles");
339
+ const roles = await deps.convergeRoles();
340
+ if (!roles.ok) {
341
+ restoreCompose(file, previousCompose);
342
+ return fail(
343
+ "roles",
344
+ `Could not converge the database roles: ${roles.error}\nThe previous release keeps running; the migrations are applied (expand-only). Fix the cause and re-run the upgrade. Database backup: ${backup.dir}`,
345
+ true,
346
+ );
347
+ }
348
+
349
+ // 6. Roll admin, then gateway, each health-checked.
325
350
  for (const service of RELEASE_SERVICES) {
326
351
  const stage = service === "admin" ? "roll-admin" : "roll-gateway";
327
352
  log.phase?.("roll");
@@ -353,7 +378,7 @@ export async function upgradeSelfHosted(
353
378
  log.info(`${service} healthy ✓`);
354
379
  }
355
380
 
356
- // 6. Record.
381
+ // 7. Record.
357
382
  log.phase?.("record");
358
383
  recordRelease(
359
384
  caeloDir,
@@ -158,12 +158,21 @@ describe("gatewayEnvContract", () => {
158
158
  CAELO_PROVIDER: "gcp",
159
159
  CAELO_ENV: "production",
160
160
  MEDIA_STORAGE_URL: "gs://acme-caelo-production-media",
161
+ // #845 — gcp: the LB appends <client>,<LB IP>.
162
+ CAELO_TRUSTED_PROXY_HOPS: "2",
161
163
  PUBLIC_DATABASE_URL: "postgresql://public_role@10.20.0.3:5432/cms_public?sslmode=require",
162
164
  PUBLIC_DATABASE_PASSWORD: "secret:caelo-production-public-role-password:latest",
163
165
  CAELO_STAGING_GATE_SECRET: "secret:caelo-production-staging-gate-secret:latest",
164
166
  });
165
167
  });
166
168
 
169
+ it("#845 — tells the gateway its provider's proxy hop count", () => {
170
+ // Firebase Hosting / the domain mapping: Cloud Run's front end only.
171
+ const fb = asRecord(gatewayEnvContract({ ...base, provider: "gcp-firebase" }));
172
+ expect(fb.CAELO_TRUSTED_PROXY_HOPS).toBe("1");
173
+ expect(asRecord(gateway).CAELO_TRUSTED_PROXY_HOPS).toBe("2");
174
+ });
175
+
167
176
  it("#650 — reads the staging host from the database, not its env", () => {
168
177
  const fb = asRecord(gatewayEnvContract({ ...base, provider: "gcp-firebase" }));
169
178
  expect(fb.CAELO_STAGING_HOST).toBeUndefined();
@@ -223,14 +232,13 @@ describe("database role secrets (#613)", () => {
223
232
  expect(DATABASE_ROLE_SECRET).toEqual({
224
233
  admin_role: "postgres-password",
225
234
  public_role: "public-role-password",
226
- gateway_role: "gateway-role-password",
227
235
  });
228
236
  });
229
237
 
230
- it("the CLI generates the gateway's role passwords (never seeded from admin_role's)", () => {
231
- expect(CLI_GENERATED_SECRETS).toEqual(
232
- expect.arrayContaining(["public-role-password", "gateway-role-password"]),
233
- );
238
+ it("the CLI generates the gateway's role password (never seeded from admin_role's)", () => {
239
+ expect(CLI_GENERATED_SECRETS).toContain("public-role-password");
240
+ // #635 — the former gateway_role is gone; nothing generates its password.
241
+ expect(CLI_GENERATED_SECRETS as readonly string[]).not.toContain("gateway-role-password");
234
242
  });
235
243
 
236
244
  it("retires the gateway SA's read access to admin_role's password", () => {
@@ -287,11 +295,7 @@ describe("runtime secrets", () => {
287
295
  });
288
296
 
289
297
  it("every CLI-generated secret is read by some service", () => {
290
- // gateway-role-password is read by no service since #635: it only keeps
291
- // the legacy gateway_role's password set until the contract release
292
- // drops the role (the previous release's gateway logs in with it while
293
- // an upgrade rolls).
294
- for (const s of CLI_GENERATED_SECRETS.filter((x) => x !== "gateway-role-password")) {
298
+ for (const s of CLI_GENERATED_SECRETS) {
295
299
  expect([...serviceSecrets("admin"), ...serviceSecrets("gateway")]).toContain(s);
296
300
  }
297
301
  });
@@ -302,7 +306,7 @@ describe("self-hosted compose", () => {
302
306
  const yaml = generateDockerCompose({
303
307
  domain: "acme.com",
304
308
  postgresPassword: "p",
305
- rolePasswords: { admin: "a", public: "b", gateway: "g" },
309
+ rolePasswords: { admin: "a", public: "b" },
306
310
  caeloSecretKek: "k",
307
311
  diskSize: "10G",
308
312
  ports: { http: 80, https: 443 },
@@ -34,6 +34,7 @@
34
34
  * Pure module — the stacks import it from `dist/`.
35
35
  */
36
36
 
37
+ import type { SupportedProvider } from "./adapter.js";
37
38
  import {
38
39
  gatewayServiceAccountEmail,
39
40
  gcpBucketName,
@@ -237,14 +238,13 @@ export function adminMediaVolumeTemplate(volume: MediaVolumeContract) {
237
238
  export type RuntimeSecret =
238
239
  | "postgres-password"
239
240
  | "public-role-password"
240
- | "gateway-role-password"
241
241
  | "secret-kek"
242
242
  | "internal-secret"
243
243
  | "tool-approval-secret"
244
244
  | "staging-gate-secret";
245
245
 
246
246
  /** The Postgres roles Caelo's services log in as, with their passwords. */
247
- export type DatabaseRole = "admin_role" | "public_role" | "gateway_role";
247
+ export type DatabaseRole = "admin_role" | "public_role";
248
248
 
249
249
  /**
250
250
  * Each database role's password secret — one per role (#613), so the
@@ -256,7 +256,6 @@ export type DatabaseRole = "admin_role" | "public_role" | "gateway_role";
256
256
  export const DATABASE_ROLE_SECRET: Readonly<Record<DatabaseRole, RuntimeSecret>> = {
257
257
  admin_role: "postgres-password",
258
258
  public_role: "public-role-password",
259
- gateway_role: "gateway-role-password",
260
259
  };
261
260
 
262
261
  /**
@@ -269,10 +268,9 @@ export const DATABASE_ROLE_SECRET: Readonly<Record<DatabaseRole, RuntimeSecret>>
269
268
  export const CLI_GENERATED_SECRETS = [
270
269
  "internal-secret",
271
270
  "tool-approval-secret",
272
- // #613 — the gateway's own role passwords. Generated (never seeded from
271
+ // #613 — the gateway's own role password. Generated (never seeded from
273
272
  // `postgres-password`), so the gateway never learns admin_role's.
274
273
  "public-role-password",
275
- "gateway-role-password",
276
274
  // #650 — the staging access gate's key: signs "Open staging" links and
277
275
  // session cookies, derives the crawler credentials. Read by the admin
278
276
  // (issues) and the gateway (verifies).
@@ -358,6 +356,42 @@ function commonEnv(inputs: ServiceEnvInputs): CloudRunEnvVar[] {
358
356
  ];
359
357
  }
360
358
 
359
+ /**
360
+ * The env var telling the gateway which `X-Forwarded-For` entry is the
361
+ * visitor (apps/api-gateway client-ip.ts): the number of entries the proxies
362
+ * in front of it append, counting the client's own.
363
+ */
364
+ export const TRUSTED_PROXY_HOPS_ENV = "CAELO_TRUSTED_PROXY_HOPS";
365
+
366
+ /**
367
+ * {@link TRUSTED_PROXY_HOPS_ENV} per provider — a property of each
368
+ * adapter's topology (CLAUDE.md §11.B), so it is declared here once and every
369
+ * adapter sets it on its gateway. The gateway refuses to boot without it.
370
+ * A change to an adapter's path to the gateway must update its entry, or
371
+ * rate limits pool every visitor (too high) or trust a forgeable entry (too
372
+ * low).
373
+ */
374
+ export const GATEWAY_TRUSTED_PROXY_HOPS: Readonly<Record<SupportedProvider, number>> = {
375
+ // External Application LB appends `<client>,<LB IP>`; the gateway's
376
+ // ingress is internal-and-LB only, so nothing reaches it around the LB.
377
+ gcp: 2,
378
+ // Cloud Run's front end appends its peer. Behind the Firebase Hosting
379
+ // rewrite that peer is the Hosting CDN edge, and the entry Hosting adds
380
+ // before it cannot be told apart from a forged one (the service is
381
+ // invokable on *.run.app too) — so the edge address is the most that can
382
+ // be trusted. Staging (Cloud Run domain mapping) gets the visitor.
383
+ "gcp-firebase": 1,
384
+ // Caddy without `trusted_proxies` replaces the header with its peer; the
385
+ // gateway is reachable only on the Compose network.
386
+ "self-hosted": 1,
387
+ // CloudFront appends the viewer, the ALB appends CloudFront. (No ECS task
388
+ // definition consumes it yet — see stacks/aws.)
389
+ aws: 2,
390
+ // Container Apps ingress appends its peer; the gateway's ingress is
391
+ // internal to the environment (no Front Door route to it yet).
392
+ azure: 1,
393
+ };
394
+
361
395
  /**
362
396
  * The env of the gateway service. Order is stable so the stacks see no
363
397
  * spurious diffs.
@@ -365,6 +399,7 @@ function commonEnv(inputs: ServiceEnvInputs): CloudRunEnvVar[] {
365
399
  export function gatewayEnvContract<V>(inputs: RuntimeEnvInputs<V>): CloudRunEnvVar<V | string>[] {
366
400
  return [
367
401
  ...commonEnv(inputs),
402
+ { name: TRUSTED_PROXY_HOPS_ENV, value: String(GATEWAY_TRUSTED_PROXY_HOPS[inputs.provider]) },
368
403
  { name: "PUBLIC_DATABASE_URL", value: inputs.databaseUrls.public },
369
404
  ...secretEnv("gateway", inputs.env),
370
405
  ];
@@ -344,6 +344,8 @@ describe("upgrading a v0.10.35 install: the gateway leaves admin_role (#613)", (
344
344
  "ADMIN_DATABASE_URL",
345
345
  // #650 — the staging gate key arrives in the same roll.
346
346
  "CAELO_STAGING_GATE_SECRET",
347
+ // #845 — and which forwarded address is the visitor.
348
+ "CAELO_TRUSTED_PROXY_HOPS",
347
349
  "PUBLIC_DATABASE_PASSWORD",
348
350
  ]);
349
351
  });
@@ -316,6 +316,8 @@ describe("planContractEnv", () => {
316
316
  "--update-secrets=CAELO_STAGING_GATE_SECRET=caelo-production-staging-gate-secret:latest",
317
317
  ]);
318
318
  expect(plan.services.gateway.flags).toEqual([
319
+ // The gateway learns which forwarded address is the visitor (#845).
320
+ "--update-env-vars=CAELO_TRUSTED_PROXY_HOPS=1",
319
321
  "--update-secrets=CAELO_STAGING_GATE_SECRET=caelo-production-staging-gate-secret:latest",
320
322
  ]);
321
323
  });
@@ -162,6 +162,15 @@ export class GcsStateStore implements StateStore {
162
162
  }
163
163
  }
164
164
 
165
+ /**
166
+ * Another run holds the install's state lock. Distinct from every other
167
+ * lock failure (no access, gcloud missing), which callers must not report
168
+ * as "busy" — retrying later will not fix those.
169
+ */
170
+ export class StateLockHeldError extends Error {
171
+ override readonly name = "StateLockHeldError";
172
+ }
173
+
165
174
  /** `gs://<bucket>/install/state.lock` — see {@link acquireBucketStateLock}. */
166
175
  export function bucketStateLockUrl(bucket: string): string {
167
176
  return `gs://${bucket}/${OPS_INSTALL_PREFIX}/state.lock`;
@@ -197,7 +206,7 @@ export function acquireBucketStateLock(
197
206
  if (!r.ok) {
198
207
  if (/PreconditionFailed|412|At least one of the pre-conditions/i.test(r.stderr)) {
199
208
  const held = run(["storage", "cat", url]);
200
- throw new Error(
209
+ throw new StateLockHeldError(
201
210
  `Another run is changing this install's state (${held.ok ? held.stdout.trim() : url}). Re-run once it has finished; if none is running, remove the stale lock: gcloud storage rm ${url}`,
202
211
  );
203
212
  }
@@ -221,7 +230,7 @@ export function acquireLocalStateLock(root: string, purpose: string): () => void
221
230
  writeFileSync(path, lockBody(purpose), { flag: "wx", mode: 0o600 });
222
231
  } catch (e) {
223
232
  if ((e as NodeJS.ErrnoException).code === "EEXIST") {
224
- throw new Error(
233
+ throw new StateLockHeldError(
225
234
  `Another run is changing this install's state (${readFileSync(path, "utf8").trim()}). Re-run once it has finished; if none is running, remove the stale lock: rm ${path}`,
226
235
  );
227
236
  }
@@ -24,11 +24,7 @@
24
24
  * {@link AppRollHooks} the app-roll calls in between, and a part after it.
25
25
  */
26
26
 
27
- import {
28
- ensureGatewayRolePassword,
29
- findSqlInstance,
30
- planPublicRoleSwitch,
31
- } from "../gateway-credentials.js";
27
+ import { findSqlInstance, planPublicRoleSwitch } from "../gateway-credentials.js";
32
28
  import type { GcloudRunner, Sleep } from "../gcloud-retry.js";
33
29
  import { upgraderServiceAccountEmail } from "../gcp-names.js";
34
30
  import type { InstallMetadata } from "../install-state.js";
@@ -291,10 +287,9 @@ export function startConverge(
291
287
  }
292
288
 
293
289
  async function databaseLogins(): Promise<boolean> {
294
- // #613 — migration 0248 creates gateway_role without a password; it gets
295
- // its secret's value before the gateway rolls onto it. public_role
296
- // moves onto its own password right before the gateway's roll.
297
- const s = r.step("Ensuring the gateway's database login (gateway_role)...");
290
+ // #613 — public_role moves onto its own password right before the
291
+ // gateway's roll. The gateway has no other database login (#635).
292
+ const s = r.step("Preparing the gateway's database login (public_role)...");
298
293
  const sqlInstance = await findSqlInstance(install, { run });
299
294
  if (!sqlInstance) {
300
295
  s.failed("Could not find the Cloud SQL instance. Aborting upgrade.");
@@ -303,16 +298,7 @@ export function startConverge(
303
298
  }
304
299
  const roleTarget = { projectId: install.projectId, env: install.env, sqlInstance };
305
300
  const roleDeps = { ...runDeps, ...(deps.http ? { http: deps.http } : {}) };
306
- const gatewayRole = await ensureGatewayRolePassword(roleTarget, roleDeps);
307
- if (gatewayRole.status === "failed") {
308
- s.failed("gateway_role could not be given its password. Aborting upgrade.");
309
- r.error(` ${gatewayRole.error ?? ""}`);
310
- r.warn(
311
- "Migrations ran (0248 creates gateway_role); no traffic was shifted. Fix the above and re-run upgrade.",
312
- );
313
- return false;
314
- }
315
- s.done("gateway_role ready");
301
+ s.done("public_role ready to switch");
316
302
  publicRole = planPublicRoleSwitch(roleTarget, service("gateway").liveEnv, roleDeps);
317
303
  return true;
318
304
  }