@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
package/src/cli.ts
ADDED
|
@@ -0,0 +1,1491 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* cms-provision — Caelo's self-hosted provisioning CLI.
|
|
7
|
+
*
|
|
8
|
+
* Sub-commands:
|
|
9
|
+
* init [--domain D --owner-email E] — first-time setup; generates
|
|
10
|
+
* secrets, resolves + cosign-verifies
|
|
11
|
+
* the release images, writes .caelo/,
|
|
12
|
+
* prints the start steps + the
|
|
13
|
+
* bootstrap token URL. `--dev` runs a
|
|
14
|
+
* Caelo checkout instead (contributors).
|
|
15
|
+
* up — re-runs against existing config.
|
|
16
|
+
* migrate — applies DB migrations with the
|
|
17
|
+
* admin image the install runs.
|
|
18
|
+
* upgrade [--version X] — verified, backed-up, health-checked
|
|
19
|
+
* image roll with rollback.
|
|
20
|
+
* agent install|status|logs|uninstall — the update agent (systemd --user
|
|
21
|
+
* timer) that installs the updates
|
|
22
|
+
* the Owner starts in the admin.
|
|
23
|
+
* regenerate-caddy — re-emits Caddyfile from the
|
|
24
|
+
* domains table; runs `caddy reload`.
|
|
25
|
+
* backup --to <path> — pgBackRest full + MinIO mirror →
|
|
26
|
+
* single .tar.zst.
|
|
27
|
+
* restore --from <path> — wipes target + restores.
|
|
28
|
+
* status — prints container health + cert
|
|
29
|
+
* expiry per domain.
|
|
30
|
+
*
|
|
31
|
+
* Pulumi-driven cloud variants (GCP / AWS / Azure) land in P15 and
|
|
32
|
+
* plug into the same CLI via `--provider <name>`.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
36
|
+
import { resolve } from "node:path";
|
|
37
|
+
import {
|
|
38
|
+
CAELO_VERSION,
|
|
39
|
+
defaultStagingHost,
|
|
40
|
+
normalizeStagingHost,
|
|
41
|
+
stagingHostProblem,
|
|
42
|
+
} from "@caelo-cms/shared";
|
|
43
|
+
import type { AgentCliContext } from "./agent/command.js";
|
|
44
|
+
import type { AgentConfig } from "./agent/config.js";
|
|
45
|
+
import { generateBootstrapToken } from "./bootstrap-token.js";
|
|
46
|
+
import { type CaddyDomainSpec, generateCaddyfile } from "./caddy.js";
|
|
47
|
+
import { redactCredentials } from "./checkpoint-secrets.js";
|
|
48
|
+
import { initDelegatesToWizard, resolveCliRoute } from "./cli-routing.js";
|
|
49
|
+
import {
|
|
50
|
+
BOOTSTRAP_DIR,
|
|
51
|
+
type ComposeImages,
|
|
52
|
+
generateDockerCompose,
|
|
53
|
+
type RolePasswords,
|
|
54
|
+
} from "./compose.js";
|
|
55
|
+
import { selfCommand } from "./self-command.js";
|
|
56
|
+
import {
|
|
57
|
+
bunHostRunner,
|
|
58
|
+
readRelease,
|
|
59
|
+
recordRelease,
|
|
60
|
+
releaseImages,
|
|
61
|
+
resolveVerifiedRelease,
|
|
62
|
+
} from "./self-hosted-release.js";
|
|
63
|
+
import {
|
|
64
|
+
convergeSelfHostedRoles,
|
|
65
|
+
decideUp,
|
|
66
|
+
selfHostedBootstrapScript,
|
|
67
|
+
} from "./self-hosted-roles.js";
|
|
68
|
+
|
|
69
|
+
interface CaeloConfig {
|
|
70
|
+
domain: string;
|
|
71
|
+
/**
|
|
72
|
+
* #650 — the staging site's host (`--staging-domain`, default
|
|
73
|
+
* `staging.<domain>`). Optional so older config.json files load;
|
|
74
|
+
* emitConfig() back-fills the default.
|
|
75
|
+
*/
|
|
76
|
+
stagingDomain?: string;
|
|
77
|
+
ownerEmail: string;
|
|
78
|
+
postgresPassword: string;
|
|
79
|
+
minioRootUser: string;
|
|
80
|
+
minioRootPassword: string;
|
|
81
|
+
/**
|
|
82
|
+
* P18 — project KEK (32 hex bytes). Auto-generated on `cms-provision init`,
|
|
83
|
+
* persisted in `.caelo/config.json`, mounted into the admin + gateway
|
|
84
|
+
* containers as CAELO_SECRET_KEK so the AES-GCM secret-box can decrypt
|
|
85
|
+
* stored AI provider keys. Optional in the type so config.json files
|
|
86
|
+
* generated before P18 still load — emitConfig() back-fills if missing.
|
|
87
|
+
*/
|
|
88
|
+
caeloSecretKek?: string;
|
|
89
|
+
/**
|
|
90
|
+
* #613 — one password per application role. Optional in the type so
|
|
91
|
+
* config.json files from before still load; `up` back-fills them and
|
|
92
|
+
* applies them to the database before any Compose URL uses them.
|
|
93
|
+
*/
|
|
94
|
+
rolePasswords?: RolePasswords;
|
|
95
|
+
/**
|
|
96
|
+
* Whether the database's roles are known to carry `rolePasswords`: true
|
|
97
|
+
* from `init` (bootstrap.sh applies them on the fresh volume's first
|
|
98
|
+
* start), false for passwords `up` just back-filled, until applied.
|
|
99
|
+
*/
|
|
100
|
+
rolesConverged?: boolean;
|
|
101
|
+
anthropicApiKey?: string;
|
|
102
|
+
resendApiKey?: string;
|
|
103
|
+
/**
|
|
104
|
+
* #696 — `dev` only for a contributor install made with `init --dev` (the
|
|
105
|
+
* admin + gateway run the Caelo checkout, bind-mounted). Absent/`release`:
|
|
106
|
+
* the prebuilt images recorded in `.caelo/release.json`.
|
|
107
|
+
*/
|
|
108
|
+
imageMode?: "release" | "dev";
|
|
109
|
+
/**
|
|
110
|
+
* #696 Phase 4c — the host update agent (`cms-provision agent install`):
|
|
111
|
+
* its database password and the loopback port Postgres is published on.
|
|
112
|
+
*/
|
|
113
|
+
agent?: AgentConfig;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const REPO_ROOT = resolve(import.meta.dir, "../../..");
|
|
117
|
+
/**
|
|
118
|
+
* Whether this CLI runs from a Caelo source checkout (contributor) rather
|
|
119
|
+
* than from an installed package (`bunx @caelo-cms/provisioning` puts it in
|
|
120
|
+
* a package cache, where `../../..` is a `node_modules` dir).
|
|
121
|
+
*/
|
|
122
|
+
const IN_REPO_CHECKOUT =
|
|
123
|
+
existsSync(resolve(REPO_ROOT, "packages/provisioning/package.json")) &&
|
|
124
|
+
existsSync(resolve(REPO_ROOT, "bun.lock"));
|
|
125
|
+
/**
|
|
126
|
+
* The install's `.caelo/` dir: `$CAELO_SELF_HOSTED_DIR` when set; in a
|
|
127
|
+
* checkout the repo's own `.caelo/` (dev mode bind-mounts `../..` from it);
|
|
128
|
+
* otherwise `./.caelo` in the working directory — never inside a package
|
|
129
|
+
* cache, which `bunx` may wipe.
|
|
130
|
+
*/
|
|
131
|
+
const CAELO_DIR = process.env.CAELO_SELF_HOSTED_DIR
|
|
132
|
+
? resolve(process.env.CAELO_SELF_HOSTED_DIR)
|
|
133
|
+
: resolve(IN_REPO_CHECKOUT ? REPO_ROOT : process.cwd(), ".caelo");
|
|
134
|
+
const CONFIG_PATH = resolve(CAELO_DIR, "config.json");
|
|
135
|
+
const COMPOSE_PATH = resolve(CAELO_DIR, "docker-compose.yml");
|
|
136
|
+
const CADDYFILE_PATH = resolve(CAELO_DIR, "Caddyfile");
|
|
137
|
+
// What the Postgres container runs on its first start (compose.ts mounts it).
|
|
138
|
+
const BOOTSTRAP_SCRIPT_PATH = resolve(CAELO_DIR, "bootstrap.sh");
|
|
139
|
+
// The admin container mounts only `.caelo/bootstrap/` (compose.ts), never the
|
|
140
|
+
// secrets next to it, and consumes the token from there on first boot.
|
|
141
|
+
const PENDING_TOKEN_PATH = resolve(CAELO_DIR, BOOTSTRAP_DIR, "pending-token.json");
|
|
142
|
+
// P15 — `cms-provision init --provider <name>` writes here so subsequent
|
|
143
|
+
// commands route to the right Pulumi stack. Defaults to "self-hosted"
|
|
144
|
+
// when missing (preserves the P14 path).
|
|
145
|
+
const PROVIDER_PATH = resolve(CAELO_DIR, "provider.json");
|
|
146
|
+
|
|
147
|
+
type Provider = "self-hosted" | "gcp" | "gcp-firebase" | "aws" | "azure";
|
|
148
|
+
|
|
149
|
+
function loadProvider(): Provider {
|
|
150
|
+
if (!existsSync(PROVIDER_PATH)) return "self-hosted";
|
|
151
|
+
try {
|
|
152
|
+
const raw = JSON.parse(readFileSync(PROVIDER_PATH, "utf8")) as { provider?: string };
|
|
153
|
+
return (raw.provider as Provider) ?? "self-hosted";
|
|
154
|
+
} catch {
|
|
155
|
+
return "self-hosted";
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function saveProvider(provider: Provider): void {
|
|
160
|
+
if (!existsSync(CAELO_DIR)) mkdirSync(CAELO_DIR, { recursive: true });
|
|
161
|
+
writeFileSync(PROVIDER_PATH, JSON.stringify({ provider }, null, 2));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* P15 review pass — guard for commands that only make sense on
|
|
166
|
+
* self-hosted (compose/caddy generation, backup/restore via docker
|
|
167
|
+
* exec). Cloud installs (gcp/aws/azure) get a Pulumi-flow message
|
|
168
|
+
* pointing at the right stack dir + the pulumi-output-sync follow-up.
|
|
169
|
+
* Exits the process when the active provider is non-self-hosted.
|
|
170
|
+
*/
|
|
171
|
+
function requireSelfHosted(commandName: string): void {
|
|
172
|
+
const provider = loadProvider();
|
|
173
|
+
if (provider === "self-hosted") return;
|
|
174
|
+
console.error(
|
|
175
|
+
`cms-provision ${commandName}: only available for --provider=self-hosted (active provider: ${provider}).\n\n` +
|
|
176
|
+
`For cloud installs, use Pulumi directly:\n` +
|
|
177
|
+
` cd packages/provisioning/stacks/${provider}\n` +
|
|
178
|
+
` pulumi up\n` +
|
|
179
|
+
` bunx cms-provision pulumi-output-sync\n`,
|
|
180
|
+
);
|
|
181
|
+
process.exit(2);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function randomSecret(bytes = 32): string {
|
|
185
|
+
const buf = new Uint8Array(bytes);
|
|
186
|
+
crypto.getRandomValues(buf);
|
|
187
|
+
return [...buf].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function loadConfig(): CaeloConfig | null {
|
|
191
|
+
if (!existsSync(CONFIG_PATH)) return null;
|
|
192
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as CaeloConfig;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function saveConfig(c: CaeloConfig): void {
|
|
196
|
+
if (!existsSync(CAELO_DIR)) mkdirSync(CAELO_DIR, { recursive: true });
|
|
197
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(c, null, 2));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function arg(name: string): string | undefined {
|
|
201
|
+
const idx = process.argv.indexOf(`--${name}`);
|
|
202
|
+
if (idx === -1) return undefined;
|
|
203
|
+
return process.argv[idx + 1];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Fresh passwords for the three application roles. */
|
|
207
|
+
function newRolePasswords(): RolePasswords {
|
|
208
|
+
return { admin: randomSecret(32), public: randomSecret(32), gateway: randomSecret(32) };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function emitConfig(cfg: CaeloConfig, extraDomains: CaddyDomainSpec[] = []): void {
|
|
212
|
+
// Back-fill caeloSecretKek for installs created before P18. Persists
|
|
213
|
+
// immediately so the next emitConfig run sees the same value (existing
|
|
214
|
+
// encrypted ai_providers rows would otherwise become un-decryptable).
|
|
215
|
+
if (!cfg.caeloSecretKek) {
|
|
216
|
+
cfg.caeloSecretKek = randomSecret(32);
|
|
217
|
+
saveConfig(cfg);
|
|
218
|
+
}
|
|
219
|
+
if (!cfg.stagingDomain) {
|
|
220
|
+
cfg.stagingDomain = defaultStagingHost(cfg.domain);
|
|
221
|
+
saveConfig(cfg);
|
|
222
|
+
}
|
|
223
|
+
emitComposeFiles(cfg);
|
|
224
|
+
// Seed the install with the operator's primary domain plus a staging
|
|
225
|
+
// sibling. Extra domains added via /settings/domains are appended by
|
|
226
|
+
// regenerate-caddy when it queries the live domains table.
|
|
227
|
+
const baseDomains: CaddyDomainSpec[] = [
|
|
228
|
+
{ hostname: cfg.domain, kind: "admin", env: "production" },
|
|
229
|
+
{ hostname: cfg.domain, kind: "public", env: "production" },
|
|
230
|
+
{ hostname: cfg.stagingDomain, kind: "public", env: "staging" },
|
|
231
|
+
];
|
|
232
|
+
const caddy = generateCaddyfile({
|
|
233
|
+
ownerEmail: cfg.ownerEmail,
|
|
234
|
+
publicSiteRoot: "/srv/caelo/output/production/current",
|
|
235
|
+
stagingSiteRoot: "/srv/caelo/output/staging/current",
|
|
236
|
+
adminPort: 5173,
|
|
237
|
+
gatewayPort: 8090,
|
|
238
|
+
domains: dedupeDomains([...baseDomains, ...extraDomains]),
|
|
239
|
+
});
|
|
240
|
+
writeFileSync(CADDYFILE_PATH, caddy);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The Compose file and the bootstrap script Postgres runs on its first
|
|
245
|
+
* start — what the agent commands re-write (never the Caddyfile, whose
|
|
246
|
+
* operator-added domains only `up` / `regenerate-caddy` read back).
|
|
247
|
+
*/
|
|
248
|
+
function emitComposeFiles(cfg: CaeloConfig): void {
|
|
249
|
+
writeFileSync(COMPOSE_PATH, renderCompose(cfg, installImages(cfg)), { mode: 0o600 });
|
|
250
|
+
// No secrets (it reads the passwords from the container's environment),
|
|
251
|
+
// and the postgres user inside the container must be able to read it.
|
|
252
|
+
writeFileSync(
|
|
253
|
+
BOOTSTRAP_SCRIPT_PATH,
|
|
254
|
+
selfHostedBootstrapScript({ agent: activeAgent(cfg) !== undefined }),
|
|
255
|
+
{ mode: 0o755 },
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The Compose file for `cfg` running `images` (secrets come from config.json). */
|
|
260
|
+
function renderCompose(cfg: CaeloConfig, images: ComposeImages): string {
|
|
261
|
+
if (!cfg.rolePasswords) {
|
|
262
|
+
// `init` sets them and `up` back-fills + applies them before calling here.
|
|
263
|
+
throw new Error("config has no rolePasswords — run `cms-provision up`");
|
|
264
|
+
}
|
|
265
|
+
if (!cfg.caeloSecretKek) throw new Error("config has no caeloSecretKek — run `cms-provision up`");
|
|
266
|
+
return generateDockerCompose({
|
|
267
|
+
domain: cfg.domain,
|
|
268
|
+
postgresPassword: cfg.postgresPassword,
|
|
269
|
+
rolePasswords: cfg.rolePasswords,
|
|
270
|
+
minioRootUser: cfg.minioRootUser,
|
|
271
|
+
minioRootPassword: cfg.minioRootPassword,
|
|
272
|
+
caeloSecretKek: cfg.caeloSecretKek,
|
|
273
|
+
anthropicApiKey: cfg.anthropicApiKey,
|
|
274
|
+
resendApiKey: cfg.resendApiKey,
|
|
275
|
+
diskSize: "20Gi",
|
|
276
|
+
images,
|
|
277
|
+
...(activeAgent(cfg) ? { agent: activeAgent(cfg) } : {}),
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The agent's settings while it is set up (not after `agent uninstall`). */
|
|
282
|
+
function activeAgent(cfg: CaeloConfig): AgentConfig | undefined {
|
|
283
|
+
return cfg.agent && !cfg.agent.disabled ? cfg.agent : undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** What the `agent` commands need from this CLI's config handling. */
|
|
287
|
+
function agentContext(cfg: CaeloConfig): AgentCliContext {
|
|
288
|
+
return {
|
|
289
|
+
caeloDir: CAELO_DIR,
|
|
290
|
+
domain: cfg.domain,
|
|
291
|
+
imageMode: cfg.imageMode,
|
|
292
|
+
agent: () => cfg.agent,
|
|
293
|
+
saveAgent: (agent) => {
|
|
294
|
+
if (agent) cfg.agent = agent;
|
|
295
|
+
else delete cfg.agent;
|
|
296
|
+
saveConfig(cfg);
|
|
297
|
+
},
|
|
298
|
+
emitConfig: () => emitComposeFiles(cfg),
|
|
299
|
+
convergeRoles: () => {
|
|
300
|
+
if (!cfg.rolePasswords) {
|
|
301
|
+
return Promise.resolve({
|
|
302
|
+
ok: false as const,
|
|
303
|
+
reachable: true,
|
|
304
|
+
error: "config.json has no role passwords — run `cms-provision up` first",
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
return convergeSelfHostedRoles(cfg.rolePasswords, {
|
|
308
|
+
probe: postgresIsUp,
|
|
309
|
+
psql: superuserPsql,
|
|
310
|
+
...(activeAgent(cfg) ? { agentPassword: activeAgent(cfg)?.dbPassword } : {}),
|
|
311
|
+
});
|
|
312
|
+
},
|
|
313
|
+
renderCompose: (images) => renderCompose(cfg, images),
|
|
314
|
+
randomSecret: () => randomSecret(32),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** `agent <install|status|logs|uninstall|run|apply>` (agent/command.ts). */
|
|
319
|
+
async function agentCli(): Promise<void> {
|
|
320
|
+
requireSelfHosted("agent");
|
|
321
|
+
const cfg = loadConfig();
|
|
322
|
+
if (!cfg) {
|
|
323
|
+
console.error("no config — run `cms-provision init` first");
|
|
324
|
+
process.exit(2);
|
|
325
|
+
}
|
|
326
|
+
const { agentCommand } = await import("./agent/command.js");
|
|
327
|
+
process.exitCode = await agentCommand(process.argv, agentContext(cfg));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The admin + gateway images the install runs: the checkout in dev mode,
|
|
332
|
+
* else the digests recorded in release.json. An install with neither (made
|
|
333
|
+
* before #696, still running the repo bind mount) is moved to the prebuilt
|
|
334
|
+
* images by `upgrade` — no silent fallback to the bind mount.
|
|
335
|
+
*/
|
|
336
|
+
function installImages(cfg: CaeloConfig): ComposeImages {
|
|
337
|
+
if (cfg.imageMode === "dev") return { mode: "dev" };
|
|
338
|
+
const release = readRelease(CAELO_DIR);
|
|
339
|
+
if (!release) {
|
|
340
|
+
throw new Error(
|
|
341
|
+
`${CAELO_DIR}/release.json records no release images. This install predates the prebuilt\n` +
|
|
342
|
+
`images (it ran the repo bind mount): run \`${selfCommand("upgrade")}\` to move it to the signed\n` +
|
|
343
|
+
'release images. (A contributor checkout that wants the bind mount sets "imageMode": "dev" in config.json.)',
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
return releaseImages(release);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function dedupeDomains(list: CaddyDomainSpec[]): CaddyDomainSpec[] {
|
|
350
|
+
// Two domain entries with the same (hostname, kind) collapse to the
|
|
351
|
+
// first occurrence — the seed wins over operator additions on a
|
|
352
|
+
// collision (which keeps admin routing intact even if someone adds
|
|
353
|
+
// their primary domain via the UI by mistake).
|
|
354
|
+
const seen = new Set<string>();
|
|
355
|
+
const out: CaddyDomainSpec[] = [];
|
|
356
|
+
for (const d of list) {
|
|
357
|
+
const key = `${d.hostname}::${d.kind}`;
|
|
358
|
+
if (seen.has(key)) continue;
|
|
359
|
+
seen.add(key);
|
|
360
|
+
out.push(d);
|
|
361
|
+
}
|
|
362
|
+
return out;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function init(): Promise<void> {
|
|
366
|
+
const domain = arg("domain");
|
|
367
|
+
const ownerEmail = arg("owner-email");
|
|
368
|
+
const providerArg = (arg("provider") ?? "self-hosted") as Provider;
|
|
369
|
+
if (!["self-hosted", "gcp", "gcp-firebase", "aws", "azure"].includes(providerArg)) {
|
|
370
|
+
console.error(
|
|
371
|
+
`Unknown --provider ${providerArg}. Choose self-hosted | gcp | gcp-firebase | aws | azure.`,
|
|
372
|
+
);
|
|
373
|
+
process.exit(2);
|
|
374
|
+
}
|
|
375
|
+
// §11.C — cloud installs are provisioned end-to-end by the wizard
|
|
376
|
+
// (gcloud bootstrap, cost table, pulumi up, DNS, owner setup URL).
|
|
377
|
+
// `init` used to print manual Pulumi steps here instead, which pointed
|
|
378
|
+
// at a stack dir that only exists inside the Caelo repo.
|
|
379
|
+
if (initDelegatesToWizard(providerArg)) {
|
|
380
|
+
await wizardCommand();
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (!domain || !ownerEmail) {
|
|
384
|
+
console.error(
|
|
385
|
+
"Usage: cms-provision init [--provider self-hosted] --domain example.com [--staging-domain staging.example.com] --owner-email me@example.com [--version x.y.z] [--skip-verify] [--dev]\n" +
|
|
386
|
+
"Cloud providers (gcp | gcp-firebase | aws | azure) run the wizard: cms-provision --provider <name>",
|
|
387
|
+
);
|
|
388
|
+
process.exit(2);
|
|
389
|
+
}
|
|
390
|
+
if (existsSync(CONFIG_PATH)) {
|
|
391
|
+
console.error(`config already exists at ${CONFIG_PATH}; run \`cms-provision up\` to re-deploy`);
|
|
392
|
+
process.exit(2);
|
|
393
|
+
}
|
|
394
|
+
const stagingArg = arg("staging-domain");
|
|
395
|
+
const stagingProblem = stagingArg
|
|
396
|
+
? stagingHostProblem(stagingArg, {
|
|
397
|
+
liveHosts: [domain, `www.${domain}`],
|
|
398
|
+
adminHosts: [`admin.${domain}`],
|
|
399
|
+
})
|
|
400
|
+
: null;
|
|
401
|
+
if (stagingProblem) {
|
|
402
|
+
console.error(`--staging-domain: ${stagingProblem}`);
|
|
403
|
+
process.exit(2);
|
|
404
|
+
}
|
|
405
|
+
// #696 — the prebuilt release images, resolved to digests and
|
|
406
|
+
// cosign-verified BEFORE anything is written, so a failed check leaves no
|
|
407
|
+
// half-made install. `--dev` (contributors only) runs this checkout instead.
|
|
408
|
+
const dev = process.argv.includes("--dev");
|
|
409
|
+
if (dev && !IN_REPO_CHECKOUT) {
|
|
410
|
+
console.error(
|
|
411
|
+
"--dev runs the admin + gateway from a Caelo source checkout and only works from one\n" +
|
|
412
|
+
"(bun packages/provisioning/src/cli.ts init --dev …). End-user installs omit it.",
|
|
413
|
+
);
|
|
414
|
+
process.exit(2);
|
|
415
|
+
}
|
|
416
|
+
const version = (arg("version") ?? CAELO_VERSION).replace(/^v/, "");
|
|
417
|
+
const skipVerify = process.argv.includes("--skip-verify");
|
|
418
|
+
let images: ComposeImages = { mode: "dev" };
|
|
419
|
+
if (!dev) {
|
|
420
|
+
console.log(`Resolving + verifying the Caelo ${version} release images …`);
|
|
421
|
+
const resolved = await resolveVerifiedRelease(version, {
|
|
422
|
+
runner: bunHostRunner,
|
|
423
|
+
skipVerify,
|
|
424
|
+
onProgress: (m) => console.log(` ${m}`),
|
|
425
|
+
});
|
|
426
|
+
if (!resolved.ok) {
|
|
427
|
+
console.error(resolved.error);
|
|
428
|
+
process.exit(1);
|
|
429
|
+
}
|
|
430
|
+
if (skipVerify) console.warn("--skip-verify set — image signatures NOT verified.");
|
|
431
|
+
images = resolved.images;
|
|
432
|
+
}
|
|
433
|
+
saveProvider(providerArg);
|
|
434
|
+
const cfg: CaeloConfig = {
|
|
435
|
+
domain,
|
|
436
|
+
stagingDomain: stagingArg ? normalizeStagingHost(stagingArg) : defaultStagingHost(domain),
|
|
437
|
+
ownerEmail,
|
|
438
|
+
postgresPassword: randomSecret(32),
|
|
439
|
+
rolePasswords: newRolePasswords(),
|
|
440
|
+
// A fresh volume: bootstrap.sh creates the roles with these on first start.
|
|
441
|
+
rolesConverged: true,
|
|
442
|
+
minioRootUser: "caelo",
|
|
443
|
+
minioRootPassword: randomSecret(32),
|
|
444
|
+
caeloSecretKek: randomSecret(32),
|
|
445
|
+
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
|
|
446
|
+
resendApiKey: process.env.RESEND_API_KEY,
|
|
447
|
+
imageMode: dev ? "dev" : "release",
|
|
448
|
+
};
|
|
449
|
+
saveConfig(cfg);
|
|
450
|
+
if (images.mode === "release") {
|
|
451
|
+
recordRelease(
|
|
452
|
+
CAELO_DIR,
|
|
453
|
+
{ version, images },
|
|
454
|
+
{ from: null, to: version, outcome: "installed" },
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
emitConfig(cfg);
|
|
458
|
+
|
|
459
|
+
// Mint the bootstrap token and stage it for hooks.server.ts to insert
|
|
460
|
+
// on first request. The CLI doesn't talk to Postgres directly because
|
|
461
|
+
// the DB hasn't started yet; the admin app picks the token up on its
|
|
462
|
+
// first boot, calls owner_bootstrap_tokens.insert, then deletes the
|
|
463
|
+
// staging file.
|
|
464
|
+
const tok = generateBootstrapToken();
|
|
465
|
+
mkdirSync(resolve(CAELO_DIR, BOOTSTRAP_DIR), { recursive: true, mode: 0o700 });
|
|
466
|
+
writeFileSync(PENDING_TOKEN_PATH, JSON.stringify(tok, null, 2), { mode: 0o600 });
|
|
467
|
+
|
|
468
|
+
// P15.1 — mint the shared HMAC secret for /api/internal/* endpoints.
|
|
469
|
+
// Same secret is read by:
|
|
470
|
+
// - admin app (process.env.CAELO_INTERNAL_SECRET),
|
|
471
|
+
// - cms-provision pulumi-output-sync (when running in CI / by hand),
|
|
472
|
+
// - any future internal-only orchestration.
|
|
473
|
+
// Long enough to defeat brute-force. GCP installs keep their own copy in
|
|
474
|
+
// Secret Manager (runtime-secrets.ts), rotated by `rotate-secret
|
|
475
|
+
// internal-secret`.
|
|
476
|
+
const internalSecret = randomSecret(48);
|
|
477
|
+
const internalSecretPath = resolve(CAELO_DIR, "internal-secret.json");
|
|
478
|
+
writeFileSync(internalSecretPath, JSON.stringify({ secret: internalSecret }, null, 2));
|
|
479
|
+
// #696 Phase 4c — the update agent (Linux + systemd): the fresh volume's
|
|
480
|
+
// bootstrap.sh creates its database login on Postgres's first start.
|
|
481
|
+
const { reportAgentSetup, setUpAgent } = await import("./agent/command.js");
|
|
482
|
+
const agentSetup =
|
|
483
|
+
images.mode === "release"
|
|
484
|
+
? await setUpAgent(agentContext(cfg), { database: "fresh", explicit: false })
|
|
485
|
+
: null;
|
|
486
|
+
console.log(`Wrote ${COMPOSE_PATH}`);
|
|
487
|
+
console.log(`Wrote ${CADDYFILE_PATH}`);
|
|
488
|
+
console.log(`Wrote ${PENDING_TOKEN_PATH} (admin will insert on first boot)`);
|
|
489
|
+
console.log("");
|
|
490
|
+
console.log(
|
|
491
|
+
images.mode === "release"
|
|
492
|
+
? `Images: Caelo ${version}, pinned by digest (${images.admin.split("@")[1]?.slice(0, 19)}…)`
|
|
493
|
+
: "Images: DEV MODE — admin + gateway run this checkout (contributors only).",
|
|
494
|
+
);
|
|
495
|
+
console.log("");
|
|
496
|
+
console.log("Next steps:");
|
|
497
|
+
console.log(` docker compose -f ${COMPOSE_PATH} up -d postgres`);
|
|
498
|
+
console.log(` ${selfCommand("migrate")} # applies the database migrations`);
|
|
499
|
+
console.log(` docker compose -f ${COMPOSE_PATH} up -d`);
|
|
500
|
+
console.log(" # Wait ~30s for Caddy + ACME");
|
|
501
|
+
console.log("");
|
|
502
|
+
console.log("Then visit:");
|
|
503
|
+
console.log(` https://${domain}/setup?token=${tok.token}`);
|
|
504
|
+
console.log(` (token expires ${tok.expiresAt})`);
|
|
505
|
+
if (agentSetup) {
|
|
506
|
+
console.log("");
|
|
507
|
+
reportAgentSetup(agentSetup);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async function up(): Promise<void> {
|
|
512
|
+
requireSelfHosted("up");
|
|
513
|
+
const cfg = loadConfig();
|
|
514
|
+
if (!cfg) {
|
|
515
|
+
console.error("no config — run `cms-provision init` first");
|
|
516
|
+
process.exit(2);
|
|
517
|
+
}
|
|
518
|
+
// #613 — the database roles first, then the Compose URLs that use them.
|
|
519
|
+
if (!cfg.rolePasswords) {
|
|
520
|
+
cfg.rolePasswords = newRolePasswords();
|
|
521
|
+
cfg.rolesConverged = false;
|
|
522
|
+
saveConfig(cfg);
|
|
523
|
+
}
|
|
524
|
+
const decision = decideUp(
|
|
525
|
+
cfg.rolesConverged ?? false,
|
|
526
|
+
await convergeSelfHostedRoles(cfg.rolePasswords, {
|
|
527
|
+
probe: postgresIsUp,
|
|
528
|
+
psql: superuserPsql,
|
|
529
|
+
...(activeAgent(cfg) ? { agentPassword: activeAgent(cfg)?.dbPassword } : {}),
|
|
530
|
+
}),
|
|
531
|
+
);
|
|
532
|
+
if (decision.kind === "abort") {
|
|
533
|
+
console.error(decision.error);
|
|
534
|
+
process.exit(1);
|
|
535
|
+
}
|
|
536
|
+
if (decision.warning) console.warn(decision.warning);
|
|
537
|
+
if (decision.markConverged) {
|
|
538
|
+
cfg.rolesConverged = true;
|
|
539
|
+
saveConfig(cfg);
|
|
540
|
+
}
|
|
541
|
+
emitConfig(cfg, await tryFetchExtraDomains(cfg));
|
|
542
|
+
console.log(`Re-emitted ${COMPOSE_PATH} + ${CADDYFILE_PATH}`);
|
|
543
|
+
console.log("Run `docker compose -f .caelo/docker-compose.yml up -d` to apply.");
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Whether the install's Postgres container answers (with the CURRENT Compose file). */
|
|
547
|
+
async function postgresIsUp(): Promise<boolean> {
|
|
548
|
+
if (!existsSync(COMPOSE_PATH)) return false;
|
|
549
|
+
const proc = Bun.spawn(
|
|
550
|
+
[
|
|
551
|
+
"docker",
|
|
552
|
+
"compose",
|
|
553
|
+
"-f",
|
|
554
|
+
COMPOSE_PATH,
|
|
555
|
+
"exec",
|
|
556
|
+
"-T",
|
|
557
|
+
"postgres",
|
|
558
|
+
"pg_isready",
|
|
559
|
+
"-U",
|
|
560
|
+
"caelo",
|
|
561
|
+
],
|
|
562
|
+
{ stdout: "ignore", stderr: "ignore" },
|
|
563
|
+
);
|
|
564
|
+
return (await proc.exited) === 0;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Run a psql script as the superuser inside the Postgres container; the script goes on stdin. */
|
|
568
|
+
async function superuserPsql(script: string): Promise<{ exitCode: number; stderr: string }> {
|
|
569
|
+
const proc = Bun.spawn(
|
|
570
|
+
[
|
|
571
|
+
"docker",
|
|
572
|
+
"compose",
|
|
573
|
+
"-f",
|
|
574
|
+
COMPOSE_PATH,
|
|
575
|
+
"exec",
|
|
576
|
+
"-T",
|
|
577
|
+
"postgres",
|
|
578
|
+
"psql",
|
|
579
|
+
"-U",
|
|
580
|
+
"caelo",
|
|
581
|
+
"-d",
|
|
582
|
+
"postgres",
|
|
583
|
+
"-q",
|
|
584
|
+
"-f",
|
|
585
|
+
"-",
|
|
586
|
+
],
|
|
587
|
+
{ stdin: new TextEncoder().encode(script), stdout: "ignore", stderr: "pipe" },
|
|
588
|
+
);
|
|
589
|
+
const stderr = await new Response(proc.stderr).text();
|
|
590
|
+
return { exitCode: await proc.exited, stderr };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
interface DomainRow {
|
|
594
|
+
hostname: string;
|
|
595
|
+
kind: "admin" | "public";
|
|
596
|
+
env: "production" | "staging";
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Pull operator-added domains directly from Postgres so the regenerated
|
|
601
|
+
* Caddyfile picks them up. Uses `docker compose exec` so we don't need
|
|
602
|
+
* the operator's host to have psql installed. Fails open: a DB-down
|
|
603
|
+
* install still emits the seed Caddyfile so the admin stays reachable.
|
|
604
|
+
*/
|
|
605
|
+
async function tryFetchExtraDomains(cfg: CaeloConfig): Promise<CaddyDomainSpec[]> {
|
|
606
|
+
try {
|
|
607
|
+
const composeFile = COMPOSE_PATH;
|
|
608
|
+
const sql =
|
|
609
|
+
"SELECT hostname, kind, env FROM domains WHERE removed_at IS NULL ORDER BY hostname";
|
|
610
|
+
const proc = Bun.spawn(
|
|
611
|
+
[
|
|
612
|
+
"docker",
|
|
613
|
+
"compose",
|
|
614
|
+
"-f",
|
|
615
|
+
composeFile,
|
|
616
|
+
"exec",
|
|
617
|
+
"-T",
|
|
618
|
+
"postgres",
|
|
619
|
+
"psql",
|
|
620
|
+
"-U",
|
|
621
|
+
"caelo",
|
|
622
|
+
"-d",
|
|
623
|
+
"caelo",
|
|
624
|
+
"-At",
|
|
625
|
+
"-F",
|
|
626
|
+
"|",
|
|
627
|
+
"-c",
|
|
628
|
+
sql,
|
|
629
|
+
],
|
|
630
|
+
{ stdout: "pipe", stderr: "pipe", env: { ...process.env, PGPASSWORD: cfg.postgresPassword } },
|
|
631
|
+
);
|
|
632
|
+
const stdout = await new Response(proc.stdout).text();
|
|
633
|
+
await proc.exited;
|
|
634
|
+
if (proc.exitCode !== 0) {
|
|
635
|
+
console.warn("regenerate-caddy: could not query domains table (DB down?); using seed only");
|
|
636
|
+
return [];
|
|
637
|
+
}
|
|
638
|
+
const rows: DomainRow[] = [];
|
|
639
|
+
for (const line of stdout.split("\n")) {
|
|
640
|
+
if (!line.trim()) continue;
|
|
641
|
+
const [hostname, kind, env] = line.split("|");
|
|
642
|
+
if (!hostname || !kind || !env) continue;
|
|
643
|
+
rows.push({
|
|
644
|
+
hostname,
|
|
645
|
+
kind: kind as DomainRow["kind"],
|
|
646
|
+
env: env as DomainRow["env"],
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
return rows.map<CaddyDomainSpec>((r) => ({
|
|
650
|
+
hostname: r.hostname,
|
|
651
|
+
kind: r.kind,
|
|
652
|
+
env: r.env,
|
|
653
|
+
}));
|
|
654
|
+
} catch (e) {
|
|
655
|
+
console.warn("regenerate-caddy: domains lookup threw (DB unreachable?); using seed only", e);
|
|
656
|
+
return [];
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async function regenerateCaddy(): Promise<void> {
|
|
661
|
+
requireSelfHosted("regenerate-caddy");
|
|
662
|
+
const cfg = loadConfig();
|
|
663
|
+
if (!cfg) {
|
|
664
|
+
console.error("no config — run `cms-provision init` first");
|
|
665
|
+
process.exit(2);
|
|
666
|
+
}
|
|
667
|
+
const extra = await tryFetchExtraDomains(cfg);
|
|
668
|
+
emitConfig(cfg, extra);
|
|
669
|
+
console.log(`Re-emitted ${CADDYFILE_PATH} (${extra.length} domain(s) from DB).`);
|
|
670
|
+
// Reload caddy in-place — no downtime, picks up cert provisioning for
|
|
671
|
+
// any new domains automatically via ACME.
|
|
672
|
+
const proc = Bun.spawn(
|
|
673
|
+
[
|
|
674
|
+
"docker",
|
|
675
|
+
"compose",
|
|
676
|
+
"-f",
|
|
677
|
+
COMPOSE_PATH,
|
|
678
|
+
"exec",
|
|
679
|
+
"caddy",
|
|
680
|
+
"caddy",
|
|
681
|
+
"reload",
|
|
682
|
+
"--config",
|
|
683
|
+
"/etc/caddy/Caddyfile",
|
|
684
|
+
],
|
|
685
|
+
{ stdout: "inherit", stderr: "inherit" },
|
|
686
|
+
);
|
|
687
|
+
await proc.exited;
|
|
688
|
+
if (proc.exitCode !== 0) {
|
|
689
|
+
console.warn(
|
|
690
|
+
"caddy reload exited non-zero — Caddyfile written, run reload manually once the container is up.",
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
async function status(): Promise<void> {
|
|
696
|
+
const provider = loadProvider();
|
|
697
|
+
console.log(`Active provider: ${provider}`);
|
|
698
|
+
if (provider !== "self-hosted") {
|
|
699
|
+
console.log(`Stack dir: packages/provisioning/stacks/${provider}`);
|
|
700
|
+
console.log(`State + outputs: pulumi stack output (run from the stack dir)`);
|
|
701
|
+
console.log(`Cert + DNS: visit /settings/dns in the admin`);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
const cfg = loadConfig();
|
|
705
|
+
if (!cfg) {
|
|
706
|
+
console.error("no config");
|
|
707
|
+
process.exit(2);
|
|
708
|
+
}
|
|
709
|
+
console.log(`Caelo install: ${cfg.domain}`);
|
|
710
|
+
console.log(`Owner email: ${cfg.ownerEmail}`);
|
|
711
|
+
console.log(`Config path: ${CONFIG_PATH}`);
|
|
712
|
+
console.log("Container health: run `docker compose -f .caelo/docker-compose.yml ps`");
|
|
713
|
+
console.log("Cert status: visit /settings/domains in the admin");
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
async function backup(): Promise<void> {
|
|
717
|
+
requireSelfHosted("backup");
|
|
718
|
+
const cfg = loadConfig();
|
|
719
|
+
if (!cfg) {
|
|
720
|
+
console.error("no config");
|
|
721
|
+
process.exit(2);
|
|
722
|
+
}
|
|
723
|
+
const to = arg("to") ?? resolve(process.cwd(), `caelo-backup-${Date.now()}.tar.zst`);
|
|
724
|
+
console.log(`Backup target: ${to}`);
|
|
725
|
+
|
|
726
|
+
// 1. pgBackRest full backup inside the sidecar container.
|
|
727
|
+
console.log("Step 1/3: pgBackRest full backup …");
|
|
728
|
+
const pgb = Bun.spawn(
|
|
729
|
+
[
|
|
730
|
+
"docker",
|
|
731
|
+
"compose",
|
|
732
|
+
"-f",
|
|
733
|
+
COMPOSE_PATH,
|
|
734
|
+
"exec",
|
|
735
|
+
"-T",
|
|
736
|
+
"pgbackrest",
|
|
737
|
+
"pgbackrest",
|
|
738
|
+
"--type=full",
|
|
739
|
+
"--stanza=caelo",
|
|
740
|
+
"backup",
|
|
741
|
+
],
|
|
742
|
+
{ stdout: "inherit", stderr: "inherit" },
|
|
743
|
+
);
|
|
744
|
+
await pgb.exited;
|
|
745
|
+
if (pgb.exitCode !== 0) {
|
|
746
|
+
console.error("pgBackRest backup failed; aborting");
|
|
747
|
+
process.exit(pgb.exitCode ?? 1);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// 2. MinIO mirror to a tmp inside the minio container.
|
|
751
|
+
console.log("Step 2/3: mirroring MinIO bucket …");
|
|
752
|
+
const mc = Bun.spawn(
|
|
753
|
+
[
|
|
754
|
+
"docker",
|
|
755
|
+
"compose",
|
|
756
|
+
"-f",
|
|
757
|
+
COMPOSE_PATH,
|
|
758
|
+
"exec",
|
|
759
|
+
"-T",
|
|
760
|
+
"minio",
|
|
761
|
+
"mc",
|
|
762
|
+
"mirror",
|
|
763
|
+
"--overwrite",
|
|
764
|
+
"/data",
|
|
765
|
+
"/tmp/minio-backup",
|
|
766
|
+
],
|
|
767
|
+
{ stdout: "inherit", stderr: "inherit" },
|
|
768
|
+
);
|
|
769
|
+
await mc.exited;
|
|
770
|
+
if (mc.exitCode !== 0) {
|
|
771
|
+
console.error("MinIO mirror failed; aborting");
|
|
772
|
+
process.exit(mc.exitCode ?? 1);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// 3. tar+zstd from named volumes onto the host.
|
|
776
|
+
console.log(`Step 3/3: archiving → ${to}`);
|
|
777
|
+
const tar = Bun.spawn(
|
|
778
|
+
[
|
|
779
|
+
"docker",
|
|
780
|
+
"run",
|
|
781
|
+
"--rm",
|
|
782
|
+
"-v",
|
|
783
|
+
"caelo_caelo-pg-backups:/pgb:ro",
|
|
784
|
+
"-v",
|
|
785
|
+
"caelo_caelo-minio:/minio:ro",
|
|
786
|
+
"-v",
|
|
787
|
+
`${resolve(to, "..")}:/out`,
|
|
788
|
+
"alpine:3",
|
|
789
|
+
"sh",
|
|
790
|
+
"-c",
|
|
791
|
+
`apk add --no-cache zstd tar >/dev/null && tar --zstd -cf /out/${to.split("/").pop()} -C /pgb . -C /minio .`,
|
|
792
|
+
],
|
|
793
|
+
{ stdout: "inherit", stderr: "inherit" },
|
|
794
|
+
);
|
|
795
|
+
await tar.exited;
|
|
796
|
+
if (tar.exitCode !== 0) {
|
|
797
|
+
console.error("tar archive failed");
|
|
798
|
+
process.exit(tar.exitCode ?? 1);
|
|
799
|
+
}
|
|
800
|
+
console.log(`Backup complete: ${to}`);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
async function restore(): Promise<void> {
|
|
804
|
+
requireSelfHosted("restore");
|
|
805
|
+
const from = arg("from");
|
|
806
|
+
if (!from) {
|
|
807
|
+
console.error("Usage: cms-provision restore --from <path> [--yes]");
|
|
808
|
+
process.exit(2);
|
|
809
|
+
}
|
|
810
|
+
if (!process.argv.includes("--yes")) {
|
|
811
|
+
console.error(
|
|
812
|
+
`DESTRUCTIVE: would restore from ${from}, wiping current pgbackrest + minio volumes. Re-run with --yes to confirm.`,
|
|
813
|
+
);
|
|
814
|
+
process.exit(1);
|
|
815
|
+
}
|
|
816
|
+
const cfg = loadConfig();
|
|
817
|
+
if (!cfg) {
|
|
818
|
+
console.error("no config");
|
|
819
|
+
process.exit(2);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
console.log(`Restoring from ${from} …`);
|
|
823
|
+
// 1. Stop dependent services so they don't write while we restore.
|
|
824
|
+
console.log("Step 1/4: stopping admin/gateway/orchestrator/runner …");
|
|
825
|
+
await Bun.spawn(
|
|
826
|
+
[
|
|
827
|
+
"docker",
|
|
828
|
+
"compose",
|
|
829
|
+
"-f",
|
|
830
|
+
COMPOSE_PATH,
|
|
831
|
+
"stop",
|
|
832
|
+
"caelo-admin",
|
|
833
|
+
"caelo-gateway",
|
|
834
|
+
"caelo-orchestrator",
|
|
835
|
+
"caelo-runner",
|
|
836
|
+
],
|
|
837
|
+
{ stdout: "inherit", stderr: "inherit" },
|
|
838
|
+
).exited;
|
|
839
|
+
|
|
840
|
+
// 2. Untar into the named volumes.
|
|
841
|
+
console.log("Step 2/4: extracting archive into volumes …");
|
|
842
|
+
const tarDir = resolve(from, "..");
|
|
843
|
+
const tarFile = from.split("/").pop();
|
|
844
|
+
const ex = Bun.spawn(
|
|
845
|
+
[
|
|
846
|
+
"docker",
|
|
847
|
+
"run",
|
|
848
|
+
"--rm",
|
|
849
|
+
"-v",
|
|
850
|
+
"caelo_caelo-pg-backups:/pgb",
|
|
851
|
+
"-v",
|
|
852
|
+
"caelo_caelo-minio:/minio",
|
|
853
|
+
"-v",
|
|
854
|
+
`${tarDir}:/in:ro`,
|
|
855
|
+
"alpine:3",
|
|
856
|
+
"sh",
|
|
857
|
+
"-c",
|
|
858
|
+
`apk add --no-cache zstd tar >/dev/null && rm -rf /pgb/* /minio/* && tar --zstd -xf /in/${tarFile} -C /pgb && tar --zstd -xf /in/${tarFile} -C /minio`,
|
|
859
|
+
],
|
|
860
|
+
{ stdout: "inherit", stderr: "inherit" },
|
|
861
|
+
);
|
|
862
|
+
await ex.exited;
|
|
863
|
+
if (ex.exitCode !== 0) {
|
|
864
|
+
console.error("extract failed");
|
|
865
|
+
process.exit(ex.exitCode ?? 1);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// 3. pgBackRest restore.
|
|
869
|
+
console.log("Step 3/4: pgBackRest restore …");
|
|
870
|
+
const pgr = Bun.spawn(
|
|
871
|
+
[
|
|
872
|
+
"docker",
|
|
873
|
+
"compose",
|
|
874
|
+
"-f",
|
|
875
|
+
COMPOSE_PATH,
|
|
876
|
+
"exec",
|
|
877
|
+
"-T",
|
|
878
|
+
"pgbackrest",
|
|
879
|
+
"pgbackrest",
|
|
880
|
+
"--stanza=caelo",
|
|
881
|
+
"restore",
|
|
882
|
+
],
|
|
883
|
+
{ stdout: "inherit", stderr: "inherit" },
|
|
884
|
+
);
|
|
885
|
+
await pgr.exited;
|
|
886
|
+
if (pgr.exitCode !== 0) {
|
|
887
|
+
console.error("pgBackRest restore failed");
|
|
888
|
+
process.exit(pgr.exitCode ?? 1);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// 4. Bring services back up.
|
|
892
|
+
console.log("Step 4/4: restarting services …");
|
|
893
|
+
await Bun.spawn(["docker", "compose", "-f", COMPOSE_PATH, "up", "-d"], {
|
|
894
|
+
stdout: "inherit",
|
|
895
|
+
stderr: "inherit",
|
|
896
|
+
}).exited;
|
|
897
|
+
console.log("Restore complete.");
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* #696 — `migrate`: apply the database migrations with the admin image the
|
|
902
|
+
* install runs (a one-off `docker compose run` of its migration entrypoint,
|
|
903
|
+
* as the GCP migrate job does). Postgres must be up; idempotent.
|
|
904
|
+
*/
|
|
905
|
+
async function migrate(): Promise<void> {
|
|
906
|
+
requireSelfHosted("migrate");
|
|
907
|
+
if (!existsSync(COMPOSE_PATH)) {
|
|
908
|
+
console.error(`no Compose file — run \`${selfCommand("init")}\` first`);
|
|
909
|
+
process.exit(2);
|
|
910
|
+
}
|
|
911
|
+
const { runMigrations } = await import("./self-hosted-upgrade.js");
|
|
912
|
+
const r = await runMigrations(CAELO_DIR, bunHostRunner);
|
|
913
|
+
if (!r.ok) {
|
|
914
|
+
console.error(r.error);
|
|
915
|
+
process.exit(1);
|
|
916
|
+
}
|
|
917
|
+
console.log("Migrations applied (cms_admin + cms_public).");
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Whether `upgrade` targets this directory's self-hosted Compose install.
|
|
922
|
+
* `--install <id|domain|all>` always names cloud installs (install.json),
|
|
923
|
+
* so it goes to the lifecycle selector even where a `.caelo/` exists.
|
|
924
|
+
*/
|
|
925
|
+
function selfHostedInstallPresent(): boolean {
|
|
926
|
+
return (
|
|
927
|
+
arg("install") === undefined && existsSync(CONFIG_PATH) && loadProvider() === "self-hosted"
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* #696 — `upgrade` for a self-hosted install: verified digests, pg_dump
|
|
933
|
+
* backup, pull, migrations, admin-then-gateway roll with health checks and
|
|
934
|
+
* rollback (self-hosted-upgrade.ts).
|
|
935
|
+
*/
|
|
936
|
+
async function upgradeSelfHostedCommand(opts: {
|
|
937
|
+
version?: string;
|
|
938
|
+
channel?: "stable" | "rc" | "beta";
|
|
939
|
+
skipVerify?: boolean;
|
|
940
|
+
}): Promise<void> {
|
|
941
|
+
const cfg = loadConfig();
|
|
942
|
+
if (!cfg) {
|
|
943
|
+
console.error("no config — run `cms-provision init` first");
|
|
944
|
+
process.exit(2);
|
|
945
|
+
}
|
|
946
|
+
if (cfg.imageMode === "dev") {
|
|
947
|
+
console.error(
|
|
948
|
+
"This is a DEV MODE install: the admin + gateway run your Caelo checkout, so there are\n" +
|
|
949
|
+
"no images to upgrade. Update the checkout (git pull), then `bun packages/provisioning/src/cli.ts migrate` and\n" +
|
|
950
|
+
`\`docker compose -f ${COMPOSE_PATH} restart caelo-admin caelo-gateway\`.`,
|
|
951
|
+
);
|
|
952
|
+
process.exit(2);
|
|
953
|
+
}
|
|
954
|
+
const { upgradeSelfHosted } = await import("./self-hosted-upgrade.js");
|
|
955
|
+
const { acquireUpgradeLock } = await import("./agent/lock.js");
|
|
956
|
+
// The update agent rolls through the same lock: never two rolls at once.
|
|
957
|
+
let unlock: () => void;
|
|
958
|
+
try {
|
|
959
|
+
unlock = acquireUpgradeLock(CAELO_DIR, "`cms-provision upgrade`");
|
|
960
|
+
} catch (e) {
|
|
961
|
+
console.error(`Nothing was changed: ${(e as Error).message} Wait for it to finish.`);
|
|
962
|
+
process.exit(1);
|
|
963
|
+
}
|
|
964
|
+
let result: Awaited<ReturnType<typeof upgradeSelfHosted>>;
|
|
965
|
+
try {
|
|
966
|
+
result = await upgradeSelfHosted(opts, {
|
|
967
|
+
caeloDir: CAELO_DIR,
|
|
968
|
+
runner: bunHostRunner,
|
|
969
|
+
renderCompose: (images) => renderCompose(cfg, images),
|
|
970
|
+
log: {
|
|
971
|
+
step: (m) => console.log(`\n==> ${m}`),
|
|
972
|
+
info: (m) => console.log(` ${m}`),
|
|
973
|
+
warn: (m) => console.warn(` ! ${m}`),
|
|
974
|
+
error: (m) => console.error(` x ${m}`),
|
|
975
|
+
},
|
|
976
|
+
});
|
|
977
|
+
} finally {
|
|
978
|
+
unlock();
|
|
979
|
+
}
|
|
980
|
+
if (result.ok) {
|
|
981
|
+
if (result.status === "upgraded" && cfg.imageMode !== "release") {
|
|
982
|
+
cfg.imageMode = "release";
|
|
983
|
+
saveConfig(cfg);
|
|
984
|
+
}
|
|
985
|
+
// #696 Phase 4c — set up the update agent, or re-pin it to this release.
|
|
986
|
+
const { reportAgentSetup, setUpAgent } = await import("./agent/command.js");
|
|
987
|
+
console.log("");
|
|
988
|
+
reportAgentSetup(await setUpAgent(agentContext(cfg), { database: "live", explicit: false }));
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
console.error(`\nUpgrade failed at "${result.stage}":\n${result.error}`);
|
|
992
|
+
console.error(
|
|
993
|
+
result.previousRunning
|
|
994
|
+
? "The previous release is still the one running."
|
|
995
|
+
: "The install may be degraded — see above.",
|
|
996
|
+
);
|
|
997
|
+
process.exit(1);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const cmd = process.argv[2];
|
|
1001
|
+
/**
|
|
1002
|
+
* P15 — `pulumi-output-sync`. Reads `pulumi stack output --json` from
|
|
1003
|
+
* the active stack (cwd or --stack-dir), writes the rendered outputs
|
|
1004
|
+
* into cms_admin.provisioning_outputs via the local `psql` shell-out.
|
|
1005
|
+
* Run after every `pulumi up` so the admin's /settings/dns page picks
|
|
1006
|
+
* up the latest snapshot.
|
|
1007
|
+
*
|
|
1008
|
+
* Self-hosted installs auto-skip — there's nothing useful to sync since
|
|
1009
|
+
* the outputs are static (single VM, single domain).
|
|
1010
|
+
*/
|
|
1011
|
+
async function pulumiOutputSync(): Promise<void> {
|
|
1012
|
+
const provider = loadProvider();
|
|
1013
|
+
if (provider === "self-hosted") {
|
|
1014
|
+
console.log("provider=self-hosted; nothing to sync (no Pulumi outputs)");
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
const stackDir = arg("stack-dir") ?? `packages/provisioning/stacks/${provider}`;
|
|
1018
|
+
const env = (arg("environment") ?? "production") as "dev" | "staging" | "production";
|
|
1019
|
+
if (!["dev", "staging", "production"].includes(env)) {
|
|
1020
|
+
console.error(`--environment must be dev | staging | production`);
|
|
1021
|
+
process.exit(2);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// 1. Read pulumi outputs.
|
|
1025
|
+
console.log(`Reading pulumi outputs from ${stackDir} …`);
|
|
1026
|
+
const pulumi = Bun.spawn(["pulumi", "stack", "output", "--json"], {
|
|
1027
|
+
cwd: resolve(REPO_ROOT, stackDir),
|
|
1028
|
+
stdout: "pipe",
|
|
1029
|
+
stderr: "inherit",
|
|
1030
|
+
});
|
|
1031
|
+
const stdout = await new Response(pulumi.stdout).text();
|
|
1032
|
+
await pulumi.exited;
|
|
1033
|
+
if (pulumi.exitCode !== 0) {
|
|
1034
|
+
console.error("pulumi stack output failed; aborting");
|
|
1035
|
+
process.exit(pulumi.exitCode ?? 1);
|
|
1036
|
+
}
|
|
1037
|
+
let outputs: Record<string, unknown>;
|
|
1038
|
+
try {
|
|
1039
|
+
outputs = JSON.parse(stdout);
|
|
1040
|
+
} catch (e) {
|
|
1041
|
+
console.error("pulumi stack output returned invalid JSON:", e);
|
|
1042
|
+
process.exit(1);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// 2. Mint a signed-JWT for the /api/internal/* endpoint.
|
|
1046
|
+
// P15.1 — admin's HTTP boundary requires a bearer token (CALEO_INTERNAL_SECRET
|
|
1047
|
+
// shared between Pulumi + admin). One short-lived token per sync call.
|
|
1048
|
+
const internalSecret = process.env.CAELO_INTERNAL_SECRET;
|
|
1049
|
+
const adminBaseUrl = process.env.CAELO_ADMIN_URL;
|
|
1050
|
+
if (!internalSecret || internalSecret.length < 32) {
|
|
1051
|
+
console.error(
|
|
1052
|
+
"CAELO_INTERNAL_SECRET not set or too short (need ≥32 chars). Self-hosted: `cms-provision init` writes it to .caelo/internal-secret.json. GCP: it lives in Secret Manager — export CAELO_INTERNAL_SECRET=$(gcloud secrets versions access latest --secret=caelo-production-internal-secret --project=<project>) and re-run.",
|
|
1053
|
+
);
|
|
1054
|
+
process.exit(2);
|
|
1055
|
+
}
|
|
1056
|
+
if (!adminBaseUrl) {
|
|
1057
|
+
console.error(
|
|
1058
|
+
"CAELO_ADMIN_URL not set (e.g. https://example.com). Required so the CLI knows where the admin lives.",
|
|
1059
|
+
);
|
|
1060
|
+
process.exit(2);
|
|
1061
|
+
}
|
|
1062
|
+
const { mintInternalToken } = await import("./internal-token.js");
|
|
1063
|
+
const token = await mintInternalToken(internalSecret, "provisioning-outputs.sync");
|
|
1064
|
+
|
|
1065
|
+
// 3. POST to /api/internal/provisioning-outputs/sync.
|
|
1066
|
+
console.log(`Posting outputs to ${adminBaseUrl}/api/internal/provisioning-outputs/sync …`);
|
|
1067
|
+
const res = await fetch(`${adminBaseUrl}/api/internal/provisioning-outputs/sync`, {
|
|
1068
|
+
method: "POST",
|
|
1069
|
+
headers: {
|
|
1070
|
+
authorization: `Bearer ${token}`,
|
|
1071
|
+
"content-type": "application/json",
|
|
1072
|
+
},
|
|
1073
|
+
body: JSON.stringify({ provider, environment: env, outputs }),
|
|
1074
|
+
});
|
|
1075
|
+
if (!res.ok) {
|
|
1076
|
+
const txt = await res.text().catch(() => "");
|
|
1077
|
+
console.error(`sync failed: ${res.status} ${res.statusText} — ${txt}`);
|
|
1078
|
+
process.exit(1);
|
|
1079
|
+
}
|
|
1080
|
+
const result = (await res.json()) as { ok?: boolean; updated?: boolean };
|
|
1081
|
+
console.log(`Synced ${provider}/${env} → ${result.updated === false ? "inserted" : "updated"}.`);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
async function version(): Promise<void> {
|
|
1085
|
+
// P17.0 — single source of truth lives in @caelo-cms/shared/version.ts.
|
|
1086
|
+
// Imported lazily so the CLI's startup cost stays small.
|
|
1087
|
+
const { CAELO_VERSION } = await import("@caelo-cms/shared");
|
|
1088
|
+
console.log(`cms-provision (Caelo v${CAELO_VERSION})`);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
const handlers: Record<string, () => Promise<void>> = {
|
|
1092
|
+
// Self-hosted P14 sub-commands (Docker Compose path)
|
|
1093
|
+
init,
|
|
1094
|
+
up,
|
|
1095
|
+
"regenerate-caddy": regenerateCaddy,
|
|
1096
|
+
migrate,
|
|
1097
|
+
// §11.C lifecycle dispatchers route by provider via install metadata.
|
|
1098
|
+
// Self-hosted bodies remain reachable as `<command>-self-hosted` for
|
|
1099
|
+
// back-compat scripts that bypassed the dispatcher.
|
|
1100
|
+
status: lifecycleStatus,
|
|
1101
|
+
"status-self-hosted": status,
|
|
1102
|
+
backup: lifecycleBackup,
|
|
1103
|
+
"backup-self-hosted": backup,
|
|
1104
|
+
restore,
|
|
1105
|
+
upgrade: lifecycleUpgrade,
|
|
1106
|
+
"rotate-secret": lifecycleRotateSecret,
|
|
1107
|
+
destroy: lifecycleDestroy,
|
|
1108
|
+
truncate: lifecycleTruncate,
|
|
1109
|
+
"admin-domain": adminDomain,
|
|
1110
|
+
"staging-domain": stagingDomain,
|
|
1111
|
+
// #696 — Pulumi state in the install's own project.
|
|
1112
|
+
state: async () => (await import("./state-command.js")).stateCommand(process.argv),
|
|
1113
|
+
attach: async () => (await import("./state-command.js")).attachCommand(process.argv),
|
|
1114
|
+
// #696 Phase 4c — the self-hosted update agent.
|
|
1115
|
+
agent: agentCli,
|
|
1116
|
+
// #696 — the install job's entry point (runs inside the Cloud Run job).
|
|
1117
|
+
"install-job": async () =>
|
|
1118
|
+
(await import("./install-job-runner.js")).installJobCommand(process.argv),
|
|
1119
|
+
// Misc
|
|
1120
|
+
"pulumi-output-sync": pulumiOutputSync,
|
|
1121
|
+
version,
|
|
1122
|
+
"--version": version,
|
|
1123
|
+
"-v": version,
|
|
1124
|
+
// §11.C wizard — explicit invocation. Default routing (no cmd) also
|
|
1125
|
+
// lands here unless `--no-wizard` is passed.
|
|
1126
|
+
wizard: wizardCommand,
|
|
1127
|
+
};
|
|
1128
|
+
|
|
1129
|
+
async function lifecycleStatus(): Promise<void> {
|
|
1130
|
+
const { statusCommand } = await import("./lifecycle.js");
|
|
1131
|
+
await statusCommand();
|
|
1132
|
+
}
|
|
1133
|
+
async function lifecycleUpgrade(): Promise<void> {
|
|
1134
|
+
// P20 — version + channel flags. Default = latest stable.
|
|
1135
|
+
// bunx @caelo-cms/provisioning upgrade
|
|
1136
|
+
// bunx @caelo-cms/provisioning upgrade --version 0.5.3
|
|
1137
|
+
// bunx @caelo-cms/provisioning upgrade --channel rc
|
|
1138
|
+
// P21 ship 4 — `--skip-verify` opts out of cosign signature checks.
|
|
1139
|
+
// Default verifies; skip only for forks/staging using unsigned images.
|
|
1140
|
+
const version = arg("version");
|
|
1141
|
+
const channelArg = arg("channel");
|
|
1142
|
+
const channel: "stable" | "rc" | "beta" | undefined =
|
|
1143
|
+
channelArg === "rc" || channelArg === "beta" || channelArg === "stable"
|
|
1144
|
+
? channelArg
|
|
1145
|
+
: undefined;
|
|
1146
|
+
const skipVerify = process.argv.includes("--skip-verify");
|
|
1147
|
+
if (selfHostedInstallPresent()) {
|
|
1148
|
+
await upgradeSelfHostedCommand({
|
|
1149
|
+
...(version ? { version } : {}),
|
|
1150
|
+
...(channel ? { channel } : {}),
|
|
1151
|
+
...(skipVerify ? { skipVerify: true } : {}),
|
|
1152
|
+
});
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
// #696 — `--skip-health-check` rolls without the post-roll deep health
|
|
1156
|
+
// check (Cloud Run readiness only), e.g. for a release that predates it.
|
|
1157
|
+
const skipHealthCheck = process.argv.includes("--skip-health-check");
|
|
1158
|
+
// #607 — optional; must match the install's recorded region (upgrade
|
|
1159
|
+
// never moves an install between regions).
|
|
1160
|
+
const region = arg("region");
|
|
1161
|
+
const { upgradeCommand } = await import("./lifecycle.js");
|
|
1162
|
+
await upgradeCommand({
|
|
1163
|
+
...(region ? { region } : {}),
|
|
1164
|
+
...(version ? { version } : {}),
|
|
1165
|
+
...(channel ? { channel } : {}),
|
|
1166
|
+
...(skipVerify ? { skipVerify: true } : {}),
|
|
1167
|
+
...(skipHealthCheck ? { skipHealthCheck: true } : {}),
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
async function lifecycleBackup(): Promise<void> {
|
|
1171
|
+
const { backupCommand } = await import("./lifecycle.js");
|
|
1172
|
+
await backupCommand();
|
|
1173
|
+
}
|
|
1174
|
+
async function lifecycleRotateSecret(): Promise<void> {
|
|
1175
|
+
const { rotateSecretCommand } = await import("./lifecycle.js");
|
|
1176
|
+
await rotateSecretCommand(process.argv[3]);
|
|
1177
|
+
}
|
|
1178
|
+
async function lifecycleDestroy(): Promise<void> {
|
|
1179
|
+
const { destroyCommand } = await import("./lifecycle.js");
|
|
1180
|
+
await destroyCommand();
|
|
1181
|
+
}
|
|
1182
|
+
async function lifecycleTruncate(): Promise<void> {
|
|
1183
|
+
const { truncateCommand } = await import("./lifecycle.js");
|
|
1184
|
+
await truncateCommand();
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* `admin-domain enable` — bind admin.<domain> to a gcp-firebase admin
|
|
1189
|
+
* (see admin-domain.ts for why this runs as the operator, not Pulumi).
|
|
1190
|
+
*/
|
|
1191
|
+
async function adminDomain(): Promise<void> {
|
|
1192
|
+
await domainMappingCommand("admin");
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/**
|
|
1196
|
+
* #650 `staging-domain enable [--staging-domain <host>]` — bind the install's
|
|
1197
|
+
* staging host to the gcp-firebase gateway, which serves the protected
|
|
1198
|
+
* staging site. #678: the command also configures the admin — without the
|
|
1199
|
+
* flag it maps the host the admin stores; `--staging-domain` sets a new one
|
|
1200
|
+
* there (the operator's explicit decision, audited). The first run turns on
|
|
1201
|
+
* basic auth and prints the generated credentials once;
|
|
1202
|
+
* `--rotate-staging-password` replaces the password, `--show-staging-password`
|
|
1203
|
+
* prints the current one.
|
|
1204
|
+
*/
|
|
1205
|
+
async function stagingDomain(): Promise<void> {
|
|
1206
|
+
await domainMappingCommand("staging");
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
async function domainMappingCommand(kind: "admin" | "staging"): Promise<void> {
|
|
1210
|
+
const command = `${kind}-domain`;
|
|
1211
|
+
const sub = process.argv[3];
|
|
1212
|
+
if (sub !== "enable") {
|
|
1213
|
+
console.log(
|
|
1214
|
+
`Usage: cms-provision ${command} enable [--install <install-id>]${kind === "staging" ? " [--staging-domain <host>] [--rotate-staging-password] [--show-staging-password]" : ""}`,
|
|
1215
|
+
);
|
|
1216
|
+
process.exit(2);
|
|
1217
|
+
}
|
|
1218
|
+
const {
|
|
1219
|
+
installFlag,
|
|
1220
|
+
installStagingHost,
|
|
1221
|
+
currentMetadata,
|
|
1222
|
+
listInstalls,
|
|
1223
|
+
recordableStagingHost,
|
|
1224
|
+
selectInstall,
|
|
1225
|
+
writeMetadata,
|
|
1226
|
+
} = await import("./install-state.js");
|
|
1227
|
+
const { enableAdminDomain, enableStagingDomain } = await import("./admin-domain.js");
|
|
1228
|
+
// Guessing among several installs could map the domain in the wrong project.
|
|
1229
|
+
const selected = selectInstall(
|
|
1230
|
+
listInstalls().filter((m) => m.projectId),
|
|
1231
|
+
installFlag(process.argv),
|
|
1232
|
+
);
|
|
1233
|
+
if (!selected.ok) {
|
|
1234
|
+
console.error(selected.message);
|
|
1235
|
+
process.exit(1);
|
|
1236
|
+
}
|
|
1237
|
+
let meta = currentMetadata(selected.meta);
|
|
1238
|
+
// The command to re-run after a stop before the admin was updated: an
|
|
1239
|
+
// explicitly requested host must survive it, or the retry would map the
|
|
1240
|
+
// admin's old host (codex review on #681).
|
|
1241
|
+
const { domainEnableRetryCommand } = await import("./staging-admin.js");
|
|
1242
|
+
let retry = domainEnableRetryCommand(command, null);
|
|
1243
|
+
const rotatePassword = process.argv.includes("--rotate-staging-password");
|
|
1244
|
+
const showPassword = process.argv.includes("--show-staging-password");
|
|
1245
|
+
if (kind === "staging") {
|
|
1246
|
+
// #678 — the admin's stored host is the truth; `--staging-domain` is the
|
|
1247
|
+
// operator's explicit change of it. Without the flag a gcp-firebase
|
|
1248
|
+
// install maps the host the admin actually renders (and records it in
|
|
1249
|
+
// install.json), so a host changed under Settings → Domains is never
|
|
1250
|
+
// undone here. Validated either way: the recorded/default host may
|
|
1251
|
+
// predate a check (e.g. a long default a Cloud Run mapping refuses).
|
|
1252
|
+
const requested = arg("staging-domain");
|
|
1253
|
+
const { normalizeStagingHost, stagingHostProblem } = await import("@caelo-cms/shared");
|
|
1254
|
+
let adminHost: string | null = null;
|
|
1255
|
+
if (!requested && meta.provider === "gcp-firebase" && meta.projectId && meta.region) {
|
|
1256
|
+
const { inspectStagingAdmin } = await import("./staging-admin.js");
|
|
1257
|
+
const inspected = await inspectStagingAdmin({
|
|
1258
|
+
projectId: meta.projectId,
|
|
1259
|
+
region: meta.region,
|
|
1260
|
+
});
|
|
1261
|
+
if (!inspected.ok) {
|
|
1262
|
+
console.error(
|
|
1263
|
+
`${command}: could not read the admin's staging host: ${inspected.error}\n` +
|
|
1264
|
+
"Fix that, or pass --staging-domain <host> to set the host explicitly.",
|
|
1265
|
+
);
|
|
1266
|
+
process.exit(1);
|
|
1267
|
+
}
|
|
1268
|
+
adminHost = inspected.value.stagingHost;
|
|
1269
|
+
}
|
|
1270
|
+
const host = requested ?? adminHost ?? installStagingHost(meta);
|
|
1271
|
+
const problem = stagingHostProblem(
|
|
1272
|
+
host,
|
|
1273
|
+
{ liveHosts: [meta.domain, `www.${meta.domain}`], adminHosts: [`admin.${meta.domain}`] },
|
|
1274
|
+
{ provider: meta.provider },
|
|
1275
|
+
);
|
|
1276
|
+
if (problem) {
|
|
1277
|
+
console.error(
|
|
1278
|
+
requested
|
|
1279
|
+
? `--staging-domain: ${problem}`
|
|
1280
|
+
: `the install's staging host: ${problem}. Pass --staging-domain <host>.`,
|
|
1281
|
+
);
|
|
1282
|
+
process.exit(1);
|
|
1283
|
+
}
|
|
1284
|
+
const recorded = normalizeStagingHost(host);
|
|
1285
|
+
if (requested) retry = domainEnableRetryCommand(command, recorded);
|
|
1286
|
+
// The host may come from the admin's answer (inspect): only a bare host
|
|
1287
|
+
// name — already checked by stagingHostProblem above — reaches
|
|
1288
|
+
// install.json, and only the value the allow-list hands back is written.
|
|
1289
|
+
const stagingDomain = recordableStagingHost(recorded);
|
|
1290
|
+
if (stagingDomain === null) {
|
|
1291
|
+
console.error(`${command}: refusing to record staging host ${JSON.stringify(recorded)}.`);
|
|
1292
|
+
process.exit(1);
|
|
1293
|
+
}
|
|
1294
|
+
meta = { ...meta, stagingDomain };
|
|
1295
|
+
writeMetadata(meta.installId, meta);
|
|
1296
|
+
}
|
|
1297
|
+
if (!meta.projectId) process.exit(1);
|
|
1298
|
+
if (!meta.region) {
|
|
1299
|
+
console.error(
|
|
1300
|
+
`install ${meta.installId} has no region recorded in install.json; re-run the wizard to record it.`,
|
|
1301
|
+
);
|
|
1302
|
+
process.exit(1);
|
|
1303
|
+
}
|
|
1304
|
+
if (meta.provider !== "gcp-firebase") {
|
|
1305
|
+
console.log(
|
|
1306
|
+
kind === "admin"
|
|
1307
|
+
? meta.provider === "gcp"
|
|
1308
|
+
? `Nothing to do: on gcp the load balancer already serves admin.${meta.domain}.`
|
|
1309
|
+
: `admin-domain is for gcp-firebase installs; this one is ${meta.provider}.`
|
|
1310
|
+
: meta.provider === "self-hosted"
|
|
1311
|
+
? `Recorded staging host ${installStagingHost(meta)}; Caddy serves it after \`cms-provision regenerate-caddy\`.`
|
|
1312
|
+
: `staging-domain is for gcp-firebase installs; on ${meta.provider} the protected staging domain is not available yet (issue #650 follow-up).`,
|
|
1313
|
+
);
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
const target = { projectId: meta.projectId, region: meta.region, domain: meta.domain };
|
|
1317
|
+
const r =
|
|
1318
|
+
kind === "admin"
|
|
1319
|
+
? await enableAdminDomain(target)
|
|
1320
|
+
: await enableStagingDomain({ ...target, stagingHost: installStagingHost(meta) });
|
|
1321
|
+
if (r.status === "failed") {
|
|
1322
|
+
console.error(`${command}: ${r.error}`);
|
|
1323
|
+
process.exit(1);
|
|
1324
|
+
}
|
|
1325
|
+
if (r.status === "needs-verification") {
|
|
1326
|
+
console.log(
|
|
1327
|
+
`Google only maps ${r.hostname} for a verified owner of ${r.verifyDomain}.\n` +
|
|
1328
|
+
"Search Console was opened for you: add the TXT record it shows at your registrar,\n" +
|
|
1329
|
+
`click Verify, then run \`${retry}\` again.`,
|
|
1330
|
+
);
|
|
1331
|
+
process.exit(3);
|
|
1332
|
+
}
|
|
1333
|
+
const mappedTo = kind === "admin" ? "the admin" : "the gateway (protected staging site)";
|
|
1334
|
+
console.log(
|
|
1335
|
+
`${r.hostname} ${r.created ? "is now mapped" : "was already mapped"} to ${mappedTo}.`,
|
|
1336
|
+
);
|
|
1337
|
+
if (kind === "staging") {
|
|
1338
|
+
// #678 — the operator's decision reaches the admin here: the host is
|
|
1339
|
+
// set there, password protection is switched on the first time, and
|
|
1340
|
+
// the password is printed once (never stored by the CLI).
|
|
1341
|
+
const { applyStagingAdmin, describeStagingApply } = await import("./staging-admin.js");
|
|
1342
|
+
const applied = await applyStagingAdmin(
|
|
1343
|
+
{ projectId: meta.projectId, region: meta.region },
|
|
1344
|
+
{
|
|
1345
|
+
host: r.hostname,
|
|
1346
|
+
...(rotatePassword ? { rotateBasicAuth: true } : {}),
|
|
1347
|
+
...(showPassword ? { revealBasicAuth: true } : {}),
|
|
1348
|
+
},
|
|
1349
|
+
);
|
|
1350
|
+
if (!applied.ok) {
|
|
1351
|
+
console.error(
|
|
1352
|
+
`${command}: ${r.hostname} is mapped, but the admin could not be updated: ${applied.error}\n` +
|
|
1353
|
+
`Run \`${retry}\` again once that is fixed (it is idempotent).`,
|
|
1354
|
+
);
|
|
1355
|
+
process.exit(5);
|
|
1356
|
+
}
|
|
1357
|
+
// Prints the staging basic-auth password ON PURPOSE (#678): the operator
|
|
1358
|
+
// asked for it to be shown once; it is never written to disk or logs.
|
|
1359
|
+
console.log(describeStagingApply(applied.value).join("\n"));
|
|
1360
|
+
}
|
|
1361
|
+
if (r.records.length === 0) {
|
|
1362
|
+
console.log(
|
|
1363
|
+
`Google has not published the DNS records yet; run \`cms-provision ${command} enable\` again in a minute.`,
|
|
1364
|
+
);
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
const waited = await waitForDomainMappingCert({
|
|
1368
|
+
command,
|
|
1369
|
+
hostname: r.hostname,
|
|
1370
|
+
records: r.records,
|
|
1371
|
+
projectId: meta.projectId,
|
|
1372
|
+
region: meta.region,
|
|
1373
|
+
});
|
|
1374
|
+
if (!waited) process.exit(4);
|
|
1375
|
+
console.log(
|
|
1376
|
+
kind === "admin"
|
|
1377
|
+
? `Admin login: https://${r.hostname}/login`
|
|
1378
|
+
: `Protected staging site: https://${r.hostname}/`,
|
|
1379
|
+
);
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* After `<kind>-domain enable` mapped the host: print the DNS records, then
|
|
1384
|
+
* poll the mapping until Google has issued its certificate (cert-wait.ts),
|
|
1385
|
+
* with a spinner. Returns false on timeout or a gcloud failure, after saying
|
|
1386
|
+
* how to check the status. Ctrl-C is safe: the command is idempotent, and
|
|
1387
|
+
* re-running it finds the mapping and resumes the wait.
|
|
1388
|
+
*/
|
|
1389
|
+
async function waitForDomainMappingCert(target: {
|
|
1390
|
+
command: string;
|
|
1391
|
+
hostname: string;
|
|
1392
|
+
records: readonly { name: string; type: string; value: string }[];
|
|
1393
|
+
projectId: string;
|
|
1394
|
+
region: string;
|
|
1395
|
+
}): Promise<boolean> {
|
|
1396
|
+
const { spinner } = await import("@clack/prompts");
|
|
1397
|
+
const { CERT_WAIT_TIMEOUT_MS, domainMappingProbe, waitForCert } = await import("./cert-wait.js");
|
|
1398
|
+
const { gcloud } = await import("./gcloud.js");
|
|
1399
|
+
const width = Math.max(...target.records.map((d) => d.name.length));
|
|
1400
|
+
console.log(
|
|
1401
|
+
`\nSet this DNS record at your registrar (if you have not already):\n${target.records
|
|
1402
|
+
.map((d) => ` ${d.type.padEnd(6)} ${d.name.padEnd(width)} → ${d.value}`)
|
|
1403
|
+
.join("\n")}\n` +
|
|
1404
|
+
"Google issues the TLS certificate once the record resolves — usually 15-60 minutes.\n" +
|
|
1405
|
+
`Safe to stop with Ctrl-C: \`cms-provision ${target.command} enable\` again resumes waiting.\n`,
|
|
1406
|
+
);
|
|
1407
|
+
// clack's spinner swallows SIGINT; exit explicitly so Ctrl-C stops the wait.
|
|
1408
|
+
const s = spinner({
|
|
1409
|
+
cancelMessage: `Stopped. Run \`cms-provision ${target.command} enable\` again to resume waiting.`,
|
|
1410
|
+
onCancel: () => process.exit(130),
|
|
1411
|
+
});
|
|
1412
|
+
s.start(`Waiting for the certificate of ${target.hostname} (DNS set? this takes 15-60 min)`);
|
|
1413
|
+
const r = await waitForCert({
|
|
1414
|
+
probe: () =>
|
|
1415
|
+
domainMappingProbe(gcloud, {
|
|
1416
|
+
hostname: target.hostname,
|
|
1417
|
+
region: target.region,
|
|
1418
|
+
projectId: target.projectId,
|
|
1419
|
+
}),
|
|
1420
|
+
timeoutMs: CERT_WAIT_TIMEOUT_MS,
|
|
1421
|
+
intervalMs: 30 * 1000,
|
|
1422
|
+
onUpdate: (detail) => s.message(`${target.hostname}: ${detail}`),
|
|
1423
|
+
});
|
|
1424
|
+
if (r.ok) {
|
|
1425
|
+
s.stop(`Certificate for ${target.hostname} is ready.`);
|
|
1426
|
+
return true;
|
|
1427
|
+
}
|
|
1428
|
+
const check = `gcloud beta run domain-mappings describe --domain=${target.hostname} --region=${target.region} --project=${target.projectId}`;
|
|
1429
|
+
if (r.reason === "error") {
|
|
1430
|
+
s.error(`Could not read the domain mapping: ${r.error}`);
|
|
1431
|
+
console.error(`Check it by hand with:\n ${check}`);
|
|
1432
|
+
return false;
|
|
1433
|
+
}
|
|
1434
|
+
s.error(`No certificate for ${target.hostname} after 60 minutes.`);
|
|
1435
|
+
console.error(
|
|
1436
|
+
[
|
|
1437
|
+
r.lastDetail ? `Google reports: ${r.lastDetail}` : "",
|
|
1438
|
+
`Check that the record resolves: dig +short ${target.hostname} ${target.records[0]?.type ?? "CNAME"}`,
|
|
1439
|
+
`Check the mapping's status: ${check}`,
|
|
1440
|
+
`Once DNS resolves, run \`cms-provision ${target.command} enable\` again to resume waiting.`,
|
|
1441
|
+
]
|
|
1442
|
+
.filter(Boolean)
|
|
1443
|
+
.join("\n"),
|
|
1444
|
+
);
|
|
1445
|
+
return false;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/**
|
|
1449
|
+
* §11.C — interactive wizard. Loaded lazily so the bare-CLI startup
|
|
1450
|
+
* cost stays small (clack + kleur are pulled in only when needed).
|
|
1451
|
+
* Reads `--provider`, `--domain`, `--owner-email`, `--project-id`, `--region`,
|
|
1452
|
+
* `--non-interactive` and the run mode (`--local` / `--install-job`, default:
|
|
1453
|
+
* the install job in Cloud Shell, local elsewhere — run-mode.ts) from argv.
|
|
1454
|
+
*/
|
|
1455
|
+
async function wizardCommand(): Promise<void> {
|
|
1456
|
+
const { runWizard } = await import("./wizard.js");
|
|
1457
|
+
const { chooseRunMode } = await import("./run-mode.js");
|
|
1458
|
+
await runWizard({
|
|
1459
|
+
runMode: chooseRunMode(process.argv, process.env),
|
|
1460
|
+
nonInteractive: process.argv.includes("--non-interactive"),
|
|
1461
|
+
provider: arg("provider") as Provider | undefined,
|
|
1462
|
+
domain: arg("domain"),
|
|
1463
|
+
stagingDomain: arg("staging-domain"),
|
|
1464
|
+
ownerEmail: arg("owner-email"),
|
|
1465
|
+
projectId: arg("project-id"),
|
|
1466
|
+
region: arg("region"),
|
|
1467
|
+
});
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// §11.C — flags-only invocations (`--provider gcp --domain …`, the
|
|
1471
|
+
// documented one-command form) and a bare `cms-provision` drop into the
|
|
1472
|
+
// wizard. `--no-wizard` / `--help` keep the legacy "print usage" shape.
|
|
1473
|
+
const route = resolveCliRoute(process.argv, Object.keys(handlers));
|
|
1474
|
+
if (route.kind === "handler" || route.kind === "wizard") {
|
|
1475
|
+
try {
|
|
1476
|
+
if (route.kind === "handler") await handlers[route.name]?.();
|
|
1477
|
+
else await wizardCommand();
|
|
1478
|
+
} catch (e) {
|
|
1479
|
+
// #656 — an error from Pulumi, gcloud or a database client can quote a
|
|
1480
|
+
// connection URL; print it with any password masked.
|
|
1481
|
+
console.error(redactCredentials(e instanceof Error ? (e.stack ?? e.message) : String(e)));
|
|
1482
|
+
process.exit(1);
|
|
1483
|
+
}
|
|
1484
|
+
} else {
|
|
1485
|
+
console.log(
|
|
1486
|
+
"Usage: cms-provision [wizard] [--provider <name> --domain <d> [--staging-domain <host>] --owner-email <e> --region <r>] [--local|--install-job] | <init|up|migrate|status|upgrade|backup|restore|rotate-secret|truncate|destroy|regenerate-caddy|admin-domain|staging-domain|state migrate|attach|pulumi-output-sync|version> [options]\n" +
|
|
1487
|
+
"With several installs on this machine, pick one with --install <install-id or domain> (status, upgrade and backup also take --install all); in a terminal you are asked otherwise.\n" +
|
|
1488
|
+
"Pass --no-wizard with no sub-command to print this usage instead of the wizard.",
|
|
1489
|
+
);
|
|
1490
|
+
process.exit(cmd && !cmd.startsWith("-") ? 2 : 0);
|
|
1491
|
+
}
|