@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.
- package/caddy/Caddyfile.production +1 -1
- package/caddy/Caddyfile.staging +1 -1
- package/dist/agent/command.js +1 -0
- package/dist/agent/command.js.map +1 -1
- package/dist/agent/units.js +1 -1
- package/dist/agent/units.js.map +1 -1
- package/dist/agent-db.js +89 -45
- package/dist/aws-task-roles.d.ts +1 -1
- package/dist/aws-task-roles.d.ts.map +1 -1
- package/dist/aws-task-roles.js +0 -1
- package/dist/aws-task-roles.js.map +1 -1
- package/dist/cli-routing.d.ts +20 -0
- package/dist/cli-routing.d.ts.map +1 -1
- package/dist/cli-routing.js +106 -0
- package/dist/cli-routing.js.map +1 -1
- package/dist/cli.js +5 -6
- package/dist/cli.js.map +1 -1
- package/dist/compose.d.ts +4 -3
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +3 -2
- package/dist/compose.js.map +1 -1
- package/dist/gateway-credentials.d.ts +3 -5
- package/dist/gateway-credentials.d.ts.map +1 -1
- package/dist/gateway-credentials.js +8 -18
- package/dist/gateway-credentials.js.map +1 -1
- package/dist/infra-contract.d.ts +1 -1
- package/dist/infra-contract.d.ts.map +1 -1
- package/dist/infra-contract.js +14 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/ops-backend.js +1 -1
- package/dist/release-contract.d.ts.map +1 -1
- package/dist/release-contract.js +3 -0
- package/dist/release-contract.js.map +1 -1
- package/dist/runtime-secrets.d.ts +1 -3
- package/dist/runtime-secrets.d.ts.map +1 -1
- package/dist/runtime-secrets.js +2 -6
- package/dist/runtime-secrets.js.map +1 -1
- package/dist/self-hosted-roles.d.ts +9 -8
- package/dist/self-hosted-roles.d.ts.map +1 -1
- package/dist/self-hosted-roles.js +9 -9
- package/dist/self-hosted-roles.js.map +1 -1
- package/dist/self-hosted-upgrade.d.ts +7 -1
- package/dist/self-hosted-upgrade.d.ts.map +1 -1
- package/dist/self-hosted-upgrade.js +17 -4
- package/dist/self-hosted-upgrade.js.map +1 -1
- package/dist/stack-contract.d.ts +52 -3
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +36 -36
- package/dist/stack-contract.js.map +1 -1
- package/dist/state-store.d.ts +8 -0
- package/dist/state-store.d.ts.map +1 -1
- package/dist/state-store.js +10 -2
- package/dist/state-store.js.map +1 -1
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +5 -13
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrader-job.d.ts.map +1 -1
- package/dist/upgrader-job.js +7 -1
- package/dist/upgrader-job.js.map +1 -1
- package/dist/upgrader-setup.d.ts +5 -0
- package/dist/upgrader-setup.d.ts.map +1 -1
- package/dist/upgrader-setup.js +40 -6
- package/dist/upgrader-setup.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +2 -31
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +7 -5
- package/src/admin-memory.test.ts +1 -1
- package/src/agent/command.ts +1 -0
- package/src/agent/units.ts +1 -1
- package/src/aws-task-roles.ts +0 -2
- package/src/caddy-redirects.docker.test.ts +8 -1
- package/src/caddy-redirects.test.ts +1 -1
- package/src/checkpoint-secrets.test.ts +0 -2
- package/src/cli-routing.test.ts +27 -1
- package/src/cli-routing.ts +131 -0
- package/src/cli.ts +5 -8
- package/src/compose.ts +13 -5
- package/src/error-pages-stacks.test.ts +38 -0
- package/src/gateway-credentials.test.ts +0 -29
- package/src/gateway-credentials.ts +11 -25
- package/src/gateway-env-isolation.test.ts +7 -2
- package/src/index.test.ts +7 -6
- package/src/infra-contract.test.ts +5 -3
- package/src/infra-contract.ts +14 -1
- package/src/ops-backend.ts +1 -1
- package/src/release-contract.ts +3 -0
- package/src/release-manifest.test.ts +1 -0
- package/src/runtime-secrets.test.ts +9 -81
- package/src/runtime-secrets.ts +2 -6
- package/src/self-hosted-compose.test.ts +1 -1
- package/src/self-hosted-roles.integration.test.ts +23 -38
- package/src/self-hosted-roles.test.ts +11 -7
- package/src/self-hosted-roles.ts +17 -16
- package/src/self-hosted-upgrade.test.ts +28 -1
- package/src/self-hosted-upgrade.ts +29 -4
- package/src/stack-contract.test.ts +15 -11
- package/src/stack-contract.ts +40 -5
- package/src/stack-converge-secrets.test.ts +2 -0
- package/src/stack-converge.test.ts +2 -0
- package/src/state-store.ts +11 -2
- package/src/upgrade/converge.ts +5 -19
- package/src/upgrader-job.test.ts +17 -1
- package/src/upgrader-job.ts +13 -3
- package/src/upgrader-setup.test.ts +12 -1
- package/src/upgrader-setup.ts +41 -5
- package/src/welcome-page.test.ts +40 -0
- package/src/wizards/gcp.ts +2 -32
- package/stacks/aws/README.md +3 -1
- package/stacks/aws/index.ts +8 -6
- package/stacks/azure/README.md +3 -1
- package/stacks/azure/index.ts +9 -6
- package/stacks/gcp/README.md +1 -1
- package/stacks/gcp/index.ts +1 -4
- package/stacks/gcp-firebase/index.ts +1 -4
- package/stacks/self-hosted/index.ts +4 -11
- 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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
"
|
|
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
|
|
71
|
-
// The
|
|
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
|
-
"
|
|
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
|
-
//
|
|
91
|
-
const
|
|
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
|
|
94
|
-
|
|
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
|
|
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
|
|
104
|
-
SELECT
|
|
105
|
-
|
|
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"
|
|
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"
|
|
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("
|
|
23
|
+
expect(script).not.toContain("CREATE ROLE gateway_role");
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
-
it("
|
|
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
|
-
|
|
29
|
-
|
|
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
|
|
package/src/self-hosted-roles.ts
CHANGED
|
@@ -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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* gateway
|
|
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"
|
|
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
|
-
|
|
74
|
-
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
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.
|
|
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
|
-
//
|
|
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
|
|
231
|
-
expect(CLI_GENERATED_SECRETS).
|
|
232
|
-
|
|
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
|
-
|
|
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"
|
|
309
|
+
rolePasswords: { admin: "a", public: "b" },
|
|
306
310
|
caeloSecretKek: "k",
|
|
307
311
|
diskSize: "10G",
|
|
308
312
|
ports: { http: 80, https: 443 },
|
package/src/stack-contract.ts
CHANGED
|
@@ -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"
|
|
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
|
|
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
|
});
|
package/src/state-store.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
}
|
package/src/upgrade/converge.ts
CHANGED
|
@@ -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 —
|
|
295
|
-
//
|
|
296
|
-
|
|
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
|
-
|
|
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
|
}
|