@caelo-cms/provisioning 0.10.50 → 0.10.52

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 (109) hide show
  1. package/caddy/Caddyfile.production +1 -1
  2. package/caddy/Caddyfile.staging +1 -1
  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/units.js +1 -1
  10. package/dist/agent/units.js.map +1 -1
  11. package/dist/agent-db.js +169 -65
  12. package/dist/aws-task-roles.d.ts +27 -0
  13. package/dist/aws-task-roles.d.ts.map +1 -0
  14. package/dist/aws-task-roles.js +35 -0
  15. package/dist/aws-task-roles.js.map +1 -0
  16. package/dist/cli.js +34 -1
  17. package/dist/cli.js.map +1 -1
  18. package/dist/compose.js +2 -2
  19. package/dist/compose.js.map +1 -1
  20. package/dist/deployed-release.d.ts +3 -2
  21. package/dist/deployed-release.d.ts.map +1 -1
  22. package/dist/deployed-release.js +12 -5
  23. package/dist/deployed-release.js.map +1 -1
  24. package/dist/gateway-credentials.d.ts.map +1 -1
  25. package/dist/gateway-credentials.js +6 -3
  26. package/dist/gateway-credentials.js.map +1 -1
  27. package/dist/gcloud.d.ts +2 -2
  28. package/dist/gcloud.d.ts.map +1 -1
  29. package/dist/gcloud.js +3 -3
  30. package/dist/gcloud.js.map +1 -1
  31. package/dist/infra-contract.d.ts +1 -1
  32. package/dist/infra-contract.d.ts.map +1 -1
  33. package/dist/infra-contract.js +12 -1
  34. package/dist/infra-contract.js.map +1 -1
  35. package/dist/ops-backend.js +1 -1
  36. package/dist/owner-reset.d.ts +37 -0
  37. package/dist/owner-reset.d.ts.map +1 -0
  38. package/dist/owner-reset.js +80 -0
  39. package/dist/owner-reset.js.map +1 -0
  40. package/dist/release-contract.d.ts.map +1 -1
  41. package/dist/release-contract.js +3 -0
  42. package/dist/release-contract.js.map +1 -1
  43. package/dist/stack-contract.d.ts +9 -10
  44. package/dist/stack-contract.d.ts.map +1 -1
  45. package/dist/stack-contract.js +21 -10
  46. package/dist/stack-contract.js.map +1 -1
  47. package/dist/staging-admin.d.ts +12 -20
  48. package/dist/staging-admin.d.ts.map +1 -1
  49. package/dist/staging-admin.js +7 -159
  50. package/dist/staging-admin.js.map +1 -1
  51. package/dist/state-store.d.ts +8 -0
  52. package/dist/state-store.d.ts.map +1 -1
  53. package/dist/state-store.js +10 -2
  54. package/dist/state-store.js.map +1 -1
  55. package/dist/upgrader-job.d.ts.map +1 -1
  56. package/dist/upgrader-job.js +7 -1
  57. package/dist/upgrader-job.js.map +1 -1
  58. package/dist/upgrader-setup.d.ts +5 -0
  59. package/dist/upgrader-setup.d.ts.map +1 -1
  60. package/dist/upgrader-setup.js +40 -6
  61. package/dist/upgrader-setup.js.map +1 -1
  62. package/dist/wizards/gcp-cost.js +1 -1
  63. package/dist/wizards/gcp.js +1 -1
  64. package/dist/wizards/gcp.js.map +1 -1
  65. package/package.json +7 -5
  66. package/src/adapter.ts +4 -6
  67. package/src/admin-internal.ts +212 -0
  68. package/src/agent/units.ts +1 -1
  69. package/src/aws-task-roles.ts +58 -0
  70. package/src/caddy-redirects.docker.test.ts +8 -1
  71. package/src/checkpoint-secrets.test.ts +0 -1
  72. package/src/cli.ts +41 -1
  73. package/src/compose.ts +2 -2
  74. package/src/deployed-release.test.ts +22 -4
  75. package/src/deployed-release.ts +14 -7
  76. package/src/error-pages-stacks.test.ts +38 -0
  77. package/src/gateway-credentials.ts +6 -3
  78. package/src/gateway-env-isolation.test.ts +212 -0
  79. package/src/gcloud.ts +5 -3
  80. package/src/index.test.ts +6 -6
  81. package/src/infra-contract.test.ts +5 -3
  82. package/src/infra-contract.ts +12 -1
  83. package/src/ops-backend.ts +1 -1
  84. package/src/owner-reset.test.ts +165 -0
  85. package/src/owner-reset.ts +106 -0
  86. package/src/release-contract.ts +3 -0
  87. package/src/release-manifest.test.ts +1 -0
  88. package/src/runtime-secrets.test.ts +25 -8
  89. package/src/stack-contract.test.ts +9 -18
  90. package/src/stack-contract.ts +25 -16
  91. package/src/stack-converge-secrets.test.ts +45 -10
  92. package/src/stack-converge.test.ts +19 -10
  93. package/src/staging-admin.ts +18 -181
  94. package/src/state-store.ts +11 -2
  95. package/src/upgrader-job.test.ts +17 -1
  96. package/src/upgrader-job.ts +13 -3
  97. package/src/upgrader-setup.test.ts +12 -1
  98. package/src/upgrader-setup.ts +41 -5
  99. package/src/welcome-page.test.ts +40 -0
  100. package/src/wizards/gcp-cost.ts +1 -1
  101. package/src/wizards/gcp.ts +1 -1
  102. package/stacks/aws/README.md +3 -1
  103. package/stacks/aws/index.ts +62 -43
  104. package/stacks/azure/README.md +3 -1
  105. package/stacks/azure/index.ts +8 -14
  106. package/stacks/gcp/README.md +1 -1
  107. package/stacks/gcp/index.ts +2 -5
  108. package/stacks/gcp-firebase/index.ts +0 -3
  109. package/static/welcome.html +93 -111
@@ -268,12 +268,7 @@ const CONVERGED_GATEWAY_DB = [
268
268
  name: "PUBLIC_DATABASE_URL",
269
269
  value: "postgresql://public_role@10.20.0.3:5432/cms_public?sslmode=require",
270
270
  },
271
- {
272
- name: "GATEWAY_DATABASE_URL",
273
- value: "postgresql://gateway_role@10.20.0.3:5432/cms_admin?sslmode=require",
274
- },
275
271
  secretRef("PUBLIC_DATABASE_PASSWORD", "public-role-password"),
276
- secretRef("GATEWAY_DATABASE_PASSWORD", "gateway-role-password"),
277
272
  ];
278
273
 
279
274
  describe("planContractEnv", () => {
@@ -776,10 +771,10 @@ describe("ensureStackInvariants", () => {
776
771
  const gatewayGrants = calls.filter(
777
772
  (c) => c.startsWith("secrets add-iam-policy-binding") && c.includes(GW_SA),
778
773
  );
779
- // #613 — never admin_role's postgres-password. #650 — the staging
780
- // gate key is the one secret the gateway shares with the admin.
774
+ // #613 — never admin_role's postgres-password; #635 — nor
775
+ // gateway_role's. #650 — the staging gate key is the one secret the
776
+ // gateway shares with the admin.
781
777
  expect(gatewayGrants).toEqual([
782
- `secrets add-iam-policy-binding caelo-production-gateway-role-password --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
783
778
  `secrets add-iam-policy-binding caelo-production-public-role-password --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
784
779
  `secrets add-iam-policy-binding caelo-production-staging-gate-secret --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
785
780
  ]);
@@ -802,7 +797,8 @@ describe("removeRetiredIamBindings (#613)", () => {
802
797
  ],
803
798
  });
804
799
  const outcomes = await removeRetiredIamBindings(gcp, { run, sleep: async () => {} });
805
- expect(outcomes.map((o) => o.status)).toEqual(["removed"]);
800
+ // postgres-password removed; gateway-role-password (#635) already clean.
801
+ expect(outcomes.map((o) => o.status)).toEqual(["removed", "absent"]);
806
802
  expect(calls).toContain(REMOVE);
807
803
  // The admin's own binding on the same secret is never touched.
808
804
  expect(calls.some((c) => c.includes("remove") && c.includes(RUN_SA))).toBe(false);
@@ -815,10 +811,23 @@ describe("removeRetiredIamBindings (#613)", () => {
815
811
  ],
816
812
  });
817
813
  const outcomes = await removeRetiredIamBindings(firebase, { run, sleep: async () => {} });
818
- expect(outcomes.map((o) => o.status)).toEqual(["absent"]);
814
+ expect(outcomes.map((o) => o.status)).toEqual(["absent", "absent"]);
819
815
  expect(calls.filter((c) => c.includes("remove-iam-policy-binding"))).toEqual([]);
820
816
  });
821
817
 
818
+ it("takes the gateway SA off gateway_role's password (#635)", async () => {
819
+ const { run, calls } = fakeGcloud({
820
+ "secrets get-iam-policy caelo-production-gateway-role-password": [
821
+ policy([{ role: "roles/secretmanager.secretAccessor", members: [GW_SA] }]),
822
+ ],
823
+ });
824
+ const outcomes = await removeRetiredIamBindings(gcp, { run, sleep: async () => {} });
825
+ expect(outcomes.map((o) => o.status)).toEqual(["absent", "removed"]);
826
+ expect(calls).toContain(
827
+ `secrets remove-iam-policy-binding caelo-production-gateway-role-password --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
828
+ );
829
+ });
830
+
822
831
  it("reports a failed removal instead of throwing", async () => {
823
832
  const { run } = fakeGcloud({
824
833
  "secrets get-iam-policy caelo-production-postgres-password": [
@@ -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
  /**
@@ -162,6 +162,15 @@ export class GcsStateStore implements StateStore {
162
162
  }
163
163
  }
164
164
 
165
+ /**
166
+ * Another run holds the install's state lock. Distinct from every other
167
+ * lock failure (no access, gcloud missing), which callers must not report
168
+ * as "busy" — retrying later will not fix those.
169
+ */
170
+ export class StateLockHeldError extends Error {
171
+ override readonly name = "StateLockHeldError";
172
+ }
173
+
165
174
  /** `gs://<bucket>/install/state.lock` — see {@link acquireBucketStateLock}. */
166
175
  export function bucketStateLockUrl(bucket: string): string {
167
176
  return `gs://${bucket}/${OPS_INSTALL_PREFIX}/state.lock`;
@@ -197,7 +206,7 @@ export function acquireBucketStateLock(
197
206
  if (!r.ok) {
198
207
  if (/PreconditionFailed|412|At least one of the pre-conditions/i.test(r.stderr)) {
199
208
  const held = run(["storage", "cat", url]);
200
- throw new Error(
209
+ throw new StateLockHeldError(
201
210
  `Another run is changing this install's state (${held.ok ? held.stdout.trim() : url}). Re-run once it has finished; if none is running, remove the stale lock: gcloud storage rm ${url}`,
202
211
  );
203
212
  }
@@ -221,7 +230,7 @@ export function acquireLocalStateLock(root: string, purpose: string): () => void
221
230
  writeFileSync(path, lockBody(purpose), { flag: "wx", mode: 0o600 });
222
231
  } catch (e) {
223
232
  if ((e as NodeJS.ErrnoException).code === "EEXIST") {
224
- throw new Error(
233
+ throw new StateLockHeldError(
225
234
  `Another run is changing this install's state (${readFileSync(path, "utf8").trim()}). Re-run once it has finished; if none is running, remove the stale lock: rm ${path}`,
226
235
  );
227
236
  }
@@ -14,6 +14,7 @@ import type { GcloudResult } from "./gcloud.js";
14
14
  import type { GcloudRunner } from "./gcloud-retry.js";
15
15
  import type { InstallMetadata } from "./install-state.js";
16
16
  import type { ReleaseManifest, ReleaseManifestResult } from "./release-manifest.js";
17
+ import { StateLockHeldError } from "./state-store.js";
17
18
  import type { HealthOutcome, HealthProbe } from "./upgrade/health.js";
18
19
  import {
19
20
  type ClaimedUpdate,
@@ -162,6 +163,7 @@ function setup(
162
163
  deep?: HealthOutcome;
163
164
  migrate?: { ok: boolean; error?: string };
164
165
  locked?: boolean;
166
+ lockDenied?: boolean;
165
167
  } = {},
166
168
  ) {
167
169
  const order: string[] = [];
@@ -188,7 +190,8 @@ function setup(
188
190
  },
189
191
  lockState: () => {
190
192
  order.push("lock");
191
- if (opts.locked) throw new Error("held by caelo installer");
193
+ if (opts.locked) throw new StateLockHeldError("held by caelo installer");
194
+ if (opts.lockDenied) throw new Error("storage.objects.list denied");
192
195
  return () => order.push("unlock");
193
196
  },
194
197
  readInstall: () => (opts.meta === undefined ? META : opts.meta),
@@ -286,6 +289,19 @@ describe("runUpgraderJob", () => {
286
289
  expect(busy.order.some((l) => l.startsWith("manifest") || l.startsWith("gcloud"))).toBe(false);
287
290
  });
288
291
 
292
+ it("does not call a lock it cannot take 'busy': an access failure asks for Cloud Shell", async () => {
293
+ const denied = setup({ lockDenied: true });
294
+ const r = await runUpgraderJob(denied.deps);
295
+ expect(r.status).toBe("needs_cloud_shell");
296
+ const end = denied.q.end();
297
+ expect(end?.codes).toEqual(["state-access"]);
298
+ expect(end?.reason).not.toContain("another install operation");
299
+ expect(end?.reason).toContain("storage.objects.list denied");
300
+ expect(denied.order.some((l) => l.startsWith("manifest") || l.startsWith("gcloud"))).toBe(
301
+ false,
302
+ );
303
+ });
304
+
289
305
  it("retries recording the end, and fails the run when it cannot", async () => {
290
306
  const { deps, q } = setup();
291
307
  let calls = 0;
@@ -43,6 +43,7 @@ import type { GcloudRunner, Sleep } from "./gcloud-retry.js";
43
43
  import { gcpNamePrefix, upgraderJobName } from "./gcp-names.js";
44
44
  import type { InstallMetadata } from "./install-state.js";
45
45
  import type { ReleaseManifest, ReleaseManifestResult } from "./release-manifest.js";
46
+ import { StateLockHeldError } from "./state-store.js";
46
47
  import {
47
48
  type AppRollPlan,
48
49
  executeAppRoll,
@@ -224,10 +225,19 @@ export async function runUpgraderJob(deps: UpgraderJobDeps): Promise<UpgraderRun
224
225
  try {
225
226
  releaseLock = deps.lockState();
226
227
  } catch (e) {
228
+ if (e instanceof StateLockHeldError) {
229
+ return stopWith(
230
+ "failed",
231
+ ["state-locked"],
232
+ `Nothing was changed: another install operation is running on this site (${errorText(e)}). Try again when it has finished.`,
233
+ );
234
+ }
235
+ // Not contention: the job cannot reach its own ops bucket. Retrying
236
+ // will not help; a Cloud Shell upgrade re-converges the job's grants.
227
237
  return stopWith(
228
- "failed",
229
- ["state-locked"],
230
- `Nothing was changed: another install operation is running on this site (${errorText(e)}). Try again when it has finished.`,
238
+ "needs_cloud_shell",
239
+ ["state-access"],
240
+ `Nothing was changed: the upgrader could not take the install's state lock (${errorText(e)}). Run the upgrade once with Cloud Shell; it repairs the upgrader's access.`,
231
241
  );
232
242
  }
233
243
 
@@ -13,6 +13,7 @@ import type { GcloudResult } from "./gcloud.js";
13
13
  import {
14
14
  ensureUpgraderJob,
15
15
  UPGRADER_FORBIDDEN_ROLES,
16
+ UPGRADER_OPS_LIST_ROLE,
16
17
  UPGRADER_SQL_BACKUP_ROLE,
17
18
  type UpgraderSetupTarget,
18
19
  } from "./upgrader-setup.js";
@@ -83,6 +84,7 @@ describe("ensureUpgraderJob", () => {
83
84
  "iam service-accounts add-iam-policy-binding caelo-production-upgrader@acme.iam.gserviceaccount.com → roles/iam.serviceAccountUser",
84
85
  "iam service-accounts add-iam-policy-binding caelo-mcp@acme.iam.gserviceaccount.com → roles/iam.serviceAccountTokenCreator",
85
86
  "storage buckets add-iam-policy-binding gs://acme-caelo-ops → roles/storage.objectUser",
87
+ "storage buckets add-iam-policy-binding gs://acme-caelo-ops → projects/acme/roles/caeloUpgraderOpsList",
86
88
  ].sort(),
87
89
  );
88
90
  // The bucket grant only reaches install.json, not the Pulumi state.
@@ -90,6 +92,12 @@ describe("ensureUpgraderJob", () => {
90
92
  expect(bucket?.condition).toContain(
91
93
  'resource.name.startsWith("projects/_/buckets/acme-caelo-ops/objects/install/")',
92
94
  );
95
+ // gcloud storage cp lists the bucket before every upload; GCS checks the
96
+ // list against the bucket itself, so this grant cannot carry the
97
+ // install/ condition — and so it carries nothing but the list.
98
+ const list = mine.find((g) => g.role.endsWith("/caeloUpgraderOpsList"));
99
+ expect(list?.condition).toBe("None");
100
+ expect(UPGRADER_OPS_LIST_ROLE.permissions).toEqual(["storage.objects.list"]);
93
101
  });
94
102
 
95
103
  it("never grants a forbidden role, a setIamPolicy permission, or a project-wide iam role", async () => {
@@ -101,7 +109,10 @@ describe("ensureUpgraderJob", () => {
101
109
  }
102
110
  if (g.role.startsWith("roles/iam.")) expect(g.where).toStartWith("iam service-accounts");
103
111
  }
104
- const role = calls.find((c) => c.slice(0, 3).join(" ") === "iam roles create");
112
+ const role = calls.find(
113
+ (c) =>
114
+ c.slice(0, 3).join(" ") === "iam roles create" && c[3] === UPGRADER_SQL_BACKUP_ROLE.roleId,
115
+ );
105
116
  const perms = (flag(role ?? [], "permissions") ?? "").split(",");
106
117
  expect(perms.sort()).toEqual([...UPGRADER_SQL_BACKUP_ROLE.permissions].sort());
107
118
  // Nothing but the one write it needs: create a backup.
@@ -31,7 +31,13 @@
31
31
  * health check signs its IAP credential as that account
32
32
  * (upgrade/health.ts). The operator-access sync job keeps it there.
33
33
  * - `roles/storage.objectUser` on the ops bucket, conditioned to the
34
- * `install/` prefix (install.json) — not the Pulumi state.
34
+ * `install/` prefix (install.json, the state lock) — not the Pulumi state.
35
+ * - the custom role `caeloUpgraderOpsList` (`storage.objects.list` only)
36
+ * on the ops bucket, unconditioned: `gcloud storage cp` lists the
37
+ * destination prefix and reads the bucket's storage layout before every
38
+ * upload, and GCS checks both against the bucket, never an object path,
39
+ * so no `install/` condition can admit them. It reveals object names,
40
+ * not contents.
35
41
  * - `roles/logging.logWriter`.
36
42
  *
37
43
  * It gets no `*.setIamPolicy`, no service-account admin, no secret
@@ -71,6 +77,18 @@ export const UPGRADER_SQL_BACKUP_ROLE: CustomRoleSpec = {
71
77
  ],
72
78
  };
73
79
 
80
+ /**
81
+ * Listing the ops bucket, which every `gcloud storage cp` into it needs (see
82
+ * the file header). Names only — reads and writes stay on `install/`.
83
+ */
84
+ export const UPGRADER_OPS_LIST_ROLE: CustomRoleSpec = {
85
+ roleId: "caeloUpgraderOpsList",
86
+ title: "Caelo upgrader: list the ops bucket",
87
+ description:
88
+ "Lets the Caelo upgrader job list object names in the install's ops bucket (gcloud storage cp needs it). Names only; object reads/writes stay limited to install/.",
89
+ permissions: ["storage.objects.list"],
90
+ };
91
+
74
92
  /** Project-level roles of the upgrader's service account. */
75
93
  export const UPGRADER_PROJECT_ROLES = [
76
94
  "roles/run.developer",
@@ -176,10 +194,12 @@ export async function ensureUpgraderJob(
176
194
  };
177
195
  }
178
196
 
179
- // 1. The backup role.
180
- const roleError = await ensureCustomRole(run, projectId, UPGRADER_SQL_BACKUP_ROLE);
181
- if (roleError) return { ok: false, done, error: roleError };
182
- done.push(`custom role ${UPGRADER_SQL_BACKUP_ROLE.roleId}`);
197
+ // 1. The backup and ops-bucket-list roles.
198
+ for (const role of [UPGRADER_SQL_BACKUP_ROLE, UPGRADER_OPS_LIST_ROLE]) {
199
+ const roleError = await ensureCustomRole(run, projectId, role);
200
+ if (roleError) return { ok: false, done, error: roleError };
201
+ done.push(`custom role ${role.roleId}`);
202
+ }
183
203
 
184
204
  // 2. The job's own service account.
185
205
  const describe = await run([
@@ -276,6 +296,22 @@ export async function ensureUpgraderJob(
276
296
  return fail(`grant the upgrader install.json in gs://${bucket}`, bucketGrant);
277
297
  done.push(`upgrader: roles/storage.objectUser on gs://${bucket}/${OPS_INSTALL_PREFIX}/`);
278
298
 
299
+ const listRole = customRoleName(projectId, UPGRADER_OPS_LIST_ROLE.roleId);
300
+ const listGrant = await retry([
301
+ "storage",
302
+ "buckets",
303
+ "add-iam-policy-binding",
304
+ `gs://${bucket}`,
305
+ `--member=${member}`,
306
+ `--role=${listRole}`,
307
+ "--condition=None",
308
+ project,
309
+ "--quiet",
310
+ "--format=none",
311
+ ]);
312
+ if (!listGrant.ok) return fail(`grant the upgrader object listing on gs://${bucket}`, listGrant);
313
+ done.push(`upgrader: ${listRole} on gs://${bucket}`);
314
+
279
315
  // 6. Its database login (migration 0267's platform_updater role).
280
316
  const sqlUser = await ensureCloudSqlIamUser(run, {
281
317
  projectId,
@@ -0,0 +1,40 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #781 — contracts of `static/welcome.html`, the placeholder the GCP
5
+ * wizard uploads to a fresh static bucket. It must stay fully
6
+ * self-contained (the bucket holds nothing else, and a first impression
7
+ * that fetches from third parties leaks the visitor), keep the
8
+ * `{{ADMIN_URL}}` placeholder the wizard substitutes, and carry the
9
+ * Caelatum sky instead of the old animated mesh.
10
+ */
11
+
12
+ import { describe, expect, it } from "bun:test";
13
+ import { readFileSync } from "node:fs";
14
+
15
+ const html = readFileSync(new URL("../static/welcome.html", import.meta.url), "utf8");
16
+
17
+ describe("welcome.html", () => {
18
+ it("makes no external requests", () => {
19
+ // Any absolute or protocol-relative URL, stylesheet/script/image
20
+ // reference or CSS import/url() would be a network fetch.
21
+ expect(html).not.toMatch(/https?:\/\//i);
22
+ expect(html).not.toMatch(/["'(]\s*\/\//);
23
+ expect(html).not.toMatch(/<link\b/i);
24
+ expect(html).not.toMatch(/<script\b/i);
25
+ expect(html).not.toMatch(/<img\b/i);
26
+ expect(html).not.toMatch(/@import|url\(|@font-face/i);
27
+ expect(html).not.toMatch(/\b(src|href)\s*=/i);
28
+ });
29
+
30
+ it("keeps the admin URL placeholder the wizard substitutes", () => {
31
+ expect(html).toContain("{{ADMIN_URL}}");
32
+ });
33
+
34
+ it("uses the sky + relief plate with the inline mark, not the mesh", () => {
35
+ expect(html).toContain("#c3dbf2 0%, #dce9f6 40%, #efe7ef 72%, #f8e6d8 100%");
36
+ expect(html).toContain("0 0 0 5px var(--plate-ring)");
37
+ expect(html).toMatch(/<svg viewBox="0 0 32 32" aria-hidden="true"/);
38
+ expect(html).not.toMatch(/class="blob|@keyframes (drift|pulse)/);
39
+ });
40
+ });
@@ -104,7 +104,7 @@ export function estimateGcpCost(inputs: CostEstimateInputs): {
104
104
  name: "Quality checks (Lighthouse on the admin)",
105
105
  monthlyUsd: 1,
106
106
  notes:
107
- "~1-2 min of admin CPU per audited Stage (up to 5 pages x 3 runs); ~$1/mo at a few Stages a day",
107
+ "~2-4 min of admin CPU per audited Stage (up to 5 pages x 5 runs); ~$1/mo at a few Stages a day",
108
108
  },
109
109
  {
110
110
  name: "Cloud Run gateway",
@@ -1965,7 +1965,7 @@ async function stepFinalize(installId: string, setupLink: "print" | "secret"): P
1965
1965
  ` click ${bold("New token")} (scope ${bold("chat")}: Caelo's AI does the work; ${bold("admin")}: your agent drives Caelo's tools),`,
1966
1966
  " copy the bearer (shown once) and run the `claude mcp add` command the page shows.",
1967
1967
  " The admin is behind Google IAP, so run `gcloud auth application-default login` once on that machine.",
1968
- ` Details: ${cyan("https://caelo-cms.com/mcp")}`,
1968
+ ` Details: ${cyan("https://caelo-cms.com/docs/mcp/")}`,
1969
1969
  ...adminNote,
1970
1970
  "",
1971
1971
  bold("Lifecycle commands:"),
@@ -10,7 +10,7 @@ Pulumi stack provisioning Caelo on AWS managed services. Implements the shared `
10
10
  | Blob storage | Two S3 buckets (`caelo-<env>-media` + `caelo-<env>-static`) |
11
11
  | CDN | CloudFront distribution with two origins (S3 static + S3 media); static files are served as files, no edge compute on the request path |
12
12
  | Container runtime | ECS Fargate cluster + four services (admin / gateway / orchestrator / runner) |
13
- | Secret store | AWS Secrets Manager (postgres-password, public-role-password, gateway-role-password, csrf-secret, cookie-secret, anthropic-api-key, resend-api-key). The gateway gets `gatewayDatabaseUrlOut` + `publicDatabaseUrlOut`, never the admin URL (#613). |
13
+ | Secret store | AWS Secrets Manager (postgres-password, public-role-password, gateway-role-password, csrf-secret, cookie-secret, anthropic-api-key, resend-api-key). The gateway gets `publicDatabaseUrlOut` only, never a cms_admin URL of any role (#613, #635). |
14
14
  | DNS + cert | ACM cert (us-east-1, DNS-validated, supports `*.<domain>`) |
15
15
  | Edge-log sink | CloudFront access logs delivered to the CloudWatch log group `/caelo/<env>/cloudfront` in us-east-1 (queryable by P12A analytics plugin) |
16
16
 
@@ -18,6 +18,8 @@ The Caelo runtime never knows it's on AWS — it just consumes `DATABASE_URL` /
18
18
 
19
19
  ## First-time install
20
20
 
21
+ Run this stack from a checkout of the Caelo repository after `bun install` at its root. `@pulumi/aws` + `@pulumi/awsx` are devDependencies of `@caelo-cms/provisioning`: the CLI wizard does not provision AWS yet, so the published package does not make every GCP and self-hosted install download them (the one-click install from Cloud Shell runs in a 5 GB home directory). They move back to `dependencies` when the AWS wizard ships.
22
+
21
23
  ```bash
22
24
  cd packages/provisioning/stacks/aws
23
25
  pulumi stack init prod