@caelo-cms/provisioning 0.10.49 → 0.10.51

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 (191) hide show
  1. package/caddy/Caddyfile.production +4 -0
  2. package/caddy/Caddyfile.staging +4 -0
  3. package/dist/adapter.d.ts +4 -6
  4. package/dist/adapter.d.ts.map +1 -1
  5. package/dist/admin-internal.d.ts +41 -0
  6. package/dist/admin-internal.d.ts.map +1 -0
  7. package/dist/admin-internal.js +169 -0
  8. package/dist/admin-internal.js.map +1 -0
  9. package/dist/agent-db.js +93 -20
  10. package/dist/aws-task-roles.d.ts +27 -0
  11. package/dist/aws-task-roles.d.ts.map +1 -0
  12. package/dist/aws-task-roles.js +35 -0
  13. package/dist/aws-task-roles.js.map +1 -0
  14. package/dist/caddy-redirects.d.ts +15 -0
  15. package/dist/caddy-redirects.d.ts.map +1 -0
  16. package/dist/caddy-redirects.js +65 -0
  17. package/dist/caddy-redirects.js.map +1 -0
  18. package/dist/caddy.d.ts +22 -2
  19. package/dist/caddy.d.ts.map +1 -1
  20. package/dist/caddy.js +66 -17
  21. package/dist/caddy.js.map +1 -1
  22. package/dist/cli-routing.d.ts.map +1 -1
  23. package/dist/cli-routing.js +7 -0
  24. package/dist/cli-routing.js.map +1 -1
  25. package/dist/cli.js +282 -255
  26. package/dist/cli.js.map +1 -1
  27. package/dist/compose.d.ts +54 -2
  28. package/dist/compose.d.ts.map +1 -1
  29. package/dist/compose.js +88 -34
  30. package/dist/compose.js.map +1 -1
  31. package/dist/deployed-release.d.ts +3 -2
  32. package/dist/deployed-release.d.ts.map +1 -1
  33. package/dist/deployed-release.js +12 -5
  34. package/dist/deployed-release.js.map +1 -1
  35. package/dist/gateway-credentials.d.ts.map +1 -1
  36. package/dist/gateway-credentials.js +6 -3
  37. package/dist/gateway-credentials.js.map +1 -1
  38. package/dist/gcloud.d.ts +2 -2
  39. package/dist/gcloud.d.ts.map +1 -1
  40. package/dist/gcloud.js +3 -3
  41. package/dist/gcloud.js.map +1 -1
  42. package/dist/gcp-names.d.ts +12 -0
  43. package/dist/gcp-names.d.ts.map +1 -1
  44. package/dist/gcp-names.js +21 -0
  45. package/dist/gcp-names.js.map +1 -1
  46. package/dist/gcp-url-map.d.ts +27 -0
  47. package/dist/gcp-url-map.d.ts.map +1 -0
  48. package/dist/gcp-url-map.js +62 -0
  49. package/dist/gcp-url-map.js.map +1 -0
  50. package/dist/index.d.ts +0 -1
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +0 -1
  53. package/dist/index.js.map +1 -1
  54. package/dist/infra-contract.d.ts +20 -1
  55. package/dist/infra-contract.d.ts.map +1 -1
  56. package/dist/infra-contract.js +44 -1
  57. package/dist/infra-contract.js.map +1 -1
  58. package/dist/owner-reset.d.ts +37 -0
  59. package/dist/owner-reset.d.ts.map +1 -0
  60. package/dist/owner-reset.js +80 -0
  61. package/dist/owner-reset.js.map +1 -0
  62. package/dist/public-role-isolation.d.ts +19 -0
  63. package/dist/public-role-isolation.d.ts.map +1 -0
  64. package/dist/public-role-isolation.js +69 -0
  65. package/dist/public-role-isolation.js.map +1 -0
  66. package/dist/release-contract.d.ts.map +1 -1
  67. package/dist/release-contract.js +3 -0
  68. package/dist/release-contract.js.map +1 -1
  69. package/dist/release-manifest.d.ts.map +1 -1
  70. package/dist/release-manifest.js +2 -1
  71. package/dist/release-manifest.js.map +1 -1
  72. package/dist/self-hosted-backup.d.ts +86 -0
  73. package/dist/self-hosted-backup.d.ts.map +1 -0
  74. package/dist/self-hosted-backup.js +350 -0
  75. package/dist/self-hosted-backup.js.map +1 -0
  76. package/dist/self-hosted-release.d.ts +12 -3
  77. package/dist/self-hosted-release.d.ts.map +1 -1
  78. package/dist/self-hosted-release.js +6 -4
  79. package/dist/self-hosted-release.js.map +1 -1
  80. package/dist/self-hosted-start.d.ts +50 -0
  81. package/dist/self-hosted-start.d.ts.map +1 -0
  82. package/dist/self-hosted-start.js +59 -0
  83. package/dist/self-hosted-start.js.map +1 -0
  84. package/dist/self-hosted-upgrade.d.ts +30 -0
  85. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  86. package/dist/self-hosted-upgrade.js +29 -7
  87. package/dist/self-hosted-upgrade.js.map +1 -1
  88. package/dist/stack-contract.d.ts +9 -10
  89. package/dist/stack-contract.d.ts.map +1 -1
  90. package/dist/stack-contract.js +21 -10
  91. package/dist/stack-contract.js.map +1 -1
  92. package/dist/staging-admin.d.ts +12 -20
  93. package/dist/staging-admin.d.ts.map +1 -1
  94. package/dist/staging-admin.js +7 -159
  95. package/dist/staging-admin.js.map +1 -1
  96. package/dist/tar-entries.d.ts +14 -0
  97. package/dist/tar-entries.d.ts.map +1 -0
  98. package/dist/tar-entries.js +104 -0
  99. package/dist/tar-entries.js.map +1 -0
  100. package/dist/upgrade/converge.d.ts +2 -2
  101. package/dist/upgrade/converge.d.ts.map +1 -1
  102. package/dist/upgrade/converge.js +44 -2
  103. package/dist/upgrade/converge.js.map +1 -1
  104. package/dist/upgrader-assess.d.ts.map +1 -1
  105. package/dist/upgrader-assess.js +2 -1
  106. package/dist/upgrader-assess.js.map +1 -1
  107. package/dist/url-map-redirects-access.d.ts +15 -0
  108. package/dist/url-map-redirects-access.d.ts.map +1 -0
  109. package/dist/url-map-redirects-access.js +81 -0
  110. package/dist/url-map-redirects-access.js.map +1 -0
  111. package/dist/wizard.d.ts.map +1 -1
  112. package/dist/wizard.js +3 -1
  113. package/dist/wizard.js.map +1 -1
  114. package/dist/wizards/gcp-cost.js +1 -1
  115. package/dist/wizards/gcp.d.ts.map +1 -1
  116. package/dist/wizards/gcp.js +45 -0
  117. package/dist/wizards/gcp.js.map +1 -1
  118. package/package.json +2 -2
  119. package/src/adapter.ts +4 -6
  120. package/src/admin-internal.ts +212 -0
  121. package/src/admin-memory.test.ts +2 -2
  122. package/src/aws-task-roles.ts +58 -0
  123. package/src/caddy-redirects.docker.test.ts +255 -0
  124. package/src/caddy-redirects.test.ts +115 -0
  125. package/src/caddy-redirects.ts +68 -0
  126. package/src/caddy-validate.test.ts +119 -0
  127. package/src/caddy.test.ts +108 -0
  128. package/src/caddy.ts +89 -20
  129. package/src/checkpoint-secrets.test.ts +0 -1
  130. package/src/cli-routing.test.ts +12 -0
  131. package/src/cli-routing.ts +7 -0
  132. package/src/cli.ts +340 -288
  133. package/src/compose.ts +108 -36
  134. package/src/deployed-release.test.ts +22 -4
  135. package/src/deployed-release.ts +14 -7
  136. package/src/gateway-credentials.ts +6 -3
  137. package/src/gateway-env-isolation.test.ts +212 -0
  138. package/src/gcloud.ts +5 -3
  139. package/src/gcp-names.ts +23 -0
  140. package/src/gcp-url-map.test.ts +150 -0
  141. package/src/gcp-url-map.ts +81 -0
  142. package/src/index.test.ts +24 -152
  143. package/src/index.ts +0 -9
  144. package/src/infra-contract.test.ts +40 -0
  145. package/src/infra-contract.ts +47 -1
  146. package/src/owner-reset.test.ts +165 -0
  147. package/src/owner-reset.ts +106 -0
  148. package/src/public-role-isolation.test.ts +89 -0
  149. package/src/public-role-isolation.ts +83 -0
  150. package/src/release-contract.ts +3 -0
  151. package/src/release-manifest.test.ts +12 -0
  152. package/src/release-manifest.ts +2 -1
  153. package/src/runtime-secrets.test.ts +25 -8
  154. package/src/self-hosted-backup.test.ts +344 -0
  155. package/src/self-hosted-backup.ts +480 -0
  156. package/src/self-hosted-compose.test.ts +88 -4
  157. package/src/self-hosted-release.ts +22 -7
  158. package/src/self-hosted-start.test.ts +68 -0
  159. package/src/self-hosted-start.ts +123 -0
  160. package/src/self-hosted-upgrade.ts +35 -8
  161. package/src/stack-contract.test.ts +11 -20
  162. package/src/stack-contract.ts +25 -16
  163. package/src/stack-converge-secrets.test.ts +45 -10
  164. package/src/stack-converge.test.ts +19 -10
  165. package/src/staging-admin.ts +18 -181
  166. package/src/tar-entries.test.ts +52 -0
  167. package/src/tar-entries.ts +116 -0
  168. package/src/upgrade/converge.ts +51 -4
  169. package/src/upgrade/upgrade.test.ts +78 -1
  170. package/src/upgrader-assess.test.ts +22 -0
  171. package/src/upgrader-assess.ts +2 -1
  172. package/src/url-map-redirects-access.test.ts +88 -0
  173. package/src/url-map-redirects-access.ts +89 -0
  174. package/src/wizard.ts +3 -1
  175. package/src/wizards/gcp-cost.ts +1 -1
  176. package/src/wizards/gcp.ts +48 -0
  177. package/stacks/aws/README.md +1 -1
  178. package/stacks/aws/index.ts +89 -45
  179. package/stacks/azure/README.md +1 -1
  180. package/stacks/azure/index.ts +21 -14
  181. package/stacks/gcp/README.md +1 -1
  182. package/stacks/gcp/index.ts +26 -22
  183. package/stacks/gcp-firebase/index.ts +7 -3
  184. package/stacks/self-hosted/Pulumi.yaml +2 -2
  185. package/stacks/self-hosted/README.md +3 -3
  186. package/stacks/self-hosted/index.ts +45 -22
  187. package/dist/redirects-emit.d.ts +0 -52
  188. package/dist/redirects-emit.d.ts.map +0 -1
  189. package/dist/redirects-emit.js +0 -111
  190. package/dist/redirects-emit.js.map +0 -1
  191. package/src/redirects-emit.ts +0 -175
@@ -3,51 +3,25 @@
3
3
  /**
4
4
  * #678 — `staging-domain enable` tells the admin itself what the operator
5
5
  * decided, instead of printing "now set the same host in the admin".
6
- *
7
- * Why an HTTP call to the admin (and not a one-shot Cloud Run job like the
8
- * migrations): the call must hand a result BACK — above all the generated
9
- * basic-auth password, which the CLI prints once. A job can only report
10
- * through Cloud Logging, which would put the password into the project's
11
- * logs. The admin endpoint (`POST /api/internal/staging/provision`) answers
12
- * over TLS straight to this process, and the password is never written
13
- * anywhere (CLAUDE.md §7, #659).
14
- *
15
- * Getting there, as the operator's own gcloud identity — the same path the
16
- * Power-MCP shim takes (packages/mcp-server/src/ingress-auth.ts):
17
- *
18
- * 1. the admin Cloud Run service and its URL;
19
- * 2. the install's internal secret from Secret Manager (in memory only),
20
- * which signs a 5-minute token for scope `staging.provision`;
21
- * 3. a JWT signed as `caelo-mcp@<project>` through the IAM Credentials
22
- * API — the admin's IAP lets that service account in, and every
23
- * IAP-allowlisted operator may sign as it (`cms-provision upgrade`
24
- * grants it) — so the request passes IAP first, then Caelo's token.
6
+ * It calls `POST /api/internal/staging/provision` through admin-internal.ts
7
+ * (IAP as caelo-mcp + the internal token, scope `staging.provision`), because
8
+ * the answer carries the generated basic-auth password back to the CLI, which
9
+ * prints it once and never stores it.
25
10
  */
26
11
 
27
- import { findService } from "./admin-domain.js";
28
- import { gcloud as defaultGcloud, type GcloudResult } from "./gcloud.js";
29
- import { GCP_STACK_ENV, gcpSecretId, mcpIapServiceAccountEmail } from "./gcp-names.js";
30
- import { mintInternalToken } from "./internal-token.js";
31
-
32
- type Run = (args: string[]) => Promise<GcloudResult>;
12
+ import {
13
+ type AdminInternalDeps,
14
+ type AdminInternalResult,
15
+ type AdminInternalTarget,
16
+ callAdminInternal,
17
+ } from "./admin-internal.js";
33
18
 
34
19
  /** Token scope the admin endpoint checks (apps/admin …/api/internal/staging/provision). */
35
20
  export const STAGING_PROVISION_SCOPE = "staging.provision";
36
21
  const ENDPOINT = "/api/internal/staging/provision";
37
- /** Signed JWTs live at most 1 hour; one call needs a few seconds. */
38
- const IAP_JWT_LIFETIME_S = 600;
39
22
 
40
- export interface StagingAdminTarget {
41
- readonly projectId: string;
42
- readonly region: string;
43
- }
44
-
45
- export interface StagingAdminDeps {
46
- readonly run?: Run;
47
- readonly fetch?: typeof fetch;
48
- /** Seconds since epoch (IAP JWT); tests pin it. */
49
- readonly now?: () => number;
50
- }
23
+ export type StagingAdminTarget = AdminInternalTarget;
24
+ export type StagingAdminDeps = AdminInternalDeps;
51
25
 
52
26
  /** Main's draft pages, as the admin reports them. */
53
27
  export interface DraftPages {
@@ -80,7 +54,7 @@ export interface StagingApply {
80
54
  readonly draftPages: DraftPages;
81
55
  }
82
56
 
83
- export type StagingAdminResult<T> = { ok: true; value: T } | { ok: false; error: string };
57
+ export type StagingAdminResult<T> = AdminInternalResult<T>;
84
58
 
85
59
  export interface ApplyRequest {
86
60
  readonly host?: string;
@@ -110,149 +84,12 @@ async function callStagingAdmin<T>(
110
84
  body: Record<string, unknown>,
111
85
  deps: StagingAdminDeps,
112
86
  ): Promise<StagingAdminResult<T>> {
113
- const run = deps.run ?? defaultGcloud;
114
- const doFetch = deps.fetch ?? fetch;
115
- const scope = [`--region=${target.region}`, `--project=${target.projectId}`];
116
-
117
- const found = await findService(run, scope, "admin");
118
- if (!found.ok) return { ok: false, error: found.error };
119
- const url = await run([
120
- "run",
121
- "services",
122
- "describe",
123
- found.service,
124
- ...scope,
125
- "--format=value(status.url)",
126
- ]);
127
- const adminUrl = url.stdout.trim();
128
- if (!url.ok || !adminUrl) {
129
- return { ok: false, error: `read the admin's URL: ${url.stderr.trim() || "empty"}` };
130
- }
131
-
132
- const secretId = gcpSecretId(GCP_STACK_ENV, "internal-secret");
133
- const secret = await run([
134
- "secrets",
135
- "versions",
136
- "access",
137
- "latest",
138
- `--secret=${secretId}`,
139
- `--project=${target.projectId}`,
140
- ]);
141
- const internalSecret = secret.stdout.trim();
142
- if (!secret.ok || internalSecret.length < 32) {
143
- return {
144
- ok: false,
145
- error: `read the install's internal secret (${secretId}) from Secret Manager: ${secret.stderr.trim() || "missing or too short"}. Your gcloud account needs secretmanager.versions.access on the project.`,
146
- };
147
- }
148
-
149
- // Installs from before #678 may not have the IAM Credentials API on;
150
- // enabling an enabled API is a no-op (codex review on #681).
151
- const api = await run([
152
- "services",
153
- "enable",
154
- "iamcredentials.googleapis.com",
155
- `--project=${target.projectId}`,
156
- ]);
157
- if (!api.ok) {
158
- return {
159
- ok: false,
160
- error: `enable the IAM Credentials API (iamcredentials.googleapis.com), needed to pass the admin's IAP: ${api.stderr.trim()}`,
161
- };
162
- }
163
- const iap = await signIapJwt(run, doFetch, target.projectId, adminUrl, deps.now);
164
- if (!iap.ok) return iap;
165
-
166
- const token = await mintInternalToken(internalSecret, STAGING_PROVISION_SCOPE);
167
- let res: Response;
168
- try {
169
- res = await doFetch(`${adminUrl}${ENDPOINT}`, {
170
- method: "POST",
171
- headers: {
172
- authorization: `Bearer ${iap.value}`,
173
- "x-caelo-internal-token": token,
174
- "content-type": "application/json",
175
- },
176
- body: JSON.stringify(body),
177
- });
178
- } catch (e) {
179
- return { ok: false, error: `reach the admin at ${adminUrl}: ${String(e)}` };
180
- }
181
- const text = await res.text();
182
- if (res.headers.get("x-goog-iap-generated-response") === "true") {
183
- return {
184
- ok: false,
185
- error: `Google IAP refused the request (HTTP ${res.status}: ${text.trim().slice(0, 200)}) — ${mcpIapServiceAccountEmail(target.projectId)} is not allowed on the admin's IAP yet. Run \`cms-provision upgrade\` (it allowlists that service account), then run this command again.`,
186
- };
187
- }
188
- if (res.status === 404) {
189
- return {
190
- ok: false,
191
- error: `the admin has no ${ENDPOINT} yet (it runs a release from before #678). Run \`cms-provision upgrade\`, then run this command again.`,
192
- };
193
- }
194
- let parsed: { ok?: boolean; error?: string } & Record<string, unknown>;
195
- try {
196
- parsed = JSON.parse(text) as typeof parsed;
197
- } catch {
198
- return {
199
- ok: false,
200
- error: `the admin answered HTTP ${res.status} with a non-JSON body: ${text.slice(0, 200)}`,
201
- };
202
- }
203
- if (!res.ok || parsed.ok !== true) {
204
- return {
205
- ok: false,
206
- error: `the admin refused (HTTP ${res.status}): ${parsed.error ?? text.slice(0, 300)}`,
207
- };
208
- }
209
- return { ok: true, value: parsed as unknown as T };
210
- }
211
-
212
- /** A JWT signed as the install's MCP service account, for the admin's IAP. */
213
- async function signIapJwt(
214
- run: Run,
215
- doFetch: typeof fetch,
216
- projectId: string,
217
- adminUrl: string,
218
- now: (() => number) | undefined,
219
- ): Promise<StagingAdminResult<string>> {
220
- const access = await run(["auth", "print-access-token"]);
221
- const accessToken = access.stdout.trim();
222
- if (!access.ok || !accessToken) {
223
- return {
224
- ok: false,
225
- error: `get an access token from gcloud: ${access.stderr.trim() || "empty"}. Run \`gcloud auth login\` with the account you provisioned with.`,
226
- };
227
- }
228
- const serviceAccount = mcpIapServiceAccountEmail(projectId);
229
- const iat = now ? now() : Math.floor(Date.now() / 1000);
230
- const res = await doFetch(
231
- `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${encodeURIComponent(serviceAccount)}:signJwt`,
232
- {
233
- method: "POST",
234
- headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json" },
235
- body: JSON.stringify({
236
- payload: JSON.stringify({
237
- iss: serviceAccount,
238
- sub: serviceAccount,
239
- aud: `${new URL(adminUrl).origin}/*`,
240
- iat,
241
- exp: iat + IAP_JWT_LIFETIME_S,
242
- }),
243
- }),
244
- },
87
+ const r = await callAdminInternal<T>(
88
+ target,
89
+ { endpoint: ENDPOINT, scope: STAGING_PROVISION_SCOPE, body, introducedBy: "#678" },
90
+ deps,
245
91
  );
246
- if (!res.ok) {
247
- const detail = (await res.text().catch(() => "")).slice(0, 300);
248
- return {
249
- ok: false,
250
- error: `sign an IAP token as ${serviceAccount} (HTTP ${res.status}): ${detail} — your account needs roles/iam.serviceAccountTokenCreator on it; \`cms-provision upgrade\` grants it to everyone the admin's IAP lets in.`,
251
- };
252
- }
253
- const { signedJwt } = (await res.json()) as { signedJwt?: string };
254
- if (!signedJwt) return { ok: false, error: "IAM signJwt answered without a signedJwt" };
255
- return { ok: true, value: signedJwt };
92
+ return r.ok ? { ok: true, value: r.value.answer } : r;
256
93
  }
257
94
 
258
95
  /**
@@ -0,0 +1,52 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
4
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { bunHostRunner } from "./self-hosted-release.js";
8
+ import { escapesRoot, listTarEntries, normalizeMemberName } from "./tar-entries.js";
9
+
10
+ let dir: string;
11
+ beforeEach(() => {
12
+ dir = mkdtempSync(join(tmpdir(), "caelo-tar-test-"));
13
+ });
14
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
15
+
16
+ describe("listTarEntries", () => {
17
+ it("reads files, directories, symlinks and long (>100 byte) names from the host's tar", async () => {
18
+ const root = join(dir, "src");
19
+ const long = `${"d".repeat(60)}/${"e".repeat(60)}/${"f".repeat(30)}.txt`;
20
+ mkdirSync(join(root, long, ".."), { recursive: true });
21
+ writeFileSync(join(root, long), "x");
22
+ writeFileSync(join(root, "ä-ü.txt"), "umlaut");
23
+ symlinkSync("target", join(root, "link"));
24
+ const out = join(dir, "a.tar");
25
+ const r = await bunHostRunner.run(["tar", "-cf", out, "-C", root, "."], {
26
+ env: { COPYFILE_DISABLE: "1" },
27
+ });
28
+ expect(r.exitCode).toBe(0);
29
+ const entries = listTarEntries(readFileSync(out));
30
+ const byName = new Map(entries.map((e) => [normalizeMemberName(e.name), e]));
31
+ expect(byName.get(long)?.type).toBe("file");
32
+ expect(byName.get("ä-ü.txt")?.type).toBe("file");
33
+ expect(byName.get("d".repeat(60))?.type).toBe("directory");
34
+ expect(byName.get("link")).toMatchObject({ type: "symlink", linkTarget: "target" });
35
+ });
36
+
37
+ it("refuses a truncated archive instead of skipping members", () => {
38
+ const header = new Uint8Array(512);
39
+ header.set(new TextEncoder().encode("a.txt"), 0);
40
+ header.set(new TextEncoder().encode("00000001000\0"), 124); // 512 bytes of data, none present
41
+ header[156] = "0".charCodeAt(0);
42
+ expect(() => listTarEntries(header)).toThrow(/truncated/);
43
+ });
44
+ });
45
+
46
+ describe("escapesRoot", () => {
47
+ it("flags absolute and parent-relative member names", () => {
48
+ expect(escapesRoot("/etc/passwd")).toBe(true);
49
+ expect(escapesRoot("srv/caelo/../../app")).toBe(true);
50
+ expect(escapesRoot("./srv/caelo/a..b")).toBe(false);
51
+ });
52
+ });
@@ -0,0 +1,116 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Read the member list of a tar archive WITHOUT extracting it, so a restore
5
+ * can refuse an archive before `tar -x` writes anything (PR #755 review: an
6
+ * absolute or `../` member, or a member written through a symlink, would
7
+ * otherwise land outside the target directory).
8
+ *
9
+ * Parses ustar headers plus the two extensions GNU tar and bsdtar emit for
10
+ * long names: GNU `L`/`K` records and pax `x`/`g` records (`path=`,
11
+ * `linkpath=`). Anything it cannot read is an error, never a skipped member.
12
+ */
13
+
14
+ const BLOCK = 512;
15
+
16
+ export type TarEntryType = "file" | "directory" | "symlink" | "other";
17
+
18
+ export interface TarEntry {
19
+ readonly name: string;
20
+ readonly type: TarEntryType;
21
+ /** The link target, for symlinks. */
22
+ readonly linkTarget?: string;
23
+ }
24
+
25
+ /** Every member of an (uncompressed) tar archive, in archive order. */
26
+ export function listTarEntries(tar: Uint8Array): TarEntry[] {
27
+ const entries: TarEntry[] = [];
28
+ let offset = 0;
29
+ let longName: string | null = null;
30
+ let longLink: string | null = null;
31
+ let paxPath: string | null = null;
32
+ let paxLink: string | null = null;
33
+ while (offset + BLOCK <= tar.length) {
34
+ const header = tar.subarray(offset, offset + BLOCK);
35
+ if (header.every((b) => b === 0)) break; // end-of-archive marker
36
+ const size = octal(header.subarray(124, 136));
37
+ const typeflag = String.fromCharCode(header[156] ?? 0);
38
+ const body = tar.subarray(offset + BLOCK, offset + BLOCK + size);
39
+ if (body.length < size) throw new Error("truncated tar archive");
40
+ offset += BLOCK + Math.ceil(size / BLOCK) * BLOCK;
41
+
42
+ if (typeflag === "L") {
43
+ longName = cString(body);
44
+ continue;
45
+ }
46
+ if (typeflag === "K") {
47
+ longLink = cString(body);
48
+ continue;
49
+ }
50
+ if (typeflag === "x" || typeflag === "g") {
51
+ const pax = paxRecords(body);
52
+ if (typeflag === "x") {
53
+ paxPath = pax.path ?? null;
54
+ paxLink = pax.linkpath ?? null;
55
+ }
56
+ continue;
57
+ }
58
+ const prefix = cString(header.subarray(345, 500));
59
+ const shortName = cString(header.subarray(0, 100));
60
+ const name = paxPath ?? longName ?? (prefix ? `${prefix}/${shortName}` : shortName);
61
+ const link = paxLink ?? longLink ?? cString(header.subarray(157, 257));
62
+ longName = longLink = paxPath = paxLink = null;
63
+ const type: TarEntryType =
64
+ typeflag === "0" || typeflag === "\0" || typeflag === "7"
65
+ ? "file"
66
+ : typeflag === "5"
67
+ ? "directory"
68
+ : typeflag === "2"
69
+ ? "symlink"
70
+ : "other";
71
+ entries.push(type === "symlink" ? { name, type, linkTarget: link } : { name, type });
72
+ }
73
+ return entries;
74
+ }
75
+
76
+ /** `./a/b/` → `a/b`; the archive root (`./`) → "". */
77
+ export function normalizeMemberName(name: string): string {
78
+ return name.replace(/^(\.\/)+/, "").replace(/\/+$/, "");
79
+ }
80
+
81
+ /** Whether a member name could write outside the extraction directory. */
82
+ export function escapesRoot(name: string): boolean {
83
+ return name.startsWith("/") || name.split("/").some((part) => part === "..");
84
+ }
85
+
86
+ function octal(field: Uint8Array): number {
87
+ const text = cString(field).trim();
88
+ if (text === "") return 0;
89
+ if (!/^[0-7]+$/.test(text)) throw new Error(`unreadable tar header size ${JSON.stringify(text)}`);
90
+ return Number.parseInt(text, 8);
91
+ }
92
+
93
+ function cString(bytes: Uint8Array): string {
94
+ const end = bytes.indexOf(0);
95
+ return new TextDecoder().decode(end === -1 ? bytes : bytes.subarray(0, end));
96
+ }
97
+
98
+ /** pax records: `<len> <key>=<value>\n`, repeated; `<len>` counts bytes. */
99
+ function paxRecords(body: Uint8Array): Record<string, string> {
100
+ const out: Record<string, string> = {};
101
+ const decode = (b: Uint8Array) => new TextDecoder().decode(b);
102
+ let i = 0;
103
+ while (i < body.length && body[i] !== 0) {
104
+ const space = body.indexOf(0x20, i);
105
+ if (space === -1) throw new Error("unreadable pax header");
106
+ const len = Number.parseInt(decode(body.subarray(i, space)), 10);
107
+ if (!Number.isFinite(len) || len <= 0 || i + len > body.length) {
108
+ throw new Error("unreadable pax header");
109
+ }
110
+ const record = decode(body.subarray(space + 1, i + len - 1));
111
+ const eq = record.indexOf("=");
112
+ if (eq !== -1) out[record.slice(0, eq)] = record.slice(eq + 1);
113
+ i += len;
114
+ }
115
+ return out;
116
+ }
@@ -5,8 +5,9 @@
5
5
  * service accounts, secrets or other cloud resources so the install matches
6
6
  * what the release's stacks declare — the gateway's run SA and the
7
7
  * CLI-generated runtime secrets, the stack's IAM/CDN/firewall invariants,
8
- * MCP access through IAP, the gateway's and public_role's database logins,
9
- * the operator-access sync job, and dropping bindings nothing reads any more.
8
+ * MCP access through IAP, public_role's Cloud SQL role memberships (#633),
9
+ * the gateway's and public_role's database logins, the operator-access sync
10
+ * job, and dropping bindings nothing reads any more.
10
11
  *
11
12
  * It needs the rights of whoever provisioned the install, so only
12
13
  * `cms-provision upgrade` (the operator's own identity) runs it — never the
@@ -33,6 +34,7 @@ import { upgraderServiceAccountEmail } from "../gcp-names.js";
33
34
  import type { InstallMetadata } from "../install-state.js";
34
35
  import { ensureMcpIapAccess, type IapResource } from "../mcp-iap.js";
35
36
  import { ensureOperatorAccessSync, resolveOperatorAccessTarget } from "../operator-access.js";
37
+ import { revokePublicRoleMemberships } from "../public-role-isolation.js";
36
38
  import {
37
39
  ensureGatewayServiceAccount,
38
40
  ensureGeneratedSecrets,
@@ -52,6 +54,7 @@ import {
52
54
  removeRetiredIamBindings,
53
55
  } from "../stack-converge.js";
54
56
  import { ensureUpgraderJob, resolveUpgraderTarget } from "../upgrader-setup.js";
57
+ import { ensureUrlMapRedirectsAccess } from "../url-map-redirects-access.js";
55
58
  import type { AppRollHooks, AppRollPlan, RollPlan, RollSlug } from "./app-roll.js";
56
59
  import type { UpgradeReporter } from "./reporter.js";
57
60
 
@@ -90,8 +93,8 @@ export interface ConvergeDeps {
90
93
 
91
94
  export interface ConvergeSession {
92
95
  /**
93
- * Before the app-roll: runtime identities + secrets, stack invariants, MCP
94
- * access. False when a step the install cannot work without failed —
96
+ * Before the app-roll: runtime identities + secrets, stack invariants,
97
+ * public_role's Cloud SQL role memberships (#633), MCP access. False when a step the install cannot work without failed —
95
98
  * nothing app-side changed then.
96
99
  */
97
100
  beforeAppRoll(): Promise<boolean>;
@@ -195,6 +198,25 @@ export function startConverge(
195
198
  return true;
196
199
  }
197
200
 
201
+ async function publicRoleIsolation(): Promise<boolean> {
202
+ // #633 — the Admin API made public_role a member of cloudsqlsuperuser.
203
+ // Revoked here, before migrations, because the migration job's role
204
+ // audit is what verifies it and fails the upgrade while it remains.
205
+ const s = r.step("Removing public_role from the Cloud SQL superuser group...");
206
+ const outcome = await revokePublicRoleMemberships(
207
+ { projectId: install.projectId, env: install.env },
208
+ runDeps,
209
+ );
210
+ if (outcome.status === "failed") {
211
+ s.failed("public_role's role memberships could not be revoked. Aborting upgrade.");
212
+ r.error(` ${outcome.error ?? ""}`);
213
+ r.warn("No traffic was shifted and no migrations ran. Fix the above and re-run.");
214
+ return false;
215
+ }
216
+ s.done(`public_role holds no database roles (${outcome.detail ?? "revoked"})`);
217
+ return true;
218
+ }
219
+
198
220
  async function mcpAccess(): Promise<void> {
199
221
  // Issue #37 — the MCP service account + its IAP/token-creator bindings.
200
222
  // Also what the deep health check signs its IAP credential as. A failure
@@ -227,6 +249,29 @@ export function startConverge(
227
249
  leaveUntouched.push(MCP_ENV_VAR);
228
250
  }
229
251
 
252
+ async function urlMapRedirectsAccess(): Promise<void> {
253
+ // #587 — on `gcp` the admin writes the redirects table into the URL map
254
+ // on Confirm-publish, through a custom role the stack declares. Without
255
+ // it Confirm-publish fails loudly (403); the rest of the install works,
256
+ // so a failure here warns.
257
+ if (install.provider !== "gcp") return;
258
+ const s = r.step("Ensuring the admin may write redirects into the URL map...");
259
+ const access = await ensureUrlMapRedirectsAccess({
260
+ projectId: install.projectId,
261
+ env: install.env,
262
+ ...runDeps,
263
+ });
264
+ if (access.ok) {
265
+ s.done(`URL-map redirects role ready (${access.role})`);
266
+ return;
267
+ }
268
+ s.warned(`URL-map redirects role not set up: ${access.error}`);
269
+ r.warn(
270
+ "The upgrade continues; Confirm-publish fails with a permission error until this succeeds. Re-run upgrade with an account that may manage IAM roles.",
271
+ );
272
+ incomplete = true;
273
+ }
274
+
230
275
  /** The admin's IAP resource: the service on gcp-firebase, the LB backend on gcp. */
231
276
  async function adminIapResource(): Promise<IapResource | null> {
232
277
  if (install.provider === "gcp-firebase") {
@@ -372,7 +417,9 @@ export function startConverge(
372
417
  if (deps.provisionerIdentity && !(await deps.provisionerIdentity())) return false;
373
418
  if (!(await runtimeIdentities())) return false;
374
419
  if (!(await stackInvariants())) return false;
420
+ if (!(await publicRoleIsolation())) return false;
375
421
  await mcpAccess();
422
+ await urlMapRedirectsAccess();
376
423
  return true;
377
424
  },
378
425
 
@@ -179,6 +179,9 @@ const TARGET: GcpUpgradeTarget = {
179
179
  skipHealthCheck: false,
180
180
  };
181
181
 
182
+ /** The same install on the `gcp` provider (load balancer + URL map). */
183
+ const GCP_TARGET: GcpUpgradeTarget = { ...TARGET, provider: "gcp" };
184
+
182
185
  function upgradeDeps(
183
186
  events: string[],
184
187
  opts: { overrides?: [RegExp, Answer][]; deep?: HealthOutcome } = {},
@@ -217,7 +220,7 @@ function upgradeDeps(
217
220
 
218
221
  /** Calls that change IAM, service accounts, secrets or APIs — converge-only. */
219
222
  const CONVERGE_WRITE =
220
- /add-iam-policy-binding|remove-iam-policy-binding|set-iam-policy|service-accounts create|secrets create|secrets versions add|services enable|roles create|firewall-rules delete/;
223
+ /add-iam-policy-binding|remove-iam-policy-binding|set-iam-policy|service-accounts create|secrets create|secrets versions add|services enable|roles create|roles update|roles undelete|firewall-rules delete|users assign-roles/;
221
224
  /** Calls that move a service or run migrations — app-roll-only. */
222
225
  const APP_ROLL_WRITE =
223
226
  /^run services update |^run services update-traffic|jobs execute caelo-migrate/;
@@ -383,6 +386,80 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
383
386
  expect(calls.some((c) => c.includes("remove-iam-policy-binding"))).toBe(false);
384
387
  });
385
388
 
389
+ it("revokes public_role's Cloud SQL role memberships before the migrations audit them (#633)", async () => {
390
+ const events: string[] = [];
391
+ const { deps, lines } = upgradeDeps(events);
392
+ expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
393
+ const revoke = indexOf(
394
+ events,
395
+ /^gcloud sql users assign-roles public_role --instance=caelo-production-pg-abc --type=BUILT_IN --revoke-existing-roles --project=acme/,
396
+ );
397
+ expect(indexOf(events, /^gcloud sql backups create/)).toBeLessThan(revoke);
398
+ expect(revoke).toBeLessThan(indexOf(events, /^migrate /));
399
+ expect(lines).toContain(
400
+ "done: public_role holds no database roles (no database roles left on caelo-production-pg-abc)",
401
+ );
402
+ });
403
+
404
+ it("a refused public_role revoke stops the upgrade before migrations and any roll (#633)", async () => {
405
+ const events: string[] = [];
406
+ const { deps, calls, lines } = upgradeDeps(events, {
407
+ overrides: [[/^sql users assign-roles public_role/, fail("PERMISSION_DENIED: no")]],
408
+ });
409
+ expect(await upgradeGcpInstall(TARGET, deps)).toBe(false);
410
+ expect(events.some((e) => e.startsWith("migrate"))).toBe(false);
411
+ expect(calls.filter((c) => APP_ROLL_WRITE.test(c))).toEqual([]);
412
+ expect(lines).toContain(
413
+ "failed: public_role's role memberships could not be revoked. Aborting upgrade.",
414
+ );
415
+ });
416
+
417
+ it("on gcp, runs both contract steps: the public_role revoke (3) and the URL-map redirects role (4)", async () => {
418
+ const events: string[] = [];
419
+ const { deps, lines } = upgradeDeps(events, {
420
+ overrides: [[/^iam roles describe/, fail("NOT_FOUND: role")]],
421
+ });
422
+ expect(await upgradeGcpInstall({ ...GCP_TARGET, infraContractVersion: 4 }, deps)).toBe(true);
423
+ const revoke = indexOf(events, /^gcloud sql users assign-roles public_role /);
424
+ const role = indexOf(
425
+ events,
426
+ /^gcloud iam roles create caelo_production_urlMapRedirects --project=acme .*--permissions=compute\.urlMaps\.list,/,
427
+ );
428
+ const grant = indexOf(
429
+ events,
430
+ /^gcloud projects add-iam-policy-binding acme --member=serviceAccount:caelo-production-run-sa@acme\.iam\.gserviceaccount\.com --role=projects\/acme\/roles\/caelo_production_urlMapRedirects /,
431
+ );
432
+ expect(revoke).toBeLessThan(indexOf(events, /^migrate /));
433
+ expect(role).toBeLessThan(grant);
434
+ expect(grant).toBeLessThan(indexOf(events, /^migrate /));
435
+ expect(lines).toContain(
436
+ "done: URL-map redirects role ready (projects/acme/roles/caelo_production_urlMapRedirects)",
437
+ );
438
+ // (This fake models gcp-firebase's listings, so gcp-only steps such as
439
+ // the default-firewall cleanup stay incomplete; the role step is not.)
440
+ expect(lines.some((l) => l.includes("URL-map redirects role not set up"))).toBe(false);
441
+ });
442
+
443
+ it("on gcp, a refused redirects role leaves the converge incomplete: contract 4 is not recorded", async () => {
444
+ const events: string[] = [];
445
+ const { deps, lines, recorded } = upgradeDeps(events, {
446
+ overrides: [
447
+ [/^iam roles describe/, fail("NOT_FOUND: role")],
448
+ [/^iam roles create/, fail("PERMISSION_DENIED: iam.roles.create")],
449
+ ],
450
+ });
451
+ expect(await upgradeGcpInstall({ ...GCP_TARGET, infraContractVersion: 4 }, deps)).toBe(true);
452
+ expect(lines.some((l) => l.startsWith("warned: URL-map redirects role not set up"))).toBe(true);
453
+ expect(recorded.contract).toEqual([]);
454
+ });
455
+
456
+ it("on gcp-firebase there is no URL map: no redirects role", async () => {
457
+ const events: string[] = [];
458
+ const { deps, calls } = upgradeDeps(events);
459
+ expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
460
+ expect(calls.some((c) => c.includes("urlMapRedirects"))).toBe(false);
461
+ });
462
+
386
463
  it("runs the keyless-provisioner step as the first converge step; its failure stops before migrations", async () => {
387
464
  const events: string[] = [];
388
465
  const { deps, calls } = upgradeDeps(events);
@@ -113,6 +113,28 @@ const ROWS: readonly Row[] = [
113
113
  expect: "needs_cloud_shell/infra-contract",
114
114
  reasonContains: "Cloud Shell",
115
115
  },
116
+ {
117
+ name: "contract 2 → 4 names both pending steps (#633, #587)",
118
+ manifest: verified({ infraContractVersion: 4 }),
119
+ installed: { version: "0.10.45", infraContractVersion: 2 },
120
+ expect: "needs_cloud_shell/infra-contract",
121
+ reasonContains:
122
+ "infrastructure contract 2 → 4: public_role out of the Cloud SQL superuser group (#633); the admin's permission to serve redirects from the load balancer (#587)",
123
+ },
124
+ {
125
+ name: "contract 3 → 4 names only the redirects role (#587)",
126
+ manifest: verified({ infraContractVersion: 4 }),
127
+ installed: { version: "0.10.45", infraContractVersion: 3 },
128
+ expect: "needs_cloud_shell/infra-contract",
129
+ reasonContains:
130
+ "infrastructure contract 3 → 4: the admin's permission to serve redirects from the load balancer (#587))",
131
+ },
132
+ {
133
+ name: "contract 4 installed, release at 4",
134
+ manifest: verified({ infraContractVersion: 4 }),
135
+ installed: { version: "0.10.45", infraContractVersion: 4 },
136
+ expect: "proceed",
137
+ },
116
138
  {
117
139
  name: "never converged at all",
118
140
  installed: { version: "0.10.45", infraContractVersion: 0 },
@@ -47,6 +47,7 @@ import {
47
47
  type StoredUpdatePolicy,
48
48
  updateKind,
49
49
  } from "@caelo-cms/shared";
50
+ import { infraStepsText } from "./infra-contract.js";
50
51
  import {
51
52
  assessAutoInstall,
52
53
  channelForVersion,
@@ -194,7 +195,7 @@ export function assessUpgradeRequest(input: {
194
195
  return stop(
195
196
  "needs_cloud_shell",
196
197
  "infra-contract",
197
- `v${target} changes this install's cloud setup (infrastructure contract ${infraContractVersion} → ${manifest.infraContractVersion}). The admin may not change cloud permissions, so this release is installed once with your own Google account in Cloud Shell.`,
198
+ `v${target} changes this install's cloud setup (infrastructure contract ${infraContractVersion} → ${manifest.infraContractVersion}: ${infraStepsText(infraContractVersion, manifest.infraContractVersion)}). The admin may not change cloud permissions, so this release is installed once with your own Google account in Cloud Shell.`,
198
199
  manifest,
199
200
  );
200
201
  }