@caelo-cms/provisioning 0.11.13 → 0.11.14

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 (159) hide show
  1. package/dist/admin-domain.d.ts +4 -46
  2. package/dist/admin-domain.d.ts.map +1 -1
  3. package/dist/admin-domain.js +4 -101
  4. package/dist/admin-domain.js.map +1 -1
  5. package/dist/agent-db.js +29 -5
  6. package/dist/audit-job-setup.d.ts.map +1 -1
  7. package/dist/audit-job-setup.js +14 -19
  8. package/dist/audit-job-setup.js.map +1 -1
  9. package/dist/caddy.d.ts +2 -1
  10. package/dist/caddy.d.ts.map +1 -1
  11. package/dist/caddy.js +21 -3
  12. package/dist/caddy.js.map +1 -1
  13. package/dist/cli.js +111 -62
  14. package/dist/cli.js.map +1 -1
  15. package/dist/compose.d.ts +22 -2
  16. package/dist/compose.d.ts.map +1 -1
  17. package/dist/compose.js +53 -3
  18. package/dist/compose.js.map +1 -1
  19. package/dist/converge-checkpoint.d.ts +1 -0
  20. package/dist/converge-checkpoint.d.ts.map +1 -1
  21. package/dist/converge-checkpoint.js +1 -0
  22. package/dist/converge-checkpoint.js.map +1 -1
  23. package/dist/deployed-release.d.ts.map +1 -1
  24. package/dist/deployed-release.js +40 -8
  25. package/dist/deployed-release.js.map +1 -1
  26. package/dist/gcp-names.d.ts +33 -14
  27. package/dist/gcp-names.d.ts.map +1 -1
  28. package/dist/gcp-names.js +43 -0
  29. package/dist/gcp-names.js.map +1 -1
  30. package/dist/gcp-url-map.d.ts +19 -4
  31. package/dist/gcp-url-map.d.ts.map +1 -1
  32. package/dist/gcp-url-map.js +33 -5
  33. package/dist/gcp-url-map.js.map +1 -1
  34. package/dist/infra-contract.d.ts +1 -1
  35. package/dist/infra-contract.d.ts.map +1 -1
  36. package/dist/infra-contract.js +18 -1
  37. package/dist/infra-contract.js.map +1 -1
  38. package/dist/lifecycle.d.ts.map +1 -1
  39. package/dist/lifecycle.js +5 -39
  40. package/dist/lifecycle.js.map +1 -1
  41. package/dist/mcp-auth-proxy-setup.d.ts +8 -2
  42. package/dist/mcp-auth-proxy-setup.d.ts.map +1 -1
  43. package/dist/mcp-auth-proxy-setup.js +4 -0
  44. package/dist/mcp-auth-proxy-setup.js.map +1 -1
  45. package/dist/release-manifest.d.ts +1 -1
  46. package/dist/release-manifest.d.ts.map +1 -1
  47. package/dist/release-manifest.js +2 -0
  48. package/dist/release-manifest.js.map +1 -1
  49. package/dist/remote-mcp.d.ts.map +1 -1
  50. package/dist/remote-mcp.js +11 -1
  51. package/dist/remote-mcp.js.map +1 -1
  52. package/dist/runtime-secrets.d.ts +10 -0
  53. package/dist/runtime-secrets.d.ts.map +1 -1
  54. package/dist/runtime-secrets.js +10 -1
  55. package/dist/runtime-secrets.js.map +1 -1
  56. package/dist/stack-contract.d.ts +61 -20
  57. package/dist/stack-contract.d.ts.map +1 -1
  58. package/dist/stack-contract.js +173 -35
  59. package/dist/stack-contract.js.map +1 -1
  60. package/dist/stack-converge.d.ts.map +1 -1
  61. package/dist/stack-converge.js +4 -2
  62. package/dist/stack-converge.js.map +1 -1
  63. package/dist/staging-admin.d.ts +2 -2
  64. package/dist/staging-admin.d.ts.map +1 -1
  65. package/dist/staging-gateway-setup.d.ts +140 -0
  66. package/dist/staging-gateway-setup.d.ts.map +1 -0
  67. package/dist/staging-gateway-setup.js +848 -0
  68. package/dist/staging-gateway-setup.js.map +1 -0
  69. package/dist/upgrade/app-roll.d.ts +4 -4
  70. package/dist/upgrade/app-roll.d.ts.map +1 -1
  71. package/dist/upgrade/app-roll.js +7 -5
  72. package/dist/upgrade/app-roll.js.map +1 -1
  73. package/dist/upgrade/converge.d.ts.map +1 -1
  74. package/dist/upgrade/converge.js +36 -27
  75. package/dist/upgrade/converge.js.map +1 -1
  76. package/dist/upgrade/image-forms.d.ts +3 -3
  77. package/dist/upgrade/image-forms.d.ts.map +1 -1
  78. package/dist/upgrade/image-forms.js +2 -2
  79. package/dist/upgrade/image-forms.js.map +1 -1
  80. package/dist/upgrade/release-images.d.ts +20 -0
  81. package/dist/upgrade/release-images.d.ts.map +1 -1
  82. package/dist/upgrade/release-images.js +28 -0
  83. package/dist/upgrade/release-images.js.map +1 -1
  84. package/dist/upgrade/upgrade.d.ts +3 -1
  85. package/dist/upgrade/upgrade.d.ts.map +1 -1
  86. package/dist/upgrade/upgrade.js +22 -1
  87. package/dist/upgrade/upgrade.js.map +1 -1
  88. package/dist/upgrader-job.d.ts.map +1 -1
  89. package/dist/upgrader-job.js +30 -5
  90. package/dist/upgrader-job.js.map +1 -1
  91. package/dist/upgrader-records.d.ts +2 -2
  92. package/dist/upgrader-records.d.ts.map +1 -1
  93. package/dist/upgrader-records.js +2 -2
  94. package/dist/upgrader-records.js.map +1 -1
  95. package/dist/wizard.d.ts.map +1 -1
  96. package/dist/wizard.js +3 -3
  97. package/dist/wizard.js.map +1 -1
  98. package/dist/wizards/gcp.d.ts.map +1 -1
  99. package/dist/wizards/gcp.js +48 -9
  100. package/dist/wizards/gcp.js.map +1 -1
  101. package/package.json +2 -2
  102. package/src/admin-domain.test.ts +0 -156
  103. package/src/admin-domain.ts +4 -117
  104. package/src/admin-memory.test.ts +2 -0
  105. package/src/audit-job-setup.test.ts +11 -11
  106. package/src/audit-job-setup.ts +14 -19
  107. package/src/caddy-redirects.docker.test.ts +10 -5
  108. package/src/caddy-redirects.test.ts +7 -5
  109. package/src/caddy-validate.test.ts +1 -1
  110. package/src/caddy.test.ts +12 -6
  111. package/src/caddy.ts +23 -4
  112. package/src/cli.ts +138 -77
  113. package/src/compose.ts +67 -2
  114. package/src/converge-checkpoint.ts +1 -0
  115. package/src/database-tls-builders.test.ts +3 -1
  116. package/src/deployed-release.test.ts +21 -0
  117. package/src/deployed-release.ts +42 -7
  118. package/src/gateway-env-isolation.test.ts +2 -0
  119. package/src/gcp-names.ts +53 -0
  120. package/src/gcp-url-map.test.ts +26 -2
  121. package/src/gcp-url-map.ts +36 -5
  122. package/src/index.test.ts +9 -3
  123. package/src/infra-contract.test.ts +4 -3
  124. package/src/infra-contract.ts +18 -1
  125. package/src/lifecycle.ts +8 -52
  126. package/src/mcp-auth-proxy-setup.test.ts +15 -6
  127. package/src/mcp-auth-proxy-setup.ts +14 -2
  128. package/src/release-manifest.ts +3 -1
  129. package/src/remote-mcp-stack-config.test.ts +1 -0
  130. package/src/remote-mcp.test.ts +5 -1
  131. package/src/remote-mcp.ts +10 -1
  132. package/src/runtime-secrets.test.ts +10 -1
  133. package/src/runtime-secrets.ts +21 -0
  134. package/src/self-hosted-backup.test.ts +6 -4
  135. package/src/self-hosted-compose.test.ts +2 -0
  136. package/src/stack-contract-parity.test.ts +7 -3
  137. package/src/stack-contract.test.ts +73 -13
  138. package/src/stack-contract.ts +195 -42
  139. package/src/stack-converge-secrets.test.ts +7 -2
  140. package/src/stack-converge.test.ts +37 -9
  141. package/src/stack-converge.ts +4 -1
  142. package/src/staging-admin.ts +2 -2
  143. package/src/staging-gateway-setup.test.ts +361 -0
  144. package/src/staging-gateway-setup.ts +1083 -0
  145. package/src/upgrade/app-roll.ts +7 -4
  146. package/src/upgrade/converge.ts +40 -26
  147. package/src/upgrade/image-forms.ts +7 -7
  148. package/src/upgrade/release-images.test.ts +28 -0
  149. package/src/upgrade/release-images.ts +44 -0
  150. package/src/upgrade/upgrade.test.ts +63 -11
  151. package/src/upgrade/upgrade.ts +29 -2
  152. package/src/upgrader-job.test.ts +23 -0
  153. package/src/upgrader-job.ts +45 -11
  154. package/src/upgrader-records.ts +7 -7
  155. package/src/wizard.ts +2 -3
  156. package/src/wizards/gcp.ts +73 -9
  157. package/stacks/gcp/index.ts +47 -6
  158. package/stacks/gcp-firebase/index.ts +44 -12
  159. package/stacks/self-hosted/index.ts +9 -2
package/src/cli.ts CHANGED
@@ -64,6 +64,7 @@ import {
64
64
  COMPOSE_ADMIN_UPSTREAM,
65
65
  COMPOSE_GATEWAY_UPSTREAM,
66
66
  COMPOSE_MCP_AUTH_PROXY_UPSTREAM,
67
+ COMPOSE_STAGING_GATEWAY_UPSTREAM,
67
68
  type ComposeImages,
68
69
  composeProjectName,
69
70
  generateDockerCompose,
@@ -111,6 +112,14 @@ interface CaeloConfig {
111
112
  * generated before P18 still load — emitConfig() back-fills if missing.
112
113
  */
113
114
  caeloSecretKek?: string;
115
+ /**
116
+ * #650 / #1330 — the staging gate's key (32 hex bytes): signs "Open
117
+ * staging" links, derives the crawler credentials. Generated once and
118
+ * persisted here; never re-minted, or every crawler credential stops
119
+ * working. The admin gets it as CAELO_STAGING_GATE_SECRET and publishes it
120
+ * to the staging gateway through the staging projection.
121
+ */
122
+ stagingGateSecret?: string;
114
123
  /**
115
124
  * #613 — one password per application role. Optional in the type so
116
125
  * config.json files from before still load; `up` back-fills them and
@@ -319,7 +328,7 @@ function writeCaddyfile(cfg: CaeloConfig, extraDomains: CaddyDomainSpec[]): void
319
328
  ownerEmail: cfg.ownerEmail,
320
329
  tls: cfg.tls,
321
330
  publicSiteRoot: `${SITE_OUTPUT_ROOT}/output/production/current`,
322
- stagingSiteRoot: `${SITE_OUTPUT_ROOT}/output/staging/current`,
331
+ stagingGatewayUpstream: COMPOSE_STAGING_GATEWAY_UPSTREAM,
323
332
  // Caddy runs in its own container: the Compose service names, never
324
333
  // localhost (#707).
325
334
  adminUpstream: COMPOSE_ADMIN_UPSTREAM,
@@ -359,6 +368,16 @@ function renderCompose(cfg: CaeloConfig, images: ComposeImages): string {
359
368
  throw new Error("config has no rolePasswords — run `cms-provision up`");
360
369
  }
361
370
  if (!cfg.caeloSecretKek) throw new Error("config has no caeloSecretKek — run `cms-provision up`");
371
+ // #1330 — back-filled once for installs from before the staging gate
372
+ // came to self-hosted; persisted before anything uses it.
373
+ if (!cfg.stagingGateSecret) {
374
+ cfg.stagingGateSecret = randomSecret(32);
375
+ saveConfig(cfg);
376
+ }
377
+ if (!cfg.stagingDomain) {
378
+ cfg.stagingDomain = defaultStagingHost(cfg.domain);
379
+ saveConfig(cfg);
380
+ }
362
381
  if (!cfg.ports || !cfg.composeProject) {
363
382
  throw new Error(
364
383
  "config has no ports / composeProject — they are back-filled on load; this is a bug",
@@ -369,6 +388,8 @@ function renderCompose(cfg: CaeloConfig, images: ComposeImages): string {
369
388
  for (const line of ensureSelfHostedDbTls(CAELO_DIR)) console.log(` database TLS: ${line}`);
370
389
  return generateDockerCompose({
371
390
  domain: cfg.domain,
391
+ stagingHost: cfg.stagingDomain,
392
+ stagingGateSecret: cfg.stagingGateSecret,
372
393
  postgresPassword: cfg.postgresPassword,
373
394
  rolePasswords: cfg.rolePasswords,
374
395
  caeloSecretKek: cfg.caeloSecretKek,
@@ -1420,7 +1441,7 @@ async function domainMappingCommand(kind: "admin" | "staging"): Promise<void> {
1420
1441
  selectInstall,
1421
1442
  writeMetadata,
1422
1443
  } = await import("./install-state.js");
1423
- const { enableAdminDomain, enableStagingDomain } = await import("./admin-domain.js");
1444
+ const { enableAdminDomain } = await import("./admin-domain.js");
1424
1445
  // Guessing among several installs could map the domain in the wrong project.
1425
1446
  const selected = selectInstall(
1426
1447
  listInstalls().filter((m) => m.projectId),
@@ -1448,13 +1469,10 @@ async function domainMappingCommand(kind: "admin" | "staging"): Promise<void> {
1448
1469
  const requested = arg("staging-domain");
1449
1470
  const { normalizeStagingHost, stagingHostProblem } = await import("@caelo-cms/shared");
1450
1471
  let adminHost: string | null = null;
1451
- if (!requested && meta.provider === "gcp-firebase" && meta.projectId && meta.region) {
1472
+ const google = googleAdminTarget(meta);
1473
+ if (!requested && google) {
1452
1474
  const { inspectStagingAdmin } = await import("./staging-admin.js");
1453
- const inspected = await inspectStagingAdmin({
1454
- provider: "gcp-firebase",
1455
- projectId: meta.projectId,
1456
- region: meta.region,
1457
- });
1475
+ const inspected = await inspectStagingAdmin(google);
1458
1476
  if (!inspected.ok) {
1459
1477
  console.error(
1460
1478
  `${command}: could not read the admin's staging host: ${inspected.error}\n` +
@@ -1465,11 +1483,10 @@ async function domainMappingCommand(kind: "admin" | "staging"): Promise<void> {
1465
1483
  adminHost = inspected.value.stagingHost;
1466
1484
  }
1467
1485
  const host = requested ?? adminHost ?? installStagingHost(meta);
1468
- const problem = stagingHostProblem(
1469
- host,
1470
- { liveHosts: [meta.domain, `www.${meta.domain}`], adminHosts: [`admin.${meta.domain}`] },
1471
- { provider: meta.provider },
1472
- );
1486
+ const problem = stagingHostProblem(host, {
1487
+ liveHosts: [meta.domain, `www.${meta.domain}`],
1488
+ adminHosts: [`admin.${meta.domain}`],
1489
+ });
1473
1490
  if (problem) {
1474
1491
  console.error(
1475
1492
  requested
@@ -1498,23 +1515,20 @@ async function domainMappingCommand(kind: "admin" | "staging"): Promise<void> {
1498
1515
  );
1499
1516
  process.exit(1);
1500
1517
  }
1518
+ if (kind === "staging") {
1519
+ await serveStagingHost(meta, command, retry, { rotatePassword, showPassword });
1520
+ return;
1521
+ }
1501
1522
  if (meta.provider !== "gcp-firebase") {
1502
1523
  console.log(
1503
- kind === "admin"
1504
- ? meta.provider === "gcp"
1505
- ? `Nothing to do: on gcp the load balancer already serves admin.${meta.domain}.`
1506
- : `admin-domain is for gcp-firebase installs; this one is ${meta.provider}.`
1507
- : meta.provider === "self-hosted"
1508
- ? `Recorded staging host ${installStagingHost(meta)}; Caddy serves it after \`cms-provision regenerate-caddy\`.`
1509
- : `staging-domain is for gcp-firebase installs; on ${meta.provider} the protected staging domain is not available yet (issue #650 follow-up).`,
1524
+ meta.provider === "gcp"
1525
+ ? `Nothing to do: on gcp the load balancer already serves admin.${meta.domain}.`
1526
+ : `admin-domain is for gcp-firebase installs; this one is ${meta.provider}.`,
1510
1527
  );
1511
1528
  return;
1512
1529
  }
1513
1530
  const target = { projectId: meta.projectId, region: meta.region, domain: meta.domain };
1514
- const r =
1515
- kind === "admin"
1516
- ? await enableAdminDomain(target)
1517
- : await enableStagingDomain({ ...target, stagingHost: installStagingHost(meta) });
1531
+ const r = await enableAdminDomain(target);
1518
1532
  if (r.status === "failed") {
1519
1533
  console.error(`${command}: ${r.error}`);
1520
1534
  process.exit(1);
@@ -1527,56 +1541,7 @@ async function domainMappingCommand(kind: "admin" | "staging"): Promise<void> {
1527
1541
  );
1528
1542
  process.exit(3);
1529
1543
  }
1530
- const mappedTo = kind === "admin" ? "the admin" : "the gateway (protected staging site)";
1531
- console.log(
1532
- `${r.hostname} ${r.created ? "is now mapped" : "was already mapped"} to ${mappedTo}.`,
1533
- );
1534
- if (kind === "staging") {
1535
- // #678 — the operator's decision reaches the admin here: the host is
1536
- // set there, password protection is switched on the first time, and
1537
- // the password is printed once (never stored by the CLI).
1538
- const { applyStagingAdmin, describeStagingApply } = await import("./staging-admin.js");
1539
- const applied = await applyStagingAdmin(
1540
- { provider: "gcp-firebase", projectId: meta.projectId, region: meta.region },
1541
- {
1542
- host: r.hostname,
1543
- ...(rotatePassword ? { rotateBasicAuth: true } : {}),
1544
- ...(showPassword ? { revealBasicAuth: true } : {}),
1545
- },
1546
- );
1547
- if (!applied.ok) {
1548
- console.error(
1549
- `${command}: ${r.hostname} is mapped, but the admin could not be updated: ${applied.error}\n` +
1550
- `Run \`${retry}\` again once that is fixed (it is idempotent).`,
1551
- );
1552
- process.exit(5);
1553
- }
1554
- // Prints the staging basic-auth password ON PURPOSE (#678): the operator
1555
- // asked for it to be shown once; it is never written to disk or logs.
1556
- console.log(describeStagingApply(applied.value).join("\n"));
1557
- // #1058 — and the admin's CAELO_STAGING_HOST, so the provisioned value
1558
- // agrees with install.json and the stored host (the converge job
1559
- // refuses an install whose records and admin disagree). After the
1560
- // provision call: the update starts a new admin revision.
1561
- const { setAdminStagingHostEnv } = await import("./admin-domain.js");
1562
- const env = await setAdminStagingHostEnv({
1563
- projectId: meta.projectId,
1564
- region: meta.region,
1565
- stagingHost: r.hostname,
1566
- });
1567
- if (env.status === "failed") {
1568
- console.error(
1569
- `${command}: ${r.hostname} is mapped and stored in the admin, but its CAELO_STAGING_HOST could not be updated: ${env.error}\n` +
1570
- `Run \`${retry}\` again once that is fixed (it is idempotent).`,
1571
- );
1572
- process.exit(5);
1573
- }
1574
- if (env.status === "set") {
1575
- console.log(
1576
- `The admin (${env.service}) now runs with CAELO_STAGING_HOST=${r.hostname}${env.previous ? ` (was ${env.previous})` : ""}.`,
1577
- );
1578
- }
1579
- }
1544
+ console.log(`${r.hostname} ${r.created ? "is now mapped" : "was already mapped"} to the admin.`);
1580
1545
  if (r.records.length === 0) {
1581
1546
  console.log(
1582
1547
  `Google has not published the DNS records yet; run \`cms-provision ${command} enable\` again in a minute.`,
@@ -1591,10 +1556,106 @@ async function domainMappingCommand(kind: "admin" | "staging"): Promise<void> {
1591
1556
  region: meta.region,
1592
1557
  });
1593
1558
  if (!waited) process.exit(4);
1559
+ console.log(`Admin login: https://${r.hostname}/login`);
1560
+ }
1561
+
1562
+ /** The admin-internal target of a Google Cloud install, or null for another provider. */
1563
+ function googleAdminTarget(
1564
+ meta: import("./install-state.js").InstallMetadata,
1565
+ ): import("./staging-admin.js").StagingAdminTarget | null {
1566
+ if (!meta.projectId || !meta.region) return null;
1567
+ if (meta.provider === "gcp-firebase") {
1568
+ return { provider: "gcp-firebase", projectId: meta.projectId, region: meta.region };
1569
+ }
1570
+ if (meta.provider === "gcp") {
1571
+ return { provider: "gcp", projectId: meta.projectId, domain: meta.domain };
1572
+ }
1573
+ return null;
1574
+ }
1575
+
1576
+ /**
1577
+ * #1330 — `staging-domain enable` on a Google Cloud install: route the
1578
+ * staging host to the staging gateway (staging-gateway-setup.ts — the same
1579
+ * step every converge runs), tell the admin the host and switch on password
1580
+ * protection the first time (#678, printing the password once), keep the
1581
+ * admin's CAELO_STAGING_HOST in step (#1058), and print the DNS records.
1582
+ */
1583
+ async function serveStagingHost(
1584
+ meta: import("./install-state.js").InstallMetadata,
1585
+ command: string,
1586
+ retry: string,
1587
+ opts: { readonly rotatePassword: boolean; readonly showPassword: boolean },
1588
+ ): Promise<void> {
1589
+ const { installStagingHost } = await import("./install-state.js");
1590
+ const host = installStagingHost(meta);
1591
+ const google = googleAdminTarget(meta);
1592
+ if (!google || !meta.projectId || !meta.region) {
1593
+ console.log(
1594
+ meta.provider === "self-hosted"
1595
+ ? `Recorded staging host ${host}; the staging gateway serves it behind Caddy after \`cms-provision regenerate-caddy\`.`
1596
+ : `On ${meta.provider} the protected staging site is not available yet: it needs the staging gateway this provider does not run yet (#749 / #750).`,
1597
+ );
1598
+ return;
1599
+ }
1600
+ const { ensureStagingGatewayEdge } = await import("./staging-gateway-setup.js");
1601
+ const { GCP_STACK_ENV } = await import("./gcp-names.js");
1602
+ const edge = await ensureStagingGatewayEdge({
1603
+ provider: meta.provider as "gcp" | "gcp-firebase",
1604
+ projectId: meta.projectId,
1605
+ region: meta.region,
1606
+ env: GCP_STACK_ENV,
1607
+ domain: meta.domain,
1608
+ stagingHost: host,
1609
+ });
1610
+ for (const line of edge.done) console.log(` ${line}`);
1611
+ if (!edge.ok) {
1612
+ console.error(
1613
+ `${command}: ${host} could not be routed to the staging gateway: ${edge.error}\nRun \`${retry}\` again once that is fixed (it is idempotent); \`cms-provision upgrade\` sets up the staging gateway itself if it is missing.`,
1614
+ );
1615
+ process.exit(1);
1616
+ }
1617
+ const { applyStagingAdmin, describeStagingApply } = await import("./staging-admin.js");
1618
+ const applied = await applyStagingAdmin(google, {
1619
+ host,
1620
+ ...(opts.rotatePassword ? { rotateBasicAuth: true } : {}),
1621
+ ...(opts.showPassword ? { revealBasicAuth: true } : {}),
1622
+ });
1623
+ if (!applied.ok) {
1624
+ console.error(
1625
+ `${command}: ${host} is routed to the staging gateway, but the admin could not be updated: ${applied.error}\nRun \`${retry}\` again once that is fixed (it is idempotent).`,
1626
+ );
1627
+ process.exit(5);
1628
+ }
1629
+ // Prints the staging basic-auth password ON PURPOSE (#678): the operator
1630
+ // asked for it to be shown once; it is never written to disk or logs.
1631
+ console.log(describeStagingApply(applied.value).join("\n"));
1632
+ const { setAdminStagingHostEnv } = await import("./admin-domain.js");
1633
+ const env = await setAdminStagingHostEnv({
1634
+ projectId: meta.projectId,
1635
+ region: meta.region,
1636
+ stagingHost: host,
1637
+ });
1638
+ if (env.status === "failed") {
1639
+ console.error(
1640
+ `${command}: ${host} is routed and stored in the admin, but its CAELO_STAGING_HOST could not be updated: ${env.error}\nRun \`${retry}\` again once that is fixed (it is idempotent).`,
1641
+ );
1642
+ process.exit(5);
1643
+ }
1644
+ if (env.status === "set") {
1645
+ console.log(
1646
+ `The admin (${env.service}) now runs with CAELO_STAGING_HOST=${host}${env.previous ? ` (was ${env.previous})` : ""}.`,
1647
+ );
1648
+ }
1649
+ if (edge.dnsRecords.length > 0) {
1650
+ const width = Math.max(...edge.dnsRecords.map((d) => d.name.length));
1651
+ console.log(
1652
+ `\nSet these DNS records at your registrar (if you have not already; Settings → Updates shows them too):\n${edge.dnsRecords
1653
+ .map((d) => ` ${d.name.padEnd(width)} ${d.type.padEnd(5)} ${d.value}`)
1654
+ .join("\n")}`,
1655
+ );
1656
+ }
1594
1657
  console.log(
1595
- kind === "admin"
1596
- ? `Admin login: https://${r.hostname}/login`
1597
- : `Protected staging site: https://${r.hostname}/`,
1658
+ `Protected staging site: https://${host}/ (its certificate is issued once the records resolve)`,
1598
1659
  );
1599
1660
  }
1600
1661
 
package/src/compose.ts CHANGED
@@ -45,6 +45,8 @@ import {
45
45
  publicSiteUrl,
46
46
  REMOTE_MCP_MODE,
47
47
  REMOTE_MCP_MODE_ENV,
48
+ STAGING_GATEWAY_TRUSTED_PROXY_HOPS,
49
+ STAGING_STORAGE_ENV,
48
50
  TRUSTED_PROXY_HOPS_ENV,
49
51
  } from "./stack-contract.js";
50
52
  import { ghcrRepository, isImageDigest, type ReleaseService } from "./upgrade/release-images.js";
@@ -120,6 +122,18 @@ export const MEDIA_ROOT = "/data/media";
120
122
  /** The gateway's HTTP port inside the Compose network. */
121
123
  export const COMPOSE_GATEWAY_PORT = 8090;
122
124
 
125
+ /** #1330 — the staging gateway's HTTP port inside the Compose network. */
126
+ export const COMPOSE_STAGING_GATEWAY_PORT = 8091;
127
+
128
+ /** #1330 — the Compose service name of the staging gateway. */
129
+ export const COMPOSE_STAGING_GATEWAY_SERVICE = "caelo-staging-gateway";
130
+
131
+ /**
132
+ * #1330 — where the staging gateway sees the staged builds: the admin's
133
+ * `output/staging` (host `.caelo/output/output/staging`), mounted read-only.
134
+ */
135
+ export const STAGING_GATEWAY_STORAGE_DIR = "/srv/staging";
136
+
123
137
  /** #1328 — the MCP-Auth-Proxy's HTTP port inside the Compose network. */
124
138
  export const COMPOSE_MCP_AUTH_PROXY_PORT = 8095;
125
139
 
@@ -130,12 +144,17 @@ export const COMPOSE_MCP_AUTH_PROXY_SERVICE = "caelo-mcp-auth-proxy";
130
144
  * The Compose services that touch the databases — what `backup` and
131
145
  * `restore` stop while they read or rewrite them. Workers run inside the
132
146
  * admin (CLAUDE.md §11.B Tier 5); there is no orchestrator or runner
133
- * service. The MCP-Auth-Proxy (#1328) is deliberately not here: it holds no
147
+ * service. The staging gateway (#1330) holds the staging database's login.
148
+ * The MCP-Auth-Proxy (#1328) is deliberately not here: it holds no
134
149
  * database login, so it keeps running, and a release from before it has no
135
150
  * such service for `up` to start. Its health is checked wherever the
136
151
  * install starts (self-hosted-start.ts) or rolls (self-hosted-upgrade.ts).
137
152
  */
138
- export const COMPOSE_APP_SERVICES = ["caelo-admin", "caelo-gateway"] as const;
153
+ export const COMPOSE_APP_SERVICES = [
154
+ "caelo-admin",
155
+ "caelo-gateway",
156
+ COMPOSE_STAGING_GATEWAY_SERVICE,
157
+ ] as const;
139
158
 
140
159
  /**
141
160
  * Where Caddy reaches the admin and the gateway: their Compose service
@@ -144,6 +163,7 @@ export const COMPOSE_APP_SERVICES = ["caelo-admin", "caelo-gateway"] as const;
144
163
  */
145
164
  export const COMPOSE_ADMIN_UPSTREAM = `caelo-admin:${COMPOSE_ADMIN_PORT}`;
146
165
  export const COMPOSE_GATEWAY_UPSTREAM = `caelo-gateway:${COMPOSE_GATEWAY_PORT}`;
166
+ export const COMPOSE_STAGING_GATEWAY_UPSTREAM = `${COMPOSE_STAGING_GATEWAY_SERVICE}:${COMPOSE_STAGING_GATEWAY_PORT}`;
147
167
  export const COMPOSE_MCP_AUTH_PROXY_UPSTREAM = `${COMPOSE_MCP_AUTH_PROXY_SERVICE}:${COMPOSE_MCP_AUTH_PROXY_PORT}`;
148
168
 
149
169
  /**
@@ -226,6 +246,15 @@ export interface RolePasswords {
226
246
 
227
247
  export interface ComposeSpec {
228
248
  readonly domain: string;
249
+ /** #650 — the staging site's host; seeds the admin's staging host setting. */
250
+ readonly stagingHost: string;
251
+ /**
252
+ * #650 / #1330 — the staging gate's key (`.caelo/config.json`
253
+ * `stagingGateSecret`, generated once): signs "Open staging" links and
254
+ * derives the crawler credentials. Admin only; the admin publishes it to
255
+ * the staging gateway through the staging projection.
256
+ */
257
+ readonly stagingGateSecret: string;
229
258
  /** The Postgres superuser's; only the database container uses it. */
230
259
  readonly postgresPassword: string; // generated by cms-provision; persisted in .caelo/secrets
231
260
  readonly rolePasswords: RolePasswords;
@@ -378,6 +407,8 @@ ${postgresAgent(spec.agent)} volumes:
378
407
  ${adminService(spec, adminMemoryMiB, env)}
379
408
 
380
409
  ${gatewayService(spec)}
410
+
411
+ ${stagingGatewayService(spec)}
381
412
  ${mcpAuthProxyService(spec.images)}
382
413
  ${schedulerService(spec)}
383
414
  volumes:
@@ -475,6 +506,12 @@ ${serviceRuntime(
475
506
  PUBLIC_STAGING_ADMIN_DATABASE_URL: "${dbUrl("admin_role", spec.rolePasswords.admin, "cms_public_staging")}"
476
507
  CAELO_SECRET_KEK: "${escapeYaml(spec.caeloSecretKek)}"
477
508
  CAELO_INTERNAL_SECRET: "${escapeYaml(spec.internalSecret)}"
509
+ # #650 / #1330 — the staging gate's key and the staging host the admin seeds.
510
+ CAELO_STAGING_GATE_SECRET: "${escapeYaml(spec.stagingGateSecret)}"
511
+ CAELO_STAGING_HOST: "${escapeYaml(spec.stagingHost)}"
512
+ # #1330 — the staging gateway on the Compose network: the Stage's
513
+ # serve check asks it whether it sees the build just written.
514
+ CAELO_STAGING_GATEWAY_URL: "http://${COMPOSE_STAGING_GATEWAY_UPSTREAM}"
478
515
  CAELO_SITE_URL: "${escapeYaml(selfHostedSiteUrl(spec.domain, spec.ports.https))}"
479
516
  PORT: "${COMPOSE_ADMIN_PORT}"
480
517
  CAELO_OUTPUT_ROOT: "${SITE_OUTPUT_ROOT}"
@@ -505,6 +542,34 @@ ${poolEnvLines("gateway")}
505
542
  NODE_ENV: production`;
506
543
  }
507
544
 
545
+ /**
546
+ * #1330 — the staging gateway: the gateway's image with
547
+ * `CAELO_GATEWAY_ENVIRONMENT=staging`. It serves the staged builds, which it
548
+ * reads from the admin's `output/staging` mounted read-only (no other part
549
+ * of the output, nothing writable), behind the staging gate, and staging's
550
+ * own data: `public_staging_role` on `cms_public_staging` is its only
551
+ * login. Caddy sends every request for the staging host here.
552
+ */
553
+ function stagingGatewayService(spec: ComposeSpec): string {
554
+ return ` ${COMPOSE_STAGING_GATEWAY_SERVICE}:
555
+ ${serviceRuntime(spec.images, "gateway", "bun run apps/api-gateway/src/server.ts", [
556
+ DATABASE_CA_MOUNT,
557
+ `./output/output/staging:${STAGING_GATEWAY_STORAGE_DIR}:ro`,
558
+ ])}
559
+ restart: unless-stopped
560
+ depends_on:
561
+ postgres: { condition: service_healthy }
562
+ environment:
563
+ PUBLIC_DATABASE_URL: "${dbUrl("public_staging_role", spec.rolePasswords.publicStaging, "cms_public_staging")}"
564
+ ${GATEWAY_ENVIRONMENT_ENV}: "staging"
565
+ GATEWAY_PORT: "${COMPOSE_STAGING_GATEWAY_PORT}"
566
+ ${STAGING_STORAGE_ENV}: "file://${STAGING_GATEWAY_STORAGE_DIR}"
567
+ # Caddy is the one proxy in front (client-ip.ts in the gateway).
568
+ ${TRUSTED_PROXY_HOPS_ENV}: "${STAGING_GATEWAY_TRUSTED_PROXY_HOPS["self-hosted"]}"
569
+ ${poolEnvLines("gateway")}
570
+ NODE_ENV: production`;
571
+ }
572
+
508
573
  /**
509
574
  * #1328 — the MCP-Auth-Proxy: remote MCP's sign-in broker, its own
510
575
  * container so it never shares a process with visitor handlers. Remote MCP
@@ -49,6 +49,7 @@ export const ConvergeCheckpointSchema = InstallJobStatusSchema.extend({
49
49
  admin: z.string().min(1),
50
50
  gateway: z.string().min(1),
51
51
  // #1328 — absent in a checkpoint an older release wrote.
52
+ "staging-gateway": z.string().min(1).optional(),
52
53
  "mcp-auth-proxy": z.string().min(1).optional(),
53
54
  })
54
55
  .optional(),
@@ -32,6 +32,8 @@ describe("database URL builders (#1338)", () => {
32
32
  caeloSecretKek: "0".repeat(64),
33
33
  dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
34
34
  internalSecret: "i".repeat(48),
35
+ stagingHost: "staging.example.com",
36
+ stagingGateSecret: "g".repeat(64),
35
37
  diskSize: "20Gi",
36
38
  images: {
37
39
  mode: "release",
@@ -43,7 +45,7 @@ describe("database URL builders (#1338)", () => {
43
45
  });
44
46
  const urls = [...yaml.matchAll(/_DATABASE_URL: "([^"]+)"/g)].map((m) => m[1] as string);
45
47
  // admin: cms_admin, cms_public, cms_public_staging (#1329); gateway: cms_public.
46
- expect(urls).toHaveLength(4);
48
+ expect(urls).toHaveLength(5);
47
49
  for (const url of urls) expect(databaseTlsProblem(url)).toBeNull();
48
50
  });
49
51
 
@@ -111,6 +111,27 @@ describe("readDeployedImageDigests", () => {
111
111
  if (!r.ok) expect(r.error).toContain("not pinned to a digest");
112
112
  });
113
113
 
114
+ it("#1330 — accepts a staging gateway on the gateway's image, refuses one on another", async () => {
115
+ const answers = (staging: string) => ({
116
+ ...firebaseLists(),
117
+ "metadata.name~^caelo-production-staging-gateway": [ok("caelo-production-staging-gateway\n")],
118
+ "describe caelo-production-admin-abc": [ok(`${REG}/admin@${A}`)],
119
+ "describe caelo-production-data-gateway": [ok(`${REG}/gateway@${B}`)],
120
+ "describe caelo-production-gateway-def": [ok(`${REG}/mcp-auth-proxy@${C}`)],
121
+ "describe caelo-production-staging-gateway": [ok(`${REG}/gateway@${staging}`)],
122
+ });
123
+ const same = fakeGcloud(answers(B));
124
+ expect(await readDeployedImageDigests({ ...FIREBASE, run: same.run })).toEqual({
125
+ ok: true,
126
+ digests: { admin: A, gateway: B, "mcp-auth-proxy": C },
127
+ });
128
+ expect(same.calls.some((c) => c.includes("caelo-production-staging-gateway"))).toBe(true);
129
+ const other = fakeGcloud(answers(A));
130
+ const r = await readDeployedImageDigests({ ...FIREBASE, run: other.run });
131
+ expect(r.ok).toBe(false);
132
+ if (!r.ok) expect(r.error).toContain("the staging gateway runs");
133
+ });
134
+
114
135
  it("surfaces a gcloud failure", async () => {
115
136
  const { run } = fakeGcloud({ "services list": [fail("PERMISSION_DENIED")] });
116
137
  const r = await readDeployedImageDigests({ ...FIREBASE, run });
@@ -21,7 +21,9 @@ import {
21
21
  cloudRunServicePrefix,
22
22
  type GcpProvider,
23
23
  RETIRED_SERVICE_ENV,
24
+ SERVICE_IMAGE_NAMES,
24
25
  SERVICE_SECRET_ENV,
26
+ type ServiceImageName,
25
27
  } from "./stack-contract.js";
26
28
  import { liveContainerEnv } from "./stack-converge.js";
27
29
 
@@ -35,7 +37,7 @@ async function findDeployedService(
35
37
  run: GcloudRunner,
36
38
  opts: { provider: GcpProvider; projectId: string; region: string },
37
39
  slug: CloudRunSlug,
38
- ): Promise<{ ok: true; name: string } | { ok: false; error: string }> {
40
+ ): Promise<{ ok: true; name: string } | { ok: false; error: string; missing: boolean }> {
39
41
  const prefix = cloudRunServicePrefix(opts.provider, GCP_STACK_ENV, slug);
40
42
  const list = await run([
41
43
  "run",
@@ -46,7 +48,8 @@ async function findDeployedService(
46
48
  `--filter=metadata.name~^${prefix}`,
47
49
  "--format=value(metadata.name)",
48
50
  ]);
49
- if (!list.ok) return { ok: false, error: `list Cloud Run services: ${list.stderr.trim()}` };
51
+ if (!list.ok)
52
+ return { ok: false, error: `list Cloud Run services: ${list.stderr.trim()}`, missing: false };
50
53
  const names = list.stdout
51
54
  .split("\n")
52
55
  .map((s) => s.trim())
@@ -58,6 +61,7 @@ async function findDeployedService(
58
61
  names.length === 0
59
62
  ? `no ${prefix}* Cloud Run service: the install runs a release from before it (the ${slug} became its own service in #1328). Run \`upgrade\` first: it creates the service and moves the release over.`
60
63
  : `expected one ${prefix}* Cloud Run service, found ${names.join(", ")}`,
64
+ missing: names.length === 0,
61
65
  };
62
66
  }
63
67
  return { ok: true, name: names[0] as string };
@@ -86,7 +90,11 @@ export async function checkDeployedRuntimeEnv(opts: {
86
90
  const run = opts.run ?? defaultGcloud;
87
91
  for (const slug of CLOUD_RUN_SLUGS) {
88
92
  const found = await findDeployedService(run, opts, slug);
89
- if (!found.ok) return found;
93
+ // #1330 — the staging gateway is the CLI's (staging-gateway-setup.ts):
94
+ // an install from before it gets it after this `up`, so a missing one
95
+ // runs no release that could mismatch the env.
96
+ if (!found.ok && slug === "staging-gateway" && found.missing) continue;
97
+ if (!found.ok) return { ok: false, error: found.error };
90
98
  const describe = await run([
91
99
  "run",
92
100
  "services",
@@ -131,8 +139,14 @@ export async function readDeployedImageDigests(opts: {
131
139
  run?: GcloudRunner;
132
140
  }): Promise<{ ok: true; digests: ImageDigests } | { ok: false; error: string }> {
133
141
  const run = opts.run ?? defaultGcloud;
134
- const digests: Record<CloudRunSlug, string> = { admin: "", gateway: "", "mcp-auth-proxy": "" };
135
- for (const slug of CLOUD_RUN_SLUGS) {
142
+ const digests: Record<ServiceImageName, string> = {
143
+ admin: "",
144
+ gateway: "",
145
+ "mcp-auth-proxy": "",
146
+ };
147
+ const digestOf = async (
148
+ slug: CloudRunSlug,
149
+ ): Promise<{ ok: true; digest: string } | { ok: false; error: string; missing: boolean }> => {
136
150
  const found = await findDeployedService(run, opts, slug);
137
151
  if (!found.ok) return found;
138
152
  const image = await run([
@@ -144,15 +158,36 @@ export async function readDeployedImageDigests(opts: {
144
158
  `--project=${opts.projectId}`,
145
159
  "--format=value(spec.template.spec.containers[0].image)",
146
160
  ]);
147
- if (!image.ok) return { ok: false, error: `describe ${found.name}: ${image.stderr.trim()}` };
161
+ if (!image.ok) {
162
+ return { ok: false, error: `describe ${found.name}: ${image.stderr.trim()}`, missing: false };
163
+ }
148
164
  const digest = digestFromImageRef(image.stdout);
149
165
  if (!digest) {
150
166
  return {
151
167
  ok: false,
152
168
  error: `${found.name} runs '${image.stdout.trim()}', which is not pinned to a digest`,
169
+ missing: false,
153
170
  };
154
171
  }
155
- digests[slug] = digest;
172
+ return { ok: true, digest };
173
+ };
174
+ // One digest per release image.
175
+ for (const slug of SERVICE_IMAGE_NAMES) {
176
+ const d = await digestOf(slug);
177
+ if (!d.ok) return { ok: false, error: d.error };
178
+ digests[slug] = d.digest;
179
+ }
180
+ // #1330 — the staging gateway runs the gateway's image and rolls with it:
181
+ // one that runs another image means the services serve two releases, and
182
+ // the wizard must not record (or deploy) either as "the" release. An
183
+ // install from before it has none yet (the CLI creates it after `up`).
184
+ const staging = await digestOf("staging-gateway");
185
+ if (!staging.ok && !staging.missing) return { ok: false, error: staging.error };
186
+ if (staging.ok && staging.digest !== digests.gateway) {
187
+ return {
188
+ ok: false,
189
+ error: `the staging gateway runs ${staging.digest}, but the gateway runs ${digests.gateway}: the two must run the same release image. Run \`upgrade\` first — it rolls both onto one release — then re-run the installer.`,
190
+ };
156
191
  }
157
192
  return { ok: true, digests };
158
193
  }
@@ -162,6 +162,8 @@ describe("self-hosted: the generated compose gateway service (#635)", () => {
162
162
  caeloSecretKek: "0".repeat(64),
163
163
  dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
164
164
  internalSecret: "i".repeat(48),
165
+ stagingHost: "staging.example.com",
166
+ stagingGateSecret: "g".repeat(64),
165
167
  diskSize: "20Gi",
166
168
  images: {
167
169
  mode: "release" as const,
package/src/gcp-names.ts CHANGED
@@ -15,6 +15,8 @@
15
15
  * the CLI's dependencies.
16
16
  */
17
17
 
18
+ import { createHash } from "node:crypto";
19
+
18
20
  /** The Pulumi stack name the wizard provisions and `upgrade` operates on. */
19
21
  export const GCP_STACK_ENV = "production";
20
22
 
@@ -87,6 +89,57 @@ export function mcpAuthProxyServiceAccountEmail(projectId: string, env: string):
87
89
  return `${mcpAuthProxyServiceAccountId(env)}@${projectId}.iam.gserviceaccount.com`;
88
90
  }
89
91
 
92
+ /**
93
+ * #1330 — account id of the service account the staging gateway runs as. It
94
+ * may read the staging database's login password and the staging bucket,
95
+ * and nothing else: no live data, no media, no gate key (the gate key
96
+ * reaches it through the staging projection). Shortened: account ids max
97
+ * out at 30 characters (`caelo-production-stg-gateway-sa` would be 31).
98
+ */
99
+ export function stagingGatewayServiceAccountId(env: string): string {
100
+ return `${gcpNamePrefix(env)}-stg-gw-sa`;
101
+ }
102
+
103
+ /** Email of the service account the staging gateway runs as. */
104
+ export function stagingGatewayServiceAccountEmail(projectId: string, env: string): string {
105
+ return `${stagingGatewayServiceAccountId(env)}@${projectId}.iam.gserviceaccount.com`;
106
+ }
107
+
108
+ /**
109
+ * #1330 — the `gcp` staging gateway's load-balancer resources, CLI-owned
110
+ * (staging-gateway-setup.ts): its serverless NEG, its backend service and
111
+ * the managed certificate for the staging host. None matches a prefix the
112
+ * other lookups use (`…-waf`, `…-admin-backend`, `…-gateway-backend`,
113
+ * `…-mcp-auth-proxy-*`).
114
+ */
115
+ export function stagingGatewayEdgeNames(env: string): {
116
+ readonly neg: string;
117
+ readonly backend: string;
118
+ readonly cert: string;
119
+ readonly pathMatcher: string;
120
+ } {
121
+ const prefix = `${gcpNamePrefix(env)}-staging-gateway`;
122
+ return {
123
+ neg: `${prefix}-neg`,
124
+ backend: `${prefix}-backend`,
125
+ cert: `${gcpNamePrefix(env)}-staging-cert`,
126
+ pathMatcher: "staging",
127
+ };
128
+ }
129
+
130
+ /**
131
+ * #1330 — the second Firebase Hosting site of a `gcp-firebase` install,
132
+ * which serves the staging host through a `**` rewrite to the staging
133
+ * gateway (E13 a of #763). Derived from the live site's id (Hosting site ids
134
+ * are global, and the live one carries a random suffix): `caelo-<env>-stg-`
135
+ * plus six hex characters of its SHA-256, so the admin and the converge,
136
+ * which both know the live site id (`CAELO_FIREBASE_SITE`), name the same
137
+ * site without storing it anywhere.
138
+ */
139
+ export function firebaseStagingSiteId(env: string, liveSiteId: string): string {
140
+ return `${gcpNamePrefix(env)}-stg-${createHash("sha256").update(liveSiteId).digest("hex").slice(0, 6)}`;
141
+ }
142
+
90
143
  /**
91
144
  * Account id of the `gcp` stack's static-publisher service account. Account
92
145
  * ids max out at 30 chars, so the env is shortened.