@caelo-cms/provisioning 0.10.61 → 0.10.64

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 (281) 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/owner-reset.d.ts +16 -19
  82. package/dist/owner-reset.d.ts.map +1 -1
  83. package/dist/owner-reset.js +92 -19
  84. package/dist/owner-reset.js.map +1 -1
  85. package/dist/preview-plan.d.ts +71 -0
  86. package/dist/preview-plan.d.ts.map +1 -0
  87. package/dist/preview-plan.js +168 -0
  88. package/dist/preview-plan.js.map +1 -0
  89. package/dist/public-role-isolation.d.ts +2 -0
  90. package/dist/public-role-isolation.d.ts.map +1 -1
  91. package/dist/public-role-isolation.js +9 -3
  92. package/dist/public-role-isolation.js.map +1 -1
  93. package/dist/release-contract.d.ts.map +1 -1
  94. package/dist/release-contract.js +4 -0
  95. package/dist/release-contract.js.map +1 -1
  96. package/dist/self-hosted-internal-secret.d.ts +27 -0
  97. package/dist/self-hosted-internal-secret.d.ts.map +1 -0
  98. package/dist/self-hosted-internal-secret.js +69 -0
  99. package/dist/self-hosted-internal-secret.js.map +1 -0
  100. package/dist/self-hosted-release.d.ts +3 -1
  101. package/dist/self-hosted-release.d.ts.map +1 -1
  102. package/dist/self-hosted-release.js +6 -2
  103. package/dist/self-hosted-release.js.map +1 -1
  104. package/dist/self-hosted-upgrade.d.ts +13 -1
  105. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  106. package/dist/self-hosted-upgrade.js +26 -2
  107. package/dist/self-hosted-upgrade.js.map +1 -1
  108. package/dist/stack-converge.d.ts +5 -2
  109. package/dist/stack-converge.d.ts.map +1 -1
  110. package/dist/stack-converge.js +73 -8
  111. package/dist/stack-converge.js.map +1 -1
  112. package/dist/staging-admin.d.ts +4 -1
  113. package/dist/staging-admin.d.ts.map +1 -1
  114. package/dist/staging-admin.js.map +1 -1
  115. package/dist/state-backend.d.ts +3 -2
  116. package/dist/state-backend.d.ts.map +1 -1
  117. package/dist/state-backend.js +3 -2
  118. package/dist/state-backend.js.map +1 -1
  119. package/dist/state-command.d.ts +17 -0
  120. package/dist/state-command.d.ts.map +1 -1
  121. package/dist/state-command.js +34 -5
  122. package/dist/state-command.js.map +1 -1
  123. package/dist/state-migrate.d.ts +14 -3
  124. package/dist/state-migrate.d.ts.map +1 -1
  125. package/dist/state-migrate.js +31 -7
  126. package/dist/state-migrate.js.map +1 -1
  127. package/dist/state-pulumi.d.ts +0 -2
  128. package/dist/state-pulumi.d.ts.map +1 -1
  129. package/dist/state-pulumi.js +19 -20
  130. package/dist/state-pulumi.js.map +1 -1
  131. package/dist/static-security-headers.d.ts +86 -0
  132. package/dist/static-security-headers.d.ts.map +1 -0
  133. package/dist/static-security-headers.js +126 -0
  134. package/dist/static-security-headers.js.map +1 -0
  135. package/dist/static-svg-gcp.d.ts +28 -0
  136. package/dist/static-svg-gcp.d.ts.map +1 -0
  137. package/dist/static-svg-gcp.js +106 -0
  138. package/dist/static-svg-gcp.js.map +1 -0
  139. package/dist/upgrade/app-roll.d.ts +3 -0
  140. package/dist/upgrade/app-roll.d.ts.map +1 -1
  141. package/dist/upgrade/app-roll.js +12 -5
  142. package/dist/upgrade/app-roll.js.map +1 -1
  143. package/dist/upgrade/backup.d.ts +2 -0
  144. package/dist/upgrade/backup.d.ts.map +1 -1
  145. package/dist/upgrade/backup.js +5 -4
  146. package/dist/upgrade/backup.js.map +1 -1
  147. package/dist/upgrade/converge.d.ts +7 -0
  148. package/dist/upgrade/converge.d.ts.map +1 -1
  149. package/dist/upgrade/converge.js +19 -7
  150. package/dist/upgrade/converge.js.map +1 -1
  151. package/dist/upgrade/gcp-lookup.d.ts +38 -3
  152. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  153. package/dist/upgrade/gcp-lookup.js +31 -9
  154. package/dist/upgrade/gcp-lookup.js.map +1 -1
  155. package/dist/upgrade/health.d.ts +31 -5
  156. package/dist/upgrade/health.d.ts.map +1 -1
  157. package/dist/upgrade/health.js +114 -25
  158. package/dist/upgrade/health.js.map +1 -1
  159. package/dist/upgrade/release-images.d.ts +17 -0
  160. package/dist/upgrade/release-images.d.ts.map +1 -1
  161. package/dist/upgrade/release-images.js +23 -12
  162. package/dist/upgrade/release-images.js.map +1 -1
  163. package/dist/upgrade/reporter.d.ts +2 -0
  164. package/dist/upgrade/reporter.d.ts.map +1 -1
  165. package/dist/upgrade/reporter.js +1 -0
  166. package/dist/upgrade/reporter.js.map +1 -1
  167. package/dist/upgrade/upgrade.d.ts +11 -1
  168. package/dist/upgrade/upgrade.d.ts.map +1 -1
  169. package/dist/upgrade/upgrade.js +5 -2
  170. package/dist/upgrade/upgrade.js.map +1 -1
  171. package/dist/upgrader-gcp.d.ts +12 -2
  172. package/dist/upgrader-gcp.d.ts.map +1 -1
  173. package/dist/upgrader-gcp.js +22 -13
  174. package/dist/upgrader-gcp.js.map +1 -1
  175. package/dist/upgrader-iam-preflight.d.ts +16 -3
  176. package/dist/upgrader-iam-preflight.d.ts.map +1 -1
  177. package/dist/upgrader-iam-preflight.js +9 -4
  178. package/dist/upgrader-iam-preflight.js.map +1 -1
  179. package/dist/upgrader-job.d.ts.map +1 -1
  180. package/dist/upgrader-job.js +10 -8
  181. package/dist/upgrader-job.js.map +1 -1
  182. package/dist/upgrader-reporter.d.ts.map +1 -1
  183. package/dist/upgrader-reporter.js +1 -0
  184. package/dist/upgrader-reporter.js.map +1 -1
  185. package/dist/wizards/gcp-pulumi.d.ts +70 -23
  186. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  187. package/dist/wizards/gcp-pulumi.js +118 -39
  188. package/dist/wizards/gcp-pulumi.js.map +1 -1
  189. package/dist/wizards/gcp.d.ts.map +1 -1
  190. package/dist/wizards/gcp.js +67 -11
  191. package/dist/wizards/gcp.js.map +1 -1
  192. package/package.json +2 -2
  193. package/src/admin-internal-answer.ts +43 -0
  194. package/src/admin-internal-google.ts +187 -0
  195. package/src/admin-internal-self-hosted.test.ts +70 -0
  196. package/src/admin-internal-self-hosted.ts +128 -0
  197. package/src/admin-internal.ts +59 -174
  198. package/src/admin-memory.test.ts +1 -0
  199. package/src/agent/command.ts +3 -0
  200. package/src/agent/lock-config.test.ts +57 -2
  201. package/src/agent/lock.ts +68 -5
  202. package/src/agent/run.test.ts +18 -1
  203. package/src/agent/run.ts +17 -6
  204. package/src/caddy-redirects.test.ts +1 -0
  205. package/src/caddy.ts +12 -0
  206. package/src/cli-routing.ts +1 -1
  207. package/src/cli.ts +102 -44
  208. package/src/compose.ts +12 -0
  209. package/src/converge-job-gcp.ts +16 -11
  210. package/src/converge-job-preflight.test.ts +108 -0
  211. package/src/converge-job-preflight.ts +124 -0
  212. package/src/converge-job.test.ts +50 -0
  213. package/src/converge-job.ts +38 -2
  214. package/src/gateway-credentials.test.ts +4 -3
  215. package/src/gateway-credentials.ts +6 -10
  216. package/src/gateway-env-isolation.test.ts +1 -0
  217. package/src/gcloud.test.ts +93 -1
  218. package/src/gcloud.ts +82 -0
  219. package/src/index.test.ts +38 -1
  220. package/src/infra-contract.test.ts +4 -3
  221. package/src/infra-contract.ts +10 -1
  222. package/src/install-job.ts +5 -2
  223. package/src/install-state.ts +13 -0
  224. package/src/lifecycle.ts +134 -28
  225. package/src/mcp-iap.test.ts +66 -5
  226. package/src/mcp-iap.ts +55 -17
  227. package/src/owner-reset-providers.test.ts +370 -0
  228. package/src/owner-reset.test.ts +2 -2
  229. package/src/owner-reset.ts +108 -19
  230. package/src/preview-plan.test.ts +233 -0
  231. package/src/preview-plan.ts +226 -0
  232. package/src/public-role-isolation.test.ts +13 -2
  233. package/src/public-role-isolation.ts +14 -4
  234. package/src/release-contract.ts +4 -0
  235. package/src/self-hosted-compose.test.ts +25 -0
  236. package/src/self-hosted-internal-secret.test.ts +61 -0
  237. package/src/self-hosted-internal-secret.ts +80 -0
  238. package/src/self-hosted-release.ts +10 -3
  239. package/src/self-hosted-upgrade.test.ts +35 -1
  240. package/src/self-hosted-upgrade.ts +40 -3
  241. package/src/stack-contract.test.ts +1 -0
  242. package/src/stack-converge.test.ts +89 -5
  243. package/src/stack-converge.ts +81 -8
  244. package/src/staging-admin.test.ts +1 -1
  245. package/src/staging-admin.ts +2 -1
  246. package/src/state-backend.ts +3 -2
  247. package/src/state-command.test.ts +31 -0
  248. package/src/state-command.ts +35 -8
  249. package/src/state-migrate.test.ts +58 -7
  250. package/src/state-migrate.ts +48 -10
  251. package/src/state-pulumi.ts +20 -21
  252. package/src/static-security-headers.test.ts +124 -0
  253. package/src/static-security-headers.ts +154 -0
  254. package/src/static-svg-gcp.test.ts +133 -0
  255. package/src/static-svg-gcp.ts +140 -0
  256. package/src/upgrade/app-roll.ts +18 -7
  257. package/src/upgrade/backup.ts +14 -5
  258. package/src/upgrade/converge.ts +25 -7
  259. package/src/upgrade/gcp-lookup.test.ts +80 -1
  260. package/src/upgrade/gcp-lookup.ts +61 -11
  261. package/src/upgrade/health.test.ts +132 -8
  262. package/src/upgrade/health.ts +169 -31
  263. package/src/upgrade/release-images.test.ts +17 -0
  264. package/src/upgrade/release-images.ts +26 -14
  265. package/src/upgrade/reporter.ts +3 -0
  266. package/src/upgrade/upgrade.test.ts +137 -8
  267. package/src/upgrade/upgrade.ts +14 -2
  268. package/src/upgrader-gcp.ts +28 -13
  269. package/src/upgrader-iam-preflight.test.ts +6 -3
  270. package/src/upgrader-iam-preflight.ts +26 -5
  271. package/src/upgrader-job.ts +14 -6
  272. package/src/upgrader-reporter.ts +1 -0
  273. package/src/wizards/gcp-pulumi.test.ts +1 -1
  274. package/src/wizards/gcp-pulumi.ts +147 -38
  275. package/src/wizards/gcp.ts +89 -9
  276. package/src/wizards/stack-work-dir.test.ts +175 -0
  277. package/stacks/aws/index.ts +40 -0
  278. package/stacks/azure/index.ts +22 -1
  279. package/stacks/gcp/README.md +4 -0
  280. package/stacks/gcp/index.ts +6 -2
  281. package/stacks/gcp/Pulumi.production.yaml +0 -16
@@ -48,10 +48,10 @@ import { publicSiteUrl, STAGING_HOST_ENV } from "./stack-contract.js";
48
48
  import { attachInstall } from "./state-attach.js";
49
49
  import { hasLocalPulumiState, opsBucketName } from "./state-backend.js";
50
50
  import { acquireBucketStateLock, GcsStateStore, realSyncGcloud } from "./state-store.js";
51
- import { resolveGcpResourceName } from "./upgrade/gcp-lookup.js";
51
+ import { lookupFailure, resolveGcpResourceName } from "./upgrade/gcp-lookup.js";
52
52
  import { releaseImageRef, verifyCosignAll } from "./upgrade/release-images.js";
53
53
  import { upgradeGcpInstallOutcome } from "./upgrade/upgrade.js";
54
- import { healthProbeFor, type UpgraderPlacement } from "./upgrader-gcp.js";
54
+ import { gcloudIamTester, healthProbeFor, type UpgraderPlacement } from "./upgrader-gcp.js";
55
55
  import type { JobLog } from "./upgrader-reporter.js";
56
56
 
57
57
  const DIGEST = /@(sha256:[0-9a-f]{64})/;
@@ -273,14 +273,16 @@ export function gcpConvergeDeps(
273
273
  projectId,
274
274
  region,
275
275
  );
276
- const described = admin
277
- ? await run(["run", "services", "describe", admin, ...location, "--format=json"])
278
- : null;
279
- const siteCheck = !admin
280
- ? "the admin service could not be found"
281
- : described?.ok
282
- ? deployedSiteMismatch(described.stdout, meta, region)
283
- : `the admin's description could not be read (${described?.stderr.trim() ?? ""})`;
276
+ const described =
277
+ admin.status === "found"
278
+ ? await run(["run", "services", "describe", admin.name, ...location, "--format=json"])
279
+ : null;
280
+ const siteCheck =
281
+ admin.status !== "found"
282
+ ? lookupFailure(admin, `${gcpNamePrefix(GCP_STACK_ENV)}-admin* Cloud Run service`, "job")
283
+ : described?.ok
284
+ ? deployedSiteMismatch(described.stdout, meta, region)
285
+ : `the admin's description could not be read (${described?.stderr.trim() ?? ""})`;
284
286
  if (siteCheck) {
285
287
  reporter.error(
286
288
  `Nothing was changed: the install's records name ${meta.domain} in ${meta.region}, which this update could not confirm against what the admin serves today (${siteCheck}). Run the update once from Cloud Shell or the computer you installed from.`,
@@ -304,6 +306,7 @@ export function gcpConvergeDeps(
304
306
  skipVerify: false,
305
307
  skipHealthCheck: false,
306
308
  migrationsContract: m.migrations.contract,
309
+ credentials: "job",
307
310
  },
308
311
  {
309
312
  run,
@@ -327,7 +330,7 @@ export function gcpConvergeDeps(
327
330
  { projectId, region: meta.region as string, imageOverride: imageRef },
328
331
  { run },
329
332
  ),
330
- healthProbe: (plan) => healthProbeFor(run, http, plan),
333
+ healthProbe: (plan, context) => healthProbeFor(run, http, plan, context),
331
334
  recordDigests: async (digests) => {
332
335
  recorded = await recordRolledRelease(
333
336
  installId,
@@ -361,6 +364,8 @@ export function gcpConvergeDeps(
361
364
  return {
362
365
  queue,
363
366
  projectId,
367
+ region,
368
+ iam: gcloudIamTester(run, http),
364
369
  execution: placement.execution,
365
370
  ownVersion: CAELO_VERSION,
366
371
  // The default deps: the fixed registry and signer, never overridden.
@@ -0,0 +1,108 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The converge job's IAM self-check list (#884): a provisioner holding what
5
+ * the install and converge-job-setup.ts grant passes it, each role the setup
6
+ * grants on top is checked, and losing one is named with what it is for.
7
+ */
8
+
9
+ import { describe, expect, it } from "bun:test";
10
+ import {
11
+ CONVERGE_PREFLIGHT_VOICE,
12
+ convergeAccessChecks,
13
+ INSTALL_ROLE_USES,
14
+ } from "./converge-job-preflight.js";
15
+ import { PROVISIONER_ROLE_LIST } from "./gcloud.js";
16
+ import { INSTALL_JOB_ROLES } from "./install-job.js";
17
+ import {
18
+ type IamTester,
19
+ preflightMissingReason,
20
+ resourceName,
21
+ runIamPreflight,
22
+ } from "./upgrader-iam-preflight.js";
23
+
24
+ const TARGET = { projectId: "acme", region: "europe-west1", env: "production" };
25
+
26
+ /**
27
+ * The permissions of the predefined roles the provisioner holds, as far as
28
+ * the self-check asks for them (cloud.google.com/iam/docs/understanding-roles).
29
+ */
30
+ const PREDEFINED: Record<string, readonly string[]> = {
31
+ "roles/run.admin": ["run.jobs.get", "run.jobs.update", "run.jobs.run", "run.executions.get"],
32
+ "roles/cloudsql.admin": ["cloudsql.instances.get", "cloudsql.instances.login"],
33
+ "roles/storage.admin": [
34
+ "storage.objects.list",
35
+ "storage.objects.get",
36
+ "storage.objects.create",
37
+ "storage.objects.delete",
38
+ ],
39
+ "roles/iam.serviceAccountUser": ["iam.serviceAccounts.actAs"],
40
+ "roles/iam.roleAdmin": ["iam.roles.create", "iam.roles.get", "iam.roles.update"],
41
+ "roles/cloudscheduler.admin": [
42
+ "cloudscheduler.jobs.create",
43
+ "cloudscheduler.jobs.get",
44
+ "cloudscheduler.jobs.update",
45
+ ],
46
+ "roles/logging.logWriter": ["logging.logEntries.create"],
47
+ };
48
+
49
+ /**
50
+ * A tester for a provisioner holding `roles` project-wide: unconditional
51
+ * project roles reach every resource of the project, so each answers alike.
52
+ */
53
+ function provisioner(roles: readonly string[]): IamTester {
54
+ const held = new Set(roles.flatMap((r) => PREDEFINED[r] ?? []));
55
+ return {
56
+ granted: async (_resource, permissions) => ({
57
+ ok: true,
58
+ granted: permissions.filter((p) => held.has(p)),
59
+ }),
60
+ };
61
+ }
62
+
63
+ describe("convergeAccessChecks", () => {
64
+ it("a provisioner with its install roles and the converge setup's roles passes", async () => {
65
+ const r = await runIamPreflight(
66
+ convergeAccessChecks(TARGET),
67
+ provisioner([...PROVISIONER_ROLE_LIST, ...INSTALL_JOB_ROLES]),
68
+ );
69
+ expect(r).toEqual({ kind: "ok" });
70
+ });
71
+
72
+ it("checks every role converge-job-setup.ts grants the provisioner", () => {
73
+ const checks = convergeAccessChecks(TARGET);
74
+ for (const role of INSTALL_JOB_ROLES) {
75
+ expect(checks).toContainEqual({
76
+ resource: { kind: "project", projectId: "acme" },
77
+ ...INSTALL_ROLE_USES[role],
78
+ });
79
+ }
80
+ });
81
+
82
+ it("asks about the converge job, the ops bucket and the provisioner's own account", () => {
83
+ const where = new Set(convergeAccessChecks(TARGET).map((c) => resourceName(c.resource)));
84
+ expect(where).toEqual(
85
+ new Set([
86
+ "projects/acme",
87
+ "gs://acme-caelo-ops",
88
+ "projects/acme/locations/europe-west1/jobs/caelo-production-converge",
89
+ "projects/acme/serviceAccounts/caelo-provisioner@acme.iam.gserviceaccount.com",
90
+ ]),
91
+ );
92
+ });
93
+
94
+ it("a provisioner without the install-job roles is stopped, each named with what it is for", async () => {
95
+ const r = await runIamPreflight(
96
+ convergeAccessChecks(TARGET),
97
+ provisioner(PROVISIONER_ROLE_LIST),
98
+ );
99
+ expect(r.kind).toBe("missing");
100
+ if (r.kind !== "missing") return;
101
+ expect(r.missing.flatMap((m) => m.missing).sort()).toEqual(
102
+ INSTALL_JOB_ROLES.flatMap((role) => INSTALL_ROLE_USES[role].permissions).sort(),
103
+ );
104
+ const reason = preflightMissingReason(r.missing, CONVERGE_PREFLIGHT_VOICE);
105
+ expect(reason).toContain("keeps the admin's access list in sync");
106
+ expect(reason).toContain("Cloud Shell or the computer you installed from");
107
+ });
108
+ });
@@ -0,0 +1,124 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The converge job's IAM self-check (#884): before the job takes the state
5
+ * lock, hands over or backs anything up, it asks Google Cloud whether its
6
+ * identity — the install's `caelo-provisioner` — holds what the converge
7
+ * relies on, and stops with every missing permission named when it does
8
+ * not. Same mechanism and types as the upgrader's (upgrader-iam-preflight.ts,
9
+ * #818); only the list and the voice differ.
10
+ *
11
+ * What is checked is what converge-job-setup.ts grants or relies on, derived
12
+ * from the same constants so the two cannot drift:
13
+ *
14
+ * - every role of {@link INSTALL_JOB_ROLES} that `ensureConvergeJob`
15
+ * grants the provisioner, each by the permissions the converge uses from
16
+ * it ({@link INSTALL_ROLE_USES} is keyed by the role, so a new role
17
+ * without a check does not compile);
18
+ * - the ops bucket: install.json + the state lock under `install/`, the
19
+ * checkpoint under `converge/`. The provisioner holds them through the
20
+ * project's roles/storage.admin, which carries no IAM condition, so the
21
+ * bucket's own testIamPermissions answers for every prefix (unlike the
22
+ * upgrader's `install/`-conditioned grant, which has to be probed);
23
+ * - the converge job itself: stage 1 points it at the target's ops image
24
+ * and starts a new execution, and the job keeps running as the
25
+ * provisioner (actAs on its own account);
26
+ * - the Cloud SQL IAM login `ensureConvergeJob` creates (member of
27
+ * {@link UPGRADER_DB_ROLE}): the request row and the timeline.
28
+ *
29
+ * Not checked: the permissions `pulumi up` needs for the stack. They are the
30
+ * provisioner's PROVISIONER_ROLE_LIST (gcloud.ts), granted at install by the
31
+ * Owner's own account; a converge cannot repair them and Pulumi names what
32
+ * it lacks precisely.
33
+ */
34
+
35
+ import { CONVERGE_STATE_PREFIX } from "./converge-checkpoint.js";
36
+ import { convergeJobName } from "./gcp-names.js";
37
+ import { INSTALL_JOB_ROLES, type InstallJobRole } from "./install-job.js";
38
+ import { provisionerServiceAccountEmail } from "./provisioner-identity.js";
39
+ import { OPS_INSTALL_PREFIX, opsBucketName } from "./state-backend.js";
40
+ import type { AccessCheck, PreflightVoice } from "./upgrader-iam-preflight.js";
41
+ import { UPGRADER_DB_ROLE } from "./upgrader-setup.js";
42
+
43
+ /**
44
+ * What a converge does with each role `ensureConvergeJob` grants the
45
+ * provisioner on top of its install roles, and the permissions of that role
46
+ * it uses (operator-access.ts creates the custom role and the scheduler
47
+ * job; the job's lines go to Cloud Logging).
48
+ */
49
+ export const INSTALL_ROLE_USES: Record<
50
+ InstallJobRole,
51
+ { readonly need: string; readonly permissions: readonly string[] }
52
+ > = {
53
+ "roles/iam.roleAdmin": {
54
+ need: "create and update the custom role that decides who may open the admin",
55
+ permissions: ["iam.roles.create", "iam.roles.get", "iam.roles.update"],
56
+ },
57
+ "roles/cloudscheduler.admin": {
58
+ need: "create and update the schedule that keeps the admin's access list in sync",
59
+ permissions: [
60
+ "cloudscheduler.jobs.create",
61
+ "cloudscheduler.jobs.get",
62
+ "cloudscheduler.jobs.update",
63
+ ],
64
+ },
65
+ "roles/logging.logWriter": {
66
+ need: "write the update's progress to Cloud Logging",
67
+ permissions: ["logging.logEntries.create"],
68
+ },
69
+ };
70
+
71
+ /** The converge job's voice: the setup is re-converged by a terminal upgrade. */
72
+ export const CONVERGE_PREFLIGHT_VOICE: PreflightVoice = {
73
+ subject: "the converge job",
74
+ repair:
75
+ "Run the update once from Cloud Shell or the computer you installed from (Settings → Updates shows the command); it repairs the converge job's access.",
76
+ };
77
+
78
+ /** Every permission a converge run relies on after its decision (see the file comment). */
79
+ export function convergeAccessChecks(target: {
80
+ readonly projectId: string;
81
+ readonly region: string;
82
+ readonly env: string;
83
+ }): AccessCheck[] {
84
+ const { projectId, region, env } = target;
85
+ const project = { kind: "project", projectId } as const;
86
+ const bucket = opsBucketName(projectId);
87
+ return [
88
+ ...INSTALL_JOB_ROLES.map((role) => ({ resource: project, ...INSTALL_ROLE_USES[role] })),
89
+ {
90
+ need: `read and write the install's records and state lock (${OPS_INSTALL_PREFIX}/) and the update's checkpoint (${CONVERGE_STATE_PREFIX}/) in the ops bucket`,
91
+ resource: { kind: "bucket", bucket },
92
+ permissions: [
93
+ "storage.objects.list",
94
+ "storage.objects.get",
95
+ "storage.objects.create",
96
+ "storage.objects.delete",
97
+ ],
98
+ },
99
+ {
100
+ need: "point the converge job at the new release's installer and start it",
101
+ resource: { kind: "run-job", projectId, region, name: convergeJobName(env) },
102
+ permissions: ["run.jobs.get", "run.jobs.update", "run.jobs.run"],
103
+ },
104
+ {
105
+ need: "see whether an earlier run of the converge job has ended",
106
+ resource: project,
107
+ permissions: ["run.executions.get"],
108
+ },
109
+ {
110
+ need: "keep the converge job running as the install's provisioner account",
111
+ resource: {
112
+ kind: "service-account",
113
+ projectId,
114
+ email: provisionerServiceAccountEmail(projectId),
115
+ },
116
+ permissions: ["iam.serviceAccounts.actAs"],
117
+ },
118
+ {
119
+ need: `sign in to the database as its ${UPGRADER_DB_ROLE} login (the update's request and timeline)`,
120
+ resource: project,
121
+ permissions: ["cloudsql.instances.get", "cloudsql.instances.login"],
122
+ },
123
+ ];
124
+ }
@@ -25,6 +25,7 @@ import type { InstallMetadata } from "./install-state.js";
25
25
  import type { ReleaseManifest, ReleaseManifestResult } from "./release-manifest.js";
26
26
  import { StateLockHeldError } from "./state-store.js";
27
27
  import type { UpgradeCheckpoint } from "./upgrade/upgrade.js";
28
+ import { type IamTester, resourceName } from "./upgrader-iam-preflight.js";
28
29
  import type { UpdateEnd, UpdateFacts } from "./upgrader-job.js";
29
30
 
30
31
  const D = (c: string) => `sha256:${c.repeat(64)}`;
@@ -144,6 +145,8 @@ function harness(
144
145
  lock?: () => () => void;
145
146
  outcome?: ConvergeUpgradeOutcome;
146
147
  imageSigned?: boolean;
148
+ /** What IAM answers the self-check; every permission asked for by default. */
149
+ iam?: IamTester;
147
150
  } = {},
148
151
  ): Harness {
149
152
  const calls: string[] = [];
@@ -154,6 +157,10 @@ function harness(
154
157
  const deps: ConvergeJobDeps = {
155
158
  queue: q.queue,
156
159
  projectId: "acme",
160
+ region: "europe-west1",
161
+ iam: opts.iam ?? {
162
+ granted: async (_resource, permissions) => ({ ok: true, granted: permissions }),
163
+ },
157
164
  execution: "caelo-production-converge-run1",
158
165
  ownVersion: opts.ownVersion ?? "0.11.0",
159
166
  fetchManifest: async (v) => {
@@ -250,6 +257,49 @@ describe("runConvergeJob — what stops the job before any change", () => {
250
257
  expect(h.calls.some((c) => /^(lock|point|start|upgrade|checkpoint)/.test(c))).toBe(false);
251
258
  };
252
259
 
260
+ it("#884: a missing grant stops either stage before the lock, every missing one named", async () => {
261
+ // The provisioner lost cloudscheduler.admin and actAs on itself.
262
+ const iam: IamTester = {
263
+ granted: async (resource, permissions) => ({
264
+ ok: true,
265
+ granted: permissions.filter(
266
+ (p) =>
267
+ !p.startsWith("cloudscheduler.") &&
268
+ !(resource.kind === "service-account" && p === "iam.serviceAccounts.actAs"),
269
+ ),
270
+ }),
271
+ };
272
+ for (const stage of ["0.10.53", "0.11.0"]) {
273
+ const h = harness({ ownVersion: stage, iam });
274
+ const r = await runConvergeJob(h.deps);
275
+ expect(r.status).toBe("needs_cloud_shell");
276
+ nothingChanged(h);
277
+ expect(h.q.ends[0]?.codes).toEqual(["iam-preflight"]);
278
+ const reason = h.q.ends[0]?.reason ?? "";
279
+ expect(reason).toStartWith("Nothing was changed: the converge job checked its access");
280
+ expect(reason).toContain("cloudscheduler.jobs.create");
281
+ expect(reason).toContain(
282
+ "projects/acme/serviceAccounts/caelo-provisioner@acme.iam.gserviceaccount.com",
283
+ );
284
+ expect(reason).toContain("repairs the converge job's access");
285
+ }
286
+ });
287
+
288
+ it("#884: IAM that cannot be asked fails the run before the lock", async () => {
289
+ const h = harness({
290
+ iam: {
291
+ granted: async (resource) =>
292
+ resource.kind === "bucket" ? { ok: false, error: "HTTP 503" } : { ok: true, granted: [] },
293
+ },
294
+ });
295
+ const r = await runConvergeJob(h.deps);
296
+ expect(r.status).toBe("failed");
297
+ nothingChanged(h);
298
+ expect(h.q.ends[0]?.reason).toContain(
299
+ `${resourceName({ kind: "bucket", bucket: "acme-caelo-ops" })}: HTTP 503`,
300
+ );
301
+ });
302
+
253
303
  it("a tampered or unsigned manifest", async () => {
254
304
  for (const stage of ["0.10.53", "0.11.0"]) {
255
305
  const h = harness({
@@ -15,8 +15,9 @@
15
15
  * fetches the target's release manifest from the FIXED registry and
16
16
  * verifies its signature (release-manifest.ts), and decides
17
17
  * (upgrader-assess.ts, `route: "converge"`): no downgrade, nothing
18
- * below minFromVersion, no automatic converge. Then it verifies the
19
- * target's ops image, points this job at it BY DIGEST and starts a new
18
+ * below minFromVersion, no automatic converge unless the policy allows
19
+ * it; then it checks its own IAM access (converge-job-preflight.ts).
20
+ * Then it verifies the target's ops image, points this job at it BY DIGEST and starts a new
20
21
  * execution, handing the request over to it. It changes nothing else:
21
22
  * the converge logic always comes from the release it installs.
22
23
  *
@@ -45,6 +46,8 @@
45
46
  */
46
47
 
47
48
  import type { ConvergeCheckpoint } from "./converge-checkpoint.js";
49
+ import { CONVERGE_PREFLIGHT_VOICE, convergeAccessChecks } from "./converge-job-preflight.js";
50
+ import { GCP_STACK_ENV } from "./gcp-names.js";
48
51
  import { decideInstallJob, type ExecutionState } from "./install-job.js";
49
52
  import type { InstallMetadata } from "./install-state.js";
50
53
  import {
@@ -55,6 +58,12 @@ import {
55
58
  import { StateLockHeldError } from "./state-store.js";
56
59
  import type { UpgradeCheckpoint } from "./upgrade/upgrade.js";
57
60
  import { assessUpgradeRequest } from "./upgrader-assess.js";
61
+ import {
62
+ type IamTester,
63
+ preflightErrorReason,
64
+ preflightMissingReason,
65
+ runIamPreflight,
66
+ } from "./upgrader-iam-preflight.js";
58
67
  import {
59
68
  type ClaimedUpdate,
60
69
  recordUpdateEnd,
@@ -111,6 +120,10 @@ export interface ConvergeJobDeps {
111
120
  readonly queue: ConvergeQueue;
112
121
  /** From the metadata server — never from env or argv. */
113
122
  readonly projectId: string;
123
+ /** The region this job runs in (metadata server). */
124
+ readonly region: string;
125
+ /** Asks IAM as this job's own identity (the IAM self-check, #884). */
126
+ readonly iam: IamTester;
114
127
  /** This Cloud Run execution; null outside Cloud Run. */
115
128
  readonly execution: string | null;
116
129
  /** The Caelo release this code belongs to (CAELO_VERSION of the image). */
@@ -297,6 +310,29 @@ export async function runConvergeJob(deps: ConvergeJobDeps): Promise<UpgraderRun
297
310
  for (const w of decision.warnings) r.warn(w);
298
311
  const m = decision.manifest;
299
312
 
313
+ // #884 — decided to run: ask IAM for what the rest needs before the
314
+ // lock, the hand-over or the backup (converge-job-preflight.ts). Both
315
+ // stages ask; stage 2's list is the target release's own.
316
+ const access = await runIamPreflight(
317
+ convergeAccessChecks({ projectId: deps.projectId, region: deps.region, env: GCP_STACK_ENV }),
318
+ deps.iam,
319
+ );
320
+ if (access.kind === "missing") {
321
+ return stopWith(
322
+ "needs_cloud_shell",
323
+ ["iam-preflight"],
324
+ preflightMissingReason(access.missing, CONVERGE_PREFLIGHT_VOICE),
325
+ );
326
+ }
327
+ if (access.kind === "error") {
328
+ return stopWith(
329
+ "failed",
330
+ ["iam-preflight"],
331
+ preflightErrorReason(access.errors, CONVERGE_PREFLIGHT_VOICE),
332
+ );
333
+ }
334
+ r.info("The converge job has every permission this update needs.");
335
+
300
336
  if (compareSemver(deps.ownVersion, m.version) !== 0) {
301
337
  return await handOver(m);
302
338
  }
@@ -141,9 +141,10 @@ describe("planPublicRoleSwitch", () => {
141
141
  describe("findSqlInstance", () => {
142
142
  it("finds the Pulumi-suffixed instance by the stack's prefix", async () => {
143
143
  const { run, lines } = fakeGcloud({});
144
- expect(await findSqlInstance({ projectId: "acme", env: "production" }, { run })).toBe(
145
- "caelo-production-pg-1a2b3c",
146
- );
144
+ expect(await findSqlInstance({ projectId: "acme", env: "production" }, { run })).toEqual({
145
+ status: "found",
146
+ name: "caelo-production-pg-1a2b3c",
147
+ });
147
148
  expect(lines).toContain(
148
149
  "sql instances list --project=acme --filter=name~^caelo-production-pg --format=value(name)",
149
150
  );
@@ -32,6 +32,7 @@ import {
32
32
  } from "./runtime-secrets.js";
33
33
  import { DATABASE_ROLE_SECRET } from "./stack-contract.js";
34
34
  import type { LiveEnvValue } from "./stack-converge.js";
35
+ import { type GcpLookup, lookupFromListing } from "./upgrade/gcp-lookup.js";
35
36
 
36
37
  /**
37
38
  * Whether this roll moves `public_role` onto its own password: true while
@@ -107,13 +108,14 @@ export function planPublicRoleSwitch(
107
108
  }
108
109
 
109
110
  /**
110
- * The install's Cloud SQL instance (Pulumi suffixes its name), or null when
111
- * none or the listing fails.
111
+ * The install's Cloud SQL instance (Pulumi suffixes its name). A failed
112
+ * listing comes back as an error with gcloud's stderr, never as "not found"
113
+ * (#630).
112
114
  */
113
115
  export async function findSqlInstance(
114
116
  install: { readonly projectId: string; readonly env: string },
115
117
  deps: { readonly run?: GcloudRunner } = {},
116
- ): Promise<string | null> {
118
+ ): Promise<GcpLookup> {
117
119
  const run = deps.run ?? defaultGcloud;
118
120
  const r = await run([
119
121
  "sql",
@@ -123,11 +125,5 @@ export async function findSqlInstance(
123
125
  `--filter=name~^${gcpNamePrefix(install.env)}-pg`,
124
126
  "--format=value(name)",
125
127
  ]);
126
- if (!r.ok) return null;
127
- return (
128
- r.stdout
129
- .split("\n")
130
- .map((l) => l.trim())
131
- .find((l) => l.length > 0) ?? null
132
- );
128
+ return lookupFromListing(r);
133
129
  }
@@ -135,6 +135,7 @@ describe("self-hosted: the generated compose gateway service (#635)", () => {
135
135
  postgresPassword: "superpw",
136
136
  rolePasswords: { admin: "adminpw", public: "publicpw" },
137
137
  caeloSecretKek: "0".repeat(64),
138
+ internalSecret: "i".repeat(48),
138
139
  diskSize: "20Gi",
139
140
  images: {
140
141
  mode: "release" as const,
@@ -1,7 +1,14 @@
1
1
  // SPDX-License-Identifier: MPL-2.0
2
2
 
3
3
  import { describe, expect, it } from "bun:test";
4
- import { type GcloudResult, grantProvisionerRoles, PROVISIONER_ROLE_LIST } from "./gcloud.js";
4
+ import {
5
+ checkGcloudAuth,
6
+ type GcloudResult,
7
+ grantProvisionerRoles,
8
+ isGcloudAuthError,
9
+ PROVISIONER_ROLE_LIST,
10
+ projectDisplayName,
11
+ } from "./gcloud.js";
5
12
 
6
13
  const ok: GcloudResult = { ok: true, stdout: "", stderr: "", exitCode: 0 };
7
14
  const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
@@ -97,3 +104,88 @@ describe("grantProvisionerRoles", () => {
97
104
  expect(result.failed).toEqual([{ role, error: SA_NOT_YET_VISIBLE }]);
98
105
  });
99
106
  });
107
+
108
+ // Mirrors the Resource Manager displayName rule: 4–30 chars of letters,
109
+ // digits, spaces, hyphens, quotes and `!`.
110
+ const GCP_DISPLAY_NAME = /^[A-Za-z0-9 '"!-]{4,30}$/;
111
+
112
+ describe("projectDisplayName", () => {
113
+ it("names the project after the domain so installs are distinguishable", () => {
114
+ expect(projectDisplayName("searchviu.com")).toBe("Caelo searchviu-com");
115
+ expect(projectDisplayName("docs.caelo-cms.com")).toBe("Caelo docs-caelo-cms-com");
116
+ });
117
+
118
+ it("stays within the GCP displayName rules for long or odd domains", () => {
119
+ for (const domain of [
120
+ "a-very-long-subdomain.of-an-even-longer-domain.example.co.uk",
121
+ "Example.COM",
122
+ "xn--mnchen-3ya.de",
123
+ "ab.c",
124
+ ]) {
125
+ const name = projectDisplayName(domain);
126
+ expect(name).toMatch(GCP_DISPLAY_NAME);
127
+ expect(name).not.toMatch(/-$/);
128
+ }
129
+ });
130
+ });
131
+
132
+ describe("checkGcloudAuth (#630)", () => {
133
+ const account = { ...ok, stdout: "owner@acme.com\n" };
134
+ const runner = (token: GcloudResult, list: GcloudResult = account) => {
135
+ const calls: string[] = [];
136
+ const run = async (args: string[]): Promise<GcloudResult> => {
137
+ calls.push(args.join(" "));
138
+ return args[1] === "list" ? list : token;
139
+ };
140
+ return { run, calls };
141
+ };
142
+
143
+ it("passes when gcloud can mint a token", async () => {
144
+ const { run, calls } = runner({ ...ok, stdout: "ya29.secret" });
145
+ expect(await checkGcloudAuth(run)).toEqual({ ok: true, account: "owner@acme.com" });
146
+ expect(calls[1]).toBe("auth print-access-token --quiet");
147
+ });
148
+
149
+ it("expired credentials pass `auth list` but fail here, with the login fix", async () => {
150
+ const { run } = runner(
151
+ fail(
152
+ "ERROR: (gcloud.auth.print-access-token) There was a problem refreshing your current auth tokens: Reauthentication failed. cannot prompt during non-interactive execution.",
153
+ ),
154
+ );
155
+ const r = await checkGcloudAuth(run);
156
+ expect(r.ok).toBe(false);
157
+ if (r.ok) return;
158
+ expect(r.error).toContain("gcloud auth login");
159
+ expect(r.error).toContain("owner@acme.com");
160
+ });
161
+
162
+ it("no logged-in account", async () => {
163
+ const { run } = runner(ok, ok);
164
+ const r = await checkGcloudAuth(run);
165
+ expect(r).toEqual({
166
+ ok: false,
167
+ error:
168
+ "No gcloud account is logged in. Run `gcloud auth login` with the Owner's Google account, then run this command again.",
169
+ });
170
+ });
171
+
172
+ it("another failure names gcloud's error, not expired credentials", async () => {
173
+ const { run } = runner(fail("ERROR: network unreachable"));
174
+ const r = await checkGcloudAuth(run);
175
+ expect(r.ok).toBe(false);
176
+ if (r.ok) return;
177
+ expect(r.error).toContain("network unreachable");
178
+ expect(r.error).not.toContain("expired");
179
+ });
180
+
181
+ it("recognises the reauth failures gcloud prints", () => {
182
+ for (const stderr of [
183
+ "Reauthentication failed. cannot prompt during non-interactive execution.",
184
+ "google.auth.exceptions.RefreshError: ('invalid_grant: Bad Request', ...)",
185
+ "Please run: $ gcloud auth login to obtain new credentials. reauth related error (invalid_rapt)",
186
+ ]) {
187
+ expect(isGcloudAuthError(stderr)).toBe(true);
188
+ }
189
+ expect(isGcloudAuthError("PERMISSION_DENIED: run.services.list")).toBe(false);
190
+ });
191
+ });