@caelo-cms/provisioning 0.10.43 → 0.10.46

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 (141) hide show
  1. package/dist/adapter.d.ts +1 -1
  2. package/dist/adc-impersonation.d.ts +28 -0
  3. package/dist/adc-impersonation.d.ts.map +1 -0
  4. package/dist/adc-impersonation.js +126 -0
  5. package/dist/adc-impersonation.js.map +1 -0
  6. package/dist/caddy.js +1 -1
  7. package/dist/checkpoint-secrets.d.ts.map +1 -1
  8. package/dist/checkpoint-secrets.js +2 -1
  9. package/dist/checkpoint-secrets.js.map +1 -1
  10. package/dist/cli.js +208 -43
  11. package/dist/cli.js.map +1 -1
  12. package/dist/compose.d.ts +45 -0
  13. package/dist/compose.d.ts.map +1 -1
  14. package/dist/compose.js +112 -29
  15. package/dist/compose.js.map +1 -1
  16. package/dist/gcloud-retry.d.ts +5 -1
  17. package/dist/gcloud-retry.d.ts.map +1 -1
  18. package/dist/gcloud-retry.js.map +1 -1
  19. package/dist/gcloud.d.ts +2 -1
  20. package/dist/gcloud.d.ts.map +1 -1
  21. package/dist/gcloud.js +11 -13
  22. package/dist/gcloud.js.map +1 -1
  23. package/dist/index.d.ts +3 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +3 -0
  26. package/dist/index.js.map +1 -1
  27. package/dist/infra-contract.d.ts +24 -0
  28. package/dist/infra-contract.d.ts.map +1 -0
  29. package/dist/infra-contract.js +25 -0
  30. package/dist/infra-contract.js.map +1 -0
  31. package/dist/install-state.d.ts +68 -2
  32. package/dist/install-state.d.ts.map +1 -1
  33. package/dist/install-state.js +166 -22
  34. package/dist/install-state.js.map +1 -1
  35. package/dist/lifecycle.d.ts +6 -0
  36. package/dist/lifecycle.d.ts.map +1 -1
  37. package/dist/lifecycle.js +185 -819
  38. package/dist/lifecycle.js.map +1 -1
  39. package/dist/mcp-iap.js +1 -1
  40. package/dist/migration-runner.d.ts +3 -0
  41. package/dist/migration-runner.d.ts.map +1 -1
  42. package/dist/migration-runner.js +7 -5
  43. package/dist/migration-runner.js.map +1 -1
  44. package/dist/ops-backend.d.ts +46 -0
  45. package/dist/ops-backend.d.ts.map +1 -0
  46. package/dist/ops-backend.js +370 -0
  47. package/dist/ops-backend.js.map +1 -0
  48. package/dist/provisioner-identity.d.ts +103 -0
  49. package/dist/provisioner-identity.d.ts.map +1 -0
  50. package/dist/provisioner-identity.js +323 -0
  51. package/dist/provisioner-identity.js.map +1 -0
  52. package/dist/regions.d.ts.map +1 -1
  53. package/dist/regions.js +2 -1
  54. package/dist/regions.js.map +1 -1
  55. package/dist/release-contract.d.ts +37 -0
  56. package/dist/release-contract.d.ts.map +1 -0
  57. package/dist/release-contract.js +55 -0
  58. package/dist/release-contract.js.map +1 -0
  59. package/dist/release-manifest.d.ts +140 -0
  60. package/dist/release-manifest.d.ts.map +1 -0
  61. package/dist/release-manifest.js +319 -0
  62. package/dist/release-manifest.js.map +1 -0
  63. package/dist/self-command.d.ts +16 -0
  64. package/dist/self-command.d.ts.map +1 -0
  65. package/dist/self-command.js +20 -0
  66. package/dist/self-command.js.map +1 -0
  67. package/dist/self-hosted-release.d.ts +81 -0
  68. package/dist/self-hosted-release.d.ts.map +1 -0
  69. package/dist/self-hosted-release.js +148 -0
  70. package/dist/self-hosted-release.js.map +1 -0
  71. package/dist/self-hosted-upgrade.d.ts +91 -0
  72. package/dist/self-hosted-upgrade.d.ts.map +1 -0
  73. package/dist/self-hosted-upgrade.js +355 -0
  74. package/dist/self-hosted-upgrade.js.map +1 -0
  75. package/dist/stack-contract.js +2 -2
  76. package/dist/stack-converge.d.ts.map +1 -1
  77. package/dist/stack-converge.js +2 -1
  78. package/dist/stack-converge.js.map +1 -1
  79. package/dist/staging-admin.js +3 -3
  80. package/dist/state-backend.d.ts +103 -0
  81. package/dist/state-backend.d.ts.map +1 -0
  82. package/dist/state-backend.js +180 -0
  83. package/dist/state-backend.js.map +1 -0
  84. package/dist/state-store.d.ts +55 -0
  85. package/dist/state-store.d.ts.map +1 -0
  86. package/dist/state-store.js +140 -0
  87. package/dist/state-store.js.map +1 -0
  88. package/dist/upgrade/app-roll.d.ts +164 -0
  89. package/dist/upgrade/app-roll.d.ts.map +1 -0
  90. package/dist/upgrade/app-roll.js +270 -0
  91. package/dist/upgrade/app-roll.js.map +1 -0
  92. package/dist/upgrade/backup.d.ts +48 -0
  93. package/dist/upgrade/backup.d.ts.map +1 -0
  94. package/dist/upgrade/backup.js +71 -0
  95. package/dist/upgrade/backup.js.map +1 -0
  96. package/dist/upgrade/converge.d.ts +45 -0
  97. package/dist/upgrade/converge.d.ts.map +1 -0
  98. package/dist/upgrade/converge.js +287 -0
  99. package/dist/upgrade/converge.js.map +1 -0
  100. package/dist/upgrade/gcp-lookup.d.ts +23 -0
  101. package/dist/upgrade/gcp-lookup.d.ts.map +1 -0
  102. package/dist/upgrade/gcp-lookup.js +95 -0
  103. package/dist/upgrade/gcp-lookup.js.map +1 -0
  104. package/dist/upgrade/health.d.ts +99 -0
  105. package/dist/upgrade/health.d.ts.map +1 -0
  106. package/dist/upgrade/health.js +214 -0
  107. package/dist/upgrade/health.js.map +1 -0
  108. package/dist/upgrade/release-images.d.ts +125 -0
  109. package/dist/upgrade/release-images.d.ts.map +1 -0
  110. package/dist/upgrade/release-images.js +315 -0
  111. package/dist/upgrade/release-images.js.map +1 -0
  112. package/dist/upgrade/reporter.d.ts +17 -0
  113. package/dist/upgrade/reporter.d.ts.map +1 -0
  114. package/dist/upgrade/reporter.js +28 -0
  115. package/dist/upgrade/reporter.js.map +1 -0
  116. package/dist/upgrade/upgrade.d.ts +79 -0
  117. package/dist/upgrade/upgrade.d.ts.map +1 -0
  118. package/dist/upgrade/upgrade.js +105 -0
  119. package/dist/upgrade/upgrade.js.map +1 -0
  120. package/dist/wizard.d.ts.map +1 -1
  121. package/dist/wizard.js +7 -3
  122. package/dist/wizard.js.map +1 -1
  123. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  124. package/dist/wizards/gcp-cost.js +7 -1
  125. package/dist/wizards/gcp-cost.js.map +1 -1
  126. package/dist/wizards/gcp-pulumi.d.ts +99 -8
  127. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  128. package/dist/wizards/gcp-pulumi.js +157 -50
  129. package/dist/wizards/gcp-pulumi.js.map +1 -1
  130. package/dist/wizards/gcp.d.ts.map +1 -1
  131. package/dist/wizards/gcp.js +185 -61
  132. package/dist/wizards/gcp.js.map +1 -1
  133. package/package.json +4 -3
  134. package/stacks/aws/README.md +1 -1
  135. package/stacks/gcp/Pulumi.yaml +1 -1
  136. package/stacks/gcp/README.md +1 -1
  137. package/stacks/gcp/index.ts +1 -1
  138. package/stacks/gcp-firebase/Pulumi.yaml +1 -1
  139. package/stacks/gcp-firebase/index.ts +2 -2
  140. package/stacks/self-hosted/README.md +4 -0
  141. package/stacks/self-hosted/index.ts +10 -2
package/dist/lifecycle.js CHANGED
@@ -15,18 +15,27 @@
15
15
  * - emits human-readable progress + a final summary
16
16
  */
17
17
  import { join, resolve as resolvePath } from "node:path";
18
+ import { provisioningUpgradeCommand } from "@caelo-cms/shared";
18
19
  import { cancel, confirm, isCancel, log, note, select, spinner } from "@clack/prompts";
19
20
  import { bold, cyan, dim, green, red, yellow } from "kleur/colors";
20
- import { ensureGatewayRolePassword, planPublicRoleSwitch } from "./gateway-credentials.js";
21
- import { gcloud } from "./gcloud.js";
22
- import { GCP_STACK_ENV, gatewayServiceAccountEmail, runServiceAccountEmail } from "./gcp-names.js";
23
- import { installFlag, installRoot, installStagingHost, listInstalls, readSecret, recordImageDigests, scrubInstallProgress, selectInstall, writeMetadata, } from "./install-state.js";
24
- import { ensureMcpIapAccess } from "./mcp-iap.js";
25
- import { ensureOperatorAccessSync, resolveOperatorAccessTarget } from "./operator-access.js";
21
+ import { activeAccount, gcloud } from "./gcloud.js";
22
+ import { realSleep } from "./gcloud-retry.js";
23
+ import { GCP_STACK_ENV } from "./gcp-names.js";
24
+ import { INFRA_CONTRACT_VERSION } from "./infra-contract.js";
25
+ import { currentMetadata, installFlag, installRoot, installStagingHost, listInstalls, readMetadata, readSecret, recordImageDigests, recordInfraContractVersion, scrubInstallProgress, selectInstall, writeMetadata, } from "./install-state.js";
26
+ import { runMigrationsViaCloudRunJob } from "./migration-runner.js";
27
+ import { convergeKeylessProvisioner, provisionerServiceAccountEmail, } from "./provisioner-identity.js";
26
28
  import { assertRegionUnchanged } from "./regions.js";
27
- import { ensureGatewayServiceAccount, ensureGeneratedSecrets, plainGeneratedSecretSeed, ROTATABLE_SECRETS, readSecretReplication, rolesOfSecret, rotateRuntimeSecret, rotationRefusal, } from "./runtime-secrets.js";
28
- import { adminMediaVolume, MCP_ENV_VAR, OPERATOR_ACCESS_JOB_ENV_VAR } from "./stack-contract.js";
29
- import { ensureStackInvariants, liveContainerEnv, liveContainerMemory, liveEnvHasInlinePassword, liveVolumes, planAdminMemory, planContractEnv, planMediaVolume, removeRetiredIamBindings, rollService, serviceRollArgs, } from "./stack-converge.js";
29
+ import { ROTATABLE_SECRETS, rolesOfSecret, rotateRuntimeSecret, rotationRefusal, } from "./runtime-secrets.js";
30
+ import { selfCommand } from "./self-command.js";
31
+ import { backendShellEnv, resolveBackend } from "./state-backend.js";
32
+ import { ROLL_ORDER, } from "./upgrade/app-roll.js";
33
+ import { restoreCommand, takeSqlBackup } from "./upgrade/backup.js";
34
+ import { findServiceUrl, resolveGcpResourceName } from "./upgrade/gcp-lookup.js";
35
+ import { createIapHealthProbe } from "./upgrade/health.js";
36
+ import { fetchLatestReleaseTag, RELEASE_REGISTRY, releaseImageRef, releaseTag, resolveTagDigest, verifyCosignAll, } from "./upgrade/release-images.js";
37
+ import { clackReporter } from "./upgrade/reporter.js";
38
+ import { upgradeGcpInstall } from "./upgrade/upgrade.js";
30
39
  /**
31
40
  * The installs a command acts on. `--install <id or domain>` names one
32
41
  * (`--install all` every one, where the command allows it). Without the flag:
@@ -52,7 +61,7 @@ async function chooseInstalls(opts) {
52
61
  }
53
62
  if (installs.length === 0)
54
63
  return exitNoInstall("No Caelo install found on this machine.");
55
- return installs;
64
+ return installs.map(currentMetadata);
56
65
  }
57
66
  if (wanted === undefined && installs.length > 1 && process.stdin.isTTY) {
58
67
  const choice = await select({
@@ -73,18 +82,18 @@ async function chooseInstalls(opts) {
73
82
  process.exit(1);
74
83
  }
75
84
  if (choice === "all")
76
- return installs;
85
+ return installs.map(currentMetadata);
77
86
  wanted = choice;
78
87
  }
79
88
  const selected = selectInstall(installs, wanted);
80
89
  if (!selected.ok)
81
90
  return exitNoInstall(selected.message);
82
- return [selected.meta];
91
+ return [currentMetadata(selected.meta)];
83
92
  }
84
93
  function exitNoInstall(message) {
85
94
  log.error(red(message));
86
95
  if (listInstalls().length === 0) {
87
- log.warn(`Run ${bold("bunx @caelo-cms/provisioning")} first to provision an install, OR copy ${dim("~/.caelo-<install-id>/")} from the provisioning machine.`);
96
+ log.warn(`Run ${bold(selfCommand())} first to provision an install, OR copy ${dim("~/.caelo-<install-id>/")} from the provisioning machine.`);
88
97
  }
89
98
  process.exit(1);
90
99
  }
@@ -138,39 +147,6 @@ function installRegion(meta, requested) {
138
147
  process.exit(1);
139
148
  }
140
149
  }
141
- /** A Cloud Run lookup is regional; there is no default region to guess. */
142
- function requiredRegion(region) {
143
- if (!region)
144
- throw new Error("a Cloud Run lookup needs the install region (install.json)");
145
- return region;
146
- }
147
- /**
148
- * Pulumi auto-naming appends a random 7-char suffix to every resource
149
- * (e.g. `caelo-production-admin-3efcfea`). Lifecycle commands need the
150
- * actual deployed names; this helper queries gcloud with a prefix
151
- * filter and returns the single match (or null if missing/ambiguous).
152
- *
153
- * Used for Cloud Run services + Cloud SQL instances. We accept a
154
- * `kind` for routing the gcloud subcommand (services vs sql instances).
155
- */
156
- async function resolveGcpResourceName(kind, prefix, projectId, region) {
157
- const args = [];
158
- if (kind === "run-service") {
159
- args.push("run", "services", "list", "--region", requiredRegion(region), "--project", projectId, "--filter", `metadata.name~^${prefix}`, "--format=value(metadata.name)");
160
- }
161
- else {
162
- args.push("sql", "instances", "list", "--project", projectId, "--filter", `name~^${prefix}`, "--format=value(name)");
163
- }
164
- const r = await gcloud(args);
165
- if (!r.ok)
166
- return null;
167
- const matches = r.stdout
168
- .trim()
169
- .split("\n")
170
- .map((s) => s.trim())
171
- .filter((s) => s.length > 0);
172
- return matches[0] ?? null;
173
- }
174
150
  // =========================================================================
175
151
  // status — health check + monthly cost
176
152
  // =========================================================================
@@ -202,23 +178,10 @@ async function getLatestReleaseTag() {
202
178
  if (releaseCheckCache && now - releaseCheckCache.fetchedAt < 10 * 60 * 1000) {
203
179
  return releaseCheckCache.latest;
204
180
  }
205
- try {
206
- const res = await fetch("https://api.github.com/repos/caelo-cms/caelo-cms/releases/latest", {
207
- headers: { Accept: "application/vnd.github+json" },
208
- });
209
- if (!res.ok) {
210
- releaseCheckCache = { fetchedAt: now, latest: null };
211
- return null;
212
- }
213
- const json = (await res.json());
214
- const latest = json.tag_name ?? null;
215
- releaseCheckCache = { fetchedAt: now, latest };
216
- return latest;
217
- }
218
- catch {
219
- releaseCheckCache = { fetchedAt: now, latest: null };
220
- return null;
221
- }
181
+ const r = await fetchLatestReleaseTag();
182
+ const latest = r.ok ? r.tag : null;
183
+ releaseCheckCache = { fetchedAt: now, latest };
184
+ return latest;
222
185
  }
223
186
  async function gcpStatus(meta) {
224
187
  if (!meta.projectId)
@@ -226,8 +189,8 @@ async function gcpStatus(meta) {
226
189
  const s = spinner();
227
190
  s.start("Resolving deployed resources + checking health...");
228
191
  const region = installRegion(meta);
229
- const adminName = await resolveGcpResourceName("run-service", "caelo-production-admin", meta.projectId, region);
230
- const sqlName = await resolveGcpResourceName("sql-instance", "caelo-production-pg", meta.projectId);
192
+ const adminName = await resolveGcpResourceName(gcloud, "run-service", "caelo-production-admin", meta.projectId, region);
193
+ const sqlName = await resolveGcpResourceName(gcloud, "sql-instance", "caelo-production-pg", meta.projectId);
231
194
  if (!adminName || !sqlName) {
232
195
  s.stop(red("Could not resolve deployed resource names — is the install live?"));
233
196
  return;
@@ -258,8 +221,10 @@ async function gcpStatus(meta) {
258
221
  const { CAELO_VERSION } = await import("@caelo-cms/shared");
259
222
  const latestTag = await getLatestReleaseTag();
260
223
  const latestStable = latestTag?.replace(/^v/, "") ?? null;
224
+ // #696 — the upgrade runs as the new release's CLI and rolls exactly that
225
+ // release, on exactly this install (same command Settings → Updates shows).
261
226
  const upgradeHint = latestStable && latestStable !== CAELO_VERSION
262
- ? `${yellow(`v${latestStable} available`)} — run \`bunx @caelo-cms/provisioning upgrade\``
227
+ ? `${yellow(`v${latestStable} available`)} — run \`${provisioningUpgradeCommand({ version: latestStable, install: meta.domain })}\``
263
228
  : latestStable === CAELO_VERSION
264
229
  ? green("up to date")
265
230
  : dim("(latest unknown)");
@@ -271,132 +236,13 @@ async function gcpStatus(meta) {
271
236
  `${dim("CLI version")} v${CAELO_VERSION} ${upgradeHint}`,
272
237
  ].join("\n"), "Status");
273
238
  }
274
- /**
275
- * Resolve a tag (e.g. `0.2.6` or `latest`) to the underlying sha256
276
- * digest via the Docker Registry V2 API directly, without needing the
277
- * caller's gcloud session to have IAM access to the registry's
278
- * project. Used for the upgrade pre-flight resolve — the registry is
279
- * public so an anonymous HEAD on the manifest endpoint suffices.
280
- */
281
- async function resolveTagDigest(region, project, repo, image, tag) {
282
- const url = `https://${region}-docker.pkg.dev/v2/${project}/${repo}/${image}/manifests/${tag}`;
283
- // Accept both Docker v2 + OCI manifest types so the registry returns
284
- // the resource we asked about (single-arch + multi-arch index both
285
- // surface a Docker-Content-Digest header that names the manifest
286
- // we'd pull on a `docker pull <image>:<tag>`).
287
- const accept = [
288
- "application/vnd.docker.distribution.manifest.v2+json",
289
- "application/vnd.oci.image.manifest.v1+json",
290
- "application/vnd.docker.distribution.manifest.list.v2+json",
291
- "application/vnd.oci.image.index.v1+json",
292
- ].join(",");
293
- try {
294
- const res = await fetch(url, { method: "HEAD", headers: { Accept: accept } });
295
- if (!res.ok) {
296
- return { ok: false, reason: `HTTP ${res.status} ${res.statusText}` };
297
- }
298
- const digest = res.headers.get("docker-content-digest");
299
- if (!digest || !/^sha256:[0-9a-f]{64}$/.test(digest)) {
300
- return { ok: false, reason: `unexpected digest header: ${digest ?? "(missing)"}` };
301
- }
302
- return { ok: true, digest };
303
- }
304
- catch (e) {
305
- return { ok: false, reason: e instanceof Error ? e.message : String(e) };
306
- }
307
- }
308
- /** The deployed service as `gcloud run services describe --format=json` returns it. */
309
- async function describeServiceJson(projectId, region, serviceName) {
310
- const r = await gcloud([
311
- "run",
312
- "services",
313
- "describe",
314
- serviceName,
315
- "--region",
316
- region,
317
- "--project",
318
- projectId,
319
- "--format=json",
320
- ]);
321
- return r.ok ? r.stdout : null;
322
- }
323
- /** Look up the currently-serving Cloud Run revision so we can roll back to it. */
324
- async function findCurrentRevision(projectId, region, serviceName) {
325
- const r = await gcloud([
326
- "run",
327
- "services",
328
- "describe",
329
- serviceName,
330
- "--region",
331
- region,
332
- "--project",
333
- projectId,
334
- "--format=value(status.traffic[0].revisionName)",
335
- ]);
336
- if (!r.ok)
337
- return null;
338
- const rev = r.stdout.trim().split("\n")[0]?.trim();
339
- return rev || null;
340
- }
341
- /** Cloud Run service URL, used as the health-probe target after a roll. */
342
- async function findServiceUrl(projectId, region, serviceName) {
343
- const r = await gcloud([
344
- "run",
345
- "services",
346
- "describe",
347
- serviceName,
348
- "--region",
349
- region,
350
- "--project",
351
- projectId,
352
- "--format=value(status.url)",
353
- ]);
354
- if (!r.ok)
355
- return null;
356
- return r.stdout.trim() || null;
357
- }
358
- async function rollbackTraffic(projectId, region, serviceName, priorRevision) {
359
- const r = await gcloud([
360
- "run",
361
- "services",
362
- "update-traffic",
363
- serviceName,
364
- "--region",
365
- region,
366
- "--project",
367
- projectId,
368
- "--to-revisions",
369
- `${priorRevision}=100`,
370
- "--quiet",
371
- ]);
372
- return r.ok;
373
- }
374
- /**
375
- * The admin's IAP resource: the Cloud Run service itself on gcp-firebase
376
- * (native IAP), the LB backend service on gcp (Pulumi-suffixed name).
377
- */
378
- async function resolveAdminIapResource(meta, region, adminServiceName) {
379
- if (meta.provider === "gcp-firebase") {
380
- return { kind: "cloud-run", service: adminServiceName, region };
381
- }
382
- const r = await gcloud([
383
- "compute",
384
- "backend-services",
385
- "list",
386
- "--global",
387
- `--project=${meta.projectId}`,
388
- "--filter=name~^caelo-production-admin-backend",
389
- "--format=value(name)",
390
- ]);
391
- const name = r.ok ? r.stdout.trim().split("\n")[0]?.trim() : "";
392
- return name ? { kind: "backend-services", service: name } : null;
393
- }
394
239
  export async function upgradeCommand(opts = {}) {
395
240
  if (opts.installId) {
396
241
  const selected = selectInstall(listInstalls(), opts.installId);
397
242
  if (!selected.ok)
398
243
  return exitNoInstall(selected.message);
399
- if (!(await upgradeInstall(selected.meta.installId, selected.meta, opts)))
244
+ const meta = currentMetadata(selected.meta);
245
+ if (!(await upgradeInstall(meta.installId, meta, opts)))
400
246
  process.exitCode = 1;
401
247
  return;
402
248
  }
@@ -407,518 +253,163 @@ export async function upgradeCommand(opts = {}) {
407
253
  ? "--region names one install's region; drop it when upgrading several installs."
408
254
  : null);
409
255
  }
256
+ /**
257
+ * Resolve both release images' digests from the public registry. Via the
258
+ * registry's anonymous V2 API, not `gcloud artifacts docker tags list`: that
259
+ * needs IAM read on Caelo's registry project, which operators don't have.
260
+ */
261
+ async function resolveReleaseImages(tag) {
262
+ const images = {};
263
+ for (const slug of ROLL_ORDER) {
264
+ const res = await resolveTagDigest(slug, tag);
265
+ if (!res.ok) {
266
+ const r = RELEASE_REGISTRY;
267
+ return {
268
+ ok: false,
269
+ error: `Couldn't resolve image digest for ${slug}:${tag}: the public registry returned ${res.reason}.\n` +
270
+ `Verify the tag exists at https://${r.region}-docker.pkg.dev/${r.project}/${r.repo}/${slug}:${tag}\n` +
271
+ "(latest releases live at https://github.com/caelo-cms/caelo-cms/releases — pass --version vX.Y.Z to pin.)",
272
+ };
273
+ }
274
+ images[slug] = { digest: res.digest, imageRef: releaseImageRef(slug, res.digest) };
275
+ }
276
+ return { ok: true, images: images };
277
+ }
278
+ /**
279
+ * `upgrade`'s step of the keyless provisioner migration (#696,
280
+ * provisioner-identity.ts) — a converge step (upgrade/converge.ts runs it
281
+ * first). Idempotent: on an install that already runs keyless it only
282
+ * confirms the grant.
283
+ *
284
+ * @returns false when the upgrade must stop (the message is printed)
285
+ */
286
+ async function upgradeKeylessProvisioner(installId, projectId) {
287
+ const account = await activeAccount();
288
+ if (!account) {
289
+ log.error(red("No active gcloud account. Run `gcloud auth login`, then re-run upgrade."));
290
+ return false;
291
+ }
292
+ const s = spinner();
293
+ s.start("Ensuring the provisioner service account is used without a key...");
294
+ const r = await convergeKeylessProvisioner({
295
+ projectId,
296
+ secretsDir: join(installRoot(installId), "secrets"),
297
+ operatorAccount: account,
298
+ });
299
+ if (!r.ok) {
300
+ s.stop(red("Could not move the provisioner to impersonation. Aborting upgrade."));
301
+ log.error(r.error);
302
+ log.warn("Nothing was rolled and no migrations ran. Fix the above and re-run.");
303
+ return false;
304
+ }
305
+ if (r.grant === "no-provisioner") {
306
+ s.stop(green(`No ${provisionerServiceAccountEmail(projectId)} in this project — nothing to do`));
307
+ return true;
308
+ }
309
+ s.stop(green(r.legacyKey === "removed"
310
+ ? "Provisioner moved to impersonation; its long-lived key is deleted"
311
+ : `Provisioner keyless ok ${dim(`(${account} may impersonate it)`)}`));
312
+ if (r.legacyKey === "removed") {
313
+ note([
314
+ `This install no longer keeps a service-account key on your machine (#696).`,
315
+ ...r.lines.map((l) => ` ${l}`),
316
+ "",
317
+ `Infrastructure runs (re-running the installer) now use your own Google login:`,
318
+ `${account} impersonates ${provisionerServiceAccountEmail(projectId)} with short-lived tokens.`,
319
+ `They need Application Default Credentials once on this machine: ${bold("gcloud auth application-default login")}`,
320
+ ].join("\n"), "Service-account key removed");
321
+ }
322
+ return true;
323
+ }
324
+ /**
325
+ * Upgrade one install: converge, then app-roll (upgrade/upgrade.ts holds the
326
+ * sequence; this wires it to gcloud, the registry, cosign and install.json).
327
+ */
410
328
  async function upgradeInstall(installId, meta, opts) {
411
329
  // #656 — earlier installers checkpointed database URLs with role
412
330
  // passwords (and the setup link) into progress.json. Scrub them first,
413
331
  // on every provider and whether or not the roll below succeeds.
414
332
  for (const line of scrubInstallProgress(installId))
415
333
  log.warn(yellow(line));
416
- // v0.5.15 — extended to cover gcp-firebase too. Both providers share
417
- // the identical admin + gateway shape on Cloud Run (Artifact
418
- // Registry image, `caelo-production-<slug>` service naming, the same
419
- // gcloud commands). The only delta is the static-site layer (Cloud
420
- // CDN vs Firebase Hosting) which upgradeCommand doesn't touch. AWS
421
- // + Azure still bail with "not yet implemented" until those
422
- // provider adapters land.
334
+ // gcp and gcp-firebase share the admin + gateway shape on Cloud Run; only
335
+ // the static-site layer differs, which upgrade doesn't touch.
423
336
  if (meta.provider !== "gcp" && meta.provider !== "gcp-firebase") {
424
337
  log.warn(`upgrade for provider ${meta.provider} not yet implemented.`);
425
338
  return false;
426
339
  }
427
340
  if (!meta.projectId)
428
341
  return false;
342
+ const provider = meta.provider;
343
+ const projectId = meta.projectId;
429
344
  // #607 — upgrade never moves an install: a `--region` other than the
430
345
  // recorded one is refused before anything rolls.
431
346
  const region = installRegion(meta, opts.region);
432
- const registryProject = "caelo-website";
433
- const registryRegion = "europe-west1";
434
- const registryRepo = "caelo-cms-images";
435
- const targetTag = (() => {
436
- if (opts.version)
437
- return opts.version.startsWith("v") ? opts.version.slice(1) : opts.version;
438
- if (opts.channel === "rc")
439
- return "rc";
440
- if (opts.channel === "beta")
441
- return "beta";
442
- return "latest";
443
- })();
347
+ const targetTag = releaseTag(opts);
444
348
  log.info(`Upgrading admin + gateway to ${bold(targetTag)}`);
445
- // ────────────────────────────────────────────────────────────────
446
- // Phase 1: pre-flight. Resolve both digests + capture both prior
447
- // revisions BEFORE rolling anything. If any service is missing or
448
- // any digest can't be resolved, abort cleanly with no partial state.
449
- // ────────────────────────────────────────────────────────────────
450
- const plans = [];
451
- const sPre = spinner();
452
- sPre.start("Pre-flight: resolving services + image digests + current revisions...");
453
- for (const slug of ["admin", "gateway"]) {
454
- const serviceName = await resolveGcpResourceName("run-service", `caelo-production-${slug}`, meta.projectId, region);
455
- if (!serviceName) {
456
- sPre.stop(red(`Could not find caelo-production-${slug}* Cloud Run service`));
457
- return false;
458
- }
459
- // Resolve the tag via the public Docker Registry V2 API directly,
460
- // not `gcloud artifacts docker tags list`. The gcloud path requires
461
- // the operator's local credentials to have IAM read on the Caelo
462
- // team's caelo-website project — end users don't have that, so
463
- // the call fails for them with a misleading "tag doesn't exist"
464
- // error. The AR repo is configured public; the V2 HEAD on the
465
- // manifest endpoint returns the Docker-Content-Digest header
466
- // anonymously. (Same source of truth `gcloud artifacts docker
467
- // tags list` is wrapping; we just skip the IAM-gated CLI.)
468
- const digestRes = await resolveTagDigest(registryRegion, registryProject, registryRepo, slug, targetTag);
469
- if (!digestRes.ok) {
470
- sPre.stop(red(`Couldn't resolve image digest for ${slug}:${targetTag}`));
471
- log.error(`Public registry returned ${digestRes.reason}.\n` +
472
- `Verify the tag exists at https://${registryRegion}-docker.pkg.dev/${registryProject}/${registryRepo}/${slug}:${targetTag}\n` +
473
- `(latest releases live at https://github.com/caelo-cms/caelo-cms/releases — pass --version vX.Y.Z to pin.)`);
474
- return false;
475
- }
476
- const digest = digestRes.digest;
477
- const priorRevision = await findCurrentRevision(meta.projectId, region, serviceName);
478
- if (!priorRevision) {
479
- sPre.stop(red(`Could not capture current revision for ${slug} — refusing to roll`));
480
- return false;
481
- }
482
- const serviceJson = await describeServiceJson(meta.projectId, region, serviceName);
483
- if (!serviceJson) {
484
- sPre.stop(red(`Could not read the ${slug} service's configuration — refusing to roll`));
485
- return false;
486
- }
487
- plans.push({
488
- slug,
489
- serviceName,
490
- digest,
491
- imageRef: `${registryRegion}-docker.pkg.dev/${registryProject}/${registryRepo}/${slug}@${digest}`,
492
- priorRevision,
493
- liveEnv: liveContainerEnv(serviceJson),
494
- liveMemory: liveContainerMemory(serviceJson),
495
- liveVolumes: liveVolumes(serviceJson),
496
- });
497
- }
498
349
  // #650 — an install from before the staging host setting converges to
499
350
  // the default (staging.<domain>) without operator action: recorded here,
500
351
  // handed to the admin as CAELO_STAGING_HOST, which seeds its setting.
501
352
  const stagingHost = installStagingHost(meta);
502
- if (!meta.stagingDomain) {
353
+ if (!meta.stagingDomain)
503
354
  writeMetadata(installId, { ...meta, stagingDomain: stagingHost });
504
- }
505
- const install = {
506
- provider: meta.provider,
507
- projectId: meta.projectId,
355
+ const run = gcloud;
356
+ const http = fetch;
357
+ const ok = await upgradeGcpInstall({
358
+ provider,
359
+ projectId,
360
+ region,
508
361
  env: GCP_STACK_ENV,
509
362
  domain: meta.domain,
510
- region,
511
363
  stagingHost,
512
- };
513
- const deployed = Object.fromEntries(plans.map((p) => [p.slug, { serviceName: p.serviceName, liveEnv: p.liveEnv }]));
514
- const envPlan = planContractEnv(install, deployed);
515
- if (!envPlan.ok) {
516
- sPre.stop(red("Pre-flight failed: the install's env contract can't be applied"));
517
- log.error(envPlan.error);
518
- return false;
519
- }
520
- // The admin's media lives in the media bucket, mounted as a Cloud Storage
521
- // volume. Without it every upload is lost on the next revision or
522
- // scale-to-zero, so a volume upgrade cannot add is a pre-flight failure.
523
- const adminLive = plans.find((p) => p.slug === "admin");
524
- const mediaVolume = adminLive
525
- ? planMediaVolume(adminLive.liveVolumes, adminMediaVolume(meta.projectId, GCP_STACK_ENV))
526
- : { ok: false, error: "no admin service planned" };
527
- if (!mediaVolume.ok) {
528
- sPre.stop(red("Pre-flight failed: the admin's media volume can't be set up"));
529
- log.error(mediaVolume.error);
530
- return false;
531
- }
532
- let rolls = plans.map((p) => {
533
- // #553 — the admin runs the Lighthouse quality audit; raise it to the
534
- // stack's memory default (never lowered; an unparsable value is kept).
535
- const memory = p.slug === "admin" ? planAdminMemory(p.liveMemory) : null;
536
- if (memory && !memory.ok)
537
- log.warn(yellow(`admin memory left as is: ${memory.error}`));
538
- return {
539
- ...p,
540
- envFlags: envPlan.services[p.slug].flags,
541
- envChanges: envPlan.services[p.slug].changes,
542
- resourceFlags: memory?.ok ? memory.flags : [],
543
- volumeFlags: p.slug === "admin" ? mediaVolume.flags : [],
544
- volumeChanges: p.slug === "admin" ? mediaVolume.changes : [],
545
- };
546
- });
547
- sPre.stop(green(`Pre-flight ok — ${rolls.length} services planned`));
548
- for (const roll of rolls) {
549
- for (const c of roll.volumeChanges)
550
- log.info(`${roll.slug} ${bold("media")}: ${c}`);
551
- for (const f of roll.resourceFlags) {
552
- log.info(`${roll.slug} ${bold("resources")}: ${roll.liveMemory ?? dim("(default)")} → ${f}`);
553
- }
554
- for (const c of roll.envChanges) {
555
- log.info(`${roll.slug} env ${bold(c.name)}: ${c.from === undefined ? dim("(unset)") : c.from} → ${c.to === undefined ? dim("(removed)") : c.to}`);
556
- }
557
- }
558
- // ────────────────────────────────────────────────────────────────
559
- // P21 ship 4 — cosign verify each resolved digest against the
560
- // Caelo release workflow's keyless OIDC identity. Refuses to roll
561
- // on signature mismatch (compromised registry, typosquatted repo,
562
- // or operator pointed at a fork's image).
563
- //
564
- // Verification is opt-out via --skip-verify for forks/staging that
565
- // intentionally use unsigned images. Cosign-not-installed produces
566
- // a clear "install cosign or pass --skip-verify" message rather
567
- // than a confusing stack.
568
- // ────────────────────────────────────────────────────────────────
569
- if (!opts.skipVerify) {
570
- const verified = await verifyCosignAll(plans, registryRegion, registryProject, registryRepo);
571
- if (!verified)
572
- return false;
573
- }
574
- else {
575
- log.warn(yellow("--skip-verify set — image signatures NOT verified."));
576
- }
577
- // ────────────────────────────────────────────────────────────────
578
- // Runtime identities + secrets the env contract references: the
579
- // gateway's own run SA and the CLI-generated secrets
580
- // (runtime-secrets.ts). Created once, never overwritten. Must exist
581
- // before the IAM invariants bind them and before the rolls reference
582
- // them, so any failure aborts here.
583
- // ────────────────────────────────────────────────────────────────
584
- const sRt = spinner();
585
- sRt.start("Ensuring the gateway service account + generated runtime secrets...");
586
- const secretsTarget = { projectId: meta.projectId, env: GCP_STACK_ENV };
587
- const replication = await readSecretReplication(secretsTarget);
588
- const runtime = [
589
- await ensureGatewayServiceAccount(secretsTarget),
590
- ...(replication.ok
591
- ? await ensureGeneratedSecrets({
592
- ...secretsTarget,
593
- region,
594
- replication: replication.replication,
595
- seed: plainGeneratedSecretSeed(deployed.admin.liveEnv),
596
- })
597
- : [{ id: "secret replication", status: "failed", error: replication.error }]),
598
- ];
599
- const runtimeFailed = runtime.filter((o) => o.status === "failed");
600
- if (runtimeFailed.length > 0) {
601
- sRt.stop(red("Runtime identities/secrets could not be ensured. Aborting upgrade."));
602
- for (const o of runtimeFailed)
603
- log.error(red(` FAILED: ${o.id}\n ${o.error ?? ""}`));
604
- log.warn("No traffic was shifted and no migrations ran. Fix the above and re-run.");
605
- return false;
606
- }
607
- sRt.stop(green("Gateway service account + runtime secrets ok"));
608
- for (const o of runtime.filter((o) => o.status === "applied")) {
609
- log.info(` created: ${o.id}${o.detail ? ` (${o.detail})` : ""}`);
610
- }
611
- // ────────────────────────────────────────────────────────────────
612
- // Converge the infrastructure the stack declares but upgrade can't get
613
- // from an image roll: IAM bindings + CDN policy added to the stacks after
614
- // this install was provisioned (stack-contract.ts). Additive + idempotent.
615
- // A failure the install can't work without aborts here, before
616
- // migrations or any traffic shift; the rest warn. Also deletes GCP's
617
- // auto-created SSH/RDP-from-anywhere firewall rules (default-firewall.ts).
618
- // ────────────────────────────────────────────────────────────────
619
- const sInv = spinner();
620
- sInv.start("Ensuring the IAM bindings + CDN settings the stack declares, removing the default network's pre-populated firewall rules...");
621
- const invariants = await ensureStackInvariants({
622
- provider: meta.provider,
623
- projectId: meta.projectId,
624
- region,
625
- env: GCP_STACK_ENV,
626
- services: Object.fromEntries(rolls.map((r) => [r.slug, r.serviceName])),
627
- });
628
- const applied = invariants.outcomes.filter((o) => o.status === "applied");
629
- const failed = invariants.outcomes.filter((o) => o.status === "failed");
630
- if (invariants.mustAbort) {
631
- sInv.stop(red("Stack invariants could not be ensured. Aborting upgrade."));
632
- }
633
- else {
634
- sInv.stop(green(`Stack invariants ok (${invariants.outcomes.length - failed.length}/${invariants.outcomes.length}, ${applied.length} applied)`));
635
- }
636
- for (const o of applied)
637
- log.info(` applied: ${o.id} ${dim(`(${o.why})`)}`);
638
- for (const o of failed) {
639
- const line = ` ${o.onFailure === "abort" ? "FAILED" : "not applied"}: ${o.id} — ${o.why}\n ${o.error ?? ""}`;
640
- if (o.onFailure === "abort")
641
- log.error(red(line));
642
- else
643
- log.warn(yellow(line));
644
- }
645
- if (invariants.mustAbort) {
646
- log.warn("No traffic was shifted and no migrations ran. Fix the bindings above and re-run.");
647
- return false;
648
- }
649
- // ────────────────────────────────────────────────────────────────
650
- // P21 ship 3 — DB migrations BEFORE traffic shifts. Idempotent
651
- // (drizzle bookkeeping table); a failure here aborts the upgrade
652
- // before the new image touches traffic, so the admin keeps
653
- // serving the old version against the existing schema.
654
- // ────────────────────────────────────────────────────────────────
655
- const sMig = spinner();
656
- sMig.start("Applying DB migrations (idempotent)...");
657
- const { runMigrationsViaCloudRunJob } = await import("./migration-runner.js");
658
- // v0.9.2 — pass the NEW admin image as imageOverride. Pre-v0.9.2 the
659
- // migration runner read the CURRENT admin image (the one being
660
- // replaced) which didn't carry the new release's migration files,
661
- // so new migrations were silently skipped. Production hit this on
662
- // every upgrade that introduced schema changes — the symptom was
663
- // post-rollout queries failing with "column does not exist".
664
- const adminPlan = rolls.find((p) => p.slug === "admin");
665
- const mig = await runMigrationsViaCloudRunJob({
666
- projectId: meta.projectId,
667
- region,
668
- ...(adminPlan ? { imageOverride: adminPlan.imageRef } : {}),
364
+ ownerEmail: meta.ownerEmail,
365
+ targetTag,
366
+ infraContractVersion: INFRA_CONTRACT_VERSION,
367
+ skipVerify: opts.skipVerify === true,
368
+ skipHealthCheck: opts.skipHealthCheck === true,
369
+ }, {
370
+ run,
371
+ http,
372
+ reporter: clackReporter(),
373
+ resolveImages: resolveReleaseImages,
374
+ verifyImages: verifyCosignAll,
375
+ migrate: (imageRef) => runMigrationsViaCloudRunJob({ projectId, region, imageOverride: imageRef }, { run }),
376
+ healthProbe: (plan) => gcpHealthProbe(run, http, plan),
377
+ recordDigests: (digests) => recordRolledDigests(installId, provider, digests),
378
+ recordInfraContractVersion: (version) => recordInfraContractVersion(installId, version),
379
+ provisionerIdentity: () => upgradeKeylessProvisioner(installId, projectId),
669
380
  });
670
- if (!mig.ok) {
671
- sMig.stop(red(`Migrations failed (${mig.error ?? "unknown"}). Aborting upgrade.`));
672
- log.warn("No traffic was shifted. Inspect the Cloud Run Job logs:\n" +
673
- " gcloud logging read 'resource.type=cloud_run_job AND " +
674
- 'resource.labels.job_name=~"caelo-migrate-.*"\' ' +
675
- `--project=${meta.projectId} --limit=50`);
676
- return false;
677
- }
678
- sMig.stop(green("Migrations applied"));
679
- // ────────────────────────────────────────────────────────────────
680
- // #613 — the gateway's own database login. Migration 0248 created
681
- // gateway_role without a password; give it the gateway-role-password
682
- // secret's value now, before the gateway rolls onto it. Without it the
683
- // new gateway could not reach cms_admin, so a failure aborts here.
684
- // public_role moves onto its own password right before the gateway's
685
- // roll (gateway-credentials.ts), so the old revision keeps working
686
- // until then.
687
- // ────────────────────────────────────────────────────────────────
688
- const sRole = spinner();
689
- sRole.start("Ensuring the gateway's database login (gateway_role)...");
690
- const sqlInstance = await resolveGcpResourceName("sql-instance", "caelo-production-pg", meta.projectId);
691
- if (!sqlInstance) {
692
- sRole.stop(red("Could not find the Cloud SQL instance. Aborting upgrade."));
693
- log.warn("Migrations ran; no traffic was shifted. Re-run upgrade once the instance is found.");
694
- return false;
695
- }
696
- const roleTarget = { projectId: meta.projectId, env: GCP_STACK_ENV, sqlInstance };
697
- const gatewayRole = await ensureGatewayRolePassword(roleTarget);
698
- if (gatewayRole.status === "failed") {
699
- sRole.stop(red("gateway_role could not be given its password. Aborting upgrade."));
700
- log.error(red(` ${gatewayRole.error ?? ""}`));
701
- log.warn("Migrations ran (0248 creates gateway_role); no traffic was shifted. Fix the above and re-run upgrade.");
702
- return false;
703
- }
704
- sRole.stop(green("gateway_role ready"));
705
- const gatewayLive = plans.find((p) => p.slug === "gateway");
706
- const publicRole = planPublicRoleSwitch(roleTarget, gatewayLive?.liveEnv ?? new Map());
707
- // ────────────────────────────────────────────────────────────────
708
- // Issue #37 — MCP through IAP. Idempotently ensure the MCP service
709
- // account + its IAP/token-creator bindings; the admin learns the SA's
710
- // email from CAELO_MCP_IAP_SERVICE_ACCOUNT in the env contract. Installs
711
- // provisioned before this existed get it here, with no operator config.
712
- // A failure only costs MCP access, so it warns instead of aborting.
713
- // ────────────────────────────────────────────────────────────────
714
- // Env vars the rolls must leave as the service has them, because the
715
- // thing they point at could not be set up.
716
- const leaveUntouched = [];
717
- if (adminPlan) {
718
- const sMcp = spinner();
719
- sMcp.start("Ensuring MCP access through IAP...");
720
- const resource = await resolveAdminIapResource(meta, region, adminPlan.serviceName);
721
- const mcp = resource
722
- ? await ensureMcpIapAccess({ projectId: meta.projectId, resource })
723
- : { ok: false, error: "admin IAP backend service not found" };
724
- if (mcp.ok) {
725
- sMcp.stop(green(`MCP access ready (${mcp.serviceAccount}; ${mcp.operators.length} operator(s))`));
726
- }
727
- else {
728
- sMcp.stop(yellow(`MCP access not configured: ${mcp.error}`));
729
- log.warn("The upgrade continues; external MCP clients stay blocked by IAP until this succeeds.");
730
- // Don't hand the admin an MCP service account that may not exist or
731
- // lacks its bindings: /security/mcp would print a `claude mcp add`
732
- // command that can't work. Leave the var as the service has it.
733
- leaveUntouched.push(MCP_ENV_VAR);
734
- }
735
- // ──────────────────────────────────────────────────────────────
736
- // Operator access: the sync job that keeps Google IAP in step with the
737
- // user list (operator-access.ts) — its own SA, the only principal that
738
- // may change the IAP binding; the admin may only start it. Also removes
739
- // the operator-access rights an earlier revision gave the admin's SA.
740
- // After migrations: the job's database user gets the role migration
741
- // 0239 creates. A failure only costs IAP following the user list, so it
742
- // warns; the admin then reports the sync as not set up, loudly.
743
- // ──────────────────────────────────────────────────────────────
744
- const sOa = spinner();
745
- sOa.start("Ensuring the operator-access sync job...");
746
- const resolved = await resolveOperatorAccessTarget({
747
- provider: meta.provider,
748
- projectId: meta.projectId,
749
- region,
750
- env: GCP_STACK_ENV,
751
- ownerEmail: meta.ownerEmail,
752
- imageRef: adminPlan.imageRef,
753
- });
754
- const oa = resolved.ok
755
- ? await ensureOperatorAccessSync(resolved.target)
756
- : { ok: false, done: [], error: resolved.error };
757
- for (const step of oa.done)
758
- log.info(` ${dim(step)}`);
759
- if (oa.ok) {
760
- sOa.stop(green("Operator-access sync job ready (IAP follows the user list)"));
761
- }
762
- else {
763
- sOa.stop(yellow(`Operator-access sync job not set up: ${oa.error}`));
764
- log.warn("The upgrade continues; user changes will report that Google IAP could not be updated until this succeeds. Re-run upgrade once the cause is fixed.");
765
- leaveUntouched.push(OPERATOR_ACCESS_JOB_ENV_VAR);
766
- }
767
- }
768
- if (leaveUntouched.length > 0) {
769
- const replan = planContractEnv(install, deployed, { leaveUntouched });
770
- if (replan.ok) {
771
- rolls = rolls.map((r) => ({
772
- ...r,
773
- envFlags: replan.services[r.slug].flags,
774
- envChanges: replan.services[r.slug].changes,
775
- }));
776
- }
777
- }
778
- // ────────────────────────────────────────────────────────────────
779
- // Phase 2: roll each service, probe health, auto-rollback on fail.
780
- // If admin succeeds but gateway fails, also roll admin back so the
781
- // operator never ends up on a mismatched-version pair. The env
782
- // contract's changes ride the same `services update` as the image, so
783
- // they land in the new revision (and roll back with it). So does the
784
- // service's run SA: the gateway moves to its own SA here.
785
- // ────────────────────────────────────────────────────────────────
786
- const rolled = [];
787
- /** A failed gateway roll goes back to a revision that reads public_role's old password. */
788
- const restorePublicRole = async (slug) => {
789
- if (slug !== "gateway")
790
- return;
791
- const outcome = await publicRole.afterGatewayRollFailed();
792
- if (outcome.startsWith("could NOT"))
793
- log.error(red(` ${outcome}`));
794
- else
795
- log.info(` ${outcome}`);
796
- };
797
- for (const plan of rolls) {
798
- const s = spinner();
799
- s.start(`Rolling ${plan.slug} → ${plan.digest.slice(0, 19)}...`);
800
- if (plan.slug === "gateway") {
801
- const moved = await publicRole.beforeGatewayRoll();
802
- if (moved.status === "failed") {
803
- s.stop(red(`public_role could not be moved onto its own password: ${moved.error ?? ""}`));
804
- await rollbackPriorlyRolled(meta.projectId, region, rolled);
805
- return false;
806
- }
807
- }
808
- const upd = await rollService(serviceRollArgs({
809
- serviceName: plan.serviceName,
810
- region,
811
- projectId: meta.projectId,
812
- imageRef: plan.imageRef,
813
- serviceAccount: plan.slug === "admin"
814
- ? runServiceAccountEmail(meta.projectId, GCP_STACK_ENV)
815
- : gatewayServiceAccountEmail(meta.projectId, GCP_STACK_ENV),
816
- envFlags: plan.envFlags,
817
- resourceFlags: plan.resourceFlags,
818
- volumeFlags: plan.volumeFlags,
819
- }));
820
- if (!upd.ok) {
821
- s.stop(red(`Failed: ${upd.stderr.trim()}`));
822
- await restorePublicRole(plan.slug);
823
- await rollbackPriorlyRolled(meta.projectId, region, rolled);
824
- return false;
825
- }
826
- // Force traffic onto the new revision. `update --image` only auto-
827
- // flips when the service has no explicit traffic config — but our
828
- // own auto-rollback path (rollbackTraffic) pins traffic to the
829
- // prior revision, and that pin survives subsequent `update --image`
830
- // calls. Without an explicit `update-traffic --to-latest` here, a
831
- // prior failed upgrade silently locks every future upgrade off the
832
- // serving path: new revisions are created, marked Ready, and never
833
- // see traffic. Re-issuing it on every successful image update is
834
- // idempotent + fixes already-pinned services.
835
- const flip = await gcloud([
836
- "run",
837
- "services",
838
- "update-traffic",
839
- plan.serviceName,
840
- "--region",
841
- region,
842
- "--project",
843
- meta.projectId,
844
- "--to-latest",
845
- "--quiet",
846
- ]);
847
- if (!flip.ok) {
848
- s.stop(red(`Traffic flip to latest failed: ${flip.stderr.trim()}`));
849
- await restorePublicRole(plan.slug);
850
- await rollbackPriorlyRolled(meta.projectId, region, rolled);
851
- return false;
852
- }
853
- if (!(await findServiceUrl(meta.projectId, region, plan.serviceName))) {
854
- s.stop(red(`Could not resolve service URL for ${plan.slug} — rolling back`));
855
- await rollbackTraffic(meta.projectId, region, plan.serviceName, plan.priorRevision);
856
- await restorePublicRole(plan.slug);
857
- await rollbackPriorlyRolled(meta.projectId, region, rolled);
858
- return false;
859
- }
860
- // Cloud Run's `update --image` (with default --quiet) returns only
861
- // after the new revision passes its container-readiness probe AND
862
- // becomes the serving revision. That's the gate we rely on now —
863
- // the prior `/_caelo/health` HTTP probe via the run.app URL always
864
- // 403'd because the admin Cloud Run service is invoker-restricted
865
- // to the IAP service account (the LB+IAP gates user traffic; direct
866
- // run.app calls are denied by design). Probing through the LB would
867
- // require an IAP-issued OAuth token, which the operator's gcloud
868
- // session doesn't carry. Trust Cloud Run's own readiness signal —
869
- // a real startup failure surfaces as `gcloud update` returning an
870
- // error, which the upd.ok check above already catches and rolls
871
- // back. Deeper checks (DB connectivity, schema match) surface on
872
- // the first request after upgrade.
873
- s.stop(green(`${plan.slug} rolled to ${plan.digest.slice(0, 19)}... ✓`));
874
- rolled.push(plan);
875
- }
876
- log.success(`Upgrade to ${bold(targetTag)} complete (admin + gateway revisions Ready).`);
877
- if (meta.provider === "gcp-firebase") {
381
+ if (ok && provider === "gcp-firebase") {
878
382
  // #650 — staging builds now render the staging host; the gateway
879
383
  // serves it once it is mapped. The command is idempotent, so this
880
384
  // reminder is safe to repeat on every upgrade.
881
- log.info(`Protected staging site: run ${bold("cms-provision staging-domain enable")} once to serve the staging host (it maps the host the admin uses — ${bold(installStagingHost(meta))} unless changed there —, turns on password protection, prints the login and the DNS record). Host and access: admin → Security → Domains / Staging access.`);
385
+ log.info(`Protected staging site: run ${bold("cms-provision staging-domain enable")} once to serve the staging host (it maps the host the admin uses — ${bold(stagingHost)} unless changed there —, turns on password protection, prints the login and the DNS record). Host and access: admin → Settings → Domains / Staging access.`);
882
386
  }
883
- // #613 — with both services on their new revisions, drop the bindings
884
- // nothing reads through any more: above all the gateway SA's access to
885
- // admin_role's password. Loud on failure — until it is gone, a
886
- // compromised gateway could still read that password.
887
- const retired = await removeRetiredIamBindings({
888
- provider: meta.provider,
889
- projectId: meta.projectId,
890
- region,
891
- env: GCP_STACK_ENV,
892
- services: Object.fromEntries(rolls.map((r) => [r.slug, r.serviceName])),
893
- });
894
- for (const o of retired) {
895
- if (o.status === "removed")
896
- log.info(` removed: ${o.id} ${dim(`(${o.why})`)}`);
897
- if (o.status === "failed") {
898
- log.error(red(` NOT removed: ${o.id} — ${o.why}\n ${o.error ?? ""}\n Re-run upgrade with an account that may change IAM, or remove the binding by hand.`));
387
+ return ok;
388
+ }
389
+ /** The deep health probe for `plan`'s install, through IAP. */
390
+ async function gcpHealthProbe(run, http, plan) {
391
+ const { install } = plan;
392
+ const urls = {};
393
+ for (const s of plan.services) {
394
+ const url = await findServiceUrl(run, install.projectId, install.region, s.serviceName);
395
+ if (!url) {
396
+ return {
397
+ ok: false,
398
+ error: `Could not read the ${s.slug} service's URL for the health check.`,
399
+ };
899
400
  }
900
- }
901
- if (publicRole.moves) {
902
- // Until this upgrade the gateway held admin_role's password; anyone who
903
- // ever compromised it could have kept a copy.
904
- log.warn(yellow(`The gateway no longer holds admin_role's password, but it did until now. Rotate it: ${bold("bunx @caelo-cms/provisioning rotate-secret postgres-password")}`));
905
- }
906
- if (rolls.some((r) => liveEnvHasInlinePassword(r.liveEnv))) {
907
- // The services just moved their database password to Secret Manager,
908
- // but every earlier revision still shows it in its env.
909
- log.warn(yellow(`The database password was stored in plain env vars before this upgrade and stays readable in the services' old revisions. Rotate it now: ${bold("bunx @caelo-cms/provisioning rotate-secret postgres-password")}`));
910
- }
911
- const digestOf = (slug) => {
912
- const roll = rolls.find((r) => r.slug === slug);
913
- if (!roll)
914
- throw new Error(`upgrade rolled no ${slug} service`);
915
- return roll.digest;
401
+ urls[s.slug] = url;
402
+ }
403
+ return {
404
+ ok: true,
405
+ probe: createIapHealthProbe({
406
+ provider: install.provider,
407
+ projectId: install.projectId,
408
+ domain: install.domain,
409
+ adminServiceUrl: urls.admin,
410
+ gatewayServiceUrl: urls.gateway,
411
+ }, { run, http, sleep: realSleep }),
916
412
  };
917
- await recordRolledDigests(installId, meta.provider, {
918
- admin: digestOf("admin"),
919
- gateway: digestOf("gateway"),
920
- });
921
- return true;
922
413
  }
923
414
  /**
924
415
  * Record the digests just rolled so nothing re-deploys an older release:
@@ -933,19 +424,27 @@ async function recordRolledDigests(installId, provider, digests) {
933
424
  recordImageDigests(installId, digests);
934
425
  }
935
426
  catch (e) {
936
- log.warn(yellow(`Could not record the rolled digests in ${installRoot(installId)}/install.json (${reason(e)}). Add this before re-running the installer, or it deploys the newest release instead:\n "imageDigests": ${JSON.stringify(digests)}`));
427
+ log.warn(yellow(`Could not record the rolled digests in the install.json of ${installId} (${reason(e)}). Add this before re-running the installer, or it deploys the newest release instead:\n "imageDigests": ${JSON.stringify(digests)}`));
428
+ }
429
+ const meta = readMetadata(installId);
430
+ const manual = stackConfigRecovery(installId, meta ?? {}, provider, digests);
431
+ let backend;
432
+ try {
433
+ if (!meta)
434
+ throw new Error(`no install.json for '${installId}'`);
435
+ backend = resolveBackend(meta, {
436
+ installRoot: installRoot(installId),
437
+ readPassphrase: () => readSecret(installId, "pulumi-passphrase"),
438
+ });
937
439
  }
938
- const manual = stackConfigRecovery(installId, provider, digests);
939
- const passphrase = readSecret(installId, "pulumi-passphrase");
940
- if (!passphrase) {
941
- log.warn(yellow(`No Pulumi passphrase in ~/.caelo-${installId}/secrets — stack config not updated. Before any manual \`pulumi up\`, run:\n${manual}`));
440
+ catch (e) {
441
+ log.warn(yellow(`Stack config not updated (${reason(e)}). Before any manual \`pulumi up\`, run:\n${manual}`));
942
442
  return;
943
443
  }
944
444
  try {
945
445
  const { writeImageDigestsToStack } = await import("./wizards/gcp-pulumi.js");
946
446
  const { removedOverrides } = await writeImageDigestsToStack({
947
- installRoot: installRoot(installId),
948
- pulumiPassphrase: passphrase,
447
+ backend,
949
448
  provider,
950
449
  digests,
951
450
  });
@@ -963,9 +462,8 @@ async function recordRolledDigests(installId, provider, digests) {
963
462
  * the same workspace, backend, stack and passphrase the CLI uses — run from
964
463
  * any directory, they cannot hit an unrelated stack.
965
464
  */
966
- function stackConfigRecovery(installId, provider, digests) {
967
- const root = installRoot(installId);
968
- const env = `PULUMI_BACKEND_URL=file://${join(root, "state")} PULUMI_CONFIG_PASSPHRASE="$(cat ${join(root, "secrets", "pulumi-passphrase")})"`;
465
+ function stackConfigRecovery(installId, meta, provider, digests) {
466
+ const env = backendShellEnv(meta, installRoot(installId));
969
467
  const cwd = `--cwd ${resolvePath(import.meta.dir, "../stacks", provider)} --stack ${GCP_STACK_ENV}`;
970
468
  const lines = Object.entries(digests).map(([svc, d]) => ` ${env} pulumi config set ${cwd} caelo-${provider}:image-digest-${svc} ${d}`);
971
469
  if (provider === "gcp") {
@@ -973,133 +471,15 @@ function stackConfigRecovery(installId, provider, digests) {
973
471
  }
974
472
  return lines.join("\n");
975
473
  }
976
- /**
977
- * Roll back any services that already passed their probe in this
978
- * upgrade run. Called when a later service fails its probe so the
979
- * operator never ends up on an admin-new + gateway-old (or vice versa)
980
- * mismatched pair.
981
- */
982
- /** Returns true if `argv` runs to a zero exit; false on non-zero or ENOENT. */
983
- function probeBinary(argv) {
984
- try {
985
- const r = Bun.spawnSync(argv, { stdout: "pipe", stderr: "pipe" });
986
- return r.exitCode === 0;
987
- }
988
- catch {
989
- return false;
990
- }
991
- }
992
- /**
993
- * Ensure cosign is on PATH. If not — and the operator is on macOS with
994
- * brew available — offer to `brew install cosign` interactively. Falls
995
- * back to printing the platform install URL + the --skip-verify
996
- * escape hatch when auto-install isn't possible.
997
- *
998
- * Returns true if cosign is callable after this function returns.
999
- */
1000
- async function ensureCosignAvailable() {
1001
- if (probeBinary(["cosign", "version"]))
1002
- return true;
1003
- log.error(red("cosign not found on PATH — required for image-signature verification."));
1004
- // macOS auto-install offer. Only when `brew` is itself on PATH —
1005
- // otherwise the operator is on a stripped-down install where
1006
- // homebrew was never set up, and the manual instructions are more
1007
- // useful than a failing brew shell-out.
1008
- if (process.platform === "darwin" && probeBinary(["brew", "--version"])) {
1009
- const proceed = await confirm({
1010
- message: "Install cosign via brew now? (one-shot: brew install cosign)",
1011
- initialValue: true,
1012
- });
1013
- if (!isCancel(proceed) && proceed === true) {
1014
- const s = spinner();
1015
- s.start("brew install cosign");
1016
- const installResult = Bun.spawnSync(["brew", "install", "cosign"], {
1017
- stdout: "pipe",
1018
- stderr: "pipe",
1019
- });
1020
- if (installResult.exitCode === 0 && probeBinary(["cosign", "version"])) {
1021
- s.stop(green("cosign installed ✓"));
1022
- return true;
1023
- }
1024
- const stderr = new TextDecoder().decode(installResult.stderr);
1025
- s.stop(red("brew install cosign failed"));
1026
- log.error(stderr.trim().slice(0, 500));
1027
- }
1028
- }
1029
- log.warn("Install cosign:\n" +
1030
- " • brew install cosign (macOS)\n" +
1031
- " • https://docs.sigstore.dev/cosign/installation/ (other)\n" +
1032
- "Or pass --skip-verify to roll without signature checks (NOT recommended for production).");
1033
- return false;
1034
- }
1035
- /**
1036
- * P21 ship 4 — verify cosign keyless signatures on every planned
1037
- * image digest. Sigstore Fulcio + Rekor; the certificate identity
1038
- * must match the Caelo release-images workflow. A mismatch means
1039
- * either a registry compromise, a typosquat, or the operator pointed
1040
- * at a fork's image. Either way: refuse to roll.
1041
- *
1042
- * Returns true on success (or skips with a clear error and returns
1043
- * false if cosign isn't installed / verification fails).
1044
- */
1045
- async function verifyCosignAll(plans, registryRegion, registryProject, registryRepo) {
1046
- // Probe cosign. Bun.spawnSync THROWS ENOENT when the binary is
1047
- // missing (instead of returning a non-zero exit), so the probe
1048
- // needs try/catch — relying on `exitCode !== 0` would surface a
1049
- // confusing stack trace to the operator instead of the actionable
1050
- // "install cosign" hint.
1051
- if (!(await ensureCosignAvailable()))
1052
- return false;
1053
- for (const plan of plans) {
1054
- const s = spinner();
1055
- s.start(`Verifying cosign signature for ${plan.slug}@${plan.digest.slice(0, 19)}...`);
1056
- const fullRef = `${registryRegion}-docker.pkg.dev/${registryProject}/${registryRepo}/${plan.slug}@${plan.digest}`;
1057
- const verify = Bun.spawnSync([
1058
- "cosign",
1059
- "verify",
1060
- fullRef,
1061
- "--certificate-identity-regexp",
1062
- // Both release-images.yml and release.yml dispatch images;
1063
- // accept either workflow as the signer identity. The repo
1064
- // path is fixed; only the workflow filename varies.
1065
- "https://github.com/caelo-cms/caelo-cms/.github/workflows/(release-images|release).yml@.*",
1066
- "--certificate-oidc-issuer",
1067
- "https://token.actions.githubusercontent.com",
1068
- ], { stdout: "pipe", stderr: "pipe" });
1069
- if (verify.exitCode !== 0) {
1070
- const stderr = new TextDecoder().decode(verify.stderr);
1071
- s.stop(red(`cosign verify FAILED for ${plan.slug}`));
1072
- log.error(red(`${plan.slug}@${plan.digest.slice(0, 19)} signature does NOT match the Caelo release workflow identity.\n` +
1073
- "Either:\n" +
1074
- " • the registry was compromised, OR\n" +
1075
- " • you're targeting a fork's image (verify your install's registry path), OR\n" +
1076
- " • cosign / sigstore had a transient outage (retry).\n" +
1077
- `cosign stderr: ${stderr.trim().slice(0, 500)}`));
1078
- return false;
1079
- }
1080
- s.stop(green(`${plan.slug} signature verified ✓`));
1081
- }
1082
- return true;
1083
- }
1084
- async function rollbackPriorlyRolled(projectId, region, rolled) {
1085
- if (rolled.length === 0)
1086
- return;
1087
- log.warn(yellow(`Rolling back ${rolled.length} service(s) that succeeded earlier in this run...`));
1088
- for (const plan of rolled) {
1089
- const ok = await rollbackTraffic(projectId, region, plan.serviceName, plan.priorRevision);
1090
- log.info(ok
1091
- ? green(` ${plan.slug} traffic restored to ${plan.priorRevision}`)
1092
- : red(` ${plan.slug} rollback FAILED — manual fix required`));
1093
- }
1094
- }
1095
474
  // =========================================================================
1096
- // backup — Cloud SQL on-demand backup + bundle Pulumi state
475
+ // backup — Cloud SQL on-demand backup
1097
476
  // =========================================================================
1098
477
  export async function backupCommand() {
1099
478
  await forEachInstall("backup", (_installId, meta) => backupOf(meta));
1100
479
  }
480
+ /** Both GCP providers keep their data in the install's Cloud SQL instance. */
1101
481
  async function backupOf(meta) {
1102
- if (meta.provider !== "gcp") {
482
+ if (meta.provider !== "gcp" && meta.provider !== "gcp-firebase") {
1103
483
  log.warn(`backup for provider ${meta.provider} not yet implemented.`);
1104
484
  return false;
1105
485
  }
@@ -1107,27 +487,13 @@ async function backupOf(meta) {
1107
487
  return false;
1108
488
  const s = spinner();
1109
489
  s.start("Resolving Cloud SQL instance + triggering on-demand backup...");
1110
- const sqlName = await resolveGcpResourceName("sql-instance", "caelo-production-pg", meta.projectId);
1111
- if (!sqlName) {
1112
- s.stop(red("Could not find caelo-production-pg* Cloud SQL instance"));
1113
- return false;
1114
- }
1115
- const r = await gcloud([
1116
- "sql",
1117
- "backups",
1118
- "create",
1119
- "--instance",
1120
- sqlName,
1121
- "--project",
1122
- meta.projectId,
1123
- "--description",
1124
- `caelo-cms backup ${new Date().toISOString()}`,
1125
- ]);
490
+ const r = await takeSqlBackup(gcloud, { projectId: meta.projectId, env: GCP_STACK_ENV }, "backup");
1126
491
  if (!r.ok) {
1127
- s.stop(red(`Failed: ${r.stderr.trim()}`));
492
+ s.stop(red(`Failed: ${r.error}`));
1128
493
  return false;
1129
494
  }
1130
- s.stop(green(`Backup created. List with \`gcloud sql backups list --instance=${sqlName}\`.`));
495
+ s.stop(green(`Backup ${r.backup.backupId} of ${r.backup.instance} created.`));
496
+ log.info(`Restore it (overwrites all data written since): ${bold(restoreCommand(r.backup))}`);
1131
497
  return true;
1132
498
  }
1133
499
  // =========================================================================
@@ -1157,7 +523,7 @@ export async function rotateSecretCommand(name) {
1157
523
  s.start(`Rotating ${secret}...`);
1158
524
  const services = {};
1159
525
  for (const slug of ["admin", "gateway"]) {
1160
- const serviceName = await resolveGcpResourceName("run-service", `caelo-production-${slug}`, meta.projectId, region);
526
+ const serviceName = await resolveGcpResourceName(gcloud, "run-service", `caelo-production-${slug}`, meta.projectId, region);
1161
527
  if (!serviceName) {
1162
528
  s.stop(red(`Could not find caelo-production-${slug}* Cloud Run service — nothing rotated`));
1163
529
  return;
@@ -1165,7 +531,7 @@ export async function rotateSecretCommand(name) {
1165
531
  services[slug] = serviceName;
1166
532
  }
1167
533
  const sqlInstance = rolesOfSecret(secret).length > 0
1168
- ? await resolveGcpResourceName("sql-instance", "caelo-production-pg", meta.projectId)
534
+ ? await resolveGcpResourceName(gcloud, "sql-instance", "caelo-production-pg", meta.projectId)
1169
535
  : null;
1170
536
  const report = await rotateRuntimeSecret({
1171
537
  projectId: meta.projectId,