@caelo-cms/provisioning 0.10.60 → 0.10.62

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 (286) hide show
  1. package/caddy/Caddyfile.production +7 -0
  2. package/caddy/Caddyfile.staging +7 -0
  3. package/dist/admin-internal-answer.d.ts +12 -0
  4. package/dist/admin-internal-answer.d.ts.map +1 -0
  5. package/dist/admin-internal-answer.js +32 -0
  6. package/dist/admin-internal-answer.js.map +1 -0
  7. package/dist/admin-internal-google.d.ts +4 -0
  8. package/dist/admin-internal-google.d.ts.map +1 -0
  9. package/dist/admin-internal-google.js +161 -0
  10. package/dist/admin-internal-google.js.map +1 -0
  11. package/dist/admin-internal-self-hosted.d.ts +30 -0
  12. package/dist/admin-internal-self-hosted.d.ts.map +1 -0
  13. package/dist/admin-internal-self-hosted.js +92 -0
  14. package/dist/admin-internal-self-hosted.js.map +1 -0
  15. package/dist/admin-internal.d.ts +33 -12
  16. package/dist/admin-internal.d.ts.map +1 -1
  17. package/dist/admin-internal.js +27 -159
  18. package/dist/admin-internal.js.map +1 -1
  19. package/dist/agent/command.d.ts +7 -0
  20. package/dist/agent/command.d.ts.map +1 -1
  21. package/dist/agent/command.js +1 -0
  22. package/dist/agent/command.js.map +1 -1
  23. package/dist/agent/lock.d.ts +13 -1
  24. package/dist/agent/lock.d.ts.map +1 -1
  25. package/dist/agent/lock.js +62 -5
  26. package/dist/agent/lock.js.map +1 -1
  27. package/dist/agent/run.d.ts +5 -1
  28. package/dist/agent/run.d.ts.map +1 -1
  29. package/dist/agent/run.js +8 -1
  30. package/dist/agent/run.js.map +1 -1
  31. package/dist/agent-db.js +192 -90
  32. package/dist/caddy.d.ts.map +1 -1
  33. package/dist/caddy.js +11 -1
  34. package/dist/caddy.js.map +1 -1
  35. package/dist/cli-routing.js +1 -1
  36. package/dist/cli-routing.js.map +1 -1
  37. package/dist/cli.js +95 -38
  38. package/dist/cli.js.map +1 -1
  39. package/dist/compose.d.ts +7 -0
  40. package/dist/compose.d.ts.map +1 -1
  41. package/dist/compose.js +5 -0
  42. package/dist/compose.js.map +1 -1
  43. package/dist/converge-job-gcp.d.ts.map +1 -1
  44. package/dist/converge-job-gcp.js +10 -7
  45. package/dist/converge-job-gcp.js.map +1 -1
  46. package/dist/converge-job-preflight.d.ts +21 -0
  47. package/dist/converge-job-preflight.d.ts.map +1 -0
  48. package/dist/converge-job-preflight.js +111 -0
  49. package/dist/converge-job-preflight.js.map +1 -0
  50. package/dist/converge-job.d.ts +8 -2
  51. package/dist/converge-job.d.ts.map +1 -1
  52. package/dist/converge-job.js +14 -0
  53. package/dist/converge-job.js.map +1 -1
  54. package/dist/gateway-credentials.d.ts +5 -3
  55. package/dist/gateway-credentials.d.ts.map +1 -1
  56. package/dist/gateway-credentials.js +5 -8
  57. package/dist/gateway-credentials.js.map +1 -1
  58. package/dist/gcloud.d.ts +29 -0
  59. package/dist/gcloud.d.ts.map +1 -1
  60. package/dist/gcloud.js +66 -0
  61. package/dist/gcloud.js.map +1 -1
  62. package/dist/infra-contract.d.ts +1 -1
  63. package/dist/infra-contract.d.ts.map +1 -1
  64. package/dist/infra-contract.js +10 -1
  65. package/dist/infra-contract.js.map +1 -1
  66. package/dist/install-job.d.ts +3 -1
  67. package/dist/install-job.d.ts.map +1 -1
  68. package/dist/install-job.js.map +1 -1
  69. package/dist/install-state.d.ts +10 -0
  70. package/dist/install-state.d.ts.map +1 -1
  71. package/dist/install-state.js +12 -0
  72. package/dist/install-state.js.map +1 -1
  73. package/dist/lifecycle.d.ts +12 -0
  74. package/dist/lifecycle.d.ts.map +1 -1
  75. package/dist/lifecycle.js +113 -28
  76. package/dist/lifecycle.js.map +1 -1
  77. package/dist/mcp-iap.d.ts +2 -0
  78. package/dist/mcp-iap.d.ts.map +1 -1
  79. package/dist/mcp-iap.js +50 -17
  80. package/dist/mcp-iap.js.map +1 -1
  81. package/dist/operator-access.d.ts +1 -2
  82. package/dist/operator-access.d.ts.map +1 -1
  83. package/dist/operator-access.js +1 -2
  84. package/dist/operator-access.js.map +1 -1
  85. package/dist/owner-reset.d.ts +16 -19
  86. package/dist/owner-reset.d.ts.map +1 -1
  87. package/dist/owner-reset.js +92 -19
  88. package/dist/owner-reset.js.map +1 -1
  89. package/dist/preview-plan.d.ts +71 -0
  90. package/dist/preview-plan.d.ts.map +1 -0
  91. package/dist/preview-plan.js +168 -0
  92. package/dist/preview-plan.js.map +1 -0
  93. package/dist/public-role-isolation.d.ts +2 -0
  94. package/dist/public-role-isolation.d.ts.map +1 -1
  95. package/dist/public-role-isolation.js +9 -3
  96. package/dist/public-role-isolation.js.map +1 -1
  97. package/dist/release-contract.d.ts.map +1 -1
  98. package/dist/release-contract.js +4 -0
  99. package/dist/release-contract.js.map +1 -1
  100. package/dist/self-hosted-internal-secret.d.ts +27 -0
  101. package/dist/self-hosted-internal-secret.d.ts.map +1 -0
  102. package/dist/self-hosted-internal-secret.js +69 -0
  103. package/dist/self-hosted-internal-secret.js.map +1 -0
  104. package/dist/self-hosted-release.d.ts +3 -1
  105. package/dist/self-hosted-release.d.ts.map +1 -1
  106. package/dist/self-hosted-release.js +6 -2
  107. package/dist/self-hosted-release.js.map +1 -1
  108. package/dist/self-hosted-upgrade.d.ts +13 -1
  109. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  110. package/dist/self-hosted-upgrade.js +26 -2
  111. package/dist/self-hosted-upgrade.js.map +1 -1
  112. package/dist/stack-converge.d.ts +5 -2
  113. package/dist/stack-converge.d.ts.map +1 -1
  114. package/dist/stack-converge.js +73 -8
  115. package/dist/stack-converge.js.map +1 -1
  116. package/dist/staging-admin.d.ts +4 -1
  117. package/dist/staging-admin.d.ts.map +1 -1
  118. package/dist/staging-admin.js.map +1 -1
  119. package/dist/state-backend.d.ts +3 -2
  120. package/dist/state-backend.d.ts.map +1 -1
  121. package/dist/state-backend.js +3 -2
  122. package/dist/state-backend.js.map +1 -1
  123. package/dist/state-command.d.ts +17 -0
  124. package/dist/state-command.d.ts.map +1 -1
  125. package/dist/state-command.js +34 -5
  126. package/dist/state-command.js.map +1 -1
  127. package/dist/state-migrate.d.ts +14 -3
  128. package/dist/state-migrate.d.ts.map +1 -1
  129. package/dist/state-migrate.js +31 -7
  130. package/dist/state-migrate.js.map +1 -1
  131. package/dist/state-pulumi.d.ts +0 -2
  132. package/dist/state-pulumi.d.ts.map +1 -1
  133. package/dist/state-pulumi.js +19 -20
  134. package/dist/state-pulumi.js.map +1 -1
  135. package/dist/static-security-headers.d.ts +86 -0
  136. package/dist/static-security-headers.d.ts.map +1 -0
  137. package/dist/static-security-headers.js +126 -0
  138. package/dist/static-security-headers.js.map +1 -0
  139. package/dist/static-svg-gcp.d.ts +28 -0
  140. package/dist/static-svg-gcp.d.ts.map +1 -0
  141. package/dist/static-svg-gcp.js +106 -0
  142. package/dist/static-svg-gcp.js.map +1 -0
  143. package/dist/upgrade/app-roll.d.ts +3 -0
  144. package/dist/upgrade/app-roll.d.ts.map +1 -1
  145. package/dist/upgrade/app-roll.js +12 -5
  146. package/dist/upgrade/app-roll.js.map +1 -1
  147. package/dist/upgrade/backup.d.ts +2 -0
  148. package/dist/upgrade/backup.d.ts.map +1 -1
  149. package/dist/upgrade/backup.js +5 -4
  150. package/dist/upgrade/backup.js.map +1 -1
  151. package/dist/upgrade/converge.d.ts +7 -0
  152. package/dist/upgrade/converge.d.ts.map +1 -1
  153. package/dist/upgrade/converge.js +19 -7
  154. package/dist/upgrade/converge.js.map +1 -1
  155. package/dist/upgrade/gcp-lookup.d.ts +38 -3
  156. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  157. package/dist/upgrade/gcp-lookup.js +31 -9
  158. package/dist/upgrade/gcp-lookup.js.map +1 -1
  159. package/dist/upgrade/health.d.ts +31 -5
  160. package/dist/upgrade/health.d.ts.map +1 -1
  161. package/dist/upgrade/health.js +114 -25
  162. package/dist/upgrade/health.js.map +1 -1
  163. package/dist/upgrade/release-images.d.ts +17 -0
  164. package/dist/upgrade/release-images.d.ts.map +1 -1
  165. package/dist/upgrade/release-images.js +23 -12
  166. package/dist/upgrade/release-images.js.map +1 -1
  167. package/dist/upgrade/reporter.d.ts +2 -0
  168. package/dist/upgrade/reporter.d.ts.map +1 -1
  169. package/dist/upgrade/reporter.js +1 -0
  170. package/dist/upgrade/reporter.js.map +1 -1
  171. package/dist/upgrade/upgrade.d.ts +11 -1
  172. package/dist/upgrade/upgrade.d.ts.map +1 -1
  173. package/dist/upgrade/upgrade.js +5 -2
  174. package/dist/upgrade/upgrade.js.map +1 -1
  175. package/dist/upgrader-gcp.d.ts +12 -2
  176. package/dist/upgrader-gcp.d.ts.map +1 -1
  177. package/dist/upgrader-gcp.js +22 -13
  178. package/dist/upgrader-gcp.js.map +1 -1
  179. package/dist/upgrader-iam-preflight.d.ts +16 -3
  180. package/dist/upgrader-iam-preflight.d.ts.map +1 -1
  181. package/dist/upgrader-iam-preflight.js +9 -4
  182. package/dist/upgrader-iam-preflight.js.map +1 -1
  183. package/dist/upgrader-job.d.ts.map +1 -1
  184. package/dist/upgrader-job.js +10 -8
  185. package/dist/upgrader-job.js.map +1 -1
  186. package/dist/upgrader-reporter.d.ts.map +1 -1
  187. package/dist/upgrader-reporter.js +1 -0
  188. package/dist/upgrader-reporter.js.map +1 -1
  189. package/dist/wizards/gcp-pulumi.d.ts +70 -23
  190. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  191. package/dist/wizards/gcp-pulumi.js +118 -39
  192. package/dist/wizards/gcp-pulumi.js.map +1 -1
  193. package/dist/wizards/gcp.d.ts.map +1 -1
  194. package/dist/wizards/gcp.js +67 -11
  195. package/dist/wizards/gcp.js.map +1 -1
  196. package/package.json +2 -2
  197. package/src/admin-internal-answer.ts +43 -0
  198. package/src/admin-internal-google.ts +187 -0
  199. package/src/admin-internal-self-hosted.test.ts +70 -0
  200. package/src/admin-internal-self-hosted.ts +128 -0
  201. package/src/admin-internal.ts +59 -174
  202. package/src/admin-memory.test.ts +1 -0
  203. package/src/agent/command.ts +3 -0
  204. package/src/agent/lock-config.test.ts +57 -2
  205. package/src/agent/lock.ts +68 -5
  206. package/src/agent/run.test.ts +18 -1
  207. package/src/agent/run.ts +17 -6
  208. package/src/caddy-redirects.test.ts +1 -0
  209. package/src/caddy.ts +12 -0
  210. package/src/cli-routing.ts +1 -1
  211. package/src/cli.ts +102 -44
  212. package/src/compose.ts +12 -0
  213. package/src/converge-job-gcp.ts +16 -11
  214. package/src/converge-job-preflight.test.ts +108 -0
  215. package/src/converge-job-preflight.ts +124 -0
  216. package/src/converge-job.test.ts +50 -0
  217. package/src/converge-job.ts +38 -2
  218. package/src/gateway-credentials.test.ts +4 -3
  219. package/src/gateway-credentials.ts +6 -10
  220. package/src/gateway-env-isolation.test.ts +1 -0
  221. package/src/gcloud.test.ts +93 -1
  222. package/src/gcloud.ts +82 -0
  223. package/src/index.test.ts +38 -1
  224. package/src/infra-contract.test.ts +4 -3
  225. package/src/infra-contract.ts +10 -1
  226. package/src/install-job.ts +5 -2
  227. package/src/install-state.ts +13 -0
  228. package/src/lifecycle.ts +134 -28
  229. package/src/mcp-iap.test.ts +66 -5
  230. package/src/mcp-iap.ts +55 -17
  231. package/src/operator-access.ts +1 -2
  232. package/src/owner-reset-providers.test.ts +370 -0
  233. package/src/owner-reset.test.ts +2 -2
  234. package/src/owner-reset.ts +108 -19
  235. package/src/preview-plan.test.ts +233 -0
  236. package/src/preview-plan.ts +226 -0
  237. package/src/public-role-isolation.test.ts +13 -2
  238. package/src/public-role-isolation.ts +14 -4
  239. package/src/release-contract.ts +4 -0
  240. package/src/self-hosted-compose.test.ts +25 -0
  241. package/src/self-hosted-internal-secret.test.ts +61 -0
  242. package/src/self-hosted-internal-secret.ts +80 -0
  243. package/src/self-hosted-release.ts +10 -3
  244. package/src/self-hosted-upgrade.test.ts +35 -1
  245. package/src/self-hosted-upgrade.ts +40 -3
  246. package/src/stack-contract.test.ts +1 -0
  247. package/src/stack-converge.test.ts +89 -5
  248. package/src/stack-converge.ts +81 -8
  249. package/src/staging-admin.test.ts +1 -1
  250. package/src/staging-admin.ts +2 -1
  251. package/src/state-backend.ts +3 -2
  252. package/src/state-command.test.ts +31 -0
  253. package/src/state-command.ts +35 -8
  254. package/src/state-migrate.test.ts +58 -7
  255. package/src/state-migrate.ts +48 -10
  256. package/src/state-pulumi.ts +20 -21
  257. package/src/static-security-headers.test.ts +124 -0
  258. package/src/static-security-headers.ts +154 -0
  259. package/src/static-svg-gcp.test.ts +133 -0
  260. package/src/static-svg-gcp.ts +140 -0
  261. package/src/upgrade/app-roll.ts +18 -7
  262. package/src/upgrade/backup.ts +14 -5
  263. package/src/upgrade/converge.ts +25 -7
  264. package/src/upgrade/gcp-lookup.test.ts +80 -1
  265. package/src/upgrade/gcp-lookup.ts +61 -11
  266. package/src/upgrade/health.test.ts +132 -8
  267. package/src/upgrade/health.ts +169 -31
  268. package/src/upgrade/release-images.test.ts +17 -0
  269. package/src/upgrade/release-images.ts +26 -14
  270. package/src/upgrade/reporter.ts +3 -0
  271. package/src/upgrade/upgrade.test.ts +137 -8
  272. package/src/upgrade/upgrade.ts +14 -2
  273. package/src/upgrader-gcp.ts +28 -13
  274. package/src/upgrader-iam-preflight.test.ts +6 -3
  275. package/src/upgrader-iam-preflight.ts +26 -5
  276. package/src/upgrader-job.ts +14 -6
  277. package/src/upgrader-reporter.ts +1 -0
  278. package/src/wizards/gcp-pulumi.test.ts +1 -1
  279. package/src/wizards/gcp-pulumi.ts +147 -38
  280. package/src/wizards/gcp.ts +89 -9
  281. package/src/wizards/stack-work-dir.test.ts +175 -0
  282. package/stacks/aws/index.ts +40 -0
  283. package/stacks/azure/index.ts +22 -1
  284. package/stacks/gcp/README.md +4 -0
  285. package/stacks/gcp/index.ts +6 -2
  286. package/stacks/gcp/Pulumi.production.yaml +0 -16
@@ -8,13 +8,17 @@
8
8
  * - {@link planEnvUpdate}: the env-var flags for the `services update` that
9
9
  * rolls the image, so env changes ride the same new revision — including
10
10
  * moving a plain var to a Secret Manager reference.
11
- * - {@link ensureStackInvariants}: IAM bindings + CDN settings. Each binding
11
+ * - {@link ensureStackInvariants}: IAM bindings + CDN settings (incl. the
12
+ * #891 response headers and the SVG backfill, static-svg-gcp.ts). Each binding
12
13
  * is checked against the resource's policy first and only added when
13
14
  * missing, so an operator without IAM-admin rights can still upgrade an
14
15
  * install that is already in shape.
15
16
  * - {@link ensureStackInvariants} also deletes the firewall rules GCP
16
17
  * pre-populates on the `default` network (SSH, RDP, ICMP, internal)
17
18
  * (default-firewall.ts), the one thing it removes.
19
+ * - {@link ensureStackInvariants} opens a gcp-firebase gateway created
20
+ * load-balancer-only to all traffic (#650, #732), before migrations:
21
+ * the upgrade's own health check reaches it on its run.app URL.
18
22
  * - {@link planMediaVolume}: the admin's media bucket volume (gen2 + Cloud
19
23
  * Storage volume + mount), added in the same `services update` when
20
24
  * missing.
@@ -55,6 +59,8 @@ import {
55
59
  stackIamInvariants,
56
60
  staticBackendBucketPrefix,
57
61
  } from "./stack-contract.js";
62
+ import { GCP_STATIC_RESPONSE_HEADERS } from "./static-security-headers.js";
63
+ import { ensureSvgDownloads } from "./static-svg-gcp.js";
58
64
 
59
65
  // ===========================================================================
60
66
  // Env vars
@@ -517,7 +523,10 @@ async function ensureIam(
517
523
  return outcomes;
518
524
  }
519
525
 
520
- /** Bring the `gcp` stack's static backend bucket to {@link STATIC_CDN_POLICY}. */
526
+ /**
527
+ * Bring the `gcp` stack's static backend bucket to {@link STATIC_CDN_POLICY}
528
+ * and its custom response headers to {@link GCP_STATIC_RESPONSE_HEADERS}.
529
+ */
521
530
  async function ensureCdnPolicy(
522
531
  install: InstallTarget,
523
532
  run: GcloudRunner,
@@ -525,7 +534,7 @@ async function ensureCdnPolicy(
525
534
  const base = {
526
535
  id: `Cloud CDN policy of ${staticBackendBucketPrefix(install.env)}*`,
527
536
  onFailure: "warn" as const,
528
- why: "immutable assets stay cached for a year instead of 1h (#555)",
537
+ why: "immutable assets stay cached for a year instead of 1h (#555), and every file is served nosniff (#891)",
529
538
  };
530
539
  const project = `--project=${install.projectId}`;
531
540
  const list = await run([
@@ -553,12 +562,18 @@ async function ensureCdnPolicy(
553
562
  "--format=json",
554
563
  ]);
555
564
  if (describe.ok) {
556
- const cdn = (JSON.parse(describe.stdout) as { cdnPolicy?: Record<string, unknown> }).cdnPolicy;
565
+ const live = JSON.parse(describe.stdout) as {
566
+ cdnPolicy?: Record<string, unknown>;
567
+ customResponseHeaders?: unknown;
568
+ };
569
+ const cdn = live.cdnPolicy;
557
570
  const inShape =
558
571
  cdn !== undefined &&
559
572
  (Object.keys(STATIC_CDN_POLICY) as (keyof typeof STATIC_CDN_POLICY)[]).every(
560
573
  (k) => String(cdn[k]) === String(STATIC_CDN_POLICY[k]),
561
- );
574
+ ) &&
575
+ JSON.stringify(live.customResponseHeaders ?? []) ===
576
+ JSON.stringify(GCP_STATIC_RESPONSE_HEADERS);
562
577
  if (inShape) return { ...base, status: "present" };
563
578
  }
564
579
  const update = await run([
@@ -571,6 +586,8 @@ async function ensureCdnPolicy(
571
586
  `--default-ttl=${STATIC_CDN_POLICY.defaultTtl}`,
572
587
  `--max-ttl=${STATIC_CDN_POLICY.maxTtl}`,
573
588
  `--client-ttl=${STATIC_CDN_POLICY.clientTtl}`,
589
+ // Replaces the bucket's list, so this is the complete set.
590
+ ...GCP_STATIC_RESPONSE_HEADERS.map((h) => `--custom-response-header=${h}`),
574
591
  "--quiet",
575
592
  ]);
576
593
  return update.ok
@@ -578,10 +595,62 @@ async function ensureCdnPolicy(
578
595
  : { ...base, status: "failed", error: update.stderr.trim() };
579
596
  }
580
597
 
598
+ /** The ingress of a described service (`all`, `internal-and-cloud-load-balancing`, …; null: unset). */
599
+ export function liveIngress(serviceJson: string): string | null {
600
+ const svc = JSON.parse(serviceJson) as {
601
+ metadata?: { annotations?: Record<string, string> };
602
+ };
603
+ return svc.metadata?.annotations?.["run.googleapis.com/ingress"] ?? null;
604
+ }
605
+
606
+ /**
607
+ * Open a gcp-firebase gateway to all traffic, as the stack declares it
608
+ * (`INGRESS_TRAFFIC_ALL` since #650). There is no load balancer on
609
+ * gcp-firebase: the staging domain mapping, remote-MCP clients and the
610
+ * upgrade's own health probe reach the gateway's run.app URL. A gateway
611
+ * created before #650 kept `internal-and-cloud-load-balancing` (only
612
+ * `pulumi up` would change it), answered those requests with Google's HTML
613
+ * 404, and every upgrade rolled back after its migrations (#732).
614
+ *
615
+ * Ingress is a service-level setting: the update creates no revision, so
616
+ * the serving one and the app-roll's rollback target stay as they are. On
617
+ * `gcp` the stack declares load-balancer-only and `staging-domain enable`
618
+ * (admin-domain.ts) opens it deliberately, so this never runs there.
619
+ */
620
+ async function ensureGatewayIngress(
621
+ install: InstallTarget,
622
+ run: GcloudRunner,
623
+ ): Promise<InvariantOutcome> {
624
+ const service = install.services.gateway;
625
+ const base = {
626
+ id: `ingress all on ${service}`,
627
+ onFailure: "abort" as const,
628
+ why: "the gcp-firebase stack's gateway ingress (#650); the health check, the staging domain and remote MCP reach its run.app URL",
629
+ };
630
+ const where = [`--region=${install.region}`, `--project=${install.projectId}`];
631
+ const describe = await run(["run", "services", "describe", service, ...where, "--format=json"]);
632
+ if (!describe.ok) return { ...base, status: "failed", error: describe.stderr.trim() };
633
+ const live = liveIngress(describe.stdout);
634
+ if (live === "all") return { ...base, status: "present" };
635
+ const update = await run([
636
+ "run",
637
+ "services",
638
+ "update",
639
+ service,
640
+ ...where,
641
+ "--ingress=all",
642
+ "--quiet",
643
+ ]);
644
+ return update.ok
645
+ ? { ...base, id: `ingress ${live ?? "(unset)"} → all on ${service}`, status: "applied" }
646
+ : { ...base, status: "failed", error: update.stderr.trim() };
647
+ }
648
+
581
649
  /**
582
650
  * Idempotently ensure every IAM binding and CDN setting the install's stack
583
- * declares (stack-contract.ts), and that the firewall rules GCP pre-populates
584
- * on the default network are gone (ABSENT_DEFAULT_FIREWALL_RULES). Bindings
651
+ * declares (stack-contract.ts), a gcp-firebase gateway's ingress, and that
652
+ * the firewall rules GCP pre-populates on the default network are gone
653
+ * (ABSENT_DEFAULT_FIREWALL_RULES). Bindings
585
654
  * are only added, never removed; those firewall rules are the only deletion. Never
586
655
  * throws for a gcloud failure; the report says what failed and whether the
587
656
  * upgrade must stop.
@@ -622,7 +691,11 @@ export async function ensureStackInvariants(
622
691
  ? invariants
623
692
  : invariants.filter((i) => i.member !== "iap-service-agent");
624
693
  outcomes.push(...(await ensureIam(checkable, install, projectNumber, run, sleep)));
625
- if (install.provider === "gcp") outcomes.push(await ensureCdnPolicy(install, run));
694
+ if (install.provider === "gcp") {
695
+ outcomes.push(await ensureCdnPolicy(install, run));
696
+ outcomes.push(await ensureSvgDownloads(install, run));
697
+ }
698
+ if (install.provider === "gcp-firebase") outcomes.push(await ensureGatewayIngress(install, run));
626
699
  outcomes.push(...(await removeDefaultFirewallRules(install.projectId, { run })));
627
700
 
628
701
  return {
@@ -22,7 +22,7 @@ const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitC
22
22
  const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
23
23
  const SECRET = "s".repeat(40);
24
24
  const ADMIN = "https://caelo-production-admin-abc-ew.a.run.app";
25
- const target = { projectId: "proj", region: "europe-west1" };
25
+ const target = { provider: "gcp-firebase" as const, projectId: "proj", region: "europe-west1" };
26
26
 
27
27
  function fakeGcloud(overrides: Record<string, GcloudResult> = {}) {
28
28
  const answers: Record<string, GcloudResult> = {
@@ -20,7 +20,8 @@ import {
20
20
  export const STAGING_PROVISION_SCOPE = "staging.provision";
21
21
  const ENDPOINT = "/api/internal/staging/provision";
22
22
 
23
- export type StagingAdminTarget = AdminInternalTarget;
23
+ /** `staging-domain enable` is gcp-firebase only (its domain mapping is). */
24
+ export type StagingAdminTarget = Extract<AdminInternalTarget, { provider: "gcp-firebase" }>;
24
25
  export type StagingAdminDeps = AdminInternalDeps;
25
26
 
26
27
  /** Main's draft pages, as the admin reports them. */
@@ -127,8 +127,9 @@ export function localStateDir(installRoot: string): string {
127
127
  * Pulumi reaches the bucket and the KMS key with the operator's Application
128
128
  * Default Credentials. Both set `PULUMI_FALLBACK_TO_STATE_SECRETS_MANAGER`:
129
129
  * Pulumi then takes the secrets provider (KMS key or passphrase salt) from
130
- * the install's own state when the stack's config file in the package's
131
- * stack dir names none (wizards/gcp-pulumi.ts alignedStackSettings).
130
+ * the install's own state when the stack's settings file in its work dir
131
+ * (install-state.ts stackWorkDir) names none (wizards/gcp-pulumi.ts
132
+ * alignedStackSettings).
132
133
  *
133
134
  * @param meta the install (its `stateBackend`, absent = local)
134
135
  * @param opts.installRoot `~/.caelo-<id>`
@@ -0,0 +1,31 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import type { GcloudResult } from "./gcloud.js";
5
+ import { adcNote } from "./state-command.js";
6
+
7
+ const result = (ok: boolean): GcloudResult => ({
8
+ ok,
9
+ stdout: ok ? "ya29.token" : "",
10
+ stderr: ok ? "" : "ERROR: Could not automatically determine credentials.",
11
+ exitCode: ok ? 0 : 1,
12
+ });
13
+
14
+ describe("adcNote (#816)", () => {
15
+ it("says nothing when this machine has working ADC", () => {
16
+ const calls: string[] = [];
17
+ expect(
18
+ adcNote((args) => {
19
+ calls.push(args.join(" "));
20
+ return result(true);
21
+ }),
22
+ ).toBeNull();
23
+ expect(calls).toEqual(["auth application-default print-access-token"]);
24
+ });
25
+
26
+ it("without ADC, says which commands need it and that upgrade does not", () => {
27
+ const note = adcNote(() => result(false));
28
+ expect(note).toContain("gcloud auth application-default login");
29
+ expect(note).toContain("upgrade, status and backup don't need them");
30
+ });
31
+ });
@@ -14,7 +14,7 @@ import { homedir } from "node:os";
14
14
  import { join } from "node:path";
15
15
  import { confirm, isCancel, log, note } from "@clack/prompts";
16
16
  import { bold, red } from "kleur/colors";
17
- import { activeAccount } from "./gcloud.js";
17
+ import { checkGcloudAuth } from "./gcloud.js";
18
18
  import {
19
19
  adoptStateBackend,
20
20
  currentMetadata,
@@ -30,6 +30,7 @@ import {
30
30
  } from "./install-state.js";
31
31
  import { installIapAllowlist } from "./operator-access.js";
32
32
  import { ensureOpsBackend } from "./ops-backend.js";
33
+ import { writePlanFile } from "./preview-plan.js";
33
34
  import { provisionerServiceAccountEmail } from "./provisioner-identity.js";
34
35
  import { selfCommand } from "./self-command.js";
35
36
  import { attachInstall } from "./state-attach.js";
@@ -43,7 +44,7 @@ import {
43
44
  } from "./state-backend.js";
44
45
  import { migrateState } from "./state-migrate.js";
45
46
  import { realStatePulumiOps } from "./state-pulumi.js";
46
- import { acquireStateLock, GcsStateStore, realSyncGcloud } from "./state-store.js";
47
+ import { acquireStateLock, GcsStateStore, realSyncGcloud, type SyncGcloud } from "./state-store.js";
47
48
  import { clackReporter } from "./upgrade/reporter.js";
48
49
  import { GCP_STACK_KNOB_DEFAULTS } from "./wizards/gcp-pulumi.js";
49
50
 
@@ -153,11 +154,12 @@ export async function stateCommand(argv: readonly string[]): Promise<void> {
153
154
  );
154
155
  process.exit(1);
155
156
  }
156
- const operator = await activeAccount();
157
- if (!operator) {
158
- log.error(red("No active gcloud account. Run `gcloud auth login`, then re-run."));
157
+ const auth = await checkGcloudAuth();
158
+ if (!auth.ok) {
159
+ log.error(red(`Nothing was changed: ${auth.error}`));
159
160
  process.exit(1);
160
161
  }
162
+ const operator = auth.account;
161
163
  const provisioner = provisionerServiceAccountEmail(projectId);
162
164
  note(
163
165
  [
@@ -257,6 +259,7 @@ export async function stateCommand(argv: readonly string[]): Promise<void> {
257
259
  renameSync(dir, moved);
258
260
  return moved;
259
261
  },
262
+ writePlan: (label, steps) => writePlanFile(root, label, steps),
260
263
  },
261
264
  );
262
265
  if (!result.ok) {
@@ -272,6 +275,18 @@ export async function attachCommand(argv: readonly string[]): Promise<void> {
272
275
  console.log("Usage: cms-provision attach --project <gcp-project-id>");
273
276
  process.exit(2);
274
277
  }
278
+ attachProject(projectId);
279
+ }
280
+
281
+ /**
282
+ * Attach the install whose records live in `projectId`'s ops bucket (or
283
+ * refresh this machine's copy of them); exits with the reason when that
284
+ * fails. `upgrade --project` runs it first, so the break-glass upgrade
285
+ * needs nothing to survive in $HOME between runs (#816).
286
+ *
287
+ * @returns the attached install's id
288
+ */
289
+ export function attachProject(projectId: string): string {
275
290
  const r = attachInstall(projectId, {
276
291
  run: realSyncGcloud,
277
292
  rootFor: installRoot,
@@ -287,7 +302,19 @@ export async function attachCommand(argv: readonly string[]): Promise<void> {
287
302
  ? `${r.domain} was already attached; its local copy of install.json is up to date.`
288
303
  : `Attached ${bold(r.domain)} (${r.installId}). status, upgrade, backup and re-running the installer now work from this machine.`,
289
304
  );
290
- log.info(
291
- "Infrastructure runs need Application Default Credentials once on this machine: gcloud auth application-default login",
292
- );
305
+ const adc = adcNote(realSyncGcloud);
306
+ if (adc) log.info(adc);
307
+ return r.installId;
308
+ }
309
+
310
+ /**
311
+ * The note `attach` prints about Application Default Credentials, or null
312
+ * when this machine has working ones. Only infrastructure runs (re-running
313
+ * the installer, `state migrate`) use ADC; `upgrade`, `status` and `backup`
314
+ * do not, so a note on every attach sent Owners on a detour (#816).
315
+ */
316
+ export function adcNote(run: SyncGcloud): string | null {
317
+ const r = run(["auth", "application-default", "print-access-token"]);
318
+ if (r.ok) return null;
319
+ return "No Application Default Credentials on this machine. upgrade, status and backup don't need them; re-running the installer or `state migrate` does: gcloud auth application-default login";
293
320
  }
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import { describe, expect, it } from "bun:test";
13
+ import type { PlannedStep } from "./preview-plan.js";
13
14
  import {
14
15
  gcsBackendRecord,
15
16
  localStateNotice,
@@ -24,7 +25,7 @@ import {
24
25
  type StatePulumiOps,
25
26
  versionAtLeast,
26
27
  } from "./state-migrate.js";
27
- import { canonicalJson, changeCount } from "./state-pulumi.js";
28
+ import { canonicalJson } from "./state-pulumi.js";
28
29
  import type { UpgradeReporter } from "./upgrade/reporter.js";
29
30
 
30
31
  interface FakeStack {
@@ -32,6 +33,20 @@ interface FakeStack {
32
33
  secretsProvider: string;
33
34
  pending: number;
34
35
  drift: number;
36
+ /** What the preview plans; `drift` in-place updates when unset. */
37
+ plan?: PlannedStep[];
38
+ }
39
+
40
+ /** `n` in-place updates of IAM members, the shape most drift has. */
41
+ function updates(n: number): PlannedStep[] {
42
+ return Array.from({ length: n }, (_, i) => ({
43
+ op: "update" as const,
44
+ type: "gcp:projects/iAMMember:IAMMember",
45
+ name: `binding-${i}`,
46
+ urn: `urn:pulumi:production::caelo-gcp-firebase::gcp:projects/iAMMember:IAMMember::binding-${i}`,
47
+ diffs: ["member"],
48
+ replaceReasons: [],
49
+ }));
35
50
  }
36
51
 
37
52
  const ROOT = "/home/op/.caelo-gcp-firebase-acme";
@@ -60,7 +75,7 @@ function fakePulumi(version = "3.260.0") {
60
75
  async previewChanges(b) {
61
76
  const s = stacks.get(b.url);
62
77
  if (!s) throw new Error(`no stack in ${b.url}`);
63
- return s.drift;
78
+ return s.plan ?? updates(s.drift);
64
79
  },
65
80
  async locked(b) {
66
81
  return faults.lockedUrl === b.url;
@@ -115,6 +130,7 @@ function recorder(): UpgradeReporter & { lines: string[] } {
115
130
  done: (m) => lines.push(`done ${m}`),
116
131
  failed: (m) => lines.push(`failed ${m}`),
117
132
  warned: (m) => lines.push(`warned ${m}`),
133
+ progress: (m) => lines.push(`progress ${m}`),
118
134
  };
119
135
  },
120
136
  info: (m) => lines.push(`info ${m}`),
@@ -137,6 +153,7 @@ function machine(initial: StateBackendRecord = { kind: "local" }) {
137
153
  locked: false,
138
154
  acceptCost: true,
139
155
  bucketRecord: (initial.kind === "gcs" ? "gcs" : null) as "gcs" | "local" | null,
156
+ planFiles: [] as { label: string; steps: number }[],
140
157
  };
141
158
  const meta = (): MigrateInstall => ({
142
159
  installId: "gcp-firebase-acme",
@@ -188,6 +205,10 @@ function machine(initial: StateBackendRecord = { kind: "local" }) {
188
205
  m.retired.push(`${ROOT}/state.migrated-x`);
189
206
  return `${ROOT}/state.migrated-x`;
190
207
  },
208
+ writePlan: (label, steps) => {
209
+ m.planFiles.push({ label, steps: steps.length });
210
+ return `${ROOT}/plan-${label}.txt`;
211
+ },
191
212
  };
192
213
  p.stacks.set(localUrl, {
193
214
  outputs: {
@@ -302,6 +323,8 @@ describe("migrateState — failures leave the source authoritative", () => {
302
323
  t.p.faults.driftOnTarget = 2;
303
324
  const r = await migrateState(t.meta(), { to: "remote", dryRun: false }, t.deps);
304
325
  expect(r.ok).toBe(false);
326
+ expect(t.report.lines.join("\n")).toContain("The copy's preview plans:");
327
+ expect(t.m.planFiles).toEqual([{ label: "state-migrate-copy", steps: 2 }]);
305
328
  expect(t.m.backend).toEqual({ kind: "local" });
306
329
  expect(t.p.stacks.has(t.gcsUrl)).toBe(false);
307
330
  });
@@ -327,6 +350,36 @@ describe("migrateState — failures leave the source authoritative", () => {
327
350
  expect(t.m.ensureRemoteCalls).toEqual([]);
328
351
  });
329
352
 
353
+ it("the refusal lists every planned step, replaces and data loss first, and writes the diff (#737)", async () => {
354
+ const t = machine();
355
+ const local = t.p.stacks.get(t.localUrl);
356
+ if (local) {
357
+ local.plan = [
358
+ ...updates(1),
359
+ {
360
+ op: "replace",
361
+ type: "gcp:sql/databaseInstance:DatabaseInstance",
362
+ name: "caelo-production-pg",
363
+ urn: "urn:pulumi:production::caelo-gcp-firebase::gcp:sql/databaseInstance:DatabaseInstance::caelo-production-pg",
364
+ diffs: ["region", "settings"],
365
+ replaceReasons: ["region"],
366
+ },
367
+ ];
368
+ }
369
+ const r = await migrateState(t.meta(), { to: "remote", dryRun: false }, t.deps);
370
+ expect(r.ok).toBe(false);
371
+ if (r.ok) return;
372
+ expect(r.error).toContain("a preview shows 2 change(s)");
373
+ expect(r.error).toContain(
374
+ "gcp:sql/databaseInstance:DatabaseInstance caelo-production-pg (replaced because region changes) <-- LOSES DATA: Cloud SQL instance",
375
+ );
376
+ expect(r.error).toContain("gcp:projects/iAMMember:IAMMember binding-0 (member)");
377
+ expect(r.error.indexOf("REPLACES")).toBeLessThan(r.error.indexOf("Updates in place"));
378
+ expect(r.error).toContain(`Full diff: ${ROOT}/plan-state-migrate.txt`);
379
+ expect(t.m.planFiles).toEqual([{ label: "state-migrate", steps: 2 }]);
380
+ expect(t.m.backups).toBe(0);
381
+ });
382
+
330
383
  it("refuses a stack with pending operations", async () => {
331
384
  const t = machine();
332
385
  const local = t.p.stacks.get(t.localUrl);
@@ -448,6 +501,9 @@ describe("migrateState — dry run", () => {
448
501
  const out = t.report.lines.join("\n");
449
502
  expect(out).toContain("Dry run — nothing changed");
450
503
  expect(out).toContain("would create gs://acme-caelo-ops");
504
+ // #737 — the preview's list even when it would migrate.
505
+ expect(out).toContain("The preview plans no changes.");
506
+ expect(t.m.planFiles).toEqual([{ label: "state-migrate-dry-run", steps: 0 }]);
451
507
  });
452
508
  });
453
509
 
@@ -465,11 +521,6 @@ describe("helpers", () => {
465
521
  );
466
522
  });
467
523
 
468
- it("changeCount ignores unchanged resources", () => {
469
- expect(changeCount({ same: 120 })).toBe(0);
470
- expect(changeCount({ same: 100, update: 2, create: 1 })).toBe(3);
471
- });
472
-
473
524
  it("upgrade's notice points a local GCP install at state migrate, dry run first", () => {
474
525
  const n = localStateNotice({ installId: "gcp-firebase-viu-one", provider: "gcp-firebase" });
475
526
  expect(n).toContain("state migrate --install gcp-firebase-viu-one --dry-run");
@@ -24,13 +24,19 @@
24
24
  * 6. flip — install.json records the new backend (locally and in the
25
25
  * bucket); the old local state dir is renamed, never deleted.
26
26
  *
27
- * `--dry-run` runs the read-only preflight and reports what it would do.
27
+ * Every preview's planned changes are listed per resource (#737): grouped
28
+ * creates / updates / REPLACES / DELETES, data-bearing resources named, the
29
+ * full diff written to a file in the install dir.
30
+ *
31
+ * `--dry-run` runs the read-only preflight and reports what it would do,
32
+ * including the preview's list.
28
33
  * The engine takes its side effects as {@link MigrateDeps} so the whole
29
34
  * sequence is tested against fakes (state-migrate.test.ts); state-command.ts
30
35
  * wires the real Pulumi CLI, gcloud and file system.
31
36
  */
32
37
 
33
38
  import type { OpsBackendResult } from "./ops-backend.js";
39
+ import { formatPlan, type PlannedStep } from "./preview-plan.js";
34
40
  import { selfCommand } from "./self-command.js";
35
41
  import {
36
42
  describeBackend,
@@ -56,8 +62,8 @@ export interface StatePulumiOps {
56
62
  stackExists(backend: ResolvedBackend): Promise<boolean>;
57
63
  /** Pending operations recorded in the stack's state (an interrupted run). */
58
64
  pendingOperations(backend: ResolvedBackend): Promise<number>;
59
- /** Steps a preview would take other than "same". */
60
- previewChanges(backend: ResolvedBackend): Promise<number>;
65
+ /** The changes a preview plans, per resource (none = converged). */
66
+ previewChanges(backend: ResolvedBackend): Promise<readonly PlannedStep[]>;
61
67
  /** sha256 of the stack outputs, secrets included. Never the values. */
62
68
  outputsDigest(backend: ResolvedBackend): Promise<string>;
63
69
  /** Whether a Pulumi operation holds the stack's lock in `backend` right now. */
@@ -106,6 +112,11 @@ export interface MigrateDeps {
106
112
  lock(): Promise<() => Promise<void>>;
107
113
  /** Rename the local state dir out of the way; returns where it went. */
108
114
  retireLocalState(): string | null;
115
+ /**
116
+ * Write a preview's full plan to a file in the install dir (preview-plan.ts
117
+ * writePlanFile); returns its path.
118
+ */
119
+ writePlan(label: string, steps: readonly PlannedStep[]): string;
109
120
  }
110
121
 
111
122
  export interface MigrateOptions {
@@ -141,6 +152,21 @@ export function versionAtLeast(a: string, b: string): boolean {
141
152
 
142
153
  const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
143
154
 
155
+ /**
156
+ * The plan's grouped list plus where its full diff was written, for a
157
+ * refusal or a dry run. A plan file that cannot be written is said, not
158
+ * fatal: the list is on screen anyway.
159
+ */
160
+ function planReport(deps: MigrateDeps, label: string, steps: readonly PlannedStep[]): string {
161
+ let where: string;
162
+ try {
163
+ where = `Full diff: ${deps.writePlan(label, steps)}`;
164
+ } catch (e) {
165
+ where = `(The full diff could not be written: ${errorText(e)})`;
166
+ }
167
+ return [...formatPlan(steps).map((l) => ` ${l}`), where].join("\n");
168
+ }
169
+
144
170
  /**
145
171
  * Run the migration. Never throws for an expected failure: the result says
146
172
  * what failed, and at every failure point the source backend is still the
@@ -198,6 +224,7 @@ export async function migrateState(
198
224
  let sourceDigest: string;
199
225
  /** The source's checkpoint, to notice another run writing it meanwhile. */
200
226
  let sourceState: string;
227
+ let sourcePlan: readonly PlannedStep[];
201
228
  try {
202
229
  const version = await pulumi.cliVersion();
203
230
  if (!version) {
@@ -232,12 +259,12 @@ export async function migrateState(
232
259
  error: `The stack records ${pending} pending operation(s) from an interrupted Pulumi run. Run upgrade first, then re-run the installer (${selfCommand()}, pick this install) so the stack is refreshed, and migrate after that. Nothing was changed.`,
233
260
  };
234
261
  }
235
- const drift = await pulumi.previewChanges(from);
236
- if (drift > 0) {
237
- pre.failed(`A preview shows ${drift} change(s)`);
262
+ sourcePlan = await pulumi.previewChanges(from);
263
+ if (sourcePlan.length > 0) {
264
+ pre.failed(`A preview shows ${sourcePlan.length} change(s)`);
238
265
  return {
239
266
  ok: false,
240
- error: `The stack is not converged (a preview shows ${drift} change(s)), so a copy can't be proven identical. Run upgrade first, then re-run the installer (${selfCommand()}, pick this install) until it reports no changes, and migrate after that. Nothing was changed.`,
267
+ error: `The stack is not converged (a preview shows ${sourcePlan.length} change(s)), so a copy can't be proven identical. Nothing was changed. The planned changes:\n${planReport(deps, "state-migrate", sourcePlan)}\nRun upgrade first, then re-run the installer (${selfCommand()}, pick this install): it shows this list again and asks before it replaces or deletes anything. Migrate once it reports no changes.`,
241
268
  };
242
269
  }
243
270
  sourceDigest = await pulumi.outputsDigest(from);
@@ -274,7 +301,12 @@ export async function migrateState(
274
301
  : []),
275
302
  );
276
303
  report.info(
277
- ["Dry run — nothing changed. The migration would:", ...plan.map((p) => ` ${p}`)].join("\n"),
304
+ [
305
+ "Dry run — nothing changed. The migration would:",
306
+ ...plan.map((p) => ` ${p}`),
307
+ "The source stack's preview:",
308
+ planReport(deps, "state-migrate-dry-run", sourcePlan),
309
+ ].join("\n"),
278
310
  );
279
311
  return { ok: true, changed: false };
280
312
  }
@@ -385,8 +417,14 @@ export async function migrateState(
385
417
  }
386
418
  try {
387
419
  const changes = await pulumi.previewChanges(to);
388
- if (changes > 0)
389
- return abandon(`Verification failed: a preview of the copy shows ${changes} change(s)`);
420
+ if (changes.length > 0) {
421
+ report.info(
422
+ `The copy's preview plans:\n${planReport(deps, "state-migrate-copy", changes)}`,
423
+ );
424
+ return abandon(
425
+ `Verification failed: a preview of the copy shows ${changes.length} change(s) (listed above)`,
426
+ );
427
+ }
390
428
  if ((await pulumi.outputsDigest(to)) !== sourceDigest) {
391
429
  return abandon("Verification failed: the copy's outputs differ from the source's");
392
430
  }