@caelo-cms/provisioning 0.10.46 → 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.
- package/dist/adc-impersonation.d.ts.map +1 -1
- package/dist/adc-impersonation.js +23 -1
- package/dist/adc-impersonation.js.map +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 +18362 -0
- package/dist/cli.js +122 -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 +19 -1
- package/dist/install-state.d.ts.map +1 -1
- package/dist/install-state.js +31 -3
- package/dist/install-state.js.map +1 -1
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +24 -5
- 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/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/state-attach.d.ts +23 -0
- package/dist/state-attach.d.ts.map +1 -0
- package/dist/state-attach.js +112 -0
- package/dist/state-attach.js.map +1 -0
- package/dist/state-backend.d.ts +11 -0
- package/dist/state-backend.d.ts.map +1 -1
- package/dist/state-backend.js +20 -0
- package/dist/state-backend.js.map +1 -1
- package/dist/state-command.d.ts +20 -0
- package/dist/state-command.d.ts.map +1 -0
- package/dist/state-command.js +237 -0
- package/dist/state-command.js.map +1 -0
- package/dist/state-migrate.d.ts +121 -0
- package/dist/state-migrate.d.ts.map +1 -0
- package/dist/state-migrate.js +290 -0
- package/dist/state-migrate.js.map +1 -0
- package/dist/state-pulumi.d.ts +17 -0
- package/dist/state-pulumi.d.ts.map +1 -0
- package/dist/state-pulumi.js +218 -0
- package/dist/state-pulumi.js.map +1 -0
- package/dist/state-store.d.ts +27 -0
- package/dist/state-store.d.ts.map +1 -1
- package/dist/state-store.js +65 -0
- package/dist/state-store.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 +12 -0
- package/dist/upgrade/release-images.d.ts.map +1 -1
- package/dist/upgrade/release-images.js +31 -0
- 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 +82 -0
- package/dist/upgrader-assess.d.ts.map +1 -0
- package/dist/upgrader-assess.js +116 -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 +131 -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 +9 -2
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +8 -6
- 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 +291 -83
- 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 +382 -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 +293 -0
- package/src/release-manifest.ts +430 -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 +391 -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 +265 -0
- package/src/upgrader-assess.ts +233 -0
- package/src/upgrader-gcp.ts +123 -0
- package/src/upgrader-job.test.ts +408 -0
- package/src/upgrader-job.ts +499 -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,220 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The release a deployed GCP install runs, read from its Cloud Run services.
|
|
5
|
+
*
|
|
6
|
+
* Installs created before `install.json` recorded `imageDigests` have no
|
|
7
|
+
* other trustworthy record: `upgrade` rolled them through gcloud without
|
|
8
|
+
* touching the Pulumi stack config, so the config may name an older release.
|
|
9
|
+
* The wizard reads the live services instead, so re-running it on such an
|
|
10
|
+
* install keeps what runs rather than rolling to `:latest` without the
|
|
11
|
+
* migrations `upgrade` would run first.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { gcloud as defaultGcloud } from "./gcloud.js";
|
|
15
|
+
import type { GcloudRunner } from "./gcloud-retry.js";
|
|
16
|
+
import { GCP_STACK_ENV, gcpNamePrefix } from "./gcp-names.js";
|
|
17
|
+
import type { ImageDigests } from "./install-state.js";
|
|
18
|
+
import { type CloudRunSlug, SERVICE_SECRET_ENV } from "./stack-contract.js";
|
|
19
|
+
import { liveContainerEnv } from "./stack-converge.js";
|
|
20
|
+
|
|
21
|
+
/** The `sha256:…` digest an image reference pins, or null for a tag reference. */
|
|
22
|
+
export function digestFromImageRef(ref: string): string | null {
|
|
23
|
+
return ref.trim().match(/@(sha256:[0-9a-f]{64})$/)?.[1] ?? null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The single deployed (Pulumi-suffixed) Cloud Run service for `slug`. */
|
|
27
|
+
async function findDeployedService(
|
|
28
|
+
run: GcloudRunner,
|
|
29
|
+
opts: { projectId: string; region: string },
|
|
30
|
+
slug: CloudRunSlug,
|
|
31
|
+
): Promise<{ ok: true; name: string } | { ok: false; error: string }> {
|
|
32
|
+
const prefix = `${gcpNamePrefix(GCP_STACK_ENV)}-${slug}`;
|
|
33
|
+
const list = await run([
|
|
34
|
+
"run",
|
|
35
|
+
"services",
|
|
36
|
+
"list",
|
|
37
|
+
`--region=${opts.region}`,
|
|
38
|
+
`--project=${opts.projectId}`,
|
|
39
|
+
`--filter=metadata.name~^${prefix}`,
|
|
40
|
+
"--format=value(metadata.name)",
|
|
41
|
+
]);
|
|
42
|
+
if (!list.ok) return { ok: false, error: `list Cloud Run services: ${list.stderr.trim()}` };
|
|
43
|
+
const names = list.stdout
|
|
44
|
+
.split("\n")
|
|
45
|
+
.map((s) => s.trim())
|
|
46
|
+
.filter(Boolean);
|
|
47
|
+
if (names.length !== 1) {
|
|
48
|
+
return {
|
|
49
|
+
ok: false,
|
|
50
|
+
error: `expected one ${prefix}* Cloud Run service, found ${names.length === 0 ? "none" : names.join(", ")}`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return { ok: true, name: names[0] as string };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether the deployed services already run on the Secret Manager runtime
|
|
58
|
+
* env (password-less database URLs + `*_PASSWORD` secret references).
|
|
59
|
+
*
|
|
60
|
+
* The stacks deploy that env; a release from before it can't use it (it
|
|
61
|
+
* reads the password from the URL). The wizard keeps the release an install
|
|
62
|
+
* runs, so re-running it on an install `upgrade` hasn't moved over yet would
|
|
63
|
+
* pair the old release with env it can't connect with. The same holds for a
|
|
64
|
+
* gateway from before #613: its release needs the admin_role pool the
|
|
65
|
+
* current contract no longer gives it (no GATEWAY_DATABASE_PASSWORD yet).
|
|
66
|
+
* Such an install has to go through `upgrade` first, which moves the image
|
|
67
|
+
* and the env in one revision.
|
|
68
|
+
*/
|
|
69
|
+
export async function checkDeployedRuntimeEnv(opts: {
|
|
70
|
+
projectId: string;
|
|
71
|
+
region: string;
|
|
72
|
+
run?: GcloudRunner;
|
|
73
|
+
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
74
|
+
const run = opts.run ?? defaultGcloud;
|
|
75
|
+
for (const slug of ["admin", "gateway"] as const) {
|
|
76
|
+
const found = await findDeployedService(run, opts, slug);
|
|
77
|
+
if (!found.ok) return found;
|
|
78
|
+
const describe = await run([
|
|
79
|
+
"run",
|
|
80
|
+
"services",
|
|
81
|
+
"describe",
|
|
82
|
+
found.name,
|
|
83
|
+
`--region=${opts.region}`,
|
|
84
|
+
`--project=${opts.projectId}`,
|
|
85
|
+
"--format=json",
|
|
86
|
+
]);
|
|
87
|
+
if (!describe.ok) {
|
|
88
|
+
return { ok: false, error: `describe ${found.name}: ${describe.stderr.trim()}` };
|
|
89
|
+
}
|
|
90
|
+
const env = liveContainerEnv(describe.stdout);
|
|
91
|
+
const missing = Object.keys(SERVICE_SECRET_ENV[slug]).filter(
|
|
92
|
+
(name) => name.endsWith("_PASSWORD") && env.get(name)?.kind !== "secret",
|
|
93
|
+
);
|
|
94
|
+
if (missing.length > 0) {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
error: `${found.name} does not read ${missing.join(", ")} from Secret Manager — its release predates the current database env (Secret Manager runtime secrets; for the gateway, its own database logins, #613). Run \`upgrade\` first: it moves the release and its env over together. Re-running the installer now would give the running release a database env it can't connect with.`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { ok: true };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Read the image digests the admin and gateway services run. Fails, naming
|
|
106
|
+
* what is missing, when a service can't be found unambiguously or doesn't
|
|
107
|
+
* run a digest-pinned image.
|
|
108
|
+
*/
|
|
109
|
+
export async function readDeployedImageDigests(opts: {
|
|
110
|
+
projectId: string;
|
|
111
|
+
region: string;
|
|
112
|
+
run?: GcloudRunner;
|
|
113
|
+
}): Promise<{ ok: true; digests: ImageDigests } | { ok: false; error: string }> {
|
|
114
|
+
const run = opts.run ?? defaultGcloud;
|
|
115
|
+
const digests = { admin: "", gateway: "" };
|
|
116
|
+
for (const slug of ["admin", "gateway"] as const) {
|
|
117
|
+
const found = await findDeployedService(run, opts, slug);
|
|
118
|
+
if (!found.ok) return found;
|
|
119
|
+
const image = await run([
|
|
120
|
+
"run",
|
|
121
|
+
"services",
|
|
122
|
+
"describe",
|
|
123
|
+
found.name,
|
|
124
|
+
`--region=${opts.region}`,
|
|
125
|
+
`--project=${opts.projectId}`,
|
|
126
|
+
"--format=value(spec.template.spec.containers[0].image)",
|
|
127
|
+
]);
|
|
128
|
+
if (!image.ok) return { ok: false, error: `describe ${found.name}: ${image.stderr.trim()}` };
|
|
129
|
+
const digest = digestFromImageRef(image.stdout);
|
|
130
|
+
if (!digest) {
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
error: `${found.name} runs '${image.stdout.trim()}', which is not pinned to a digest`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
digests[slug] = digest;
|
|
137
|
+
}
|
|
138
|
+
return { ok: true, digests };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Where the digests a wizard run deploys come from. */
|
|
142
|
+
export type DigestChoice =
|
|
143
|
+
| {
|
|
144
|
+
readonly ok: true;
|
|
145
|
+
readonly source: "recorded" | "live" | "latest";
|
|
146
|
+
readonly digests: ImageDigests;
|
|
147
|
+
}
|
|
148
|
+
| { readonly ok: false; readonly error: string };
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Pick the release a wizard run deploys. A re-run never changes versions —
|
|
152
|
+
* that is `upgrade`'s job, which migrates the database before shifting
|
|
153
|
+
* traffic:
|
|
154
|
+
*
|
|
155
|
+
* - digests recorded in install.json win;
|
|
156
|
+
* - an install that was provisioned (`deployed`) but predates the record
|
|
157
|
+
* keeps what its services run;
|
|
158
|
+
* - only a new install resolves the floating release tag.
|
|
159
|
+
*/
|
|
160
|
+
export async function chooseImageDigests(opts: {
|
|
161
|
+
readonly recorded: ImageDigests | null;
|
|
162
|
+
readonly deployed: boolean;
|
|
163
|
+
readonly readLive: () => ReturnType<typeof readDeployedImageDigests>;
|
|
164
|
+
readonly resolveLatest: () => Promise<ImageDigests>;
|
|
165
|
+
}): Promise<DigestChoice> {
|
|
166
|
+
if (opts.recorded) return { ok: true, source: "recorded", digests: opts.recorded };
|
|
167
|
+
if (!opts.deployed) return { ok: true, source: "latest", digests: await opts.resolveLatest() };
|
|
168
|
+
const live = await opts.readLive();
|
|
169
|
+
if (!live.ok) {
|
|
170
|
+
return {
|
|
171
|
+
ok: false,
|
|
172
|
+
error: `This install was provisioned before the release it runs was recorded, and the running release can't be read (${live.error}). Run \`upgrade\` once — it records the release it rolls to — then re-run the installer.`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
return { ok: true, source: "live", digests: live.digests };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The region a deployed install's admin service runs in, read across all
|
|
180
|
+
* regions, or null when no admin service exists (nothing deployed yet).
|
|
181
|
+
* Used when install.json has no region recorded, so a re-run keeps the
|
|
182
|
+
* install where it is instead of asking (#607). Fails when the services
|
|
183
|
+
* can't be listed or the admin service exists in more than one region.
|
|
184
|
+
*/
|
|
185
|
+
export async function readDeployedRegion(opts: {
|
|
186
|
+
projectId: string;
|
|
187
|
+
run?: GcloudRunner;
|
|
188
|
+
}): Promise<{ ok: true; region: string | null } | { ok: false; error: string }> {
|
|
189
|
+
const run = opts.run ?? defaultGcloud;
|
|
190
|
+
const prefix = `${gcpNamePrefix(GCP_STACK_ENV)}-admin`;
|
|
191
|
+
const list = await run([
|
|
192
|
+
"run",
|
|
193
|
+
"services",
|
|
194
|
+
"list",
|
|
195
|
+
`--project=${opts.projectId}`,
|
|
196
|
+
`--filter=metadata.name~^${prefix}`,
|
|
197
|
+
'--format=value(metadata.labels."cloud.googleapis.com/location")',
|
|
198
|
+
]);
|
|
199
|
+
if (!list.ok) {
|
|
200
|
+
return {
|
|
201
|
+
ok: false,
|
|
202
|
+
error: `list Cloud Run services to find the install's region: ${list.stderr.trim() || "no output"}`,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
const regions = [
|
|
206
|
+
...new Set(
|
|
207
|
+
list.stdout
|
|
208
|
+
.split("\n")
|
|
209
|
+
.map((s) => s.trim())
|
|
210
|
+
.filter(Boolean),
|
|
211
|
+
),
|
|
212
|
+
];
|
|
213
|
+
if (regions.length > 1) {
|
|
214
|
+
return {
|
|
215
|
+
ok: false,
|
|
216
|
+
error: `${prefix}* Cloud Run services exist in several regions (${regions.join(", ")}); record the install's region in install.json ("region"), then re-run.`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
return { ok: true, region: regions[0] ?? null };
|
|
220
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cloudflare DNS adapter — zero-touch via the Cloudflare API.
|
|
5
|
+
* Uses the public REST API directly (no SDK) so the Caelo install
|
|
6
|
+
* doesn't carry the cloudflare-sdk's transitive dependency tree.
|
|
7
|
+
*
|
|
8
|
+
* Auth: CLOUDFLARE_API_TOKEN env var. Token scope needed:
|
|
9
|
+
* Zone:DNS:Edit on the target zone
|
|
10
|
+
* (Read-only Zone:Zone:Read is enough for `detectCloudflareAuth`.)
|
|
11
|
+
*
|
|
12
|
+
* Records are upserted — repeated runs are idempotent. Each CREATE
|
|
13
|
+
* is followed by a public-DNS poll to verify the change is visible
|
|
14
|
+
* (Cloudflare propagates within ~60s typically).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { promises as dns } from "node:dns";
|
|
18
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
19
|
+
import { log, spinner } from "@clack/prompts";
|
|
20
|
+
import { dim, green, red } from "kleur/colors";
|
|
21
|
+
import type { DnsAdapter, DnsRecord } from "./types.js";
|
|
22
|
+
|
|
23
|
+
const CF_API = "https://api.cloudflare.com/client/v4";
|
|
24
|
+
|
|
25
|
+
export async function detectCloudflareAuth(): Promise<{ token: string | null }> {
|
|
26
|
+
const token = process.env.CLOUDFLARE_API_TOKEN ?? null;
|
|
27
|
+
if (!token) return { token: null };
|
|
28
|
+
// Verify the token resolves (cheap HEAD-style call).
|
|
29
|
+
try {
|
|
30
|
+
const r = await fetch(`${CF_API}/user/tokens/verify`, {
|
|
31
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
32
|
+
});
|
|
33
|
+
if (!r.ok) return { token: null };
|
|
34
|
+
return { token };
|
|
35
|
+
} catch {
|
|
36
|
+
return { token: null };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface CfZone {
|
|
41
|
+
id: string;
|
|
42
|
+
name: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function findZoneId(token: string, domain: string): Promise<string | null> {
|
|
46
|
+
// Try the apex first, then walk up subdomains until we hit a zone.
|
|
47
|
+
// (e.g. caelo-cms.com → caelo-cms.com)
|
|
48
|
+
const parts = domain.split(".");
|
|
49
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
50
|
+
const candidate = parts.slice(i).join(".");
|
|
51
|
+
const r = await fetch(`${CF_API}/zones?name=${encodeURIComponent(candidate)}`, {
|
|
52
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
53
|
+
});
|
|
54
|
+
if (!r.ok) continue;
|
|
55
|
+
const body = (await r.json()) as { result?: CfZone[] };
|
|
56
|
+
if (body.result && body.result.length > 0) return body.result[0]?.id ?? null;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface CfRecord {
|
|
62
|
+
id: string;
|
|
63
|
+
type: string;
|
|
64
|
+
name: string;
|
|
65
|
+
content: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function existingRecord(
|
|
69
|
+
token: string,
|
|
70
|
+
zoneId: string,
|
|
71
|
+
hostname: string,
|
|
72
|
+
type: string,
|
|
73
|
+
): Promise<CfRecord | null> {
|
|
74
|
+
const r = await fetch(
|
|
75
|
+
`${CF_API}/zones/${zoneId}/dns_records?type=${type}&name=${encodeURIComponent(hostname)}`,
|
|
76
|
+
{ headers: { Authorization: `Bearer ${token}` } },
|
|
77
|
+
);
|
|
78
|
+
if (!r.ok) return null;
|
|
79
|
+
const body = (await r.json()) as { result?: CfRecord[] };
|
|
80
|
+
return body.result?.[0] ?? null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function upsertRecord(token: string, zoneId: string, record: DnsRecord): Promise<void> {
|
|
84
|
+
const existing = await existingRecord(token, zoneId, record.hostname, record.type);
|
|
85
|
+
const body = {
|
|
86
|
+
type: record.type,
|
|
87
|
+
name: record.hostname,
|
|
88
|
+
content: record.value,
|
|
89
|
+
ttl: record.ttl ?? 1, // 1 = auto in Cloudflare
|
|
90
|
+
proxied: false, // Caelo expects raw A records hitting GCP LB IP, not Cloudflare's proxy
|
|
91
|
+
};
|
|
92
|
+
if (existing) {
|
|
93
|
+
if (existing.content === record.value) return; // already correct
|
|
94
|
+
const r = await fetch(`${CF_API}/zones/${zoneId}/dns_records/${existing.id}`, {
|
|
95
|
+
method: "PUT",
|
|
96
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
97
|
+
body: JSON.stringify(body),
|
|
98
|
+
});
|
|
99
|
+
if (!r.ok) throw new Error(`Cloudflare PUT ${record.hostname}: ${r.status} ${await r.text()}`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const r = await fetch(`${CF_API}/zones/${zoneId}/dns_records`, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
105
|
+
body: JSON.stringify(body),
|
|
106
|
+
});
|
|
107
|
+
if (!r.ok) throw new Error(`Cloudflare POST ${record.hostname}: ${r.status} ${await r.text()}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function makeCloudflareAdapter(opts: { domain: string; apiToken: string }): DnsAdapter {
|
|
111
|
+
return {
|
|
112
|
+
name: `Cloudflare (zero-touch via API)`,
|
|
113
|
+
async applyRecords(records: DnsRecord[]): Promise<void> {
|
|
114
|
+
const s = spinner();
|
|
115
|
+
s.start(`Locating Cloudflare zone for ${opts.domain}...`);
|
|
116
|
+
const zoneId = await findZoneId(opts.apiToken, opts.domain);
|
|
117
|
+
if (!zoneId) {
|
|
118
|
+
s.stop(red(`No Cloudflare zone found containing ${opts.domain}.`));
|
|
119
|
+
throw new Error(
|
|
120
|
+
`Domain ${opts.domain} isn't in your Cloudflare account. Add it as a zone first, or unset CLOUDFLARE_API_TOKEN to use the manual paste flow.`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
s.stop(green(`Cloudflare zone ${dim(zoneId)} found`));
|
|
124
|
+
|
|
125
|
+
for (const record of records) {
|
|
126
|
+
const s2 = spinner();
|
|
127
|
+
s2.start(`Upserting ${record.type} ${record.hostname} → ${record.value}...`);
|
|
128
|
+
try {
|
|
129
|
+
await upsertRecord(opts.apiToken, zoneId, record);
|
|
130
|
+
} catch (e) {
|
|
131
|
+
s2.stop(red(`Failed: ${e instanceof Error ? e.message : String(e)}`));
|
|
132
|
+
throw e;
|
|
133
|
+
}
|
|
134
|
+
s2.stop(green(`${record.type} ${record.hostname} set`));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Quick public-DNS poll — Cloudflare usually propagates within
|
|
138
|
+
// ~60s. We give 5 min before giving up + telling the operator
|
|
139
|
+
// to verify manually.
|
|
140
|
+
const s3 = spinner();
|
|
141
|
+
s3.start("Verifying public DNS resolution...");
|
|
142
|
+
const start = Date.now();
|
|
143
|
+
const remaining = new Set(records.map((r) => `${r.type}:${r.hostname}`));
|
|
144
|
+
while (remaining.size > 0 && Date.now() - start < 5 * 60 * 1000) {
|
|
145
|
+
for (const key of [...remaining]) {
|
|
146
|
+
const record = records.find((r) => `${r.type}:${r.hostname}` === key);
|
|
147
|
+
if (!record) {
|
|
148
|
+
remaining.delete(key);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (await checkRecord(record)) remaining.delete(key);
|
|
152
|
+
}
|
|
153
|
+
if (remaining.size > 0) await sleep(5000);
|
|
154
|
+
}
|
|
155
|
+
if (remaining.size > 0) {
|
|
156
|
+
s3.stop(red(`Some records still unresolved: ${[...remaining].join(", ")}`));
|
|
157
|
+
log.error(
|
|
158
|
+
`Cloudflare API succeeded but public DNS doesn't reflect the change yet. Wait + verify with \`dig\` then re-run.`,
|
|
159
|
+
);
|
|
160
|
+
throw new Error("DNS verify timeout.");
|
|
161
|
+
}
|
|
162
|
+
s3.stop(green("All records resolve publicly."));
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function checkRecord(record: DnsRecord): Promise<boolean> {
|
|
168
|
+
try {
|
|
169
|
+
if (record.type === "A") {
|
|
170
|
+
const result = await dns.resolve4(record.hostname);
|
|
171
|
+
return result.includes(record.value);
|
|
172
|
+
}
|
|
173
|
+
if (record.type === "CNAME") {
|
|
174
|
+
const result = await dns.resolveCname(record.hostname);
|
|
175
|
+
const expected = record.value.replace(/\.$/, "");
|
|
176
|
+
return result.some((v) => v.replace(/\.$/, "") === expected);
|
|
177
|
+
}
|
|
178
|
+
return true; // AAAA + TXT — assume Cloudflare API success means it's there
|
|
179
|
+
} catch {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "bun:test";
|
|
4
|
+
import { formatDnsRecordGroups, groupDnsRecords, switchesPublicSite } from "./groups.js";
|
|
5
|
+
|
|
6
|
+
const domain = "example.com";
|
|
7
|
+
|
|
8
|
+
describe("groupDnsRecords", () => {
|
|
9
|
+
it("keeps the main domain's traffic records for go-live (gcp load balancer)", () => {
|
|
10
|
+
const records = [
|
|
11
|
+
{ hostname: "example.com", type: "A", value: "203.0.113.7" },
|
|
12
|
+
{ hostname: "admin.example.com", type: "A", value: "203.0.113.7" },
|
|
13
|
+
];
|
|
14
|
+
const g = groupDnsRecords(records, domain);
|
|
15
|
+
expect(g.setNow.map((r) => r.hostname)).toEqual(["admin.example.com"]);
|
|
16
|
+
expect(g.atGoLive.map((r) => r.hostname)).toEqual(["example.com"]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("sets verification TXT records now and apex A records at go-live (gcp-firebase)", () => {
|
|
20
|
+
const records = [
|
|
21
|
+
{ hostname: "admin.example.com", type: "CNAME", value: "ghs.googlehosted.com." },
|
|
22
|
+
{ hostname: "example.com", type: "TXT", value: "hosting-site=caelo" },
|
|
23
|
+
{ hostname: "_acme-challenge.example.com", type: "TXT", value: "abc" },
|
|
24
|
+
{ hostname: "example.com", type: "A", value: "199.36.158.100" },
|
|
25
|
+
{ hostname: "www.example.com", type: "CNAME", value: "example.web.app." },
|
|
26
|
+
];
|
|
27
|
+
const g = groupDnsRecords(records, domain);
|
|
28
|
+
expect(g.setNow.map((r) => `${r.type} ${r.hostname}`)).toEqual([
|
|
29
|
+
"CNAME admin.example.com",
|
|
30
|
+
"TXT example.com",
|
|
31
|
+
"TXT _acme-challenge.example.com",
|
|
32
|
+
]);
|
|
33
|
+
expect(g.atGoLive.map((r) => `${r.type} ${r.hostname}`)).toEqual([
|
|
34
|
+
"A example.com",
|
|
35
|
+
"CNAME www.example.com",
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("compares host names case- and trailing-dot-insensitively", () => {
|
|
40
|
+
expect(
|
|
41
|
+
switchesPublicSite({ hostname: "Example.com.", type: "aaaa", value: "::1" }, domain),
|
|
42
|
+
).toBe(true);
|
|
43
|
+
expect(
|
|
44
|
+
switchesPublicSite({ hostname: "staging.example.com", type: "CNAME", value: "x" }, domain),
|
|
45
|
+
).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("formatDnsRecordGroups", () => {
|
|
50
|
+
it("labels both groups and warns about the go-live records", () => {
|
|
51
|
+
const lines = formatDnsRecordGroups(
|
|
52
|
+
groupDnsRecords(
|
|
53
|
+
[
|
|
54
|
+
{ hostname: "example.com", type: "A", value: "1.2.3.4" },
|
|
55
|
+
{ hostname: "admin.example.com", type: "A", value: "1.2.3.4" },
|
|
56
|
+
],
|
|
57
|
+
domain,
|
|
58
|
+
),
|
|
59
|
+
domain,
|
|
60
|
+
);
|
|
61
|
+
expect(lines[0]).toBe("Set now (new hosts, nothing live uses them):");
|
|
62
|
+
expect(lines).toContain("Only at go-live (these move example.com to Caelo):");
|
|
63
|
+
expect(lines.join("\n")).toContain("takes a website that is live on example.com offline");
|
|
64
|
+
expect(lines.indexOf("Only at go-live (these move example.com to Caelo):")).toBeGreaterThan(
|
|
65
|
+
lines.findIndex((l) => /^\s+A\s+admin\.example\.com\s/.test(l)),
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("omits an empty group", () => {
|
|
70
|
+
const lines = formatDnsRecordGroups(
|
|
71
|
+
groupDnsRecords([{ hostname: "admin.example.com", type: "A", value: "1.2.3.4" }], domain),
|
|
72
|
+
domain,
|
|
73
|
+
);
|
|
74
|
+
expect(lines.some((l) => l.includes("go-live"))).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which install DNS records are safe to set right away, and which switch the
|
|
5
|
+
* public website and so wait for go-live.
|
|
6
|
+
*
|
|
7
|
+
* An operator installing Caelo usually still runs a live site on the main
|
|
8
|
+
* domain. The admin host, the staging host and verification TXT records are
|
|
9
|
+
* new names nobody serves yet — set them now. An A / AAAA / CNAME record on
|
|
10
|
+
* the main domain or `www.` repoints every visitor to Caelo, which serves an
|
|
11
|
+
* empty site until the first publish: setting it during the install takes
|
|
12
|
+
* the old site down. Those wait for go-live.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** A record as the stacks report it (`dnsRecordsRequiredOut`). */
|
|
16
|
+
export interface InstallDnsRecord {
|
|
17
|
+
readonly hostname: string;
|
|
18
|
+
readonly type: string;
|
|
19
|
+
readonly value: string;
|
|
20
|
+
readonly purpose?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface DnsRecordGroups<R extends InstallDnsRecord> {
|
|
24
|
+
/** Records for hosts no live site uses — set during the install. */
|
|
25
|
+
readonly setNow: R[];
|
|
26
|
+
/** Records that move the public site to Caelo — set at go-live only. */
|
|
27
|
+
readonly atGoLive: R[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const TRAFFIC_TYPES = new Set(["A", "AAAA", "CNAME"]);
|
|
31
|
+
|
|
32
|
+
function bare(hostname: string): string {
|
|
33
|
+
return hostname.trim().toLowerCase().replace(/\.$/, "");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Whether `record` repoints the public site on `domain` (apex or www). */
|
|
37
|
+
export function switchesPublicSite(record: InstallDnsRecord, domain: string): boolean {
|
|
38
|
+
const host = bare(record.hostname);
|
|
39
|
+
const apex = bare(domain);
|
|
40
|
+
return TRAFFIC_TYPES.has(record.type.toUpperCase()) && (host === apex || host === `www.${apex}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Split `records` into {@link DnsRecordGroups} for the install's `domain`. */
|
|
44
|
+
export function groupDnsRecords<R extends InstallDnsRecord>(
|
|
45
|
+
records: readonly R[],
|
|
46
|
+
domain: string,
|
|
47
|
+
): DnsRecordGroups<R> {
|
|
48
|
+
const setNow: R[] = [];
|
|
49
|
+
const atGoLive: R[] = [];
|
|
50
|
+
for (const r of records) (switchesPublicSite(r, domain) ? atGoLive : setNow).push(r);
|
|
51
|
+
return { setNow, atGoLive };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One printable line per record: `TYPE host → value`. */
|
|
55
|
+
export function dnsRecordLine(r: InstallDnsRecord, hostWidth: number): string {
|
|
56
|
+
return ` ${r.type.padEnd(6)} ${r.hostname.padEnd(hostWidth)} → ${r.value}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The two labelled groups as text lines, for the installer's final summary.
|
|
61
|
+
* An empty group is left out; the go-live group always carries its warning.
|
|
62
|
+
*/
|
|
63
|
+
export function formatDnsRecordGroups(
|
|
64
|
+
groups: DnsRecordGroups<InstallDnsRecord>,
|
|
65
|
+
domain: string,
|
|
66
|
+
): string[] {
|
|
67
|
+
const width = Math.max(
|
|
68
|
+
domain.length,
|
|
69
|
+
...[...groups.setNow, ...groups.atGoLive].map((r) => r.hostname.length),
|
|
70
|
+
);
|
|
71
|
+
const lines: string[] = [];
|
|
72
|
+
if (groups.setNow.length > 0) {
|
|
73
|
+
lines.push("Set now (new hosts, nothing live uses them):");
|
|
74
|
+
for (const r of groups.setNow) lines.push(dnsRecordLine(r, width));
|
|
75
|
+
}
|
|
76
|
+
if (groups.atGoLive.length > 0) {
|
|
77
|
+
if (lines.length > 0) lines.push("");
|
|
78
|
+
lines.push(`Only at go-live (these move ${domain} to Caelo):`);
|
|
79
|
+
for (const r of groups.atGoLive) lines.push(dnsRecordLine(r, width));
|
|
80
|
+
lines.push(
|
|
81
|
+
` Warning: setting these now takes a website that is live on ${domain} offline.`,
|
|
82
|
+
" Replace the existing records once you have published the new site.",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return lines;
|
|
86
|
+
}
|
package/src/dns/index.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DNS adapters — each provider implements the same `DnsAdapter`
|
|
5
|
+
* interface so the wizard can pick by registrar. Per CLAUDE.md §11.C:
|
|
6
|
+
* "DNS records land automatically when the registrar API is supported.
|
|
7
|
+
* Otherwise the CLI prints the records, polls DNS, and continues only
|
|
8
|
+
* when resolution succeeds."
|
|
9
|
+
*
|
|
10
|
+
* v1 adapters:
|
|
11
|
+
* - Cloudflare — zero-touch via the Cloudflare API
|
|
12
|
+
* - Route53 — zero-touch via the AWS SDK
|
|
13
|
+
* - manual — print records + verify-poll via dig
|
|
14
|
+
*
|
|
15
|
+
* Detection order: Cloudflare → Route53 → manual fallback. Detection
|
|
16
|
+
* is best-effort; the wizard always prompts to confirm.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { detectCloudflareAuth, makeCloudflareAdapter } from "./cloudflare.js";
|
|
20
|
+
import { makeManualAdapter } from "./manual.js";
|
|
21
|
+
import type { DnsAdapter } from "./types.js";
|
|
22
|
+
|
|
23
|
+
export type { DnsAdapter } from "./types.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Pick the right DNS adapter based on environment + interactive
|
|
27
|
+
* detection. The CLI's wizard calls this; if a registrar's API is
|
|
28
|
+
* reachable, returns its adapter. Otherwise falls back to manual.
|
|
29
|
+
*/
|
|
30
|
+
export async function pickDnsAdapter(opts: {
|
|
31
|
+
domain: string;
|
|
32
|
+
forceManual?: boolean;
|
|
33
|
+
/** No prompts: the manual adapter prints the records and polls (the install job). */
|
|
34
|
+
nonInteractive?: boolean;
|
|
35
|
+
}): Promise<DnsAdapter> {
|
|
36
|
+
const manual = { domain: opts.domain, nonInteractive: opts.nonInteractive ?? false };
|
|
37
|
+
if (opts.forceManual) {
|
|
38
|
+
return makeManualAdapter(manual);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Cloudflare — needs CLOUDFLARE_API_TOKEN (read-only zone scope is
|
|
42
|
+
// enough for detection; create needs Zone:DNS:Edit).
|
|
43
|
+
const cf = await detectCloudflareAuth();
|
|
44
|
+
if (cf.token) {
|
|
45
|
+
return makeCloudflareAdapter({
|
|
46
|
+
domain: opts.domain,
|
|
47
|
+
apiToken: cf.token,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Route53 — would need AWS_PROFILE / AWS_ACCESS_KEY_ID. Not yet
|
|
52
|
+
// implemented; falls through to manual. The interface + adapter
|
|
53
|
+
// skeleton land here so the future PR is one file.
|
|
54
|
+
|
|
55
|
+
return makeManualAdapter(manual);
|
|
56
|
+
}
|