@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,277 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "bun:test";
|
|
4
|
+
import {
|
|
5
|
+
assertRegionUnchanged,
|
|
6
|
+
type CliResult,
|
|
7
|
+
checkRegion,
|
|
8
|
+
decideRegion,
|
|
9
|
+
detectCliRegion,
|
|
10
|
+
installableRegions,
|
|
11
|
+
type RegionalProvider,
|
|
12
|
+
regionCatalog,
|
|
13
|
+
requireInstallRegion,
|
|
14
|
+
suggestRegion,
|
|
15
|
+
} from "./regions.js";
|
|
16
|
+
|
|
17
|
+
const ok = (stdout = ""): CliResult => ({ ok: true, stdout, stderr: "" });
|
|
18
|
+
const fail = (stderr: string): CliResult => ({ ok: false, stdout: "", stderr });
|
|
19
|
+
|
|
20
|
+
/** Fake CLI answering by `cmd args` substring; records every call. */
|
|
21
|
+
function fakeCli(answers: Record<string, CliResult>) {
|
|
22
|
+
const calls: string[] = [];
|
|
23
|
+
const run = async (cmd: string, args: readonly string[]) => {
|
|
24
|
+
const line = `${cmd} ${args.join(" ")}`;
|
|
25
|
+
calls.push(line);
|
|
26
|
+
const key = Object.keys(answers).find((k) => line.includes(k));
|
|
27
|
+
return key ? (answers[key] as CliResult) : fail(`${cmd}: command not found`);
|
|
28
|
+
};
|
|
29
|
+
return { run, calls };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ids = (p: RegionalProvider) => regionCatalog(p).regions.map((r) => r.id);
|
|
33
|
+
const PROVIDERS: RegionalProvider[] = ["gcp", "gcp-firebase", "aws", "azure"];
|
|
34
|
+
|
|
35
|
+
describe("region catalogs", () => {
|
|
36
|
+
for (const provider of PROVIDERS) {
|
|
37
|
+
it(`${provider}: the EU default is installable, ids are unique`, () => {
|
|
38
|
+
const catalog = regionCatalog(provider);
|
|
39
|
+
expect(ids(provider)).toContain(catalog.euDefault);
|
|
40
|
+
expect(new Set(ids(provider)).size).toBe(ids(provider).length);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
it("gcp needs every regional service in the region", () => {
|
|
45
|
+
expect(ids("gcp")).toContain("europe-west1");
|
|
46
|
+
expect(ids("gcp")).toContain("europe-west3");
|
|
47
|
+
// No Cloud Scheduler (the operator-access sync schedule) in these.
|
|
48
|
+
expect(ids("gcp")).not.toContain("europe-north1");
|
|
49
|
+
expect(ids("gcp")).not.toContain("europe-west10");
|
|
50
|
+
// No Cloud SQL.
|
|
51
|
+
expect(ids("gcp")).not.toContain("europe-north2");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("gcp-firebase additionally needs Cloud Run domain mapping (admin.<domain>)", () => {
|
|
55
|
+
expect(ids("gcp-firebase")).toContain("europe-west1");
|
|
56
|
+
expect(ids("gcp-firebase")).toContain("europe-west4");
|
|
57
|
+
expect(ids("gcp-firebase")).not.toContain("europe-west3");
|
|
58
|
+
expect(ids("gcp-firebase").every((id) => ids("gcp").includes(id))).toBe(true);
|
|
59
|
+
expect(regionCatalog("gcp-firebase").services).toContain("Cloud Run domain mapping");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("gcp never offers access-restricted regions (Dammam)", () => {
|
|
63
|
+
expect(ids("gcp")).not.toContain("me-central2");
|
|
64
|
+
expect(ids("gcp")).toContain("me-central1");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("aws and azure exclude GovCloud / China partitions", () => {
|
|
68
|
+
expect(ids("aws").some((id) => id.startsWith("us-gov") || id.startsWith("cn-"))).toBe(false);
|
|
69
|
+
expect(ids("azure").some((id) => id.includes("gov") || id.startsWith("china"))).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("installableRegions", () => {
|
|
74
|
+
it("gcp is the static catalog — no CLI call", async () => {
|
|
75
|
+
const { run, calls } = fakeCli({});
|
|
76
|
+
const r = await installableRegions("gcp", run);
|
|
77
|
+
expect(r.ok && r.regions.map((x) => x.id)).toEqual(ids("gcp"));
|
|
78
|
+
expect(calls).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("azure narrows the static list to where Container Apps is offered", async () => {
|
|
82
|
+
const { run } = fakeCli({
|
|
83
|
+
"az provider show": ok(JSON.stringify(["West Europe", "Germany West Central", "Mars"])),
|
|
84
|
+
});
|
|
85
|
+
const r = await installableRegions("azure", run);
|
|
86
|
+
expect(r.ok && r.regions.map((x) => x.id)).toEqual(["westeurope", "germanywestcentral"]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("azure fails loudly when the CLI can't answer (offline / not logged in)", async () => {
|
|
90
|
+
const { run } = fakeCli({ "az provider show": fail("Please run 'az login'") });
|
|
91
|
+
const r = await installableRegions("azure", run);
|
|
92
|
+
expect(r.ok).toBe(false);
|
|
93
|
+
if (!r.ok) {
|
|
94
|
+
expect(r.error).toContain("az login");
|
|
95
|
+
expect(r.error).toContain("Container Apps");
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("checkRegion", () => {
|
|
101
|
+
it("accepts an installable region", () => {
|
|
102
|
+
expect(checkRegion("gcp", "europe-west1", regionCatalog("gcp").regions)).toEqual({ ok: true });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("names the missing services and every valid region", () => {
|
|
106
|
+
const r = checkRegion("gcp-firebase", "europe-west3", regionCatalog("gcp-firebase").regions);
|
|
107
|
+
expect(r.ok).toBe(false);
|
|
108
|
+
if (!r.ok) {
|
|
109
|
+
expect(r.error).toContain("Cloud Run domain mapping");
|
|
110
|
+
expect(r.error).toContain("europe-west1 (Belgium)");
|
|
111
|
+
expect(r.error).toContain("us-central1 (Iowa)");
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("detectCliRegion", () => {
|
|
117
|
+
it("gcp: run/region wins, compute/region next, (unset) is no default", async () => {
|
|
118
|
+
const both = fakeCli({
|
|
119
|
+
"run/region": ok("europe-west4\n"),
|
|
120
|
+
"compute/region": ok("us-east1\n"),
|
|
121
|
+
});
|
|
122
|
+
expect(await detectCliRegion("gcp", both.run)).toBe("europe-west4");
|
|
123
|
+
const computeOnly = fakeCli({
|
|
124
|
+
"run/region": ok("(unset)\n"),
|
|
125
|
+
"compute/region": ok("us-east1"),
|
|
126
|
+
});
|
|
127
|
+
expect(await detectCliRegion("gcp-firebase", computeOnly.run)).toBe("us-east1");
|
|
128
|
+
expect(await detectCliRegion("gcp", fakeCli({}).run)).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("aws: AWS_REGION, then AWS_DEFAULT_REGION, then the configured profile", async () => {
|
|
132
|
+
const cli = fakeCli({ "aws configure get region": ok("eu-west-1\n") });
|
|
133
|
+
expect(await detectCliRegion("aws", cli.run, { AWS_REGION: "eu-north-1" })).toBe("eu-north-1");
|
|
134
|
+
expect(await detectCliRegion("aws", cli.run, { AWS_DEFAULT_REGION: "eu-west-3" })).toBe(
|
|
135
|
+
"eu-west-3",
|
|
136
|
+
);
|
|
137
|
+
expect(await detectCliRegion("aws", cli.run, {})).toBe("eu-west-1");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("azure: the az default location", async () => {
|
|
141
|
+
const cli = fakeCli({ "az config get defaults.location": ok("northeurope\n") });
|
|
142
|
+
expect(await detectCliRegion("azure", cli.run)).toBe("northeurope");
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("suggestRegion", () => {
|
|
147
|
+
const regions = regionCatalog("gcp-firebase").regions;
|
|
148
|
+
|
|
149
|
+
it("preselects the CLI default when it can host the install", () => {
|
|
150
|
+
expect(suggestRegion("gcp-firebase", "us-east4", regions)).toEqual({
|
|
151
|
+
region: "us-east4",
|
|
152
|
+
source: "cli",
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("falls back to the EU default, explaining a CLI default it can't use", () => {
|
|
157
|
+
const s = suggestRegion("gcp-firebase", "europe-west3", regions);
|
|
158
|
+
expect(s.region).toBe("europe-west1");
|
|
159
|
+
expect(s.source).toBe("eu-default");
|
|
160
|
+
expect(s.note).toContain("europe-west3");
|
|
161
|
+
expect(suggestRegion("gcp-firebase", null, regions)).toEqual({
|
|
162
|
+
region: "europe-west1",
|
|
163
|
+
source: "eu-default",
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("decideRegion", () => {
|
|
169
|
+
const base = {
|
|
170
|
+
provider: "gcp-firebase" as const,
|
|
171
|
+
installId: "gcp-firebase-acme",
|
|
172
|
+
recorded: null,
|
|
173
|
+
deployed: null,
|
|
174
|
+
requested: undefined,
|
|
175
|
+
nonInteractive: false,
|
|
176
|
+
regions: regionCatalog("gcp-firebase").regions,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
it("keeps the recorded region (existing installs keep working unchanged)", () => {
|
|
180
|
+
expect(decideRegion({ ...base, recorded: "europe-west1" })).toEqual({
|
|
181
|
+
kind: "keep",
|
|
182
|
+
region: "europe-west1",
|
|
183
|
+
from: "install.json",
|
|
184
|
+
});
|
|
185
|
+
expect(
|
|
186
|
+
decideRegion({ ...base, recorded: "europe-west1", requested: "europe-west1" }).kind,
|
|
187
|
+
).toBe("keep");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("keeps a recorded region even if it is not in today's catalog", () => {
|
|
191
|
+
expect(decideRegion({ ...base, recorded: "europe-west3", nonInteractive: true })).toEqual({
|
|
192
|
+
kind: "keep",
|
|
193
|
+
region: "europe-west3",
|
|
194
|
+
from: "install.json",
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("refuses a different --region on an existing install", () => {
|
|
199
|
+
const d = decideRegion({ ...base, recorded: "europe-west1", requested: "us-central1" });
|
|
200
|
+
expect(d.kind).toBe("refuse");
|
|
201
|
+
if (d.kind === "refuse") {
|
|
202
|
+
expect(d.error).toContain("runs in europe-west1");
|
|
203
|
+
expect(d.error).toContain("fixed after install");
|
|
204
|
+
expect(d.error).toContain("--region europe-west1");
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("adopts the deployed region of an install that predates the record", () => {
|
|
209
|
+
expect(decideRegion({ ...base, deployed: "us-east1" })).toEqual({
|
|
210
|
+
kind: "keep",
|
|
211
|
+
region: "us-east1",
|
|
212
|
+
from: "deployed",
|
|
213
|
+
});
|
|
214
|
+
expect(decideRegion({ ...base, deployed: "us-east1", requested: "europe-west1" }).kind).toBe(
|
|
215
|
+
"refuse",
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("refuses when install.json and the deployed services disagree", () => {
|
|
220
|
+
const d = decideRegion({ ...base, recorded: "europe-west1", deployed: "us-east1" });
|
|
221
|
+
expect(d.kind).toBe("refuse");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("new install: validates --region", () => {
|
|
225
|
+
expect(decideRegion({ ...base, requested: "europe-west4" })).toEqual({
|
|
226
|
+
kind: "use",
|
|
227
|
+
region: "europe-west4",
|
|
228
|
+
});
|
|
229
|
+
const d = decideRegion({ ...base, requested: "europe-west3" });
|
|
230
|
+
expect(d.kind).toBe("refuse");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("a region picked in a run that never deployed is preselected, not fixed", () => {
|
|
234
|
+
// Interactive: offered again, preselected.
|
|
235
|
+
expect(decideRegion({ ...base, preselected: "europe-west4" })).toEqual({
|
|
236
|
+
kind: "prompt",
|
|
237
|
+
preselected: "europe-west4",
|
|
238
|
+
});
|
|
239
|
+
// --region may still change it.
|
|
240
|
+
expect(decideRegion({ ...base, preselected: "europe-west4", requested: "us-east1" })).toEqual({
|
|
241
|
+
kind: "use",
|
|
242
|
+
region: "us-east1",
|
|
243
|
+
});
|
|
244
|
+
// Non-interactive re-run without --region keeps the earlier pick.
|
|
245
|
+
expect(decideRegion({ ...base, preselected: "europe-west4", nonInteractive: true })).toEqual({
|
|
246
|
+
kind: "use",
|
|
247
|
+
region: "europe-west4",
|
|
248
|
+
});
|
|
249
|
+
// An earlier pick that is no longer installable is not reused.
|
|
250
|
+
expect(decideRegion({ ...base, preselected: "europe-west3" })).toEqual({ kind: "prompt" });
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("new install: prompts when interactive, requires --region when not", () => {
|
|
254
|
+
expect(decideRegion(base)).toEqual({ kind: "prompt" });
|
|
255
|
+
const d = decideRegion({ ...base, nonInteractive: true });
|
|
256
|
+
expect(d.kind).toBe("refuse");
|
|
257
|
+
if (d.kind === "refuse") {
|
|
258
|
+
expect(d.error).toContain("--region is required");
|
|
259
|
+
expect(d.error).toContain("europe-west1 (Belgium)");
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe("lifecycle region guards", () => {
|
|
265
|
+
const meta = { installId: "gcp-firebase-acme", region: "europe-west1" };
|
|
266
|
+
|
|
267
|
+
it("requireInstallRegion returns the recorded region, never a default", () => {
|
|
268
|
+
expect(requireInstallRegion(meta)).toBe("europe-west1");
|
|
269
|
+
expect(() => requireInstallRegion({ ...meta, region: null })).toThrow(/no region recorded/);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("upgrade: no --region or the same one passes, a different one is refused", () => {
|
|
273
|
+
expect(assertRegionUnchanged(meta, undefined)).toBe("europe-west1");
|
|
274
|
+
expect(assertRegionUnchanged(meta, "europe-west1")).toBe("europe-west1");
|
|
275
|
+
expect(() => assertRegionUnchanged(meta, "us-central1")).toThrow(/fixed after install/);
|
|
276
|
+
});
|
|
277
|
+
});
|
package/src/regions.ts
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The cloud region an install runs in: which regions each provider adapter
|
|
5
|
+
* can be installed in, what the wizard suggests, and the rule that the
|
|
6
|
+
* region is fixed once an install exists (#607).
|
|
7
|
+
*
|
|
8
|
+
* Why a static list instead of asking the cloud at run time: none of the
|
|
9
|
+
* providers offers one query for "every service this stack needs is
|
|
10
|
+
* available here". GCP's `gcloud run regions list` only covers Cloud Run and
|
|
11
|
+
* needs a project with the API already enabled — i.e. after we created
|
|
12
|
+
* billable state. So each catalog below is the intersection of the per-
|
|
13
|
+
* service region lists from the provider's own documentation (URLs next to
|
|
14
|
+
* each list). Azure is the exception: Microsoft documents no static
|
|
15
|
+
* Container Apps list and names `az provider show` as the source of truth,
|
|
16
|
+
* so the Azure catalog adds that live check on top of its static list and
|
|
17
|
+
* fails loudly when it can't run.
|
|
18
|
+
*
|
|
19
|
+
* The region is immutable after install: Cloud SQL, buckets, secrets, Cloud
|
|
20
|
+
* Run services and the scheduler job all live in it, and moving them is a
|
|
21
|
+
* migration Caelo does not do. A different region on a re-run or `upgrade`
|
|
22
|
+
* is refused, never applied.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { spawn } from "node:child_process";
|
|
26
|
+
import { gcloudEnv } from "./gcloud.js";
|
|
27
|
+
import {
|
|
28
|
+
AWS_REGIONS,
|
|
29
|
+
AZURE_REGIONS,
|
|
30
|
+
GCP_REGION_NAMES,
|
|
31
|
+
GCP_RESTRICTED_REGIONS,
|
|
32
|
+
GCP_SERVICE_REGIONS,
|
|
33
|
+
GCP_SERVICES,
|
|
34
|
+
type RegionOption,
|
|
35
|
+
} from "./region-data.js";
|
|
36
|
+
import { selfCommand } from "./self-command.js";
|
|
37
|
+
|
|
38
|
+
/** Providers whose stack is placed in a cloud region (self-hosted is not). */
|
|
39
|
+
export type RegionalProvider = "gcp" | "gcp-firebase" | "aws" | "azure";
|
|
40
|
+
|
|
41
|
+
/** Result of running a provider CLI. */
|
|
42
|
+
export interface CliResult {
|
|
43
|
+
readonly ok: boolean;
|
|
44
|
+
readonly stdout: string;
|
|
45
|
+
readonly stderr: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Runs one CLI command (`gcloud`, `aws`, `az`); injectable for tests. */
|
|
49
|
+
export type CliRunner = (cmd: string, args: readonly string[]) => Promise<CliResult>;
|
|
50
|
+
|
|
51
|
+
/** Default {@link CliRunner}: spawns the binary; a missing binary is a failed result. */
|
|
52
|
+
export const runCli: CliRunner = (cmd, args) =>
|
|
53
|
+
new Promise((resolve) => {
|
|
54
|
+
// gcloudEnv: a hidden gcloud prompt would hang this captured run; the
|
|
55
|
+
// variable is inert for `aws` / `az`.
|
|
56
|
+
const child = spawn(cmd, [...args], { stdio: ["ignore", "pipe", "pipe"], env: gcloudEnv() });
|
|
57
|
+
const out: Buffer[] = [];
|
|
58
|
+
const err: Buffer[] = [];
|
|
59
|
+
child.stdout.on("data", (c: Buffer) => out.push(c));
|
|
60
|
+
child.stderr.on("data", (c: Buffer) => err.push(c));
|
|
61
|
+
child.on("error", (e) => resolve({ ok: false, stdout: "", stderr: e.message }));
|
|
62
|
+
child.on("close", (code) =>
|
|
63
|
+
resolve({
|
|
64
|
+
ok: code === 0,
|
|
65
|
+
stdout: Buffer.concat(out).toString("utf8"),
|
|
66
|
+
stderr: Buffer.concat(err).toString("utf8"),
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Regions where Azure Container Apps managed environments are offered for
|
|
73
|
+
* the signed-in subscription, as the Azure CLI reports them. Never throws;
|
|
74
|
+
* a missing CLI or no permission is an error result.
|
|
75
|
+
*/
|
|
76
|
+
async function queryAzureContainerAppsRegions(
|
|
77
|
+
run: CliRunner,
|
|
78
|
+
): Promise<{ ok: true; regions: Set<string> } | { ok: false; error: string }> {
|
|
79
|
+
const r = await run("az", [
|
|
80
|
+
"provider",
|
|
81
|
+
"show",
|
|
82
|
+
"--namespace",
|
|
83
|
+
"Microsoft.App",
|
|
84
|
+
"--query",
|
|
85
|
+
"resourceTypes[?resourceType=='managedEnvironments'].locations | [0]",
|
|
86
|
+
"--output",
|
|
87
|
+
"json",
|
|
88
|
+
]);
|
|
89
|
+
if (!r.ok) {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
error: `could not ask Azure where Container Apps is available (az provider show --namespace Microsoft.App): ${r.stderr.trim() || "no output"}. Install the Azure CLI and run \`az login\`, then re-run.`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
let names: unknown;
|
|
96
|
+
try {
|
|
97
|
+
names = JSON.parse(r.stdout);
|
|
98
|
+
} catch {
|
|
99
|
+
return { ok: false, error: `az provider show returned unparseable output: ${r.stdout.trim()}` };
|
|
100
|
+
}
|
|
101
|
+
if (!Array.isArray(names) || names.length === 0) {
|
|
102
|
+
return { ok: false, error: "az provider show listed no Container Apps regions." };
|
|
103
|
+
}
|
|
104
|
+
// The API reports display names ("West Europe"); region ids are the
|
|
105
|
+
// lower-cased name without spaces ("westeurope").
|
|
106
|
+
return {
|
|
107
|
+
ok: true,
|
|
108
|
+
regions: new Set(names.map((n) => String(n).toLowerCase().replace(/\s+/g, ""))),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ===========================================================================
|
|
113
|
+
// Catalogs
|
|
114
|
+
// ===========================================================================
|
|
115
|
+
|
|
116
|
+
/** Where a provider adapter can be installed, and what the wizard suggests. */
|
|
117
|
+
export interface RegionCatalog {
|
|
118
|
+
readonly provider: RegionalProvider;
|
|
119
|
+
/** Suggested when the provider CLI has no default region configured. */
|
|
120
|
+
readonly euDefault: string;
|
|
121
|
+
readonly regions: readonly RegionOption[];
|
|
122
|
+
/** The regional services the list was intersected over (for messages + docs). */
|
|
123
|
+
readonly services: readonly string[];
|
|
124
|
+
/** Narrows `regions` with a live provider query, where no static list exists. */
|
|
125
|
+
readonly liveRegions?: (
|
|
126
|
+
run: CliRunner,
|
|
127
|
+
) => Promise<{ ok: true; regions: Set<string> } | { ok: false; error: string }>;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function gcpCatalog(provider: "gcp" | "gcp-firebase"): RegionCatalog {
|
|
131
|
+
const services: string[] = [...GCP_SERVICES];
|
|
132
|
+
if (provider === "gcp-firebase") services.push("Cloud Run domain mapping");
|
|
133
|
+
const lists = services.map((s) => new Set(GCP_SERVICE_REGIONS[s] ?? []));
|
|
134
|
+
const regions = Object.keys(GCP_REGION_NAMES)
|
|
135
|
+
.filter((id) => !GCP_RESTRICTED_REGIONS.includes(id) && lists.every((l) => l.has(id)))
|
|
136
|
+
.map((id) => ({ id, name: GCP_REGION_NAMES[id] as string }));
|
|
137
|
+
return { provider, euDefault: "europe-west1", regions, services };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The region catalog for `provider`. */
|
|
141
|
+
export function regionCatalog(provider: RegionalProvider): RegionCatalog {
|
|
142
|
+
switch (provider) {
|
|
143
|
+
case "gcp":
|
|
144
|
+
case "gcp-firebase":
|
|
145
|
+
return gcpCatalog(provider);
|
|
146
|
+
case "aws":
|
|
147
|
+
return {
|
|
148
|
+
provider,
|
|
149
|
+
euDefault: "eu-central-1",
|
|
150
|
+
regions: AWS_REGIONS,
|
|
151
|
+
services: ["ECS Fargate", "RDS for PostgreSQL", "S3", "Secrets Manager"],
|
|
152
|
+
};
|
|
153
|
+
case "azure":
|
|
154
|
+
return {
|
|
155
|
+
provider,
|
|
156
|
+
euDefault: "westeurope",
|
|
157
|
+
regions: AZURE_REGIONS,
|
|
158
|
+
services: ["Container Apps", "Database for PostgreSQL flexible server", "Key Vault"],
|
|
159
|
+
liveRegions: queryAzureContainerAppsRegions,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The regions `provider` can be installed in right now: the static catalog,
|
|
166
|
+
* narrowed by the live query where the catalog has one.
|
|
167
|
+
*/
|
|
168
|
+
export async function installableRegions(
|
|
169
|
+
provider: RegionalProvider,
|
|
170
|
+
run: CliRunner = runCli,
|
|
171
|
+
): Promise<{ ok: true; regions: RegionOption[] } | { ok: false; error: string }> {
|
|
172
|
+
const catalog = regionCatalog(provider);
|
|
173
|
+
if (!catalog.liveRegions) return { ok: true, regions: [...catalog.regions] };
|
|
174
|
+
const live = await catalog.liveRegions(run);
|
|
175
|
+
if (!live.ok) return live;
|
|
176
|
+
return { ok: true, regions: catalog.regions.filter((r) => live.regions.has(r.id)) };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The "valid regions" list for error messages. */
|
|
180
|
+
export function formatRegionList(regions: readonly RegionOption[]): string {
|
|
181
|
+
return regions.map((r) => `${r.id} (${r.name})`).join(", ");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Check `region` against the installable regions. The error names every
|
|
186
|
+
* valid region, so a non-interactive caller can fix its `--region`.
|
|
187
|
+
*/
|
|
188
|
+
export function checkRegion(
|
|
189
|
+
provider: RegionalProvider,
|
|
190
|
+
region: string,
|
|
191
|
+
regions: readonly RegionOption[],
|
|
192
|
+
): { ok: true } | { ok: false; error: string } {
|
|
193
|
+
if (regions.some((r) => r.id === region)) return { ok: true };
|
|
194
|
+
const services = regionCatalog(provider).services.join(", ");
|
|
195
|
+
return {
|
|
196
|
+
ok: false,
|
|
197
|
+
error: `"${region}" is not a region the ${provider} install can use (it needs ${services} in the same region). Valid regions: ${formatRegionList(regions)}.`,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The default region configured in the provider's CLI, or null when none
|
|
203
|
+
* is set or the CLI isn't installed (the wizard then suggests the EU
|
|
204
|
+
* default; the operator confirms either way).
|
|
205
|
+
*/
|
|
206
|
+
export async function detectCliRegion(
|
|
207
|
+
provider: RegionalProvider,
|
|
208
|
+
run: CliRunner = runCli,
|
|
209
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
210
|
+
): Promise<string | null> {
|
|
211
|
+
const first = (r: CliResult) => {
|
|
212
|
+
const v = r.ok ? r.stdout.trim().split("\n")[0]?.trim() : "";
|
|
213
|
+
return v && v !== "(unset)" ? v : null;
|
|
214
|
+
};
|
|
215
|
+
switch (provider) {
|
|
216
|
+
case "gcp":
|
|
217
|
+
case "gcp-firebase":
|
|
218
|
+
return (
|
|
219
|
+
first(await run("gcloud", ["config", "get-value", "run/region"])) ??
|
|
220
|
+
first(await run("gcloud", ["config", "get-value", "compute/region"]))
|
|
221
|
+
);
|
|
222
|
+
case "aws":
|
|
223
|
+
return (
|
|
224
|
+
env.AWS_REGION?.trim() ||
|
|
225
|
+
env.AWS_DEFAULT_REGION?.trim() ||
|
|
226
|
+
first(await run("aws", ["configure", "get", "region"]))
|
|
227
|
+
);
|
|
228
|
+
case "azure":
|
|
229
|
+
return first(
|
|
230
|
+
await run("az", ["config", "get", "defaults.location", "--query", "value", "-o", "tsv"]),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The region the picker preselects: the CLI default when it is installable,
|
|
237
|
+
* otherwise the catalog's EU default. `note` explains a skipped CLI default.
|
|
238
|
+
*/
|
|
239
|
+
export function suggestRegion(
|
|
240
|
+
provider: RegionalProvider,
|
|
241
|
+
cliRegion: string | null,
|
|
242
|
+
regions: readonly RegionOption[],
|
|
243
|
+
): { region: string; source: "cli" | "eu-default"; note?: string } {
|
|
244
|
+
const euDefault = regionCatalog(provider).euDefault;
|
|
245
|
+
if (cliRegion && regions.some((r) => r.id === cliRegion)) {
|
|
246
|
+
return { region: cliRegion, source: "cli" };
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
region: euDefault,
|
|
250
|
+
source: "eu-default",
|
|
251
|
+
...(cliRegion
|
|
252
|
+
? {
|
|
253
|
+
note: `Your CLI's default region ${cliRegion} lacks a service this install needs (${regionCatalog(provider).services.join(", ")}); suggesting ${euDefault} instead.`,
|
|
254
|
+
}
|
|
255
|
+
: {}),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Why a region differs from the one an install was created in. */
|
|
260
|
+
export function regionChangeRefusal(
|
|
261
|
+
installId: string,
|
|
262
|
+
recorded: string,
|
|
263
|
+
requested: string,
|
|
264
|
+
): string {
|
|
265
|
+
return (
|
|
266
|
+
`Install ${installId} runs in ${recorded}; its region is fixed after install, so ${requested} can't be applied. ` +
|
|
267
|
+
`Its Cloud SQL database, buckets, secrets and services live in ${recorded}, and moving them is a migration Caelo does not do. ` +
|
|
268
|
+
`Re-run without --region (or with --region ${recorded}). For another region, provision a new install there.`
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* What the wizard does about the region, given what is already known.
|
|
274
|
+
* Pure: the caller detects the deployed region and prompts.
|
|
275
|
+
*
|
|
276
|
+
* - recorded region of a deployed install, or the deployed region → keep
|
|
277
|
+
* it; a different `--region` is refused.
|
|
278
|
+
* - otherwise `--region` → validated, used.
|
|
279
|
+
* - otherwise a preselected region (earlier run, never deployed) →
|
|
280
|
+
* preselected in the prompt, used as-is when non-interactive.
|
|
281
|
+
* - otherwise interactive → prompt; non-interactive → refuse with the list.
|
|
282
|
+
*/
|
|
283
|
+
export type RegionDecision =
|
|
284
|
+
| { readonly kind: "keep"; readonly region: string; readonly from: "install.json" | "deployed" }
|
|
285
|
+
| { readonly kind: "use"; readonly region: string }
|
|
286
|
+
| { readonly kind: "prompt"; readonly preselected?: string }
|
|
287
|
+
| { readonly kind: "refuse"; readonly error: string };
|
|
288
|
+
|
|
289
|
+
/** Decide the install region; see {@link RegionDecision}. */
|
|
290
|
+
export function decideRegion(input: {
|
|
291
|
+
readonly provider: RegionalProvider;
|
|
292
|
+
readonly installId: string;
|
|
293
|
+
readonly recorded: string | null;
|
|
294
|
+
readonly deployed: string | null;
|
|
295
|
+
/**
|
|
296
|
+
* A region picked in an earlier run that never got as far as deploying
|
|
297
|
+
* (cancelled at the cost table, a failed setup step). Not fixed yet: it
|
|
298
|
+
* is preselected, and `--region` may still change it.
|
|
299
|
+
*/
|
|
300
|
+
readonly preselected?: string | null;
|
|
301
|
+
readonly requested: string | undefined;
|
|
302
|
+
readonly nonInteractive: boolean;
|
|
303
|
+
readonly regions: readonly RegionOption[];
|
|
304
|
+
}): RegionDecision {
|
|
305
|
+
if (input.recorded && input.deployed && input.recorded !== input.deployed) {
|
|
306
|
+
return {
|
|
307
|
+
kind: "refuse",
|
|
308
|
+
error: `install.json records region ${input.recorded} for ${input.installId}, but its services run in ${input.deployed}. Fix install.json to the region the install runs in, then re-run.`,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
const fixed = input.recorded ?? input.deployed;
|
|
312
|
+
if (fixed) {
|
|
313
|
+
if (input.requested && input.requested !== fixed) {
|
|
314
|
+
return {
|
|
315
|
+
kind: "refuse",
|
|
316
|
+
error: regionChangeRefusal(input.installId, fixed, input.requested),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
return { kind: "keep", region: fixed, from: input.recorded ? "install.json" : "deployed" };
|
|
320
|
+
}
|
|
321
|
+
if (input.requested) {
|
|
322
|
+
const check = checkRegion(input.provider, input.requested, input.regions);
|
|
323
|
+
return check.ok
|
|
324
|
+
? { kind: "use", region: input.requested }
|
|
325
|
+
: { kind: "refuse", error: check.error };
|
|
326
|
+
}
|
|
327
|
+
const preselected =
|
|
328
|
+
input.preselected && checkRegion(input.provider, input.preselected, input.regions).ok
|
|
329
|
+
? input.preselected
|
|
330
|
+
: null;
|
|
331
|
+
if (input.nonInteractive) {
|
|
332
|
+
if (preselected) return { kind: "use", region: preselected };
|
|
333
|
+
return {
|
|
334
|
+
kind: "refuse",
|
|
335
|
+
error: `--region is required with --non-interactive: the region is fixed after install, so Caelo won't pick one for you. Valid regions: ${formatRegionList(input.regions)}.`,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
return preselected ? { kind: "prompt", preselected } : { kind: "prompt" };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The region an existing install runs in, for lifecycle commands. Throws an
|
|
343
|
+
* actionable error when install.json has none (no silent default — §2).
|
|
344
|
+
*/
|
|
345
|
+
export function requireInstallRegion(meta: {
|
|
346
|
+
readonly installId: string;
|
|
347
|
+
readonly region: string | null;
|
|
348
|
+
}): string {
|
|
349
|
+
if (!meta.region) {
|
|
350
|
+
throw new Error(
|
|
351
|
+
`install ${meta.installId} has no region recorded in install.json. Re-run the installer (\`${selfCommand()}\`, pick "Resume wizard"): it reads the region from the deployed services and records it.`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
return meta.region;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Refuse a `--region` that differs from the install's recorded one (used by
|
|
359
|
+
* `upgrade`, which never moves an install). No `requested` passes.
|
|
360
|
+
*/
|
|
361
|
+
export function assertRegionUnchanged(
|
|
362
|
+
meta: { readonly installId: string; readonly region: string | null },
|
|
363
|
+
requested: string | undefined,
|
|
364
|
+
): string {
|
|
365
|
+
const recorded = requireInstallRegion(meta);
|
|
366
|
+
if (requested && requested !== recorded) {
|
|
367
|
+
throw new Error(regionChangeRefusal(meta.installId, recorded, requested));
|
|
368
|
+
}
|
|
369
|
+
return recorded;
|
|
370
|
+
}
|