@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
@@ -28,7 +28,7 @@ import { acquireBucketStateLock, GcsStateStore, realSyncGcloud } from "./state-s
28
28
  import type { AppRollPlan } from "./upgrade/app-roll.js";
29
29
  import { findServiceUrl } from "./upgrade/gcp-lookup.js";
30
30
  import { createIapHealthProbe } from "./upgrade/health.js";
31
- import { restIamTester } from "./upgrader-iam-preflight.js";
31
+ import { type IamTester, restIamTester } from "./upgrader-iam-preflight.js";
32
32
  import type { UpdateQueue, UpgraderJobDeps } from "./upgrader-job.js";
33
33
  import type { JobLog } from "./upgrader-reporter.js";
34
34
  import { UPGRADER_TASK_TIMEOUT_MINUTES } from "./upgrader-setup.js";
@@ -59,9 +59,16 @@ export async function upgraderDatabaseHost(
59
59
 
60
60
  /**
61
61
  * The IAP-backed deep health probe for `plan`'s install (the upgrader job;
62
- * the converge job, #819, too).
62
+ * the converge job, #819, too). `accessJustChanged`: a converge right
63
+ * before changed access the probe relies on (health.ts); the upgrader job
64
+ * changes none.
63
65
  */
64
- export async function healthProbeFor(run: GcloudRunner, http: HttpFetch, plan: AppRollPlan) {
66
+ export async function healthProbeFor(
67
+ run: GcloudRunner,
68
+ http: HttpFetch,
69
+ plan: AppRollPlan,
70
+ context: { readonly accessJustChanged: boolean },
71
+ ) {
65
72
  const { install } = plan;
66
73
  const urls: Partial<Record<"admin" | "gateway", string>> = {};
67
74
  for (const s of plan.services) {
@@ -81,11 +88,26 @@ export async function healthProbeFor(run: GcloudRunner, http: HttpFetch, plan: A
81
88
  adminServiceUrl: urls.admin as string,
82
89
  gatewayServiceUrl: urls.gateway as string,
83
90
  },
84
- { run, http, sleep: realSleep },
91
+ { run, http, sleep: realSleep, accessJustChanged: context.accessJustChanged },
85
92
  ),
86
93
  };
87
94
  }
88
95
 
96
+ /**
97
+ * The IAM self-check's tester as this job's own account: gcloud's token from
98
+ * the metadata server, so it sees exactly the job's grants (#818, #884).
99
+ */
100
+ export function gcloudIamTester(run: GcloudRunner, http: HttpFetch): IamTester {
101
+ return restIamTester({
102
+ http,
103
+ token: async () => {
104
+ const t = await run(["auth", "print-access-token"]);
105
+ if (!t.ok) throw new Error(`gcloud auth print-access-token: ${t.stderr.trim()}`);
106
+ return t.stdout.trim();
107
+ },
108
+ });
109
+ }
110
+
89
111
  /** Every dependency of {@link runUpgraderJob} except the database and the log. */
90
112
  export function gcpUpgraderDeps(
91
113
  placement: UpgraderPlacement,
@@ -109,14 +131,7 @@ export function gcpUpgraderDeps(
109
131
  execution: placement.execution,
110
132
  // #818 — the IAM self-check asks as the job's own account (gcloud's
111
133
  // token from the metadata server), so it sees exactly the job's grants.
112
- iam: restIamTester({
113
- http,
114
- token: async () => {
115
- const t = await run(["auth", "print-access-token"]);
116
- if (!t.ok) throw new Error(`gcloud auth print-access-token: ${t.stderr.trim()}`);
117
- return t.stdout.trim();
118
- },
119
- }),
134
+ iam: gcloudIamTester(run, http),
120
135
  // The default deps: the fixed registry and signer, never overridden.
121
136
  fetchManifest: (version) => fetchVerifiedReleaseManifest(version),
122
137
  // #815 — the lock names this execution, so a later run can break it
@@ -155,7 +170,7 @@ export function gcpUpgraderDeps(
155
170
  { projectId: placement.projectId, region: placement.region, imageOverride: imageRef },
156
171
  { run },
157
172
  ),
158
- healthProbe: (plan) => healthProbeFor(run, http, plan),
173
+ healthProbe: (plan) => healthProbeFor(run, http, plan, { accessJustChanged: false }),
159
174
  now: () => new Date(),
160
175
  log,
161
176
  };
@@ -19,6 +19,7 @@ import {
19
19
  restIamTester,
20
20
  runIamPreflight,
21
21
  testIamCall,
22
+ UPGRADER_PREFLIGHT_VOICE,
22
23
  upgraderAccessChecks,
23
24
  } from "./upgrader-iam-preflight.js";
24
25
  import {
@@ -127,7 +128,9 @@ describe("upgraderAccessChecks", () => {
127
128
  ["iam.serviceAccounts.actAs"],
128
129
  ],
129
130
  ]);
130
- expect(preflightMissingReason(r.missing)).toContain("quality-audit job");
131
+ expect(preflightMissingReason(r.missing, UPGRADER_PREFLIGHT_VOICE)).toContain(
132
+ "quality-audit job",
133
+ );
131
134
  });
132
135
 
133
136
  it("never needs a permission only a forbidden role would give (no compute, no IAM admin)", () => {
@@ -185,7 +188,7 @@ describe("runIamPreflight", () => {
185
188
  "run.jobs.run",
186
189
  "storage.objects.list",
187
190
  ]);
188
- const reason = preflightMissingReason(r.missing);
191
+ const reason = preflightMissingReason(r.missing, UPGRADER_PREFLIGHT_VOICE);
189
192
  expect(reason).toContain("run the database migrations");
190
193
  expect(reason).toContain("missing storage.objects.list on gs://acme-caelo-ops");
191
194
  expect(reason).toContain("Run the upgrade once with Cloud Shell");
@@ -209,7 +212,7 @@ describe("runIamPreflight", () => {
209
212
  if (r.kind !== "missing") return;
210
213
  expect(r.missing.flatMap((m) => m.missing)).not.toContain("storage.objects.get");
211
214
  expect(r.missing.flatMap((m) => m.unchecked ?? [])).toEqual(["storage.objects.get"]);
212
- expect(preflightMissingReason(r.missing)).toContain(
215
+ expect(preflightMissingReason(r.missing, UPGRADER_PREFLIGHT_VOICE)).toContain(
213
216
  "storage.objects.get on gs://acme-caelo-ops/install/ could not be checked",
214
217
  );
215
218
  });
@@ -84,7 +84,7 @@ export type IamResource =
84
84
 
85
85
  /** One thing the run will do, the permissions it takes, and where. */
86
86
  export interface AccessCheck {
87
- /** What the upgrader could not do without them, in plain words. */
87
+ /** What the job could not do without them, in plain words. */
88
88
  readonly need: string;
89
89
  readonly resource: IamResource;
90
90
  readonly permissions: readonly string[];
@@ -304,11 +304,32 @@ export async function runIamPreflight(
304
304
  const OBJECT_LIST = "storage.objects.list";
305
305
  const OBJECT_GET = "storage.objects.get";
306
306
 
307
+ /**
308
+ * Who ran a self-check and how its access is repaired, for the request's
309
+ * end reason. The upgrader and the converge job (converge-job-preflight.ts)
310
+ * share the check; their identities and their repair differ.
311
+ */
312
+ export interface PreflightVoice {
313
+ /** The job, as the sentence names it: "the upgrader". */
314
+ readonly subject: string;
315
+ /** The one next step that re-converges its grants. */
316
+ readonly repair: string;
317
+ }
318
+
319
+ /** The upgrader job's voice: Cloud Shell's upgrade re-converges upgrader-setup.ts. */
320
+ export const UPGRADER_PREFLIGHT_VOICE: PreflightVoice = {
321
+ subject: "the upgrader",
322
+ repair: "Run the upgrade once with Cloud Shell; it repairs the upgrader's access.",
323
+ };
324
+
307
325
  /**
308
326
  * The request's end reason for missing permissions: every one of them, each
309
327
  * with what it is for, then the one next step.
310
328
  */
311
- export function preflightMissingReason(missing: readonly MissingAccess[]): string {
329
+ export function preflightMissingReason(
330
+ missing: readonly MissingAccess[],
331
+ voice: PreflightVoice,
332
+ ): string {
312
333
  const lines = missing.map((m) => {
313
334
  const where = resourceName(m.check.resource);
314
335
  const parts = [
@@ -321,12 +342,12 @@ export function preflightMissingReason(missing: readonly MissingAccess[]): strin
321
342
  ];
322
343
  return `it ${m.missing.length > 0 ? "cannot" : "may not be able to"} ${m.check.need} (${parts.join("; ")})`;
323
344
  });
324
- return `Nothing was changed: the upgrader checked its access before starting and is missing permissions this update needs — ${lines.join("; ")}. Run the upgrade once with Cloud Shell; it repairs the upgrader's access.`;
345
+ return `Nothing was changed: ${voice.subject} checked its access before starting and is missing permissions this update needs — ${lines.join("; ")}. ${voice.repair}`;
325
346
  }
326
347
 
327
348
  /** The request's end reason when IAM could not be asked. */
328
- export function preflightErrorReason(errors: readonly string[]): string {
329
- return `Nothing was changed: the upgrader could not check its access before starting (${errors.join("; ")}). Try again in a few minutes; if it keeps failing, run the upgrade once with Cloud Shell.`;
349
+ export function preflightErrorReason(errors: readonly string[], voice: PreflightVoice): string {
350
+ return `Nothing was changed: ${voice.subject} could not check its access before starting (${errors.join("; ")}). Try again in a few minutes; if it keeps failing: ${voice.repair}`;
330
351
  }
331
352
 
332
353
  /** A resource's full name, as IAM and the messages name it. */
@@ -70,6 +70,7 @@ import {
70
70
  import { restoreCommand, takeSqlBackup } from "./upgrade/backup.js";
71
71
  import {
72
72
  findServingImage,
73
+ lookupFailure,
73
74
  resolveGcpResourceName,
74
75
  servingImageDigests,
75
76
  } from "./upgrade/gcp-lookup.js";
@@ -81,6 +82,7 @@ import {
81
82
  preflightErrorReason,
82
83
  preflightMissingReason,
83
84
  runIamPreflight,
85
+ UPGRADER_PREFLIGHT_VOICE,
84
86
  upgraderAccessChecks,
85
87
  } from "./upgrader-iam-preflight.js";
86
88
  import {
@@ -467,11 +469,15 @@ export async function runUpgraderJob(deps: UpgraderJobDeps): Promise<UpgraderRun
467
469
  return stopWith(
468
470
  "needs_cloud_shell",
469
471
  ["iam-preflight"],
470
- preflightMissingReason(access.missing),
472
+ preflightMissingReason(access.missing, UPGRADER_PREFLIGHT_VOICE),
471
473
  );
472
474
  }
473
475
  if (access.kind === "error") {
474
- return stopWith("failed", ["iam-preflight"], preflightErrorReason(access.errors));
476
+ return stopWith(
477
+ "failed",
478
+ ["iam-preflight"],
479
+ preflightErrorReason(access.errors, UPGRADER_PREFLIGHT_VOICE),
480
+ );
475
481
  }
476
482
  r.info("The upgrader has every permission this update needs.");
477
483
 
@@ -491,6 +497,7 @@ export async function runUpgraderJob(deps: UpgraderJobDeps): Promise<UpgraderRun
491
497
  env: deps.env,
492
498
  domain: meta.domain,
493
499
  stagingHost: meta.stagingDomain ?? `staging.${meta.domain}`,
500
+ credentials: "job",
494
501
  },
495
502
  images,
496
503
  deps.run,
@@ -510,7 +517,7 @@ export async function runUpgraderJob(deps: UpgraderJobDeps): Promise<UpgraderRun
510
517
  const sBak = r.step("Backing up the database before anything changes...");
511
518
  const backup = await takeSqlBackup(
512
519
  deps.run,
513
- { projectId: deps.projectId, env: deps.env },
520
+ { projectId: deps.projectId, env: deps.env, credentials: "job" },
514
521
  `pre-upgrade to ${m.version}`,
515
522
  deps.now,
516
523
  );
@@ -665,9 +672,10 @@ async function servingDigests(
665
672
  deps.projectId,
666
673
  deps.region,
667
674
  );
668
- const image = service
669
- ? await findServingImage(deps.run, deps.projectId, deps.region, service)
670
- : null;
675
+ if (service.status !== "found") {
676
+ return { ok: false, error: lookupFailure(service, `${prefix}* Cloud Run service`, "job") };
677
+ }
678
+ const image = await findServingImage(deps.run, deps.projectId, deps.region, service.name);
671
679
  if (!image) return { ok: false, error: `could not read which image the ${slug} serves` };
672
680
  digests[slug] = servingImageDigests(image);
673
681
  }
@@ -96,6 +96,7 @@ export function eventReporter(sink: EventSink, log: JobLog, requestId: string):
96
96
  done: (m) => emit("success", m),
97
97
  failed: (m) => emit("error", m),
98
98
  warned: (m) => emit("warn", m),
99
+ progress: (m) => emit("info", m),
99
100
  };
100
101
  },
101
102
  info: (m) => emit("info", m),
@@ -193,7 +193,7 @@ describe("pulumiGcpEnv with the install's ops bucket (#696)", () => {
193
193
  });
194
194
  });
195
195
 
196
- describe("alignedStackSettings (#696 — the package's stale Pulumi.production.yaml)", () => {
196
+ describe("alignedStackSettings (#696 — a settings file the state moved away from)", () => {
197
197
  const KMS =
198
198
  "gcpkms://projects/acme/locations/europe-west1/keyRings/caelo/cryptoKeys/pulumi-state";
199
199
  const gcs = { kind: "gcs" as const, secretsProvider: KMS };
@@ -11,12 +11,14 @@
11
11
  * config and up; failures surface inline with provider errors.
12
12
  */
13
13
 
14
- import { resolve as resolvePath } from "node:path";
14
+ import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
15
+ import { join, relative, resolve as resolvePath } from "node:path";
15
16
  import * as pulumi from "@pulumi/pulumi/automation";
16
17
  import { AUDIT_PARALLELISM_DEFAULT } from "../audit-job-setup.js";
17
18
  import { checkpointableOutputs } from "../checkpoint-secrets.js";
18
19
  import { GCP_STACK_ENV } from "../gcp-names.js";
19
- import type { ImageDigests } from "../install-state.js";
20
+ import { type ImageDigests, stackWorkDir } from "../install-state.js";
21
+ import { collectPreviewSteps, type PlannedStep } from "../preview-plan.js";
20
22
  import {
21
23
  AMBIENT_GOOGLE_CREDENTIAL_ENV,
22
24
  withoutAmbientGoogleCredentials,
@@ -102,18 +104,99 @@ export interface PulumiUpResult {
102
104
  }
103
105
 
104
106
  /**
105
- * Resolve the GCP stack workdir relative to the installed package.
106
- * Works whether the CLI runs from source (apps/admin/scripts/...)
107
- * or from the published npm tarball (node_modules/@caelo-cms/...).
107
+ * The stack program shipped in the package (`stacks/<provider>/`: its
108
+ * Pulumi.yaml and index.ts), whether the CLI runs from source or from the
109
+ * published tarball. Read-only at runtime: Pulumi runs in the install's own
110
+ * work dir (install-state.ts `stackWorkDir`, {@link prepareStackWorkDir}),
111
+ * never here.
108
112
  */
109
- export function gcpStackWorkDir(provider: "gcp" | "gcp-firebase" = "gcp"): string {
110
- // The Pulumi.yaml + stacks/<provider>/index.ts ship in the npm
111
- // tarball under `stacks/<provider>/`. From this file's location at
112
- // runtime (dist/wizards/gcp-pulumi.js), the stack dir is two
113
- // levels up + into the provider-specific stack folder.
113
+ export function packageStackDir(provider: "gcp" | "gcp-firebase"): string {
114
+ // From this file at runtime (src/ or dist/wizards/), two levels up.
114
115
  return resolvePath(import.meta.dir, `../../stacks/${provider}`);
115
116
  }
116
117
 
118
+ /**
119
+ * The package's project file with `main` pointing from `workDir` at the
120
+ * package's stack program, so Pulumi runs that program (its relative
121
+ * imports, tsconfig and node_modules resolve from the package) while the
122
+ * stack settings file stays in `workDir`. Pulumi joins `main` to the
123
+ * project dir, so it must be relative — and computed between real paths:
124
+ * Pulumi resolves the project dir through symlinks (macOS `/var` →
125
+ * `/private/var`, a symlinked home or package cache), so a path relative to
126
+ * the symlinked spelling points nowhere.
127
+ *
128
+ * @param programDir the package's stack dir, symlinks resolved
129
+ * @param workDir the install's work dir, symlinks resolved
130
+ */
131
+ export function workDirProjectFile(
132
+ projectYaml: string,
133
+ programDir: string,
134
+ workDir: string,
135
+ ): string {
136
+ const mains = [...projectYaml.matchAll(/^main:\s*(\S+)\s*$/gm)];
137
+ if (mains.length !== 1) {
138
+ throw new Error(
139
+ `${join(programDir, "Pulumi.yaml")} must name its program in exactly one top-level "main:" line (found ${mains.length}); the CLI points the install's stack work dir at it.`,
140
+ );
141
+ }
142
+ const program = resolvePath(programDir, (mains[0] as RegExpMatchArray)[1] as string);
143
+ return projectYaml.replace(/^main:\s*\S+\s*$/m, `main: ${relative(workDir, program)}`);
144
+ }
145
+
146
+ /**
147
+ * Create or refresh the install's stack work dir (install-state.ts
148
+ * `stackWorkDir`) and
149
+ * return it. The project file is rewritten on every run: each `bunx`
150
+ * version unpacks the package somewhere else. The settings file is left as
151
+ * it is (it is the install's own). Atomic (temp file + rename), so two runs
152
+ * for the same install never leave a half-written project file.
153
+ */
154
+ export function prepareStackWorkDir(
155
+ installRoot: string,
156
+ provider: "gcp" | "gcp-firebase",
157
+ programDir: string = packageStackDir(provider),
158
+ ): string {
159
+ const workDir = stackWorkDir(installRoot, provider);
160
+ mkdirSync(workDir, { recursive: true, mode: 0o700 });
161
+ const project = workDirProjectFile(
162
+ readFileSync(join(programDir, "Pulumi.yaml"), "utf8"),
163
+ realpathSync(programDir),
164
+ realpathSync(workDir),
165
+ );
166
+ const target = join(workDir, "Pulumi.yaml");
167
+ const tmp = `${target}.${process.pid}.tmp`;
168
+ writeFileSync(tmp, project, { mode: 0o600 });
169
+ renameSync(tmp, target);
170
+ return workDir;
171
+ }
172
+
173
+ /**
174
+ * Refuse to run Pulumi when the stack config names another project than
175
+ * the install's (#736, CLAUDE.md §2): a preview or `up` would plan the
176
+ * install's state against another install's project — replacing its
177
+ * Firebase site, buckets and IAM members. Defense in depth: each install
178
+ * has its own work dir, so nothing should ever put another project there.
179
+ */
180
+ export async function assertStackProject(
181
+ stack: pulumi.Stack,
182
+ provider: "gcp" | "gcp-firebase",
183
+ projectId: string,
184
+ ): Promise<void> {
185
+ const key = `caelo-${provider}:project`;
186
+ const configured = await stack.getConfig(key).then(
187
+ (v) => v.value,
188
+ (e: unknown) => {
189
+ if (/not found/i.test(String(e))) return undefined;
190
+ throw e;
191
+ },
192
+ );
193
+ if (configured !== projectId) {
194
+ throw new Error(
195
+ `the stack config ${join(stack.workspace.workDir, `Pulumi.${GCP_STACK_ENV}.yaml`)} names project ${configured ?? "(none)"}, but this install is ${projectId}. Nothing was run. Delete that file and re-run: the CLI writes the install's config again.`,
196
+ );
197
+ }
198
+ }
199
+
117
200
  /**
118
201
  * The stack config keys that pin each Cloud Run service's image
119
202
  * (`<ns>:image-digest-<service>`, read by `imageRef` / `imageTag` in the
@@ -144,16 +227,17 @@ export function imageDigestConfig(
144
227
  * @returns the override keys it removed.
145
228
  */
146
229
  export async function writeImageDigestsToStack(inputs: {
230
+ installRoot: string;
147
231
  backend: ResolvedBackend;
148
232
  provider: "gcp" | "gcp-firebase";
149
233
  digests: ImageDigests;
150
234
  }): Promise<{ removedOverrides: string[] }> {
151
- const stack = await selectInstallStack(inputs.backend, inputs.provider);
235
+ const stack = await selectInstallStack(inputs.installRoot, inputs.backend, inputs.provider);
152
236
  await stack.setAllConfig(imageDigestConfig(inputs.provider, inputs.digests));
153
237
  // Read the settings file, not `getAllConfig()`: `pulumi config` refuses
154
238
  // to list a stack whose required keys (domain, project, …) are unset, and
155
- // the settings file in a freshly unpacked `bunx` package holds only what
156
- // this call just wrote — so every upgrade from a new CLI version failed.
239
+ // a settings file the installer never wrote (an install from before the
240
+ // per-install work dir, #736) holds only what this call just wrote.
157
241
  const settings = await stack.workspace.stackSettings(stack.name);
158
242
  const removedOverrides = presentImageOverrides(inputs.provider, settings.config);
159
243
  if (removedOverrides.length > 0) await stack.removeAllConfig(removedOverrides);
@@ -180,24 +264,24 @@ export function presentImageOverrides(
180
264
  * @returns the output's value, or undefined when the stack has no such output
181
265
  */
182
266
  export async function readStackOutput(inputs: {
267
+ installRoot: string;
183
268
  backend: ResolvedBackend;
184
269
  provider: "gcp" | "gcp-firebase";
185
270
  name: string;
186
271
  }): Promise<unknown> {
187
- const stack = await selectInstallStack(inputs.backend, inputs.provider);
272
+ const stack = await selectInstallStack(inputs.installRoot, inputs.backend, inputs.provider);
188
273
  const outputs = await stack.outputs();
189
274
  return outputs[inputs.name]?.value;
190
275
  }
191
276
 
192
277
  /**
193
- * The stack's settings file in the package's stack dir with secrets
194
- * settings that can't contradict the install's state, or null when it is
195
- * already fine (#696).
278
+ * The install's stack settings file with secrets settings that can't
279
+ * contradict its state, or null when it is already fine (#696).
196
280
  *
197
- * That file (`Pulumi.production.yaml`) lives in the package, not with the
198
- * state: every `bunx` version unpacks its own, and installs of the same
199
- * provider on one machine share it, so it can name another install's
200
- * passphrase salt or KMS key. A file naming anything but the install's own
281
+ * That file (`Pulumi.production.yaml`, in the install's work dir) is not
282
+ * the state: `state migrate` moves the state to another backend and secrets
283
+ * provider, and the file can still name the old passphrase salt or KMS key.
284
+ * A file naming anything but the install's own
201
285
  * KMS key loses its secrets-provider fields and the `secure:` values
202
286
  * encrypted under them (no stack config the CLI sets is secret);
203
287
  * `PULUMI_FALLBACK_TO_STATE_SECRETS_MANAGER` (resolveBackend) then makes
@@ -230,17 +314,16 @@ export function alignedStackSettings(
230
314
  }
231
315
 
232
316
  /**
233
- * Bring the package's stack settings file in line with the install's backend
317
+ * Bring the install's stack settings file in line with its backend
234
318
  * ({@link alignedStackSettings}) before Pulumi reads it.
319
+ *
320
+ * @param workDir the install's prepared work dir ({@link prepareStackWorkDir})
235
321
  */
236
322
  export async function alignStackSettingsFile(
323
+ workDir: string,
237
324
  backend: ResolvedBackend,
238
- provider: "gcp" | "gcp-firebase",
239
325
  ): Promise<void> {
240
- const ws = await pulumi.LocalWorkspace.create({
241
- workDir: gcpStackWorkDir(provider),
242
- envVars: stateOnlyEnv(backend),
243
- });
326
+ const ws = await pulumi.LocalWorkspace.create({ workDir, envVars: stateOnlyEnv(backend) });
244
327
  let settings: pulumi.StackSettings;
245
328
  try {
246
329
  settings = await ws.stackSettings(GCP_STACK_ENV);
@@ -266,14 +349,16 @@ export function stateOnlyEnv(backend: Pick<ResolvedBackend, "envVars">): Record<
266
349
  };
267
350
  }
268
351
 
269
- /** The install's existing stack, on its backend. */
352
+ /** The install's existing stack, on its backend, in its own work dir. */
270
353
  export async function selectInstallStack(
354
+ installRoot: string,
271
355
  backend: ResolvedBackend,
272
356
  provider: "gcp" | "gcp-firebase",
273
357
  ): Promise<pulumi.Stack> {
274
- await alignStackSettingsFile(backend, provider);
358
+ const workDir = prepareStackWorkDir(installRoot, provider);
359
+ await alignStackSettingsFile(workDir, backend);
275
360
  return pulumi.LocalWorkspace.selectStack(
276
- { stackName: GCP_STACK_ENV, workDir: gcpStackWorkDir(provider) },
361
+ { stackName: GCP_STACK_ENV, workDir },
277
362
  { envVars: stateOnlyEnv(backend) },
278
363
  );
279
364
  }
@@ -318,8 +403,8 @@ export interface StateResource {
318
403
  /**
319
404
  * The region an existing stack was deployed in, read from its state (the
320
405
  * stacks' explicit `gcp.Provider` carries it), or null for a stack with
321
- * nothing deployed. State, not stack config: the config file lives in the
322
- * package's stack dir, which a newer `bunx` version doesn't share.
406
+ * nothing deployed. State, not stack config: the state is authoritative,
407
+ * and the settings file of an install from before #736 starts empty.
323
408
  */
324
409
  export function deployedStackRegion(resources: ReadonlyArray<StateResource>): string | null {
325
410
  const regions = new Set(
@@ -368,10 +453,10 @@ export type StackConfigInputs = Pick<
368
453
 
369
454
  /**
370
455
  * Set every config value the stack reads, then its `secretReplication`
371
- * ({@link resolveSecretReplication}). The config file lives in the package's
372
- * stack dir, so every run sets all of it — the wizard before `up`, and
373
- * `state migrate` before the previews that prove a migration changed
374
- * nothing (#696).
456
+ * ({@link resolveSecretReplication}). The wizard sets all of it before every
457
+ * `up`, so install.json stays the source of truth; `state migrate` sets it
458
+ * before the previews that prove a migration changed nothing (#696) when
459
+ * the install's settings file does not hold it yet.
375
460
  *
376
461
  * @returns the secret replication now configured
377
462
  */
@@ -449,22 +534,34 @@ export function pulumiGcpEnv(
449
534
  };
450
535
  }
451
536
 
537
+ /** Thrown when the review (preview-plan.ts planGate) stops `pulumi up` after the preview. */
538
+ export class PulumiUpDeclinedError extends Error {
539
+ override readonly name = "PulumiUpDeclinedError";
540
+ }
541
+
452
542
  /**
453
543
  * Run `pulumi up` against the GCP stack via the Automation SDK.
454
544
  * Streams resource-create events to the supplied onEvent callback so
455
545
  * the wizard can render a live progress bar.
546
+ *
547
+ * On a stack that already holds resources, a preview runs first (#737) and
548
+ * `review` sees its planned steps: it lists them and decides (planGate)
549
+ * whether `up` may apply them. Nothing is applied when it says no.
550
+ *
551
+ * @throws PulumiUpDeclinedError when `review` declines the plan
456
552
  */
457
553
  export async function pulumiUpGcp(
458
554
  inputs: PulumiUpInputs,
459
555
  onEvent: (kind: "resource" | "error" | "log", message: string) => void,
556
+ review: (steps: readonly PlannedStep[]) => Promise<boolean>,
460
557
  ): Promise<PulumiUpResult> {
461
558
  const env = pulumiGcpEnv(process.env, inputs);
462
559
 
463
560
  const provider = inputs.provider ?? "gcp";
464
561
  const stackName = GCP_STACK_ENV;
465
- const workDir = gcpStackWorkDir(provider);
562
+ const workDir = prepareStackWorkDir(inputs.installRoot, provider);
466
563
 
467
- await alignStackSettingsFile(inputs.backend, provider);
564
+ await alignStackSettingsFile(workDir, inputs.backend);
468
565
  const workspaceOpts = {
469
566
  envVars: env,
470
567
  // Used when the stack is created: a new install's secrets are
@@ -506,8 +603,20 @@ export async function pulumiUpGcp(
506
603
  }
507
604
 
508
605
  // Refresh state first to detect drift from any out-of-band changes.
606
+ await assertStackProject(stack, provider, inputs.projectId);
509
607
  await stack.refresh({ onOutput: (msg) => onEvent("log", msg) });
510
608
 
609
+ // #737 — never `up` a deployed stack blind: list what it would change
610
+ // (replaces and deletes first) and let the review stop it. A first run
611
+ // on an empty stack only creates, so it skips the extra preview.
612
+ if (stateResources.length > 0) {
613
+ const collector = collectPreviewSteps();
614
+ await stack.preview({ onEvent: collector.onEvent });
615
+ if (!(await review(collector.steps()))) {
616
+ throw new PulumiUpDeclinedError("pulumi up was not run: the planned changes were declined.");
617
+ }
618
+ }
619
+
511
620
  // Run `up`. The onEvent callback receives the same JSON-RPC events
512
621
  // Pulumi prints to stdout in --json mode.
513
622
  const result = await stack.up({