@caelo-cms/provisioning 0.10.61 → 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 (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
@@ -57,6 +57,8 @@ import {
57
57
  describeServiceJson,
58
58
  findCurrentRevision,
59
59
  findServiceUrl,
60
+ type GcloudCredentials,
61
+ lookupFailure,
60
62
  pinTraffic,
61
63
  resolveGcpResourceName,
62
64
  } from "./gcp-lookup.js";
@@ -78,6 +80,8 @@ export interface AppRollInstall {
78
80
  readonly domain: string;
79
81
  /** The protected staging host the admin is told about (#650). */
80
82
  readonly stagingHost: string;
83
+ /** Whose gcloud credentials the lookups run with; names the fix for an auth failure. */
84
+ readonly credentials: GcloudCredentials;
81
85
  }
82
86
 
83
87
  /** The release image one service rolls to. */
@@ -147,15 +151,20 @@ export async function planAppRoll(
147
151
  const services: ServicePlan[] = [];
148
152
  for (const slug of ROLL_ORDER) {
149
153
  const prefix = `caelo-${install.env}-${slug}`;
150
- const serviceName = await resolveGcpResourceName(
154
+ const lookup = await resolveGcpResourceName(
151
155
  run,
152
156
  "run-service",
153
157
  prefix,
154
158
  install.projectId,
155
159
  install.region,
156
160
  );
157
- if (!serviceName)
158
- return { ok: false, error: `Could not find the ${prefix}* Cloud Run service` };
161
+ if (lookup.status !== "found") {
162
+ return {
163
+ ok: false,
164
+ error: lookupFailure(lookup, `${prefix}* Cloud Run service`, install.credentials),
165
+ };
166
+ }
167
+ const serviceName = lookup.name;
159
168
  const priorRevision = await findCurrentRevision(
160
169
  run,
161
170
  install.projectId,
@@ -404,10 +413,12 @@ export async function executeAppRoll(
404
413
  // migrations, and roll a healthy release back.
405
414
  if (deps.health) {
406
415
  const s = r.step("Checking the health check can reach admin + gateway...");
407
- const base = await deps.health.baseline().catch((e: unknown) => ({
408
- ok: false as const,
409
- error: e instanceof Error ? e.message : String(e),
410
- }));
416
+ const base = await deps.health
417
+ .baseline((reason) => s.progress(reason))
418
+ .catch((e: unknown) => ({
419
+ ok: false as const,
420
+ error: e instanceof Error ? e.message : String(e),
421
+ }));
411
422
  if (!base.ok) {
412
423
  s.failed(`Health check can't reach the install: ${base.error}`);
413
424
  r.warn(
@@ -13,7 +13,7 @@
13
13
 
14
14
  import type { GcloudRunner } from "../gcloud-retry.js";
15
15
  import { gcpNamePrefix } from "../gcp-names.js";
16
- import { resolveGcpResourceName } from "./gcp-lookup.js";
16
+ import { type GcloudCredentials, lookupFailure, resolveGcpResourceName } from "./gcp-lookup.js";
17
17
 
18
18
  /** A finished on-demand backup. */
19
19
  export interface SqlBackup {
@@ -38,22 +38,31 @@ export type SqlBackupResult =
38
38
  */
39
39
  export async function takeSqlBackup(
40
40
  run: GcloudRunner,
41
- install: { readonly projectId: string; readonly env: string },
41
+ install: {
42
+ readonly projectId: string;
43
+ readonly env: string;
44
+ readonly credentials: GcloudCredentials;
45
+ },
42
46
  purpose: string,
43
47
  now: () => Date = () => new Date(),
44
48
  ): Promise<SqlBackupResult> {
45
- const instance = await resolveGcpResourceName(
49
+ const lookup = await resolveGcpResourceName(
46
50
  run,
47
51
  "sql-instance",
48
52
  `${gcpNamePrefix(install.env)}-pg`,
49
53
  install.projectId,
50
54
  );
51
- if (!instance) {
55
+ if (lookup.status !== "found") {
52
56
  return {
53
57
  ok: false,
54
- error: `no ${gcpNamePrefix(install.env)}-pg* Cloud SQL instance in project ${install.projectId} (or listing it was denied)`,
58
+ error: lookupFailure(
59
+ lookup,
60
+ `${gcpNamePrefix(install.env)}-pg* Cloud SQL instance in project ${install.projectId}`,
61
+ install.credentials,
62
+ ),
55
63
  };
56
64
  }
65
+ const instance = lookup.name;
57
66
  const description = `caelo-cms ${purpose} ${now().toISOString()}`;
58
67
  const project = ["--project", install.projectId];
59
68
  const create = await run([
@@ -65,6 +65,7 @@ import {
65
65
  import { ensureUpgraderJob, resolveUpgraderTarget } from "../upgrader-setup.js";
66
66
  import { ensureUrlMapRedirectsAccess } from "../url-map-redirects-access.js";
67
67
  import type { AppRollHooks, AppRollPlan, RollPlan, RollSlug } from "./app-roll.js";
68
+ import { lookupFailure } from "./gcp-lookup.js";
68
69
  import type { UpgradeReporter } from "./reporter.js";
69
70
 
70
71
  /**
@@ -116,6 +117,13 @@ export interface ConvergeSession {
116
117
  * infrastructure contract version be recorded.
117
118
  */
118
119
  complete(): boolean;
120
+ /**
121
+ * Whether the part before the app-roll changed access the health check
122
+ * relies on (an IAM binding, MCP access, the gateway's ingress). Google
123
+ * applies such changes only after a while, so the health baseline then
124
+ * waits for a refusal to clear (health.ts, #734).
125
+ */
126
+ accessJustChanged(): boolean;
119
127
  }
120
128
 
121
129
  /** Start a converge for the install `plan` rolls. */
@@ -137,6 +145,7 @@ export function startConverge(
137
145
  services,
138
146
  };
139
147
  let incomplete = false;
148
+ let accessChanged = false;
140
149
  // Env vars the rolls must leave as the services have them, because the
141
150
  // thing they point at could not be set up.
142
151
  const leaveUntouched: string[] = [];
@@ -179,10 +188,12 @@ export function startConverge(
179
188
 
180
189
  async function stackInvariants(): Promise<boolean> {
181
190
  // IAM bindings + CDN policy added to the stacks after this install was
182
- // provisioned (stack-contract.ts), and GCP's SSH/RDP-from-anywhere
183
- // firewall rules removed (default-firewall.ts). Additive + idempotent.
191
+ // provisioned (stack-contract.ts), a gcp-firebase gateway's ingress
192
+ // (#732), and GCP's SSH/RDP-from-anywhere firewall rules removed
193
+ // (default-firewall.ts). Before the app-roll: its health baseline
194
+ // reaches the gateway through that ingress. Idempotent.
184
195
  const s = r.step(
185
- "Ensuring the IAM bindings + CDN settings the stack declares, removing the default network's pre-populated firewall rules...",
196
+ "Ensuring the IAM bindings, CDN settings and gateway ingress the stack declares, removing the default network's pre-populated firewall rules...",
186
197
  );
187
198
  const report = await ensureStackInvariants(target, runDeps);
188
199
  const applied = report.outcomes.filter((o) => o.status === "applied");
@@ -193,6 +204,7 @@ export function startConverge(
193
204
  `Stack invariants ok (${report.outcomes.length - failed.length}/${report.outcomes.length}, ${applied.length} applied)`,
194
205
  );
195
206
  }
207
+ if (applied.length > 0) accessChanged = true;
196
208
  for (const o of applied) r.info(` applied: ${o.id} (${o.why})`);
197
209
  for (const o of failed) {
198
210
  const line = ` ${o.onFailure === "abort" ? "FAILED" : "not applied"}: ${o.id} — ${o.why}\n ${o.error ?? ""}`;
@@ -213,7 +225,7 @@ export function startConverge(
213
225
  // audit is what verifies it and fails the upgrade while it remains.
214
226
  const s = r.step("Removing public_role from the Cloud SQL superuser group...");
215
227
  const outcome = await revokePublicRoleMemberships(
216
- { projectId: install.projectId, env: install.env },
228
+ { projectId: install.projectId, env: install.env, credentials: install.credentials },
217
229
  runDeps,
218
230
  );
219
231
  if (outcome.status === "failed") {
@@ -250,6 +262,8 @@ export function startConverge(
250
262
  : { ok: false as const, error: "admin IAP backend service not found" };
251
263
  if (mcp.ok) {
252
264
  s.done(`MCP access ready (${mcp.serviceAccount}; ${mcp.operators.length} operator(s))`);
265
+ if (mcp.granted.length > 0) accessChanged = true;
266
+ for (const g of mcp.granted) r.info(` granted: ${g}`);
253
267
  return;
254
268
  }
255
269
  s.warned(`MCP access not configured: ${mcp.error}`);
@@ -408,12 +422,15 @@ export function startConverge(
408
422
  // #613 — public_role moves onto its own password right before the
409
423
  // gateway's roll. The gateway has no other database login (#635).
410
424
  const s = r.step("Preparing the gateway's database login (public_role)...");
411
- const sqlInstance = await findSqlInstance(install, { run });
412
- if (!sqlInstance) {
413
- s.failed("Could not find the Cloud SQL instance. Aborting upgrade.");
425
+ const lookup = await findSqlInstance(install, { run });
426
+ if (lookup.status !== "found") {
427
+ s.failed(
428
+ `${lookupFailure(lookup, "Cloud SQL instance", install.credentials)}. Aborting upgrade.`,
429
+ );
414
430
  r.warn("Migrations ran; no traffic was shifted. Re-run upgrade once the instance is found.");
415
431
  return false;
416
432
  }
433
+ const sqlInstance = lookup.name;
417
434
  const roleTarget = { projectId: install.projectId, env: install.env, sqlInstance };
418
435
  const roleDeps = { ...runDeps, ...(deps.http ? { http: deps.http } : {}) };
419
436
  s.done("public_role ready to switch");
@@ -645,5 +662,6 @@ export function startConverge(
645
662
  },
646
663
 
647
664
  complete: () => !incomplete,
665
+ accessJustChanged: () => accessChanged,
648
666
  };
649
667
  }
@@ -10,13 +10,92 @@
10
10
  import { describe, expect, it } from "bun:test";
11
11
  import type { GcloudResult } from "../gcloud.js";
12
12
  import type { GcloudRunner } from "../gcloud-retry.js";
13
- import { findServingImage, servingImageDigests } from "./gcp-lookup.js";
13
+ import {
14
+ findServingImage,
15
+ lookupFailure,
16
+ resolveGcpResourceName,
17
+ servingImageDigests,
18
+ } from "./gcp-lookup.js";
14
19
 
15
20
  const INDEX = `sha256:${"a".repeat(64)}`;
16
21
  const PLATFORM = `sha256:${"b".repeat(64)}`;
17
22
  const REPO = "europe-west1-docker.pkg.dev/caelo-website/caelo-cms-images/admin";
18
23
 
19
24
  const ok = (stdout: string): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
25
+ const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
26
+ const REAUTH =
27
+ "ERROR: (gcloud.run.services.list) There was a problem refreshing your current auth tokens: Reauthentication failed. cannot prompt during non-interactive execution.";
28
+
29
+ describe("resolveGcpResourceName (#630)", () => {
30
+ const lookup = (r: GcloudResult) =>
31
+ resolveGcpResourceName(async () => r, "run-service", "caelo-production-admin", "acme", "eu");
32
+
33
+ it("finds the first listed name", async () => {
34
+ expect(await lookup(ok("\ncaelo-production-admin-3efcfea\nother\n"))).toEqual({
35
+ status: "found",
36
+ name: "caelo-production-admin-3efcfea",
37
+ });
38
+ });
39
+
40
+ it("an empty listing is not-found", async () => {
41
+ expect(await lookup(ok("\n"))).toEqual({ status: "not-found" });
42
+ });
43
+
44
+ it("a failed listing is an error with gcloud's stderr, never not-found", async () => {
45
+ expect(await lookup(fail(REAUTH))).toEqual({
46
+ status: "error",
47
+ stderr: REAUTH,
48
+ authExpired: true,
49
+ });
50
+ expect(await lookup(fail("ERROR: PERMISSION_DENIED: run.services.list"))).toEqual({
51
+ status: "error",
52
+ stderr: "ERROR: PERMISSION_DENIED: run.services.list",
53
+ authExpired: false,
54
+ });
55
+ });
56
+ });
57
+
58
+ describe("lookupFailure", () => {
59
+ const what = "caelo-production-admin* Cloud Run service";
60
+
61
+ it("not-found reads as before", () => {
62
+ expect(lookupFailure({ status: "not-found" }, what, "operator")).toBe(
63
+ "Could not find the caelo-production-admin* Cloud Run service",
64
+ );
65
+ });
66
+
67
+ it("expired operator credentials name gcloud auth login and what gcloud said", () => {
68
+ const m = lookupFailure(
69
+ { status: "error", stderr: REAUTH, authExpired: true },
70
+ what,
71
+ "operator",
72
+ );
73
+ expect(m).toContain("gcloud credentials expired");
74
+ expect(m).toContain("gcloud auth login");
75
+ expect(m).toContain("gcloud auth application-default login");
76
+ expect(m).toContain("Reauthentication failed");
77
+ expect(m).not.toContain("Could not find");
78
+ });
79
+
80
+ it("a job never tells anyone to run gcloud auth login", () => {
81
+ const m = lookupFailure({ status: "error", stderr: REAUTH, authExpired: true }, what, "job");
82
+ expect(m).not.toContain("gcloud auth login");
83
+ expect(m).toContain("Nothing was changed");
84
+ expect(m).toContain("Cloud Shell");
85
+ });
86
+
87
+ it("any other failure surfaces gcloud's first error line", () => {
88
+ expect(
89
+ lookupFailure(
90
+ { status: "error", stderr: "ERROR: PERMISSION_DENIED: nope\nmore", authExpired: false },
91
+ what,
92
+ "operator",
93
+ ),
94
+ ).toBe(
95
+ "Could not list the caelo-production-admin* Cloud Run service: gcloud failed (PERMISSION_DENIED: nope)",
96
+ );
97
+ });
98
+ });
20
99
 
21
100
  function gcloud(revision: GcloudResult): GcloudRunner {
22
101
  return async (args) => {
@@ -8,13 +8,70 @@
8
8
  */
9
9
 
10
10
  import { z } from "zod";
11
+ import {
12
+ GCLOUD_REAUTH_HINT,
13
+ type GcloudResult,
14
+ gcloudErrorLine,
15
+ isGcloudAuthError,
16
+ } from "../gcloud.js";
11
17
  import type { GcloudRunner } from "../gcloud-retry.js";
12
18
 
19
+ /**
20
+ * Whose gcloud credentials a lookup ran with. It decides the fix an auth
21
+ * failure names: the Owner logs in again; a job in the install's project
22
+ * cannot, so it points at the break-glass run instead (#630).
23
+ */
24
+ export type GcloudCredentials = "operator" | "job";
25
+
26
+ /**
27
+ * The outcome of finding a deployed resource by name prefix. A failed
28
+ * listing is NOT "not found": expired credentials used to read as
29
+ * "Could not find caelo-production-admin*" while the service was there (#630).
30
+ */
31
+ export type GcpLookup =
32
+ | { readonly status: "found"; readonly name: string }
33
+ | { readonly status: "not-found" }
34
+ | { readonly status: "error"; readonly stderr: string; readonly authExpired: boolean };
35
+
36
+ /** A lookup that did not find its resource. */
37
+ export type GcpLookupMiss = Exclude<GcpLookup, { readonly status: "found" }>;
38
+
39
+ /** The first name a `--format=value(name)` listing printed, as a {@link GcpLookup}. */
40
+ export function lookupFromListing(r: GcloudResult): GcpLookup {
41
+ if (!r.ok) {
42
+ return { status: "error", stderr: r.stderr, authExpired: isGcloudAuthError(r.stderr) };
43
+ }
44
+ const name = r.stdout
45
+ .split("\n")
46
+ .map((s) => s.trim())
47
+ .find((s) => s.length > 0);
48
+ return name ? { status: "found", name } : { status: "not-found" };
49
+ }
50
+
51
+ /**
52
+ * The error for a lookup that found nothing, for the operator or the job
53
+ * that ran it.
54
+ *
55
+ * @param what the resource, as the operator knows it, e.g. "caelo-production-admin* Cloud Run service"
56
+ */
57
+ export function lookupFailure(
58
+ lookup: GcpLookupMiss,
59
+ what: string,
60
+ credentials: GcloudCredentials,
61
+ ): string {
62
+ if (lookup.status === "not-found") return `Could not find the ${what}`;
63
+ const said = gcloudErrorLine(lookup.stderr);
64
+ if (!lookup.authExpired) return `Could not list the ${what}: gcloud failed (${said})`;
65
+ return credentials === "operator"
66
+ ? `Could not look up the ${what}: ${GCLOUD_REAUTH_HINT} (gcloud: ${said})`
67
+ : `Could not look up the ${what}: Google rejected this job's credentials (${said}). Nothing was changed. Start the update again; if it fails the same way, run the break-glass upgrade from Cloud Shell, which repairs the job's access.`;
68
+ }
69
+
13
70
  /**
14
71
  * Pulumi auto-naming appends a random 7-char suffix to every resource
15
72
  * (e.g. `caelo-production-admin-3efcfea`), so lifecycle commands find the
16
- * deployed name by prefix. Returns the first match, or null when the listing
17
- * fails or finds nothing.
73
+ * deployed name by prefix: the first match, "not-found" when the listing is
74
+ * empty, "error" with gcloud's stderr when the listing itself failed.
18
75
  */
19
76
  export async function resolveGcpResourceName(
20
77
  run: GcloudRunner,
@@ -22,7 +79,7 @@ export async function resolveGcpResourceName(
22
79
  prefix: string,
23
80
  projectId: string,
24
81
  region?: string,
25
- ): Promise<string | null> {
82
+ ): Promise<GcpLookup> {
26
83
  const args =
27
84
  kind === "run-service"
28
85
  ? [
@@ -47,14 +104,7 @@ export async function resolveGcpResourceName(
47
104
  `name~^${prefix}`,
48
105
  "--format=value(name)",
49
106
  ];
50
- const r = await run(args);
51
- if (!r.ok) return null;
52
- return (
53
- r.stdout
54
- .split("\n")
55
- .map((s) => s.trim())
56
- .find((s) => s.length > 0) ?? null
57
- );
107
+ return lookupFromListing(await run(args));
58
108
  }
59
109
 
60
110
  /** A Cloud Run lookup is regional; there is no default region to guess. */
@@ -9,6 +9,7 @@ import {
9
9
  evaluateGatewayDeepHealth,
10
10
  type HealthTarget,
11
11
  healthOrigins,
12
+ IAM_PROPAGATION_WINDOW_MS,
12
13
  } from "./health.js";
13
14
 
14
15
  const migrations = (adminPending = 0, publicPending = 0) => ({
@@ -153,6 +154,7 @@ describe("createIapHealthProbe", () => {
153
154
  run: gcloudOk,
154
155
  http: net.http,
155
156
  sleep: async () => {},
157
+ accessJustChanged: false,
156
158
  now: () => 1000,
157
159
  });
158
160
  expect(await probe.deep({ version: "0.11.0" })).toEqual({
@@ -198,6 +200,7 @@ describe("createIapHealthProbe", () => {
198
200
  run: gcloudOk,
199
201
  http: net.http,
200
202
  sleep: async () => {},
203
+ accessJustChanged: false,
201
204
  attempts: 5,
202
205
  });
203
206
  expect((await probe.deep({ version: null })).ok).toBe(true);
@@ -211,6 +214,7 @@ describe("createIapHealthProbe", () => {
211
214
  run: gcloudOk,
212
215
  http: stuck.http,
213
216
  sleep: async () => {},
217
+ accessJustChanged: false,
214
218
  attempts: 2,
215
219
  });
216
220
  const r = await failing.deep({ version: null });
@@ -227,6 +231,7 @@ describe("createIapHealthProbe", () => {
227
231
  run: gcloudOk,
228
232
  http: net.http,
229
233
  sleep: async () => {},
234
+ accessJustChanged: false,
230
235
  });
231
236
  const r = await probe.baseline();
232
237
  expect(r.ok).toBe(false);
@@ -234,7 +239,7 @@ describe("createIapHealthProbe", () => {
234
239
  expect(r.error).toContain("IAP may not admit caelo-mcp@acme.iam.gserviceaccount.com");
235
240
  });
236
241
 
237
- it("baseline succeeds when the old admin answers its shallow check and the gateway answers at all", async () => {
242
+ it("#733 — baseline succeeds when the old admin answers its shallow check and an old gateway its own JSON 404", async () => {
238
243
  const net = network({
239
244
  admin: () => [200, JSON.stringify({ ok: true, version: "0.10.43" })],
240
245
  gateway: () => [404, '{"ok":false}'],
@@ -243,6 +248,7 @@ describe("createIapHealthProbe", () => {
243
248
  run: gcloudOk,
244
249
  http: net.http,
245
250
  sleep: async () => {},
251
+ accessJustChanged: false,
246
252
  });
247
253
  expect(await probe.baseline()).toEqual({
248
254
  ok: true,
@@ -250,16 +256,133 @@ describe("createIapHealthProbe", () => {
250
256
  });
251
257
  });
252
258
 
253
- it("baseline fails with the cause when the operator can't sign as the MCP account", async () => {
254
- const http: HttpFetch = async () => ({
255
- ok: false,
256
- status: 403,
257
- text: async () => "Permission iam.serviceAccounts.signJwt denied",
259
+ it("#733 — baseline fails when the gateway answers Google's HTML 404, naming the ingress", async () => {
260
+ const net = network({
261
+ admin: () => [200, JSON.stringify({ ok: true, version: "0.10.20" })],
262
+ gateway: () => [404, "<!DOCTYPE html><html><title>Error 404 (Not Found)!!1</title>"],
263
+ });
264
+ const probe = createIapHealthProbe(FIREBASE, {
265
+ run: gcloudOk,
266
+ http: net.http,
267
+ sleep: async () => {},
268
+ accessJustChanged: false,
258
269
  });
259
- const probe = createIapHealthProbe(FIREBASE, { run: gcloudOk, http, sleep: async () => {} });
260
270
  const r = await probe.baseline();
261
271
  expect(r.ok).toBe(false);
262
- if (!r.ok) expect(r.error).toContain("roles/iam.serviceAccountTokenCreator");
272
+ if (!r.ok) {
273
+ expect(r.error).toContain("answered HTTP 404 without JSON");
274
+ expect(r.error).toContain('ingress "all"');
275
+ }
276
+ const lb = createIapHealthProbe(
277
+ { ...FIREBASE, provider: "gcp" },
278
+ { run: gcloudOk, http: net.http, sleep: async () => {}, accessJustChanged: false },
279
+ );
280
+ const viaLb = await lb.baseline();
281
+ if (!viaLb.ok) expect(viaLb.error).toContain("route /api/* to the gateway backend");
282
+ expect(viaLb.ok).toBe(false);
283
+ });
284
+
285
+ describe("#734 — a refused signJwt right after converge granted it", () => {
286
+ /** signJwt answers 403 `denials` times, then signs. */
287
+ function propagating(denials: number) {
288
+ let signs = 0;
289
+ const http: HttpFetch = async (url) => {
290
+ if (url.includes(":signJwt")) {
291
+ signs++;
292
+ return signs <= denials
293
+ ? {
294
+ ok: false,
295
+ status: 403,
296
+ text: async () => "Permission 'iam.serviceAccounts.signJwt' denied",
297
+ }
298
+ : { ok: true, status: 200, text: async () => JSON.stringify({ signedJwt: "s.j" }) };
299
+ }
300
+ return url.includes("admin")
301
+ ? { ok: true, status: 200, text: async () => JSON.stringify({ version: "0.10.20" }) }
302
+ : { ok: false, status: 404, text: async () => '{"ok":false}' };
303
+ };
304
+ return { http, signs: () => signs };
305
+ }
306
+
307
+ it("waits for the grant to apply, saying why, then passes", async () => {
308
+ const net = propagating(4);
309
+ const slept: number[] = [];
310
+ const waits: string[] = [];
311
+ const probe = createIapHealthProbe(FIREBASE, {
312
+ run: gcloudOk,
313
+ http: net.http,
314
+ sleep: async (ms) => {
315
+ slept.push(ms);
316
+ },
317
+ accessJustChanged: true,
318
+ });
319
+ expect(await probe.baseline((reason) => waits.push(reason))).toEqual({
320
+ ok: true,
321
+ detail: "admin 0.10.20 and gateway reachable",
322
+ });
323
+ expect(net.signs()).toBe(5);
324
+ expect(slept).toEqual([5_000, 10_000, 20_000, 30_000]);
325
+ expect(waits).toHaveLength(4);
326
+ expect(waits[0]).toContain("Waiting for the access the converge step just granted");
327
+ });
328
+
329
+ it("gives up after the propagation window: the grant exists, re-run in a few minutes", async () => {
330
+ const net = propagating(Number.POSITIVE_INFINITY);
331
+ let slept = 0;
332
+ const probe = createIapHealthProbe(FIREBASE, {
333
+ run: gcloudOk,
334
+ http: net.http,
335
+ sleep: async (ms) => {
336
+ slept += ms;
337
+ },
338
+ accessJustChanged: true,
339
+ });
340
+ const r = await probe.baseline();
341
+ expect(slept).toBe(IAM_PROPAGATION_WINDOW_MS);
342
+ expect(r.ok).toBe(false);
343
+ if (!r.ok) {
344
+ expect(r.error).toContain("The grant exists; nothing was changed: re-run upgrade");
345
+ expect(r.error).toContain("after 7 minutes");
346
+ }
347
+ });
348
+
349
+ it("fails at once when nothing was just granted: the grant is missing", async () => {
350
+ const net = propagating(Number.POSITIVE_INFINITY);
351
+ let slept = 0;
352
+ const probe = createIapHealthProbe(FIREBASE, {
353
+ run: gcloudOk,
354
+ http: net.http,
355
+ sleep: async (ms) => {
356
+ slept += ms;
357
+ },
358
+ accessJustChanged: false,
359
+ });
360
+ const r = await probe.baseline();
361
+ expect(slept).toBe(0);
362
+ expect(net.signs()).toBe(1);
363
+ expect(r.ok).toBe(false);
364
+ if (!r.ok) {
365
+ expect(r.error).toContain("it lacks roles/iam.serviceAccountTokenCreator");
366
+ expect(r.error).toContain("run upgrade as an allowlisted account");
367
+ }
368
+ });
369
+
370
+ it("never waits out a failure that is not a refusal", async () => {
371
+ const http: HttpFetch = async () => {
372
+ throw new Error("ENOTFOUND");
373
+ };
374
+ let slept = 0;
375
+ const probe = createIapHealthProbe(FIREBASE, {
376
+ run: gcloudOk,
377
+ http,
378
+ sleep: async (ms) => {
379
+ slept += ms;
380
+ },
381
+ accessJustChanged: true,
382
+ });
383
+ expect((await probe.baseline()).ok).toBe(false);
384
+ expect(slept).toBe(0);
385
+ });
263
386
  });
264
387
 
265
388
  it("a transport failure while signing is a failed check, not a throw", async () => {
@@ -270,6 +393,7 @@ describe("createIapHealthProbe", () => {
270
393
  run: gcloudOk,
271
394
  http,
272
395
  sleep: async () => {},
396
+ accessJustChanged: false,
273
397
  attempts: 2,
274
398
  });
275
399
  const r = await probe.deep({ version: null });