@caelo-cms/provisioning 0.10.47 → 0.10.48

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 (315) hide show
  1. package/dist/agent/apply.d.ts +45 -0
  2. package/dist/agent/apply.d.ts.map +1 -0
  3. package/dist/agent/apply.js +83 -0
  4. package/dist/agent/apply.js.map +1 -0
  5. package/dist/agent/cli-provenance.d.ts +42 -0
  6. package/dist/agent/cli-provenance.d.ts.map +1 -0
  7. package/dist/agent/cli-provenance.js +138 -0
  8. package/dist/agent/cli-provenance.js.map +1 -0
  9. package/dist/agent/command.d.ts +79 -0
  10. package/dist/agent/command.d.ts.map +1 -0
  11. package/dist/agent/command.js +454 -0
  12. package/dist/agent/command.js.map +1 -0
  13. package/dist/agent/config.d.ts +30 -0
  14. package/dist/agent/config.d.ts.map +1 -0
  15. package/dist/agent/config.js +60 -0
  16. package/dist/agent/config.js.map +1 -0
  17. package/dist/agent/lock.d.ts +13 -0
  18. package/dist/agent/lock.d.ts.map +1 -0
  19. package/dist/agent/lock.js +100 -0
  20. package/dist/agent/lock.js.map +1 -0
  21. package/dist/agent/queue.d.ts +35 -0
  22. package/dist/agent/queue.d.ts.map +1 -0
  23. package/dist/agent/queue.js +50 -0
  24. package/dist/agent/queue.js.map +1 -0
  25. package/dist/agent/run.d.ts +97 -0
  26. package/dist/agent/run.d.ts.map +1 -0
  27. package/dist/agent/run.js +198 -0
  28. package/dist/agent/run.js.map +1 -0
  29. package/dist/agent/systemd.d.ts +55 -0
  30. package/dist/agent/systemd.d.ts.map +1 -0
  31. package/dist/agent/systemd.js +171 -0
  32. package/dist/agent/systemd.js.map +1 -0
  33. package/dist/agent/units.d.ts +44 -0
  34. package/dist/agent/units.d.ts.map +1 -0
  35. package/dist/agent/units.js +127 -0
  36. package/dist/agent/units.js.map +1 -0
  37. package/dist/agent-db.js +18362 -0
  38. package/dist/cli.js +119 -16
  39. package/dist/cli.js.map +1 -1
  40. package/dist/cloudsql-iam-user.d.ts +39 -0
  41. package/dist/cloudsql-iam-user.d.ts.map +1 -0
  42. package/dist/cloudsql-iam-user.js +119 -0
  43. package/dist/cloudsql-iam-user.js.map +1 -0
  44. package/dist/compose.d.ts +16 -0
  45. package/dist/compose.d.ts.map +1 -1
  46. package/dist/compose.js +26 -1
  47. package/dist/compose.js.map +1 -1
  48. package/dist/dns/index.d.ts +2 -0
  49. package/dist/dns/index.d.ts.map +1 -1
  50. package/dist/dns/index.js +3 -2
  51. package/dist/dns/index.js.map +1 -1
  52. package/dist/dns/manual.d.ts +5 -0
  53. package/dist/dns/manual.d.ts.map +1 -1
  54. package/dist/dns/manual.js +28 -12
  55. package/dist/dns/manual.js.map +1 -1
  56. package/dist/gcloud.d.ts +2 -0
  57. package/dist/gcloud.d.ts.map +1 -1
  58. package/dist/gcloud.js +1 -1
  59. package/dist/gcloud.js.map +1 -1
  60. package/dist/gcp-names.d.ts +15 -0
  61. package/dist/gcp-names.d.ts.map +1 -1
  62. package/dist/gcp-names.js +23 -0
  63. package/dist/gcp-names.js.map +1 -1
  64. package/dist/index.d.ts +6 -0
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +4 -0
  67. package/dist/index.js.map +1 -1
  68. package/dist/infra-contract.d.ts +1 -1
  69. package/dist/infra-contract.js +7 -1
  70. package/dist/infra-contract.js.map +1 -1
  71. package/dist/install-job-runner.d.ts +13 -0
  72. package/dist/install-job-runner.d.ts.map +1 -0
  73. package/dist/install-job-runner.js +155 -0
  74. package/dist/install-job-runner.js.map +1 -0
  75. package/dist/install-job.d.ts +210 -0
  76. package/dist/install-job.d.ts.map +1 -0
  77. package/dist/install-job.js +449 -0
  78. package/dist/install-job.js.map +1 -0
  79. package/dist/install-state.d.ts +10 -1
  80. package/dist/install-state.d.ts.map +1 -1
  81. package/dist/install-state.js +10 -2
  82. package/dist/install-state.js.map +1 -1
  83. package/dist/lifecycle.d.ts.map +1 -1
  84. package/dist/lifecycle.js +19 -4
  85. package/dist/lifecycle.js.map +1 -1
  86. package/dist/migration-runner.d.ts +5 -0
  87. package/dist/migration-runner.d.ts.map +1 -1
  88. package/dist/migration-runner.js +1 -1
  89. package/dist/migration-runner.js.map +1 -1
  90. package/dist/operator-access.d.ts +14 -8
  91. package/dist/operator-access.d.ts.map +1 -1
  92. package/dist/operator-access.js +16 -102
  93. package/dist/operator-access.js.map +1 -1
  94. package/dist/run-mode.d.ts +27 -0
  95. package/dist/run-mode.d.ts.map +1 -0
  96. package/dist/run-mode.js +22 -0
  97. package/dist/run-mode.js.map +1 -0
  98. package/dist/runtime-secrets.d.ts +19 -0
  99. package/dist/runtime-secrets.d.ts.map +1 -1
  100. package/dist/runtime-secrets.js +44 -0
  101. package/dist/runtime-secrets.js.map +1 -1
  102. package/dist/self-hosted-roles.d.ts +22 -1
  103. package/dist/self-hosted-roles.d.ts.map +1 -1
  104. package/dist/self-hosted-roles.js +81 -12
  105. package/dist/self-hosted-roles.js.map +1 -1
  106. package/dist/self-hosted-upgrade.d.ts +17 -0
  107. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  108. package/dist/self-hosted-upgrade.js +16 -1
  109. package/dist/self-hosted-upgrade.js.map +1 -1
  110. package/dist/stack-contract.d.ts +6 -0
  111. package/dist/stack-contract.d.ts.map +1 -1
  112. package/dist/stack-contract.js +10 -1
  113. package/dist/stack-contract.js.map +1 -1
  114. package/dist/upgrade/converge.d.ts +6 -0
  115. package/dist/upgrade/converge.d.ts.map +1 -1
  116. package/dist/upgrade/converge.js +59 -2
  117. package/dist/upgrade/converge.js.map +1 -1
  118. package/dist/upgrade/gcp-lookup.d.ts +6 -0
  119. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  120. package/dist/upgrade/gcp-lookup.js +25 -0
  121. package/dist/upgrade/gcp-lookup.js.map +1 -1
  122. package/dist/upgrade/release-images.d.ts +12 -0
  123. package/dist/upgrade/release-images.d.ts.map +1 -1
  124. package/dist/upgrade/release-images.js +31 -0
  125. package/dist/upgrade/release-images.js.map +1 -1
  126. package/dist/upgrade/upgrade.d.ts +5 -0
  127. package/dist/upgrade/upgrade.d.ts.map +1 -1
  128. package/dist/upgrade/upgrade.js +28 -1
  129. package/dist/upgrade/upgrade.js.map +1 -1
  130. package/dist/upgrader-assess.d.ts +82 -0
  131. package/dist/upgrader-assess.d.ts.map +1 -0
  132. package/dist/upgrader-assess.js +116 -0
  133. package/dist/upgrader-assess.js.map +1 -0
  134. package/dist/upgrader-gcp.d.ts +17 -0
  135. package/dist/upgrader-gcp.d.ts.map +1 -0
  136. package/dist/upgrader-gcp.js +87 -0
  137. package/dist/upgrader-gcp.js.map +1 -0
  138. package/dist/upgrader-job.d.ts +131 -0
  139. package/dist/upgrader-job.d.ts.map +1 -0
  140. package/dist/upgrader-job.js +259 -0
  141. package/dist/upgrader-job.js.map +1 -0
  142. package/dist/upgrader-reporter.d.ts +35 -0
  143. package/dist/upgrader-reporter.d.ts.map +1 -0
  144. package/dist/upgrader-reporter.js +79 -0
  145. package/dist/upgrader-reporter.js.map +1 -0
  146. package/dist/upgrader-setup.d.ts +59 -0
  147. package/dist/upgrader-setup.d.ts.map +1 -0
  148. package/dist/upgrader-setup.js +308 -0
  149. package/dist/upgrader-setup.js.map +1 -0
  150. package/dist/wizard.d.ts +6 -0
  151. package/dist/wizard.d.ts.map +1 -1
  152. package/dist/wizard.js +32 -4
  153. package/dist/wizard.js.map +1 -1
  154. package/dist/wizards/gcp-firebase.d.ts +3 -0
  155. package/dist/wizards/gcp-firebase.d.ts.map +1 -1
  156. package/dist/wizards/gcp-firebase.js +1 -0
  157. package/dist/wizards/gcp-firebase.js.map +1 -1
  158. package/dist/wizards/gcp-install-job.d.ts +74 -0
  159. package/dist/wizards/gcp-install-job.d.ts.map +1 -0
  160. package/dist/wizards/gcp-install-job.js +398 -0
  161. package/dist/wizards/gcp-install-job.js.map +1 -0
  162. package/dist/wizards/gcp-prompts.d.ts +3 -0
  163. package/dist/wizards/gcp-prompts.d.ts.map +1 -0
  164. package/dist/wizards/gcp-prompts.js +25 -0
  165. package/dist/wizards/gcp-prompts.js.map +1 -0
  166. package/dist/wizards/gcp-pulumi.d.ts +4 -2
  167. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  168. package/dist/wizards/gcp-pulumi.js +1 -1
  169. package/dist/wizards/gcp-pulumi.js.map +1 -1
  170. package/dist/wizards/gcp.d.ts +40 -0
  171. package/dist/wizards/gcp.d.ts.map +1 -1
  172. package/dist/wizards/gcp.js +223 -59
  173. package/dist/wizards/gcp.js.map +1 -1
  174. package/package.json +6 -4
  175. package/src/adapter.ts +97 -0
  176. package/src/adc-impersonation.ts +176 -0
  177. package/src/admin-domain.test.ts +254 -0
  178. package/src/admin-domain.ts +245 -0
  179. package/src/admin-memory.test.ts +151 -0
  180. package/src/agent/apply.test.ts +185 -0
  181. package/src/agent/apply.ts +124 -0
  182. package/src/agent/cli-provenance.test.ts +159 -0
  183. package/src/agent/cli-provenance.ts +191 -0
  184. package/src/agent/command.test.ts +77 -0
  185. package/src/agent/command.ts +564 -0
  186. package/src/agent/config.ts +86 -0
  187. package/src/agent/lock-config.test.ts +97 -0
  188. package/src/agent/lock.ts +110 -0
  189. package/src/agent/queue.test.ts +10 -0
  190. package/src/agent/queue.ts +91 -0
  191. package/src/agent/run.test.ts +382 -0
  192. package/src/agent/run.ts +295 -0
  193. package/src/agent/systemd.ts +209 -0
  194. package/src/agent/units.test.ts +223 -0
  195. package/src/agent/units.ts +163 -0
  196. package/src/azure-front-door.test.ts +119 -0
  197. package/src/azure-front-door.ts +56 -0
  198. package/src/bootstrap-token.ts +20 -0
  199. package/src/caddy.ts +112 -0
  200. package/src/cdn-copy.ts +91 -0
  201. package/src/cert-wait.test.ts +194 -0
  202. package/src/cert-wait.ts +163 -0
  203. package/src/checkpoint-secrets.test.ts +380 -0
  204. package/src/checkpoint-secrets.ts +283 -0
  205. package/src/cli-routing.test.ts +45 -0
  206. package/src/cli-routing.ts +43 -0
  207. package/src/cli.ts +1491 -0
  208. package/src/cloudsql-iam-user.ts +147 -0
  209. package/src/compose.ts +334 -0
  210. package/src/default-firewall.test.ts +411 -0
  211. package/src/default-firewall.ts +342 -0
  212. package/src/deployed-release.test.ts +231 -0
  213. package/src/deployed-release.ts +220 -0
  214. package/src/dns/cloudflare.ts +182 -0
  215. package/src/dns/groups.test.ts +76 -0
  216. package/src/dns/groups.ts +86 -0
  217. package/src/dns/index.ts +56 -0
  218. package/src/dns/manual.ts +130 -0
  219. package/src/dns/types.ts +27 -0
  220. package/src/gateway-credentials.test.ts +180 -0
  221. package/src/gateway-credentials.ts +144 -0
  222. package/src/gcloud-components.test.ts +76 -0
  223. package/src/gcloud-components.ts +85 -0
  224. package/src/gcloud-retry.ts +41 -0
  225. package/src/gcloud-runner.test.ts +61 -0
  226. package/src/gcloud.test.ts +99 -0
  227. package/src/gcloud.ts +322 -0
  228. package/src/gcp-names.ts +135 -0
  229. package/src/index.test.ts +332 -0
  230. package/src/index.ts +114 -0
  231. package/src/infra-contract.ts +32 -0
  232. package/src/install-job-runner.ts +166 -0
  233. package/src/install-job.test.ts +566 -0
  234. package/src/install-job.ts +542 -0
  235. package/src/install-state.test.ts +239 -0
  236. package/src/install-state.ts +583 -0
  237. package/src/internal-token.ts +35 -0
  238. package/src/lifecycle.ts +900 -0
  239. package/src/mcp-iap-stacks.test.ts +58 -0
  240. package/src/mcp-iap.test.ts +149 -0
  241. package/src/mcp-iap.ts +131 -0
  242. package/src/media-volume.test.ts +296 -0
  243. package/src/migration-runner.ts +365 -0
  244. package/src/operator-access-stacks.test.ts +55 -0
  245. package/src/operator-access.test.ts +332 -0
  246. package/src/operator-access.ts +554 -0
  247. package/src/ops-backend.test.ts +320 -0
  248. package/src/ops-backend.ts +414 -0
  249. package/src/provisioner-identity.test.ts +482 -0
  250. package/src/provisioner-identity.ts +407 -0
  251. package/src/redirects-emit.ts +175 -0
  252. package/src/region-data.ts +428 -0
  253. package/src/regions.test.ts +277 -0
  254. package/src/regions.ts +370 -0
  255. package/src/release-contract.ts +57 -0
  256. package/src/release-manifest.test.ts +293 -0
  257. package/src/release-manifest.ts +430 -0
  258. package/src/run-mode.ts +40 -0
  259. package/src/runtime-secrets.test.ts +627 -0
  260. package/src/runtime-secrets.ts +754 -0
  261. package/src/self-command.test.ts +14 -0
  262. package/src/self-command.ts +21 -0
  263. package/src/self-hosted-compose.test.ts +177 -0
  264. package/src/self-hosted-release.ts +240 -0
  265. package/src/self-hosted-roles.integration.test.ts +161 -0
  266. package/src/self-hosted-roles.test.ts +124 -0
  267. package/src/self-hosted-roles.ts +203 -0
  268. package/src/self-hosted-upgrade.test.ts +394 -0
  269. package/src/self-hosted-upgrade.ts +548 -0
  270. package/src/stack-contract-parity.test.ts +199 -0
  271. package/src/stack-contract.test.ts +391 -0
  272. package/src/stack-contract.ts +888 -0
  273. package/src/stack-converge-secrets.test.ts +421 -0
  274. package/src/stack-converge.test.ts +833 -0
  275. package/src/stack-converge.ts +909 -0
  276. package/src/staging-admin.test.ts +246 -0
  277. package/src/staging-admin.ts +324 -0
  278. package/src/state-attach.test.ts +211 -0
  279. package/src/state-attach.ts +132 -0
  280. package/src/state-backend.test.ts +145 -0
  281. package/src/state-backend.ts +262 -0
  282. package/src/state-command.ts +293 -0
  283. package/src/state-migrate.test.ts +479 -0
  284. package/src/state-migrate.ts +434 -0
  285. package/src/state-pulumi.ts +289 -0
  286. package/src/state-store.test.ts +212 -0
  287. package/src/state-store.ts +243 -0
  288. package/src/upgrade/app-roll.ts +486 -0
  289. package/src/upgrade/backup.ts +109 -0
  290. package/src/upgrade/converge.ts +437 -0
  291. package/src/upgrade/gcp-lookup.ts +167 -0
  292. package/src/upgrade/health.test.ts +279 -0
  293. package/src/upgrade/health.ts +305 -0
  294. package/src/upgrade/release-images.test.ts +119 -0
  295. package/src/upgrade/release-images.ts +391 -0
  296. package/src/upgrade/reporter.ts +46 -0
  297. package/src/upgrade/upgrade.test.ts +626 -0
  298. package/src/upgrade/upgrade.ts +249 -0
  299. package/src/upgrader-assess.test.ts +265 -0
  300. package/src/upgrader-assess.ts +233 -0
  301. package/src/upgrader-gcp.ts +123 -0
  302. package/src/upgrader-job.test.ts +408 -0
  303. package/src/upgrader-job.ts +499 -0
  304. package/src/upgrader-reporter.test.ts +46 -0
  305. package/src/upgrader-reporter.ts +105 -0
  306. package/src/upgrader-setup.test.ts +169 -0
  307. package/src/upgrader-setup.ts +355 -0
  308. package/src/wizard.ts +425 -0
  309. package/src/wizards/gcp-cost.ts +188 -0
  310. package/src/wizards/gcp-firebase.ts +72 -0
  311. package/src/wizards/gcp-install-job.ts +506 -0
  312. package/src/wizards/gcp-prompts.ts +26 -0
  313. package/src/wizards/gcp-pulumi.test.ts +246 -0
  314. package/src/wizards/gcp-pulumi.ts +516 -0
  315. package/src/wizards/gcp.ts +1935 -0
@@ -0,0 +1,130 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Manual DNS adapter — fallback for registrars without an API
5
+ * integration (Namecheap, GoDaddy, Porkbun, etc.). Prints the records,
6
+ * waits for the operator to paste them at the registrar, polls public
7
+ * DNS until resolution succeeds.
8
+ *
9
+ * Per CLAUDE.md §11.C: this IS one of the few human-required steps
10
+ * the wizard can't skip. The polling makes it close to zero-friction —
11
+ * the operator pastes records, presses Enter, the wizard waits.
12
+ */
13
+
14
+ import { promises as dns } from "node:dns";
15
+ import { setTimeout as sleep } from "node:timers/promises";
16
+ import { confirm, isCancel, log, note, spinner } from "@clack/prompts";
17
+ import { bold, cyan, dim, green, red } from "kleur/colors";
18
+ import type { DnsAdapter, DnsRecord } from "./types.js";
19
+
20
+ const POLL_INTERVAL_MS = 5000;
21
+ const POLL_TIMEOUT_MS = 30 * 60 * 1000; // 30 min — managed certs need ~15 min to issue after DNS
22
+ /**
23
+ * Without a prompt nobody confirms the records were set, so the install job
24
+ * (#696) waits longer for the operator to reach their registrar; a timeout
25
+ * ends the run and the next one resumes at this step.
26
+ */
27
+ const NON_INTERACTIVE_POLL_TIMEOUT_MS = 2 * 60 * 60 * 1000;
28
+
29
+ /**
30
+ * @param opts.nonInteractive print the records and poll without asking for
31
+ * confirmation first (the install job has no terminal)
32
+ */
33
+ export function makeManualAdapter(opts: { domain: string; nonInteractive?: boolean }): DnsAdapter {
34
+ const timeoutMs = opts.nonInteractive ? NON_INTERACTIVE_POLL_TIMEOUT_MS : POLL_TIMEOUT_MS;
35
+ const timeoutLabel = `${Math.round(timeoutMs / 60_000)} min`;
36
+ return {
37
+ name: `Manual (paste at your registrar for ${opts.domain})`,
38
+ async applyRecords(records: DnsRecord[]): Promise<void> {
39
+ note(
40
+ [
41
+ bold("Paste these DNS records at your registrar:"),
42
+ "",
43
+ ...records.map(
44
+ (r) => ` ${dim(r.type.padEnd(6))} ${r.hostname.padEnd(35)} → ${bold(r.value)}`,
45
+ ),
46
+ "",
47
+ dim(
48
+ opts.nonInteractive
49
+ ? "Waiting until they resolve — the install continues on its own."
50
+ : "After pasting, press Enter to verify resolution.",
51
+ ),
52
+ ].join("\n"),
53
+ "DNS records",
54
+ );
55
+
56
+ if (!opts.nonInteractive) {
57
+ const ack = await confirm({
58
+ message: "Records pasted at your registrar?",
59
+ initialValue: true,
60
+ });
61
+ if (isCancel(ack) || !ack) {
62
+ log.error(red("Aborted at DNS step."));
63
+ throw new Error("DNS records not pasted; aborted by operator.");
64
+ }
65
+ }
66
+
67
+ const s = spinner();
68
+ s.start(`Polling DNS — up to ${timeoutLabel} while propagation + records settle...`);
69
+ const start = Date.now();
70
+ const remaining = new Set(records.map((r) => recordKey(r)));
71
+
72
+ while (remaining.size > 0) {
73
+ if (Date.now() - start > timeoutMs) {
74
+ s.stop(red(`DNS verify timeout (${timeoutLabel}).`));
75
+ throw new Error(
76
+ `Records still unresolved after ${timeoutLabel}: ${[...remaining].join(", ")}. Re-run after fixing.`,
77
+ );
78
+ }
79
+ for (const key of [...remaining]) {
80
+ const record = records.find((r) => recordKey(r) === key);
81
+ if (!record) {
82
+ remaining.delete(key);
83
+ continue;
84
+ }
85
+ if (await checkRecord(record)) {
86
+ remaining.delete(key);
87
+ log.success(
88
+ green(`✓ ${record.type} ${record.hostname} resolves to expected ${record.value}`),
89
+ );
90
+ }
91
+ }
92
+ if (remaining.size > 0) await sleep(POLL_INTERVAL_MS);
93
+ }
94
+ s.stop(green("All DNS records resolve."));
95
+ void cyan;
96
+ },
97
+ };
98
+ }
99
+
100
+ function recordKey(r: DnsRecord): string {
101
+ return `${r.type}:${r.hostname}`;
102
+ }
103
+
104
+ async function checkRecord(record: DnsRecord): Promise<boolean> {
105
+ try {
106
+ if (record.type === "A") {
107
+ const result = await dns.resolve4(record.hostname);
108
+ return result.includes(record.value);
109
+ }
110
+ if (record.type === "AAAA") {
111
+ const result = await dns.resolve6(record.hostname);
112
+ return result.includes(record.value);
113
+ }
114
+ if (record.type === "CNAME") {
115
+ const result = await dns.resolveCname(record.hostname);
116
+ // CNAME values often have a trailing dot; normalize
117
+ const expected = record.value.replace(/\.$/, "");
118
+ return result.some((v) => v.replace(/\.$/, "") === expected);
119
+ }
120
+ if (record.type === "TXT") {
121
+ const result = await dns.resolveTxt(record.hostname);
122
+ const expected = record.value.replace(/^"|"$/g, "");
123
+ return result.some((arr) => arr.join("") === expected);
124
+ }
125
+ return false;
126
+ } catch {
127
+ // ENOTFOUND / SERVFAIL — record not propagated yet
128
+ return false;
129
+ }
130
+ }
@@ -0,0 +1,27 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Shared DNS-adapter interface. Every adapter (Cloudflare / Route53 /
5
+ * Cloud DNS / manual) implements `applyRecords` end-to-end: takes the
6
+ * desired records, makes them real (via API or operator paste), and
7
+ * resolves only when public DNS lookups succeed.
8
+ */
9
+
10
+ export interface DnsRecord {
11
+ hostname: string;
12
+ type: "A" | "AAAA" | "CNAME" | "TXT";
13
+ value: string;
14
+ ttl?: number;
15
+ }
16
+
17
+ export interface DnsAdapter {
18
+ /** Human-readable name shown in wizard output. */
19
+ readonly name: string;
20
+
21
+ /**
22
+ * Make the records exist + verify they resolve. Resolves when
23
+ * `dig <hostname> <type>` returns the expected value globally.
24
+ * Throws on terminal failure (operator should fix + re-run).
25
+ */
26
+ applyRecords(records: DnsRecord[]): Promise<void>;
27
+ }
@@ -0,0 +1,180 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import {
5
+ ensureGatewayRolePassword,
6
+ findSqlInstance,
7
+ gatewayMovesPublicRole,
8
+ planPublicRoleSwitch,
9
+ } from "./gateway-credentials.js";
10
+ import type { GcloudResult } from "./gcloud.js";
11
+ import type { HttpFetch } from "./runtime-secrets.js";
12
+ import type { LiveEnvValue } from "./stack-converge.js";
13
+
14
+ const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
15
+ const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
16
+
17
+ /** Fake gcloud: secret values by secret id, a token, everything else ok. */
18
+ function fakeGcloud(secrets: Record<string, string>) {
19
+ const lines: string[] = [];
20
+ const run = async (args: string[]) => {
21
+ const line = args.join(" ");
22
+ lines.push(line);
23
+ if (line.startsWith("auth print-access-token")) return ok("tok\n");
24
+ const secret = /--secret=caelo-production-([a-z-]+)/.exec(line)?.[1];
25
+ if (line.startsWith("secrets versions access") && secret) {
26
+ const value = secrets[secret];
27
+ return value === undefined ? fail(`NOT_FOUND: ${secret}`) : ok(value);
28
+ }
29
+ if (line.startsWith("sql instances list")) return ok("caelo-production-pg-1a2b3c\n");
30
+ return ok();
31
+ };
32
+ return { run, lines };
33
+ }
34
+
35
+ /** Fake Cloud SQL Admin API recording `role password` per user update. */
36
+ function fakeSqlApi(failRoles: string[] = []) {
37
+ const set: string[] = [];
38
+ const http: HttpFetch = async (url, init) => {
39
+ const role = new URL(url).searchParams.get("name") ?? "?";
40
+ if (init.method === "PUT") {
41
+ if (failRoles.includes(role)) {
42
+ return { ok: false, status: 403, text: async () => "PERMISSION_DENIED" };
43
+ }
44
+ set.push(`${role} ${(JSON.parse(init.body ?? "{}") as { password: string }).password}`);
45
+ }
46
+ return { ok: true, status: 200, text: async () => JSON.stringify({ status: "DONE" }) };
47
+ };
48
+ return { http, set };
49
+ }
50
+
51
+ const target = { projectId: "acme", env: "production", sqlInstance: "caelo-production-pg-1" };
52
+ const SECRETS = {
53
+ "postgres-password": "admin-pw",
54
+ "public-role-password": "public-pw",
55
+ "gateway-role-password": "gateway-pw",
56
+ };
57
+ const secretRef = (secret: string): LiveEnvValue => ({
58
+ kind: "secret",
59
+ secret: `caelo-production-${secret}`,
60
+ version: "latest",
61
+ });
62
+ /** The gateway as #579 left it: public_role on admin_role's password. */
63
+ const BEFORE_613 = new Map<string, LiveEnvValue>([
64
+ ["PUBLIC_DATABASE_PASSWORD", secretRef("postgres-password")],
65
+ ["ADMIN_DATABASE_PASSWORD", secretRef("postgres-password")],
66
+ ]);
67
+ const AFTER_613 = new Map<string, LiveEnvValue>([
68
+ ["PUBLIC_DATABASE_PASSWORD", secretRef("public-role-password")],
69
+ ["GATEWAY_DATABASE_PASSWORD", secretRef("gateway-role-password")],
70
+ ]);
71
+
72
+ describe("gatewayMovesPublicRole", () => {
73
+ it("is true until the gateway reads public_role's own secret", () => {
74
+ expect(gatewayMovesPublicRole(BEFORE_613, "production")).toBe(true);
75
+ // v0.10.29: the password inline in a plain URL.
76
+ expect(
77
+ gatewayMovesPublicRole(
78
+ new Map([
79
+ ["PUBLIC_DATABASE_URL", { kind: "value", value: "postgres://public_role:x@h/db" }],
80
+ ]),
81
+ "production",
82
+ ),
83
+ ).toBe(true);
84
+ expect(gatewayMovesPublicRole(AFTER_613, "production")).toBe(false);
85
+ });
86
+ });
87
+
88
+ describe("ensureGatewayRolePassword", () => {
89
+ it("sets gateway_role from its secret through the API, never on argv", async () => {
90
+ const { run, lines } = fakeGcloud(SECRETS);
91
+ const sql = fakeSqlApi();
92
+ const r = await ensureGatewayRolePassword(target, {
93
+ run,
94
+ http: sql.http,
95
+ sleep: async () => {},
96
+ });
97
+ expect(r.status).toBe("applied");
98
+ expect(sql.set).toEqual(["gateway_role gateway-pw"]);
99
+ expect(lines.some((l) => l.includes("gateway-pw"))).toBe(false);
100
+ });
101
+
102
+ it("fails, naming the secret, when the secret can't be read", async () => {
103
+ const { run } = fakeGcloud({});
104
+ const r = await ensureGatewayRolePassword(target, {
105
+ run,
106
+ http: fakeSqlApi().http,
107
+ sleep: async () => {},
108
+ });
109
+ expect(r.status).toBe("failed");
110
+ expect(r.error).toContain("caelo-production-gateway-role-password");
111
+ });
112
+ });
113
+
114
+ describe("planPublicRoleSwitch", () => {
115
+ it("first upgrade: moves public_role onto its own password before the gateway rolls", async () => {
116
+ const { run } = fakeGcloud(SECRETS);
117
+ const sql = fakeSqlApi();
118
+ const sw = planPublicRoleSwitch(target, BEFORE_613, {
119
+ run,
120
+ http: sql.http,
121
+ sleep: async () => {},
122
+ });
123
+ expect(sw.moves).toBe(true);
124
+ expect((await sw.beforeGatewayRoll()).status).toBe("applied");
125
+ expect(sql.set).toEqual(["public_role public-pw"]);
126
+ });
127
+
128
+ it("a failed gateway roll puts public_role back on the password the old revision reads", async () => {
129
+ const { run } = fakeGcloud(SECRETS);
130
+ const sql = fakeSqlApi();
131
+ const sw = planPublicRoleSwitch(target, BEFORE_613, {
132
+ run,
133
+ http: sql.http,
134
+ sleep: async () => {},
135
+ });
136
+ await sw.beforeGatewayRoll();
137
+ expect(await sw.afterGatewayRollFailed()).toContain("set back");
138
+ expect(sql.set).toEqual(["public_role public-pw", "public_role admin-pw"]);
139
+ });
140
+
141
+ it("later upgrades re-apply the same value and have nothing to undo", async () => {
142
+ const { run } = fakeGcloud(SECRETS);
143
+ const sql = fakeSqlApi();
144
+ const sw = planPublicRoleSwitch(target, AFTER_613, {
145
+ run,
146
+ http: sql.http,
147
+ sleep: async () => {},
148
+ });
149
+ expect(sw.moves).toBe(false);
150
+ await sw.beforeGatewayRoll();
151
+ expect(await sw.afterGatewayRollFailed()).toBe("public_role was not changed");
152
+ expect(sql.set).toEqual(["public_role public-pw"]);
153
+ });
154
+
155
+ it("says so loudly when public_role can't be set back", async () => {
156
+ const { run } = fakeGcloud(SECRETS);
157
+ let calls = 0;
158
+ const http: HttpFetch = async (_url, init) => {
159
+ if (init.method === "PUT" && ++calls === 2) {
160
+ return { ok: false, status: 403, text: async () => "PERMISSION_DENIED" };
161
+ }
162
+ return { ok: true, status: 200, text: async () => JSON.stringify({ status: "DONE" }) };
163
+ };
164
+ const sw = planPublicRoleSwitch(target, BEFORE_613, { run, http, sleep: async () => {} });
165
+ await sw.beforeGatewayRoll();
166
+ expect(await sw.afterGatewayRollFailed()).toMatch(/could NOT set public_role back.*by hand/);
167
+ });
168
+ });
169
+
170
+ describe("findSqlInstance", () => {
171
+ it("finds the Pulumi-suffixed instance by the stack's prefix", async () => {
172
+ const { run, lines } = fakeGcloud({});
173
+ expect(await findSqlInstance({ projectId: "acme", env: "production" }, { run })).toBe(
174
+ "caelo-production-pg-1a2b3c",
175
+ );
176
+ expect(lines).toContain(
177
+ "sql instances list --project=acme --filter=name~^caelo-production-pg --format=value(name)",
178
+ );
179
+ });
180
+ });
@@ -0,0 +1,144 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Moving an existing install's API gateway off admin_role (issue #613),
5
+ * as `cms-provision upgrade` does it. The env side — GATEWAY_DATABASE_URL
6
+ * and the per-role password secrets in, ADMIN_DATABASE_* out — rides the
7
+ * gateway's roll (stack-contract.ts). The database side is ordered around
8
+ * that roll so /api/* keeps working:
9
+ *
10
+ * 1. after migrations (which create `gateway_role` without a password):
11
+ * `gateway_role` gets its password. Nothing uses the role yet, so this
12
+ * is safe at any point before the gateway rolls.
13
+ * 2. right before the gateway rolls: `public_role` moves off admin_role's
14
+ * password onto its own. Only the gateway logs in as public_role, so
15
+ * the window in which the old revision cannot open NEW public
16
+ * connections is the roll itself (open connections stay up).
17
+ * 3. if the gateway's roll fails and it is rolled back: `public_role`
18
+ * goes back to the password the prior revision reads, so the rollback
19
+ * leaves a working gateway, not a locked-out one.
20
+ *
21
+ * On every later upgrade the gateway already reads public_role's own
22
+ * secret: step 2 then re-applies the same value (a no-op for clients) and
23
+ * step 3 has nothing to undo.
24
+ */
25
+
26
+ import { gcloud as defaultGcloud } from "./gcloud.js";
27
+ import type { GcloudRunner, Sleep } from "./gcloud-retry.js";
28
+ import { gcpNamePrefix, gcpSecretId } from "./gcp-names.js";
29
+ import {
30
+ type DatabaseRoleTarget,
31
+ type EnsureOutcome,
32
+ ensureDatabaseRolePassword,
33
+ type HttpFetch,
34
+ readSecretValue,
35
+ } from "./runtime-secrets.js";
36
+ import { DATABASE_ROLE_SECRET } from "./stack-contract.js";
37
+ import type { LiveEnvValue } from "./stack-converge.js";
38
+
39
+ /**
40
+ * Whether this roll moves `public_role` onto its own password: true while
41
+ * the live gateway does not yet read PUBLIC_DATABASE_PASSWORD from the
42
+ * `public-role-password` secret (an install from before #613, where it was
43
+ * admin_role's `postgres-password` or inline in the URL).
44
+ */
45
+ export function gatewayMovesPublicRole(
46
+ liveGatewayEnv: ReadonlyMap<string, LiveEnvValue>,
47
+ env: string,
48
+ ): boolean {
49
+ const live = liveGatewayEnv.get("PUBLIC_DATABASE_PASSWORD");
50
+ const own = gcpSecretId(env, DATABASE_ROLE_SECRET.public_role);
51
+ return !(live?.kind === "secret" && (live.secret === own || live.secret.endsWith(`/${own}`)));
52
+ }
53
+
54
+ export interface GatewayCredentialDeps {
55
+ readonly run?: GcloudRunner;
56
+ readonly sleep?: Sleep;
57
+ readonly http?: HttpFetch;
58
+ }
59
+
60
+ /** Step 1: `gateway_role`'s password from its secret. */
61
+ export function ensureGatewayRolePassword(
62
+ target: DatabaseRoleTarget,
63
+ deps: GatewayCredentialDeps = {},
64
+ ): Promise<EnsureOutcome> {
65
+ return ensureDatabaseRolePassword(target, "gateway_role", deps);
66
+ }
67
+
68
+ /** Steps 2 and 3 for one roll of the gateway. */
69
+ export interface PublicRoleSwitch {
70
+ /** Whether this roll moves public_role off admin_role's password. */
71
+ readonly moves: boolean;
72
+ /** Step 2 — run right before the gateway's `services update`. */
73
+ beforeGatewayRoll(): Promise<EnsureOutcome>;
74
+ /**
75
+ * Step 3 — run when the gateway's roll failed and it goes back to its
76
+ * prior revision. Returns what happened, for the operator.
77
+ */
78
+ afterGatewayRollFailed(): Promise<string>;
79
+ }
80
+
81
+ /** Plan steps 2 and 3 from the gateway's live env (read before the roll). */
82
+ export function planPublicRoleSwitch(
83
+ target: DatabaseRoleTarget,
84
+ liveGatewayEnv: ReadonlyMap<string, LiveEnvValue>,
85
+ deps: GatewayCredentialDeps = {},
86
+ ): PublicRoleSwitch {
87
+ const moves = gatewayMovesPublicRole(liveGatewayEnv, target.env);
88
+ let switched = false;
89
+ return {
90
+ moves,
91
+ async beforeGatewayRoll() {
92
+ const outcome = await ensureDatabaseRolePassword(target, "public_role", deps);
93
+ switched = moves && outcome.status === "applied";
94
+ return outcome;
95
+ },
96
+ async afterGatewayRollFailed() {
97
+ if (!switched) return "public_role was not changed";
98
+ // The prior revision reads admin_role's password for public_role.
99
+ const prior = await readSecretValue(
100
+ deps.run ?? defaultGcloud,
101
+ target,
102
+ DATABASE_ROLE_SECRET.admin_role,
103
+ );
104
+ if (!prior.ok) {
105
+ return `could NOT set public_role back for the rolled-back gateway (${prior.error}); set its password to the latest version of ${gcpSecretId(target.env, "postgres-password")} by hand`;
106
+ }
107
+ const restored = await ensureDatabaseRolePassword(target, "public_role", {
108
+ ...deps,
109
+ password: prior.value,
110
+ });
111
+ if (restored.status !== "applied") {
112
+ return `could NOT set public_role back for the rolled-back gateway (${restored.error ?? "?"}); set its password to the latest version of ${gcpSecretId(target.env, "postgres-password")} by hand`;
113
+ }
114
+ switched = false;
115
+ return "public_role was set back to the password the rolled-back gateway uses";
116
+ },
117
+ };
118
+ }
119
+
120
+ /**
121
+ * The install's Cloud SQL instance (Pulumi suffixes its name), or null when
122
+ * none or the listing fails.
123
+ */
124
+ export async function findSqlInstance(
125
+ install: { readonly projectId: string; readonly env: string },
126
+ deps: { readonly run?: GcloudRunner } = {},
127
+ ): Promise<string | null> {
128
+ const run = deps.run ?? defaultGcloud;
129
+ const r = await run([
130
+ "sql",
131
+ "instances",
132
+ "list",
133
+ `--project=${install.projectId}`,
134
+ `--filter=name~^${gcpNamePrefix(install.env)}-pg`,
135
+ "--format=value(name)",
136
+ ]);
137
+ if (!r.ok) return null;
138
+ return (
139
+ r.stdout
140
+ .split("\n")
141
+ .map((l) => l.trim())
142
+ .find((l) => l.length > 0) ?? null
143
+ );
144
+ }
@@ -0,0 +1,76 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import type { GcloudResult } from "./gcloud.js";
5
+ import { ensureGcloudComponent } from "./gcloud-components.js";
6
+
7
+ const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
8
+ const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
9
+
10
+ function fake(list: GcloudResult, install: GcloudResult = ok()) {
11
+ const calls: string[][] = [];
12
+ const logs: string[] = [];
13
+ const run = async (args: string[]) => {
14
+ calls.push(args);
15
+ return args[1] === "list" ? list : install;
16
+ };
17
+ return { calls, logs, opts: { run, log: (m: string) => logs.push(m) } };
18
+ }
19
+
20
+ const LIST_ARGS = [
21
+ "components",
22
+ "list",
23
+ "--only-local-state",
24
+ "--filter=id=beta",
25
+ "--format=value(id)",
26
+ ];
27
+
28
+ describe("ensureGcloudComponent", () => {
29
+ it("does nothing when the component is installed", async () => {
30
+ const f = fake(ok("beta\n"));
31
+ expect(await ensureGcloudComponent("beta", f.opts)).toEqual({ ok: true, installed: false });
32
+ expect(f.calls).toEqual([LIST_ARGS]);
33
+ expect(f.logs).toEqual([]);
34
+ });
35
+
36
+ it("installs a missing component non-interactively and says so", async () => {
37
+ const f = fake(ok(""));
38
+ expect(await ensureGcloudComponent("beta", f.opts)).toEqual({ ok: true, installed: true });
39
+ expect(f.calls).toEqual([LIST_ARGS, ["components", "install", "beta", "--quiet"]]);
40
+ expect(f.logs).toHaveLength(1);
41
+ expect(f.logs[0]).toContain('"beta"');
42
+ });
43
+
44
+ it("names the package-manager command when the component manager is disabled", async () => {
45
+ const f = fake(
46
+ ok(""),
47
+ fail(
48
+ "ERROR: (gcloud.components.install) You cannot perform this action because this Google Cloud CLI installation is managed by an external package manager.",
49
+ ),
50
+ );
51
+ const r = await ensureGcloudComponent("beta", f.opts);
52
+ expect(r.ok).toBe(false);
53
+ const error = r.ok ? "" : r.error;
54
+ expect(error).toContain("sudo apt-get install google-cloud-cli");
55
+ expect(error).toContain("managed by an external package manager");
56
+ });
57
+
58
+ it("tells the operator to install it themselves when the install fails for another reason", async () => {
59
+ const f = fake(ok(""), fail("ERROR: Permission denied: /opt/google-cloud-sdk"));
60
+ const r = await ensureGcloudComponent("alpha", f.opts);
61
+ expect(r.ok).toBe(false);
62
+ const error = r.ok ? "" : r.error;
63
+ expect(error).toContain("Permission denied");
64
+ expect(error).toContain("gcloud components install alpha");
65
+ });
66
+
67
+ it("fails instead of guessing when gcloud cannot list components", async () => {
68
+ const f = fake(fail("gcloud crashed"));
69
+ const r = await ensureGcloudComponent("beta", f.opts);
70
+ expect(r).toEqual({
71
+ ok: false,
72
+ error: 'check whether the gcloud "beta" component is installed: gcloud crashed',
73
+ });
74
+ expect(f.calls).toHaveLength(1);
75
+ });
76
+ });
@@ -0,0 +1,85 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Ensure an optional gcloud component (`beta`, `alpha`, …) is installed
5
+ * BEFORE a command that needs it runs.
6
+ *
7
+ * Why this exists: invoking `gcloud beta …` without the `beta` component
8
+ * makes gcloud ask "Do you want to continue (Y/n)?" to install it. Our
9
+ * runner captures gcloud's output, so that prompt was invisible and the CLI
10
+ * hung forever (`admin-domain enable`). The runner now sets
11
+ * `CLOUDSDK_CORE_DISABLE_PROMPTS=1`, which turns the hang into a fast
12
+ * failure; this helper turns the failure into the bootstrap step §11.C
13
+ * promises — check, install with a visible message, carry on.
14
+ *
15
+ * Installs from a system package manager (apt/dnf `google-cloud-cli`, snap)
16
+ * have the component manager disabled: `gcloud components install` refuses.
17
+ * There we cannot fix it ourselves, so we fail with the command to run —
18
+ * gcloud's own message already names the exact package-manager command,
19
+ * so it is passed through verbatim next to ours.
20
+ */
21
+
22
+ import type { GcloudResult } from "./gcloud.js";
23
+
24
+ type Run = (args: string[]) => Promise<GcloudResult>;
25
+
26
+ export type EnsureComponentResult = { ok: true; installed: boolean } | { ok: false; error: string };
27
+
28
+ /** gcloud's wording when its component manager is disabled for this installation. */
29
+ const COMPONENT_MANAGER_DISABLED =
30
+ /component manager\s+is disabled|managed by an external package manager/i;
31
+
32
+ /**
33
+ * Install `component` if it is not installed locally.
34
+ *
35
+ * @param opts.run - gcloud runner (injectable for tests).
36
+ * @param opts.log - receives the progress line shown while installing, so the
37
+ * operator sees why the command pauses (the install downloads for ~1 min).
38
+ * @returns `installed: true` when this call installed it; `ok: false` with an
39
+ * actionable message when it is missing and could not be installed.
40
+ */
41
+ export async function ensureGcloudComponent(
42
+ component: string,
43
+ opts: { run: Run; log: (message: string) => void },
44
+ ): Promise<EnsureComponentResult> {
45
+ const listed = await opts.run([
46
+ "components",
47
+ "list",
48
+ "--only-local-state",
49
+ `--filter=id=${component}`,
50
+ "--format=value(id)",
51
+ ]);
52
+ if (!listed.ok) {
53
+ return {
54
+ ok: false,
55
+ error: `check whether the gcloud "${component}" component is installed: ${listed.stderr.trim()}`,
56
+ };
57
+ }
58
+ const installedIds = listed.stdout.split("\n").map((s) => s.trim());
59
+ if (installedIds.includes(component)) return { ok: true, installed: false };
60
+
61
+ opts.log(`Installing the gcloud "${component}" component (needed once; takes about a minute)…`);
62
+ const install = await opts.run(["components", "install", component, "--quiet"]);
63
+ if (install.ok) return { ok: true, installed: true };
64
+
65
+ const stderr = install.stderr.trim();
66
+ if (COMPONENT_MANAGER_DISABLED.test(stderr)) {
67
+ return {
68
+ ok: false,
69
+ error:
70
+ `the gcloud "${component}" component is missing, and this gcloud was installed by a ` +
71
+ "system package manager, so it cannot install components itself. Install it with " +
72
+ "your package manager (Debian/Ubuntu: `sudo apt-get install google-cloud-cli`; " +
73
+ "Fedora/RHEL: `sudo dnf install google-cloud-cli`), or switch to the standalone " +
74
+ "Google Cloud CLI from https://cloud.google.com/sdk/docs/install, then re-run this " +
75
+ `command.\ngcloud said:\n${stderr}`,
76
+ };
77
+ }
78
+ return {
79
+ ok: false,
80
+ error:
81
+ `install the gcloud "${component}" component: ${stderr}\n` +
82
+ `Run \`gcloud components install ${component}\` yourself (add \`sudo\` if your ` +
83
+ "Google Cloud CLI directory is not writable by your user), then re-run this command.",
84
+ };
85
+ }
@@ -0,0 +1,41 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Retry for gcloud IAM writes that fail transiently: a service account
5
+ * created seconds ago is not yet bindable, and concurrent writers to the
6
+ * same IAM policy lose the etag race. Anything else fails fast — a
7
+ * PERMISSION_DENIED does not get better by waiting.
8
+ */
9
+
10
+ import type { GcloudResult } from "./gcloud.js";
11
+
12
+ /**
13
+ * Runs one gcloud command; `stdin` feeds secret payloads (`--data-file=-`),
14
+ * `env` replaces the base environment (default: this process's).
15
+ */
16
+ export type GcloudRunner = (
17
+ args: string[],
18
+ opts?: { readonly stdin?: string; readonly env?: NodeJS.ProcessEnv },
19
+ ) => Promise<GcloudResult>;
20
+ export type Sleep = (ms: number) => Promise<void>;
21
+
22
+ /** Errors worth retrying: propagation delay and policy write races. */
23
+ const TRANSIENT = /does not exist|not found|NOT_FOUND|concurrent|etag|ABORTED|409/i;
24
+ const RETRY_DELAYS_MS: readonly number[] = [2_000, 4_000, 8_000, 16_000];
25
+
26
+ export const realSleep: Sleep = (ms) => new Promise<void>((r) => setTimeout(r, ms));
27
+
28
+ /** Run `args`, retrying with backoff while the failure looks transient. */
29
+ export async function runWithRetry(
30
+ run: GcloudRunner,
31
+ sleep: Sleep,
32
+ args: string[],
33
+ ): Promise<GcloudResult> {
34
+ let r = await run(args);
35
+ for (const delay of RETRY_DELAYS_MS) {
36
+ if (r.ok || !TRANSIENT.test(r.stderr)) break;
37
+ await sleep(delay);
38
+ r = await run(args);
39
+ }
40
+ return r;
41
+ }