@caelo-cms/provisioning 0.10.47 → 0.10.49
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.
- package/caddy/Caddyfile.production +1 -1
- package/caddy/Caddyfile.staging +1 -1
- package/dist/agent/apply.d.ts +45 -0
- package/dist/agent/apply.d.ts.map +1 -0
- package/dist/agent/apply.js +83 -0
- package/dist/agent/apply.js.map +1 -0
- package/dist/agent/cli-provenance.d.ts +42 -0
- package/dist/agent/cli-provenance.d.ts.map +1 -0
- package/dist/agent/cli-provenance.js +138 -0
- package/dist/agent/cli-provenance.js.map +1 -0
- package/dist/agent/command.d.ts +79 -0
- package/dist/agent/command.d.ts.map +1 -0
- package/dist/agent/command.js +454 -0
- package/dist/agent/command.js.map +1 -0
- package/dist/agent/config.d.ts +30 -0
- package/dist/agent/config.d.ts.map +1 -0
- package/dist/agent/config.js +60 -0
- package/dist/agent/config.js.map +1 -0
- package/dist/agent/lock.d.ts +13 -0
- package/dist/agent/lock.d.ts.map +1 -0
- package/dist/agent/lock.js +100 -0
- package/dist/agent/lock.js.map +1 -0
- package/dist/agent/queue.d.ts +35 -0
- package/dist/agent/queue.d.ts.map +1 -0
- package/dist/agent/queue.js +50 -0
- package/dist/agent/queue.js.map +1 -0
- package/dist/agent/run.d.ts +97 -0
- package/dist/agent/run.d.ts.map +1 -0
- package/dist/agent/run.js +198 -0
- package/dist/agent/run.js.map +1 -0
- package/dist/agent/systemd.d.ts +55 -0
- package/dist/agent/systemd.d.ts.map +1 -0
- package/dist/agent/systemd.js +171 -0
- package/dist/agent/systemd.js.map +1 -0
- package/dist/agent/units.d.ts +44 -0
- package/dist/agent/units.d.ts.map +1 -0
- package/dist/agent/units.js +127 -0
- package/dist/agent/units.js.map +1 -0
- package/dist/agent-db.js +18402 -0
- package/dist/cli.js +119 -16
- package/dist/cli.js.map +1 -1
- package/dist/cloudsql-iam-user.d.ts +39 -0
- package/dist/cloudsql-iam-user.d.ts.map +1 -0
- package/dist/cloudsql-iam-user.js +119 -0
- package/dist/cloudsql-iam-user.js.map +1 -0
- package/dist/compose.d.ts +16 -0
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +26 -1
- package/dist/compose.js.map +1 -1
- package/dist/dns/index.d.ts +2 -0
- package/dist/dns/index.d.ts.map +1 -1
- package/dist/dns/index.js +3 -2
- package/dist/dns/index.js.map +1 -1
- package/dist/dns/manual.d.ts +5 -0
- package/dist/dns/manual.d.ts.map +1 -1
- package/dist/dns/manual.js +28 -12
- package/dist/dns/manual.js.map +1 -1
- package/dist/gcloud.d.ts +2 -0
- package/dist/gcloud.d.ts.map +1 -1
- package/dist/gcloud.js +1 -1
- package/dist/gcloud.js.map +1 -1
- package/dist/gcp-names.d.ts +15 -0
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +23 -0
- package/dist/gcp-names.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/infra-contract.d.ts +1 -1
- package/dist/infra-contract.js +7 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/install-job-runner.d.ts +13 -0
- package/dist/install-job-runner.d.ts.map +1 -0
- package/dist/install-job-runner.js +155 -0
- package/dist/install-job-runner.js.map +1 -0
- package/dist/install-job.d.ts +210 -0
- package/dist/install-job.d.ts.map +1 -0
- package/dist/install-job.js +449 -0
- package/dist/install-job.js.map +1 -0
- package/dist/install-state.d.ts +10 -1
- package/dist/install-state.d.ts.map +1 -1
- package/dist/install-state.js +10 -2
- package/dist/install-state.js.map +1 -1
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +19 -4
- package/dist/lifecycle.js.map +1 -1
- package/dist/migration-runner.d.ts +5 -0
- package/dist/migration-runner.d.ts.map +1 -1
- package/dist/migration-runner.js +1 -1
- package/dist/migration-runner.js.map +1 -1
- package/dist/operator-access.d.ts +14 -8
- package/dist/operator-access.d.ts.map +1 -1
- package/dist/operator-access.js +16 -102
- package/dist/operator-access.js.map +1 -1
- package/dist/release-manifest.d.ts +2 -41
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js +18 -121
- package/dist/release-manifest.js.map +1 -1
- package/dist/run-mode.d.ts +27 -0
- package/dist/run-mode.d.ts.map +1 -0
- package/dist/run-mode.js +22 -0
- package/dist/run-mode.js.map +1 -0
- package/dist/runtime-secrets.d.ts +19 -0
- package/dist/runtime-secrets.d.ts.map +1 -1
- package/dist/runtime-secrets.js +44 -0
- package/dist/runtime-secrets.js.map +1 -1
- package/dist/self-hosted-roles.d.ts +22 -1
- package/dist/self-hosted-roles.d.ts.map +1 -1
- package/dist/self-hosted-roles.js +81 -12
- package/dist/self-hosted-roles.js.map +1 -1
- package/dist/self-hosted-upgrade.d.ts +17 -0
- package/dist/self-hosted-upgrade.d.ts.map +1 -1
- package/dist/self-hosted-upgrade.js +16 -1
- package/dist/self-hosted-upgrade.js.map +1 -1
- package/dist/stack-contract.d.ts +6 -0
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +10 -1
- package/dist/stack-contract.js.map +1 -1
- package/dist/upgrade/converge.d.ts +6 -0
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +59 -2
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrade/gcp-lookup.d.ts +6 -0
- package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
- package/dist/upgrade/gcp-lookup.js +25 -0
- package/dist/upgrade/gcp-lookup.js.map +1 -1
- package/dist/upgrade/release-images.d.ts +26 -2
- package/dist/upgrade/release-images.d.ts.map +1 -1
- package/dist/upgrade/release-images.js +35 -2
- package/dist/upgrade/release-images.js.map +1 -1
- package/dist/upgrade/upgrade.d.ts +5 -0
- package/dist/upgrade/upgrade.d.ts.map +1 -1
- package/dist/upgrade/upgrade.js +28 -1
- package/dist/upgrade/upgrade.js.map +1 -1
- package/dist/upgrader-assess.d.ts +85 -0
- package/dist/upgrader-assess.d.ts.map +1 -0
- package/dist/upgrader-assess.js +122 -0
- package/dist/upgrader-assess.js.map +1 -0
- package/dist/upgrader-gcp.d.ts +17 -0
- package/dist/upgrader-gcp.d.ts.map +1 -0
- package/dist/upgrader-gcp.js +87 -0
- package/dist/upgrader-gcp.js.map +1 -0
- package/dist/upgrader-job.d.ts +133 -0
- package/dist/upgrader-job.d.ts.map +1 -0
- package/dist/upgrader-job.js +259 -0
- package/dist/upgrader-job.js.map +1 -0
- package/dist/upgrader-reporter.d.ts +35 -0
- package/dist/upgrader-reporter.d.ts.map +1 -0
- package/dist/upgrader-reporter.js +79 -0
- package/dist/upgrader-reporter.js.map +1 -0
- package/dist/upgrader-setup.d.ts +59 -0
- package/dist/upgrader-setup.d.ts.map +1 -0
- package/dist/upgrader-setup.js +308 -0
- package/dist/upgrader-setup.js.map +1 -0
- package/dist/wizard.d.ts +6 -0
- package/dist/wizard.d.ts.map +1 -1
- package/dist/wizard.js +32 -4
- package/dist/wizard.js.map +1 -1
- package/dist/wizards/gcp-firebase.d.ts +3 -0
- package/dist/wizards/gcp-firebase.d.ts.map +1 -1
- package/dist/wizards/gcp-firebase.js +1 -0
- package/dist/wizards/gcp-firebase.js.map +1 -1
- package/dist/wizards/gcp-install-job.d.ts +74 -0
- package/dist/wizards/gcp-install-job.d.ts.map +1 -0
- package/dist/wizards/gcp-install-job.js +398 -0
- package/dist/wizards/gcp-install-job.js.map +1 -0
- package/dist/wizards/gcp-prompts.d.ts +3 -0
- package/dist/wizards/gcp-prompts.d.ts.map +1 -0
- package/dist/wizards/gcp-prompts.js +25 -0
- package/dist/wizards/gcp-prompts.js.map +1 -0
- package/dist/wizards/gcp-pulumi.d.ts +4 -2
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +1 -1
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts +40 -0
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +223 -59
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +6 -4
- package/src/adapter.ts +97 -0
- package/src/adc-impersonation.ts +176 -0
- package/src/admin-domain.test.ts +254 -0
- package/src/admin-domain.ts +245 -0
- package/src/admin-memory.test.ts +151 -0
- package/src/agent/apply.test.ts +185 -0
- package/src/agent/apply.ts +124 -0
- package/src/agent/cli-provenance.test.ts +159 -0
- package/src/agent/cli-provenance.ts +191 -0
- package/src/agent/command.test.ts +77 -0
- package/src/agent/command.ts +564 -0
- package/src/agent/config.ts +86 -0
- package/src/agent/lock-config.test.ts +97 -0
- package/src/agent/lock.ts +110 -0
- package/src/agent/queue.test.ts +10 -0
- package/src/agent/queue.ts +91 -0
- package/src/agent/run.test.ts +437 -0
- package/src/agent/run.ts +295 -0
- package/src/agent/systemd.ts +209 -0
- package/src/agent/units.test.ts +223 -0
- package/src/agent/units.ts +163 -0
- package/src/azure-front-door.test.ts +119 -0
- package/src/azure-front-door.ts +56 -0
- package/src/bootstrap-token.ts +20 -0
- package/src/caddy.ts +112 -0
- package/src/cdn-copy.ts +91 -0
- package/src/cert-wait.test.ts +194 -0
- package/src/cert-wait.ts +163 -0
- package/src/checkpoint-secrets.test.ts +380 -0
- package/src/checkpoint-secrets.ts +283 -0
- package/src/cli-routing.test.ts +45 -0
- package/src/cli-routing.ts +43 -0
- package/src/cli.ts +1491 -0
- package/src/cloudsql-iam-user.ts +147 -0
- package/src/compose.ts +334 -0
- package/src/default-firewall.test.ts +411 -0
- package/src/default-firewall.ts +342 -0
- package/src/deployed-release.test.ts +231 -0
- package/src/deployed-release.ts +220 -0
- package/src/dns/cloudflare.ts +182 -0
- package/src/dns/groups.test.ts +76 -0
- package/src/dns/groups.ts +86 -0
- package/src/dns/index.ts +56 -0
- package/src/dns/manual.ts +130 -0
- package/src/dns/types.ts +27 -0
- package/src/gateway-credentials.test.ts +180 -0
- package/src/gateway-credentials.ts +144 -0
- package/src/gcloud-components.test.ts +76 -0
- package/src/gcloud-components.ts +85 -0
- package/src/gcloud-retry.ts +41 -0
- package/src/gcloud-runner.test.ts +61 -0
- package/src/gcloud.test.ts +99 -0
- package/src/gcloud.ts +322 -0
- package/src/gcp-names.ts +135 -0
- package/src/index.test.ts +332 -0
- package/src/index.ts +114 -0
- package/src/infra-contract.ts +32 -0
- package/src/install-job-runner.ts +166 -0
- package/src/install-job.test.ts +566 -0
- package/src/install-job.ts +542 -0
- package/src/install-state.test.ts +239 -0
- package/src/install-state.ts +583 -0
- package/src/internal-token.ts +35 -0
- package/src/lifecycle.ts +900 -0
- package/src/mcp-iap-stacks.test.ts +58 -0
- package/src/mcp-iap.test.ts +149 -0
- package/src/mcp-iap.ts +131 -0
- package/src/media-volume.test.ts +296 -0
- package/src/migration-runner.ts +365 -0
- package/src/operator-access-stacks.test.ts +55 -0
- package/src/operator-access.test.ts +332 -0
- package/src/operator-access.ts +554 -0
- package/src/ops-backend.test.ts +320 -0
- package/src/ops-backend.ts +414 -0
- package/src/provisioner-identity.test.ts +482 -0
- package/src/provisioner-identity.ts +407 -0
- package/src/redirects-emit.ts +175 -0
- package/src/region-data.ts +428 -0
- package/src/regions.test.ts +277 -0
- package/src/regions.ts +370 -0
- package/src/release-contract.ts +57 -0
- package/src/release-manifest.test.ts +300 -0
- package/src/release-manifest.ts +310 -0
- package/src/run-mode.ts +40 -0
- package/src/runtime-secrets.test.ts +627 -0
- package/src/runtime-secrets.ts +754 -0
- package/src/self-command.test.ts +14 -0
- package/src/self-command.ts +21 -0
- package/src/self-hosted-compose.test.ts +177 -0
- package/src/self-hosted-release.ts +240 -0
- package/src/self-hosted-roles.integration.test.ts +161 -0
- package/src/self-hosted-roles.test.ts +124 -0
- package/src/self-hosted-roles.ts +203 -0
- package/src/self-hosted-upgrade.test.ts +394 -0
- package/src/self-hosted-upgrade.ts +548 -0
- package/src/stack-contract-parity.test.ts +199 -0
- package/src/stack-contract.test.ts +391 -0
- package/src/stack-contract.ts +888 -0
- package/src/stack-converge-secrets.test.ts +421 -0
- package/src/stack-converge.test.ts +833 -0
- package/src/stack-converge.ts +909 -0
- package/src/staging-admin.test.ts +246 -0
- package/src/staging-admin.ts +324 -0
- package/src/state-attach.test.ts +211 -0
- package/src/state-attach.ts +132 -0
- package/src/state-backend.test.ts +145 -0
- package/src/state-backend.ts +262 -0
- package/src/state-command.ts +293 -0
- package/src/state-migrate.test.ts +479 -0
- package/src/state-migrate.ts +434 -0
- package/src/state-pulumi.ts +289 -0
- package/src/state-store.test.ts +212 -0
- package/src/state-store.ts +243 -0
- package/src/upgrade/app-roll.ts +486 -0
- package/src/upgrade/backup.ts +109 -0
- package/src/upgrade/converge.ts +437 -0
- package/src/upgrade/gcp-lookup.ts +167 -0
- package/src/upgrade/health.test.ts +279 -0
- package/src/upgrade/health.ts +305 -0
- package/src/upgrade/release-images.test.ts +119 -0
- package/src/upgrade/release-images.ts +393 -0
- package/src/upgrade/reporter.ts +46 -0
- package/src/upgrade/upgrade.test.ts +626 -0
- package/src/upgrade/upgrade.ts +249 -0
- package/src/upgrader-assess.test.ts +306 -0
- package/src/upgrader-assess.ts +250 -0
- package/src/upgrader-gcp.ts +123 -0
- package/src/upgrader-job.test.ts +411 -0
- package/src/upgrader-job.ts +502 -0
- package/src/upgrader-reporter.test.ts +46 -0
- package/src/upgrader-reporter.ts +105 -0
- package/src/upgrader-setup.test.ts +169 -0
- package/src/upgrader-setup.ts +355 -0
- package/src/wizard.ts +425 -0
- package/src/wizards/gcp-cost.ts +188 -0
- package/src/wizards/gcp-firebase.ts +72 -0
- package/src/wizards/gcp-install-job.ts +506 -0
- package/src/wizards/gcp-prompts.ts +26 -0
- package/src/wizards/gcp-pulumi.test.ts +246 -0
- package/src/wizards/gcp-pulumi.ts +516 -0
- 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
|
+
}
|
package/src/dns/types.ts
ADDED
|
@@ -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
|
+
}
|