@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
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 {
|
|
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
|
|
164
|
-
*
|
|
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 —
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* roll
|
|
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.
|
|
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
|
-
*
|
|
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
|
|
26
|
-
* step
|
|
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
|
-
/**
|
|
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
|
|
61
|
+
/** Step 1 — run right before the gateway's `services update`. */
|
|
76
62
|
beforeGatewayRoll(): Promise<EnsureOutcome>;
|
|
77
63
|
/**
|
|
78
|
-
* Step
|
|
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"
|
|
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([
|
|
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"
|
|
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"
|
|
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
|
|
154
|
-
//
|
|
155
|
-
expect(out).toContain('
|
|
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"
|
|
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
|
|
24
|
-
expect(INFRA_CONTRACT_VERSION).toBe(
|
|
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(
|
|
40
|
+
expect(infraStepsText(7, 8)).toBe("infrastructure step 8 (see that release's notes)");
|
|
39
41
|
});
|
|
40
42
|
});
|
package/src/infra-contract.ts
CHANGED
|
@@ -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 =
|
|
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
|
/**
|
package/src/ops-backend.ts
CHANGED
|
@@ -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);
|
|
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
|
package/src/release-contract.ts
CHANGED
|
@@ -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
|
|
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-
|
|
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(
|
|
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": [
|
package/src/runtime-secrets.ts
CHANGED
|
@@ -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 `
|
|
17
|
-
*
|
|
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"
|
|
22
|
+
rolePasswords: { admin: "adminpw", public: "publicpw" },
|
|
23
23
|
caeloSecretKek: "0".repeat(64),
|
|
24
24
|
anthropicApiKey: "sk-ant-x",
|
|
25
25
|
diskSize: "20Gi",
|