@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,1935 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GCP provider wizard — end-to-end automation per §11.C:
|
|
5
|
+
* 1. Detects active gcloud account; prompts `gcloud auth login` if none
|
|
6
|
+
* 2. Lists billing accounts; user picks
|
|
7
|
+
* 3. Captures GCP project id (default suggested from domain)
|
|
8
|
+
* 4. Creates the project (skips if exists)
|
|
9
|
+
* 5. Links billing
|
|
10
|
+
* 6. Enables 13 APIs in one call
|
|
11
|
+
* 7. Creates the provisioner SA + grants 15 IAM roles
|
|
12
|
+
* 8. Lets the operator's gcloud account impersonate that SA (#696): no
|
|
13
|
+
* key is ever minted; an older install's `sa-key.json` is deleted,
|
|
14
|
+
* locally and in IAM (provisioner-identity.ts)
|
|
15
|
+
* 9. Pre-flight cost-estimate table; single y/N confirm
|
|
16
|
+
* 10. Ensures the install's ops bucket + KMS key in its own project and
|
|
17
|
+
* keeps Pulumi's state + install.json there (#696, ops-backend.ts).
|
|
18
|
+
* An older install whose state is on this machine keeps it there
|
|
19
|
+
* and its passphrase in secrets/pulumi-passphrase
|
|
20
|
+
* 11. Pulumi up via the Automation SDK; streams progress, then deletes
|
|
21
|
+
* the firewall rules GCP pre-populates on the default network
|
|
22
|
+
* 12. Prints DNS records + bootstrap URL + a note pointing the
|
|
23
|
+
* operator at /settings/ai for AI provider key configuration
|
|
24
|
+
* (the runtime path; pre-v0.3.2 there was a wizard prompt for
|
|
25
|
+
* the Anthropic key but it landed in Secret Manager + never
|
|
26
|
+
* reached the running admin — dead code, dropped).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { randomBytes } from "node:crypto";
|
|
30
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
31
|
+
import { homedir } from "node:os";
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
import { cancel, confirm, isCancel, log, note, select, spinner } from "@clack/prompts";
|
|
34
|
+
import { bold, cyan, dim, green, red, yellow } from "kleur/colors";
|
|
35
|
+
import { checkAdcImpersonation } from "../adc-impersonation.js";
|
|
36
|
+
import { CERT_WAIT_TIMEOUT_MS, lbCertProbe, waitForCert } from "../cert-wait.js";
|
|
37
|
+
import { redactCredentials } from "../checkpoint-secrets.js";
|
|
38
|
+
import { removeDefaultFirewallRules } from "../default-firewall.js";
|
|
39
|
+
import {
|
|
40
|
+
checkDeployedRuntimeEnv,
|
|
41
|
+
chooseImageDigests,
|
|
42
|
+
readDeployedImageDigests,
|
|
43
|
+
readDeployedRegion,
|
|
44
|
+
} from "../deployed-release.js";
|
|
45
|
+
import { formatDnsRecordGroups, groupDnsRecords, type InstallDnsRecord } from "../dns/groups.js";
|
|
46
|
+
import { pickDnsAdapter } from "../dns/index.js";
|
|
47
|
+
import type { DnsRecord } from "../dns/types.js";
|
|
48
|
+
import { ensureGatewayRolePassword, findSqlInstance } from "../gateway-credentials.js";
|
|
49
|
+
import {
|
|
50
|
+
activeAccount,
|
|
51
|
+
type BillingAccount,
|
|
52
|
+
createProject,
|
|
53
|
+
createServiceAccount,
|
|
54
|
+
enableApis,
|
|
55
|
+
gcloud,
|
|
56
|
+
gcloudEnv,
|
|
57
|
+
grantProvisionerRoles,
|
|
58
|
+
linkBilling,
|
|
59
|
+
listBillingAccounts,
|
|
60
|
+
PROVISIONER_ROLE_LIST,
|
|
61
|
+
projectExists,
|
|
62
|
+
REQUIRED_API_LIST,
|
|
63
|
+
serviceAccountExists,
|
|
64
|
+
} from "../gcloud.js";
|
|
65
|
+
import { GCP_STACK_ENV, upgraderServiceAccountEmail } from "../gcp-names.js";
|
|
66
|
+
import { INFRA_CONTRACT_VERSION } from "../infra-contract.js";
|
|
67
|
+
import {
|
|
68
|
+
INSTALL_JOB_NAME,
|
|
69
|
+
INSTALL_JOB_ROLES,
|
|
70
|
+
INSTALL_SETUP_LINK_SECRET,
|
|
71
|
+
storeSetupLink,
|
|
72
|
+
} from "../install-job.js";
|
|
73
|
+
import {
|
|
74
|
+
adoptStateBackend,
|
|
75
|
+
ensureInstallDir,
|
|
76
|
+
type ImageDigests,
|
|
77
|
+
type InstallMetadata,
|
|
78
|
+
installRoot,
|
|
79
|
+
installStagingHost,
|
|
80
|
+
isStepDone,
|
|
81
|
+
markStepDone,
|
|
82
|
+
readLocalMetadata,
|
|
83
|
+
readMetadata,
|
|
84
|
+
readSecret,
|
|
85
|
+
recordedImageDigests,
|
|
86
|
+
recordImageDigests,
|
|
87
|
+
recordInfraContractVersion,
|
|
88
|
+
writeMetadata,
|
|
89
|
+
writeSecret,
|
|
90
|
+
} from "../install-state.js";
|
|
91
|
+
import {
|
|
92
|
+
ensureOperatorAccessSync,
|
|
93
|
+
installIapAllowlist,
|
|
94
|
+
resolveOperatorAccessTarget,
|
|
95
|
+
} from "../operator-access.js";
|
|
96
|
+
import { ensureOpsBackend } from "../ops-backend.js";
|
|
97
|
+
import {
|
|
98
|
+
convergeKeylessProvisioner,
|
|
99
|
+
PROVISIONER_ACCOUNT_ID,
|
|
100
|
+
provisionerServiceAccountEmail,
|
|
101
|
+
} from "../provisioner-identity.js";
|
|
102
|
+
import { decideRegion, detectCliRegion, installableRegions, suggestRegion } from "../regions.js";
|
|
103
|
+
import type { RunMode } from "../run-mode.js";
|
|
104
|
+
import { selfCommand } from "../self-command.js";
|
|
105
|
+
import { ABSENT_DEFAULT_FIREWALL_RULES } from "../stack-contract.js";
|
|
106
|
+
import {
|
|
107
|
+
chooseInstallBackend,
|
|
108
|
+
describeBackend,
|
|
109
|
+
gcsBackendRecord,
|
|
110
|
+
hasLocalPulumiState,
|
|
111
|
+
installBackend,
|
|
112
|
+
parseStateBackend,
|
|
113
|
+
type ResolvedBackend,
|
|
114
|
+
resolveBackend,
|
|
115
|
+
} from "../state-backend.js";
|
|
116
|
+
import { acquireStateLock, GcsStateStore } from "../state-store.js";
|
|
117
|
+
import {
|
|
118
|
+
releaseVersionForDigest,
|
|
119
|
+
resolveOpsImageRef,
|
|
120
|
+
verifyCosignAll,
|
|
121
|
+
} from "../upgrade/release-images.js";
|
|
122
|
+
import { ensureUpgraderJob, resolveUpgraderTarget } from "../upgrader-setup.js";
|
|
123
|
+
import { estimateGcpCost } from "./gcp-cost.js";
|
|
124
|
+
import { promptProjectId } from "./gcp-prompts.js";
|
|
125
|
+
import { GCP_STACK_KNOB_DEFAULTS, pulumiUpGcp, readStackOutput } from "./gcp-pulumi.js";
|
|
126
|
+
|
|
127
|
+
export interface GcpWizardOpts {
|
|
128
|
+
installId: string;
|
|
129
|
+
domain: string;
|
|
130
|
+
ownerEmail: string;
|
|
131
|
+
projectId: string | null;
|
|
132
|
+
nonInteractive: boolean;
|
|
133
|
+
/** #607 — `--region`; required for a new install when non-interactive. */
|
|
134
|
+
region?: string;
|
|
135
|
+
/**
|
|
136
|
+
* v0.3.1 — provider variant. 'gcp' is the LB-based topology;
|
|
137
|
+
* 'gcp-firebase' adds Firebase Hosting + Cloud Run direct. The
|
|
138
|
+
* variant gates per-provider gcloud steps (e.g. Firebase APIs
|
|
139
|
+
* enablement, Search Console verification prompts).
|
|
140
|
+
*/
|
|
141
|
+
provider?: "gcp" | "gcp-firebase";
|
|
142
|
+
/**
|
|
143
|
+
* #696 — where `pulumi up` and the rest run (run-mode.ts): here, or in the
|
|
144
|
+
* install job in the project. Default `local`.
|
|
145
|
+
*/
|
|
146
|
+
runMode?: RunMode;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function runGcpWizard(opts: GcpWizardOpts): Promise<void> {
|
|
150
|
+
const { installId } = opts;
|
|
151
|
+
const provider = opts.provider ?? "gcp";
|
|
152
|
+
const runMode = opts.runMode ?? "local";
|
|
153
|
+
const { secretsDir } = ensureInstallDir(installId);
|
|
154
|
+
|
|
155
|
+
// === 1. gcloud auth ===
|
|
156
|
+
await stepActiveGcloud();
|
|
157
|
+
|
|
158
|
+
// === 2. Project id ===
|
|
159
|
+
const projectId = await stepProjectId(opts);
|
|
160
|
+
|
|
161
|
+
// Persist the project id once we have it.
|
|
162
|
+
const meta = readMetadata(installId);
|
|
163
|
+
if (meta) {
|
|
164
|
+
const updated: InstallMetadata = { ...meta, projectId };
|
|
165
|
+
writeMetadata(installId, updated);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// === 2.5. Region (#607) — chosen before anything billable exists, then
|
|
169
|
+
// fixed: every later step and every lifecycle command reads it from
|
|
170
|
+
// install.json.
|
|
171
|
+
const region = await stepRegion(opts, projectId, meta);
|
|
172
|
+
if (meta) writeMetadata(installId, { ...meta, projectId, region });
|
|
173
|
+
|
|
174
|
+
// === 3. Project create ===
|
|
175
|
+
await stepProjectCreate(installId, projectId);
|
|
176
|
+
|
|
177
|
+
// === 4. Billing link ===
|
|
178
|
+
await stepBillingLink(installId, projectId, opts.nonInteractive);
|
|
179
|
+
|
|
180
|
+
// === 5. Enable APIs ===
|
|
181
|
+
await stepEnableApis(installId, projectId, provider);
|
|
182
|
+
|
|
183
|
+
// === 6. Service account + roles + impersonation (no key, #696) ===
|
|
184
|
+
// v0.3.12 — dropped the firebase-hosting-identity step. The
|
|
185
|
+
// Service Usage GenerateServiceIdentity API doesn't support
|
|
186
|
+
// `firebasehosting.googleapis.com` (returns
|
|
187
|
+
// IAM_SERVICE_NOT_CONFIGURED_FOR_IDENTITIES). The Firebase
|
|
188
|
+
// Hosting SA `service-<projectnum>@gcp-sa-firebasehosting.iam.gserviceaccount.com`
|
|
189
|
+
// is created lazily by Firebase on first deploy with a Cloud
|
|
190
|
+
// Run rewrite. We instead grant `roles/run.invoker` at the
|
|
191
|
+
// PROJECT level (vs the Cloud Run service level) — project IAM
|
|
192
|
+
// accepts deferred google-managed principals; v1 Cloud Run IAM
|
|
193
|
+
// didn't.
|
|
194
|
+
const saEmail = provisionerServiceAccountEmail(projectId);
|
|
195
|
+
await stepServiceAccount(installId, projectId, saEmail);
|
|
196
|
+
await stepGrantRoles(installId, projectId, saEmail, runMode);
|
|
197
|
+
// Pulumi runs here only in local mode; the install job runs AS the
|
|
198
|
+
// provisioner, so this terminal needs no Application Default Credentials.
|
|
199
|
+
await stepKeylessProvisioner(projectId, secretsDir, {
|
|
200
|
+
nonInteractive: opts.nonInteractive,
|
|
201
|
+
checkAdc: runMode === "local",
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// === 8. Cost-estimate pre-flight ===
|
|
205
|
+
// v0.3.3 — provider variant threads through so gcp-firebase
|
|
206
|
+
// drops the LB / Cloud CDN / Cloud Armor lines (saves ~$19/mo
|
|
207
|
+
// vs gcp). Default 'gcp' for backwards compatibility with
|
|
208
|
+
// installs that don't set the field.
|
|
209
|
+
const estimate = estimateGcpCost({ ...GCP_STACK_KNOB_DEFAULTS, provider, region });
|
|
210
|
+
note(
|
|
211
|
+
[
|
|
212
|
+
bold(
|
|
213
|
+
"Estimated monthly cost (resource floor — actual usage adds AI calls + egress + storage growth)",
|
|
214
|
+
),
|
|
215
|
+
` ${estimate.regionNote}`,
|
|
216
|
+
"",
|
|
217
|
+
...estimate.lines.map(
|
|
218
|
+
(l) =>
|
|
219
|
+
` ${dim(l.name.padEnd(40))} ${green(`$${l.monthlyUsd}`.padStart(5))}/mo ${dim(l.notes ?? "")}`,
|
|
220
|
+
),
|
|
221
|
+
"",
|
|
222
|
+
` ${bold("TOTAL".padEnd(40))} ${bold(`$${estimate.totalUsd}`.padStart(5))}/mo`,
|
|
223
|
+
"",
|
|
224
|
+
` ${dim(`Also deletes the default network's pre-populated firewall rules ${ABSENT_DEFAULT_FIREWALL_RULES.map((r) => r.name).join(" + ")} (Caelo runs no VMs and does not use that network).`)}`,
|
|
225
|
+
...(runMode === "job"
|
|
226
|
+
? [
|
|
227
|
+
` ${dim(`The install itself runs as a one-off Cloud Run job (${INSTALL_JOB_NAME}) in the project: about ${INSTALL_JOB_COST_NOTE} for one install run, nothing while idle.`)}`,
|
|
228
|
+
]
|
|
229
|
+
: []),
|
|
230
|
+
].join("\n"),
|
|
231
|
+
"Pre-flight",
|
|
232
|
+
);
|
|
233
|
+
if (!opts.nonInteractive) {
|
|
234
|
+
const proceed = await confirm({
|
|
235
|
+
message: `Provision ~${estimate.totalUsd} USD/mo on GCP project ${bold(projectId)} in ${bold(region)}?`,
|
|
236
|
+
initialValue: true,
|
|
237
|
+
});
|
|
238
|
+
if (isCancel(proceed) || !proceed) {
|
|
239
|
+
cancel("Cancelled at cost confirmation.");
|
|
240
|
+
process.exit(0);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// === 8.5. Where Pulumi's state lives (#696) ===
|
|
245
|
+
// A new install: the ops bucket + KMS key in its own project — after the
|
|
246
|
+
// cost confirmation, which lists them (CLAUDE.md §11.C: nothing billable
|
|
247
|
+
// before the y/N). An older install keeps its local state + passphrase.
|
|
248
|
+
const backend = await stepStateBackend(installId, projectId, region, saEmail, runMode);
|
|
249
|
+
|
|
250
|
+
// === 9. The rest runs where the run mode says (run-mode.ts) ===
|
|
251
|
+
if (runMode === "job") {
|
|
252
|
+
const { launchInstallJob } = await import("./gcp-install-job.js");
|
|
253
|
+
await launchInstallJob({
|
|
254
|
+
installId,
|
|
255
|
+
projectId,
|
|
256
|
+
region,
|
|
257
|
+
provisionerEmail: saEmail,
|
|
258
|
+
nonInteractive: opts.nonInteractive,
|
|
259
|
+
});
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
await runGcpConverge({
|
|
263
|
+
installId,
|
|
264
|
+
projectId,
|
|
265
|
+
region,
|
|
266
|
+
provider,
|
|
267
|
+
impersonateServiceAccount: saEmail,
|
|
268
|
+
backend,
|
|
269
|
+
nonInteractive: opts.nonInteractive,
|
|
270
|
+
setupLink: "print",
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** One-off cost of an install-job run (2 vCPU + 4 GiB for up to ~2 h). */
|
|
275
|
+
const INSTALL_JOB_COST_NOTE = "$0.20-0.40";
|
|
276
|
+
|
|
277
|
+
/** Inputs of {@link runGcpConverge}. */
|
|
278
|
+
export interface GcpConvergeOpts {
|
|
279
|
+
readonly installId: string;
|
|
280
|
+
readonly projectId: string;
|
|
281
|
+
readonly region: string;
|
|
282
|
+
readonly provider: "gcp" | "gcp-firebase";
|
|
283
|
+
/**
|
|
284
|
+
* The service account Pulumi's GCP provider impersonates (the operator's
|
|
285
|
+
* terminal), or null when this process already runs AS the provisioner
|
|
286
|
+
* (the install job, whose runtime service account it is).
|
|
287
|
+
*/
|
|
288
|
+
readonly impersonateServiceAccount: string | null;
|
|
289
|
+
readonly backend: ResolvedBackend;
|
|
290
|
+
/** No prompts: the DNS step polls without asking (the install job). */
|
|
291
|
+
readonly nonInteractive: boolean;
|
|
292
|
+
/**
|
|
293
|
+
* `print`: show the Owner setup link (the operator's terminal).
|
|
294
|
+
* `secret`: store it in Secret Manager for the operator's terminal to
|
|
295
|
+
* read — the install job, whose output lands in Cloud Logging (#656).
|
|
296
|
+
*/
|
|
297
|
+
readonly setupLink: "print" | "secret";
|
|
298
|
+
/** Called as each step starts — the install job records it in the bucket. */
|
|
299
|
+
readonly onPhase?: (phase: string, detail?: string) => void;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The half of an install that needs no operator identity: image digests,
|
|
304
|
+
* `pulumi up`, DNS + certificate waits, migrations, database logins, the
|
|
305
|
+
* operator-access job, the placeholder page and the summary. Runs in the
|
|
306
|
+
* operator's terminal (local mode) or in the install job (install-job.ts).
|
|
307
|
+
* Every step is idempotent or checkpointed in progress.json, so a re-run —
|
|
308
|
+
* or a new job execution — resumes where the last one stopped.
|
|
309
|
+
*/
|
|
310
|
+
export async function runGcpConverge(opts: GcpConvergeOpts): Promise<void> {
|
|
311
|
+
const { installId, projectId, region, provider, backend } = opts;
|
|
312
|
+
const phase = opts.onPhase ?? (() => {});
|
|
313
|
+
const meta = readMetadata(installId);
|
|
314
|
+
if (!meta) throw new Error(`No install.json for install '${installId}'.`);
|
|
315
|
+
const { domain, ownerEmail } = meta;
|
|
316
|
+
const costInputs = { ...GCP_STACK_KNOB_DEFAULTS, provider, region };
|
|
317
|
+
|
|
318
|
+
// === 9. Image digests to deploy ===
|
|
319
|
+
// An install that already runs a recorded release (from an earlier
|
|
320
|
+
// wizard run or from `upgrade`) keeps it: re-running the wizard converges
|
|
321
|
+
// infrastructure, it never changes versions — that is `upgrade`'s job,
|
|
322
|
+
// which also migrates the database before shifting traffic. Without this
|
|
323
|
+
// a re-run silently rolled a pinned/rc install back to `:latest`.
|
|
324
|
+
//
|
|
325
|
+
// An install provisioned before the record existed keeps what its
|
|
326
|
+
// services run. A new install resolves the floating `:latest` tag to a
|
|
327
|
+
// fixed sha256 digest: Cloud Run keys revisions by image reference, so a
|
|
328
|
+
// tag would never trigger a new revision. (The install job's launcher
|
|
329
|
+
// records the digests of its signed release first, so a job install never
|
|
330
|
+
// reaches `:latest`.)
|
|
331
|
+
phase("Choosing the release images");
|
|
332
|
+
const deployed = isStepDone(installId, `pulumi-up-${projectId}`);
|
|
333
|
+
if (deployed) {
|
|
334
|
+
// The kept release must be able to use the env the stack deploys.
|
|
335
|
+
const runtimeEnv = await checkDeployedRuntimeEnv({ projectId, region });
|
|
336
|
+
if (!runtimeEnv.ok) {
|
|
337
|
+
cancel(runtimeEnv.error);
|
|
338
|
+
process.exit(1);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const choice = await chooseImageDigests({
|
|
342
|
+
recorded: recordedImageDigests(meta),
|
|
343
|
+
deployed,
|
|
344
|
+
readLive: () => readDeployedImageDigests({ projectId, region }),
|
|
345
|
+
resolveLatest: resolveImageDigests,
|
|
346
|
+
});
|
|
347
|
+
if (!choice.ok) {
|
|
348
|
+
cancel(choice.error);
|
|
349
|
+
process.exit(1);
|
|
350
|
+
}
|
|
351
|
+
const imageDigests = choice.digests;
|
|
352
|
+
if (choice.source !== "latest") {
|
|
353
|
+
log.info(
|
|
354
|
+
`Keeping the release this install runs (admin ${dim(`${imageDigests.admin.slice(0, 19)}...`)}). Change versions with ${bold("upgrade")}.`,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// === 9.5. Drain the legacy `caelo_admin` SQL user before pulumi-up ===
|
|
359
|
+
// The 3a81c37 rename (caelo_admin → admin_role) leaves the old role
|
|
360
|
+
// in pg_roles owning N objects (sequences/grants survive ALTER TABLE
|
|
361
|
+
// OWNER). Pulumi's User-resource delete fails with "role cannot be
|
|
362
|
+
// dropped because some objects depend on it" (Postgres 400). REASSIGN
|
|
363
|
+
// OWNED + DROP OWNED clears the dependencies first; the DO block
|
|
364
|
+
// is a no-op when caelo_admin already doesn't exist (fresh installs +
|
|
365
|
+
// post-cleanup re-runs both pass through cleanly).
|
|
366
|
+
await stepDrainLegacyCaeloAdminUser(projectId, region);
|
|
367
|
+
|
|
368
|
+
// === 10. Pulumi up via Automation SDK ===
|
|
369
|
+
// Pre-built signed images live at the Caelo-team public AR repo
|
|
370
|
+
// (caelo-website/caelo-cms-images by default). Cloud Run reads them
|
|
371
|
+
// directly with no operator-side IAM binding — anonymous public pull.
|
|
372
|
+
phase("Provisioning the infrastructure (pulumi up, 8-15 min)");
|
|
373
|
+
await stepPulumiUp(installId, {
|
|
374
|
+
projectId,
|
|
375
|
+
domain,
|
|
376
|
+
// #650 — the wizard recorded it (or the install predates the setting).
|
|
377
|
+
stagingDomain: installStagingHost({ domain, stagingDomain: meta.stagingDomain }),
|
|
378
|
+
ownerEmail,
|
|
379
|
+
region,
|
|
380
|
+
impersonateServiceAccount: opts.impersonateServiceAccount,
|
|
381
|
+
backend,
|
|
382
|
+
cloudSqlTier: costInputs.cloudSqlTier,
|
|
383
|
+
cloudSqlHa: costInputs.cloudSqlHa,
|
|
384
|
+
adminMinInstances: costInputs.adminMinInstances,
|
|
385
|
+
adminMemory: costInputs.adminMemory,
|
|
386
|
+
gatewayMinInstances: costInputs.gatewayMinInstances,
|
|
387
|
+
wafAdaptiveProtection: costInputs.wafAdaptiveProtection,
|
|
388
|
+
// Same list the operator-access sync job keeps (installIapAllowlist).
|
|
389
|
+
iapAllowlist: installIapAllowlist(ownerEmail),
|
|
390
|
+
imageDigests,
|
|
391
|
+
// v0.3.1 — route pulumi up at the right stack folder + use the
|
|
392
|
+
// matching config namespace.
|
|
393
|
+
provider,
|
|
394
|
+
});
|
|
395
|
+
recordImageDigests(installId, imageDigests);
|
|
396
|
+
|
|
397
|
+
// === 10.1. Remove the default network's pre-populated firewall rules ===
|
|
398
|
+
// GCP creates them when Compute Engine is enabled (step 5). Run after
|
|
399
|
+
// Pulumi rather than right after enabling the API: the default network
|
|
400
|
+
// can appear asynchronously, and by now it has. Idempotent, every pass.
|
|
401
|
+
await stepRemoveDefaultFirewallRules(projectId);
|
|
402
|
+
|
|
403
|
+
// === 10. Wait for managed cert to flip from PROVISIONING → ACTIVE ===
|
|
404
|
+
// Pulumi reports the cert "created" the moment GCP queues it; the
|
|
405
|
+
// actual issuance + DNS validation takes 5-30 min depending on
|
|
406
|
+
// load + DNS propagation. The bootstrap URL is meaningless until
|
|
407
|
+
// ACTIVE — without this step the wizard would tell the operator
|
|
408
|
+
// "you're done" while HTTPS still ERR_CONNECTION_CLOSEDs.
|
|
409
|
+
//
|
|
410
|
+
// v0.3.14 — gcp-firebase has no LB and no managed cert; Firebase
|
|
411
|
+
// Hosting's HostingCustomDomain resource handles TLS issuance
|
|
412
|
+
// internally (status surfaces via dnsRecordsRequired output).
|
|
413
|
+
// Skip the cert wait on that provider.
|
|
414
|
+
//
|
|
415
|
+
// The DNS records come first: the certificate only validates once the
|
|
416
|
+
// admin host resolves to the load balancer. Only the "set now" records
|
|
417
|
+
// are applied here — the main domain moves at go-live (dns/groups.ts).
|
|
418
|
+
if (provider === "gcp") {
|
|
419
|
+
await stepGcpDnsRecords(installId, { nonInteractive: opts.nonInteractive, onPhase: phase });
|
|
420
|
+
phase(`Waiting for the TLS certificate of admin.${domain} (15-60 min)`);
|
|
421
|
+
await stepWaitForCertActive(projectId, `admin.${domain}`);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// === 11. Apply DB migrations against Cloud SQL via a one-shot Job ===
|
|
425
|
+
// Cloud SQL lives on a private VPC IP that's only reachable from
|
|
426
|
+
// inside the VPC. Spawn a Cloud Run Job using the same admin image
|
|
427
|
+
// (which carries packages/migrations) to apply admin + public schema.
|
|
428
|
+
// Idempotent — drizzle's __drizzle_migrations table tracks applied
|
|
429
|
+
// versions, so re-runs only apply NEW migrations on subsequent ups.
|
|
430
|
+
// P21 ship 3 — shared with `cms-provision upgrade`. Aborts the
|
|
431
|
+
// wizard with a clear error if migrations fail (vs. silently
|
|
432
|
+
// continuing with a half-migrated DB).
|
|
433
|
+
phase("Applying database migrations");
|
|
434
|
+
{
|
|
435
|
+
const { runMigrationsViaCloudRunJob } = await import("../migration-runner.js");
|
|
436
|
+
const r = await runMigrationsViaCloudRunJob({ projectId, region });
|
|
437
|
+
if (!r.ok) {
|
|
438
|
+
cancel(`Migrations failed: ${r.error}. Inspect the Cloud Run Job logs and re-run.`);
|
|
439
|
+
process.exit(1);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// === 11.2. The gateway's database login (#613) ===
|
|
444
|
+
// Migration 0248 created gateway_role without a password; it gets the
|
|
445
|
+
// gateway-role-password secret's value here. The gateway (deployed by
|
|
446
|
+
// Pulumi before migrations) retries its database on every request, so it
|
|
447
|
+
// starts serving /api/* as soon as this lands — no restart.
|
|
448
|
+
phase("Finishing: database logins, operator access, welcome page");
|
|
449
|
+
await stepGatewayRolePassword(projectId);
|
|
450
|
+
|
|
451
|
+
// === 11.4. The upgrader job: Settings → Updates → Install update (#696) ===
|
|
452
|
+
// Before operator access, which keeps the upgrader's caelo-mcp grant.
|
|
453
|
+
const releaseVersion = await releaseVersionForDigest(gcloud, "admin", imageDigests.admin);
|
|
454
|
+
const upgraderReady = await stepUpgraderJob(projectId, region, releaseVersion);
|
|
455
|
+
const keepSigner = upgraderReady || (await upgraderExists(projectId));
|
|
456
|
+
|
|
457
|
+
// === 11.5. Operator access: IAP follows the Caelo user list ===
|
|
458
|
+
await stepOperatorAccessSync(
|
|
459
|
+
projectId,
|
|
460
|
+
region,
|
|
461
|
+
provider,
|
|
462
|
+
ownerEmail,
|
|
463
|
+
keepSigner ? [`serviceAccount:${upgraderServiceAccountEmail(projectId, GCP_STACK_ENV)}`] : [],
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
// A fresh install ran every converge step above, so it is at this CLI's
|
|
467
|
+
// infrastructure contract — recorded only when the upgrader is in place
|
|
468
|
+
// (the last step a contract bump added). With the release version, the
|
|
469
|
+
// admin can install the next update itself.
|
|
470
|
+
if (upgraderReady) {
|
|
471
|
+
const version = releaseVersion;
|
|
472
|
+
recordImageDigests(installId, imageDigests, version);
|
|
473
|
+
recordInfraContractVersion(installId, INFRA_CONTRACT_VERSION);
|
|
474
|
+
if (!version) {
|
|
475
|
+
log.warn(
|
|
476
|
+
yellow(
|
|
477
|
+
"Could not tell which Caelo release the admin runs, so in-product updates stay off until the first `upgrade`.",
|
|
478
|
+
),
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// === 12. Upload the fresh-install placeholder to the static bucket ===
|
|
484
|
+
// Without this, https://<domain>/ returns the raw GCS NoSuchKey XML
|
|
485
|
+
// until the operator publishes their first deploy. Idempotent: skips
|
|
486
|
+
// if any object already exists at the bucket root (i.e. the static
|
|
487
|
+
// generator has already published).
|
|
488
|
+
//
|
|
489
|
+
// v0.3.14 — gcp-firebase serves the static site from Firebase
|
|
490
|
+
// Hosting, not a GCS bucket; the static-publisher's first deploy
|
|
491
|
+
// creates the initial Firebase Hosting version. Skip the GCS
|
|
492
|
+
// placeholder upload on that provider.
|
|
493
|
+
if (provider === "gcp") {
|
|
494
|
+
await stepUploadStaticPlaceholder(installId, projectId, domain);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// === 12. DNS records + bootstrap URL ===
|
|
498
|
+
// IAP is enabled directly on the LB BackendService (Pulumi-managed);
|
|
499
|
+
// no post-up gcloud step needed.
|
|
500
|
+
await stepFinalize(installId, opts.setupLink);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Resolve the floating `:latest` tag on each service's public AR image
|
|
505
|
+
* to its current sha256 digest, so the Pulumi stack can pin Cloud Run
|
|
506
|
+
* to the exact image rather than the mutable tag. Without this, a
|
|
507
|
+
* fresh release doesn't trigger a new Cloud Run revision because the
|
|
508
|
+
* image reference in the stack (":latest") is unchanged.
|
|
509
|
+
*
|
|
510
|
+
* `latest` = newest release — the same channel `cms-provision upgrade`
|
|
511
|
+
* defaults to. Deliberately NOT the `:main` dev tag: fresh installs
|
|
512
|
+
* start on a released version, and the per-merge dev images are pruned
|
|
513
|
+
* after 7 days (docs/maintainer-public-registry-setup.md, "Retention").
|
|
514
|
+
*/
|
|
515
|
+
async function resolveImageDigests(): Promise<ImageDigests> {
|
|
516
|
+
// The Caelo public image registry — not the install region.
|
|
517
|
+
const project = "caelo-website";
|
|
518
|
+
const registryRegion = "europe-west1";
|
|
519
|
+
const repo = "caelo-cms-images";
|
|
520
|
+
const out = { admin: "", gateway: "" };
|
|
521
|
+
for (const service of ["admin", "gateway"] as const) {
|
|
522
|
+
const s = spinner();
|
|
523
|
+
s.start(`Resolving ${service}:latest → digest...`);
|
|
524
|
+
// gcloud's --filter='tag=latest' is in a transitional state (warns +
|
|
525
|
+
// matches nothing) and 'tag:latest' substring-matching is unreliable.
|
|
526
|
+
// List all tags and grep for the exact-match row in JS.
|
|
527
|
+
const r = await gcloud([
|
|
528
|
+
"artifacts",
|
|
529
|
+
"docker",
|
|
530
|
+
"tags",
|
|
531
|
+
"list",
|
|
532
|
+
`${registryRegion}-docker.pkg.dev/${project}/${repo}/${service}`,
|
|
533
|
+
"--format=value(tag,version)",
|
|
534
|
+
]);
|
|
535
|
+
if (!r.ok) {
|
|
536
|
+
s.stop(red(`Could not list ${service} tags: ${r.stderr.trim() || "(no output)"}`));
|
|
537
|
+
cancel("Aborted.");
|
|
538
|
+
process.exit(1);
|
|
539
|
+
}
|
|
540
|
+
const exact = r.stdout
|
|
541
|
+
.split("\n")
|
|
542
|
+
.map((line) => line.trim().split(/\s+/))
|
|
543
|
+
.find(([tag]) => tag === "latest");
|
|
544
|
+
const digest = exact?.[1] ?? "";
|
|
545
|
+
if (!/^sha256:[0-9a-f]{64}$/.test(digest)) {
|
|
546
|
+
s.stop(red(`No usable 'latest' release image for ${service} (got: ${digest || "no tag"})`));
|
|
547
|
+
cancel(
|
|
548
|
+
"Aborted — the public mirror has no released image. Releases live at https://github.com/caelo-cms/caelo-cms/releases.",
|
|
549
|
+
);
|
|
550
|
+
process.exit(1);
|
|
551
|
+
}
|
|
552
|
+
s.stop(green(`${service}: ${dim(`${digest.slice(0, 19)}...`)}`));
|
|
553
|
+
out[service] = digest;
|
|
554
|
+
}
|
|
555
|
+
return out;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// =========================================================================
|
|
559
|
+
// Per-step helpers
|
|
560
|
+
// =========================================================================
|
|
561
|
+
|
|
562
|
+
async function stepActiveGcloud(): Promise<void> {
|
|
563
|
+
const stepName = "gcloud-active";
|
|
564
|
+
const account = await activeAccount();
|
|
565
|
+
if (!account) {
|
|
566
|
+
log.error(red("No active gcloud account."));
|
|
567
|
+
log.warn(
|
|
568
|
+
`Run ${bold("gcloud auth login")} in another terminal, then re-run ${bold(selfCommand())}.`,
|
|
569
|
+
);
|
|
570
|
+
cancel("Aborted — no gcloud auth.");
|
|
571
|
+
process.exit(2);
|
|
572
|
+
}
|
|
573
|
+
log.success(`gcloud auth: ${bold(account)}`);
|
|
574
|
+
void stepName;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async function stepProjectId(opts: GcpWizardOpts): Promise<string> {
|
|
578
|
+
if (opts.projectId) {
|
|
579
|
+
log.info(`Project id: ${bold(opts.projectId)} ${dim("(supplied via --project-id)")}`);
|
|
580
|
+
return opts.projectId;
|
|
581
|
+
}
|
|
582
|
+
const guess = opts.domain.split(".")[0]?.replace(/[^a-z0-9-]/g, "-") ?? "caelo";
|
|
583
|
+
return promptProjectId(guess);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Settle the install region (#607). A recorded region — or, for an install
|
|
588
|
+
* deployed before install.json recorded one, the region its services run
|
|
589
|
+
* in — is kept and a different `--region` is refused. A new install takes
|
|
590
|
+
* a validated `--region`, or asks: the picker lists only regions with every
|
|
591
|
+
* service the stack needs and preselects the gcloud default region (else
|
|
592
|
+
* the EU default). Non-interactive without `--region` aborts with the list.
|
|
593
|
+
*/
|
|
594
|
+
async function stepRegion(
|
|
595
|
+
opts: GcpWizardOpts,
|
|
596
|
+
projectId: string,
|
|
597
|
+
meta: InstallMetadata | null,
|
|
598
|
+
): Promise<string> {
|
|
599
|
+
const provider = opts.provider ?? "gcp";
|
|
600
|
+
const abort = (error: string): never => {
|
|
601
|
+
log.error(red(error));
|
|
602
|
+
cancel("Aborted.");
|
|
603
|
+
process.exit(1);
|
|
604
|
+
};
|
|
605
|
+
// A region becomes fixed with the first `pulumi up`. One recorded by an
|
|
606
|
+
// earlier run that stopped before it (cancelled at the cost table, a
|
|
607
|
+
// failed setup step) is only preselected and may still change.
|
|
608
|
+
const installed = isStepDone(opts.installId, `pulumi-up-${projectId}`);
|
|
609
|
+
let deployed: string | null = null;
|
|
610
|
+
if (!meta?.region && installed) {
|
|
611
|
+
const live = await readDeployedRegion({ projectId });
|
|
612
|
+
if (!live.ok) abort(live.error);
|
|
613
|
+
else deployed = live.region;
|
|
614
|
+
}
|
|
615
|
+
const available = await installableRegions(provider);
|
|
616
|
+
if (!available.ok) return abort(available.error);
|
|
617
|
+
const regions = available.regions;
|
|
618
|
+
const decision = decideRegion({
|
|
619
|
+
provider,
|
|
620
|
+
installId: opts.installId,
|
|
621
|
+
recorded: installed ? (meta?.region ?? null) : null,
|
|
622
|
+
deployed,
|
|
623
|
+
preselected: installed ? null : (meta?.region ?? null),
|
|
624
|
+
requested: opts.region,
|
|
625
|
+
nonInteractive: opts.nonInteractive,
|
|
626
|
+
regions,
|
|
627
|
+
});
|
|
628
|
+
switch (decision.kind) {
|
|
629
|
+
case "refuse":
|
|
630
|
+
return abort(decision.error);
|
|
631
|
+
case "keep":
|
|
632
|
+
log.info(
|
|
633
|
+
`Region: ${bold(decision.region)} ${dim(`(${decision.from === "install.json" ? "recorded at install" : "where the install runs"} — fixed)`)}`,
|
|
634
|
+
);
|
|
635
|
+
return decision.region;
|
|
636
|
+
case "use":
|
|
637
|
+
log.info(`Region: ${bold(decision.region)} ${dim("(fixed once installed)")}`);
|
|
638
|
+
return decision.region;
|
|
639
|
+
case "prompt":
|
|
640
|
+
break;
|
|
641
|
+
}
|
|
642
|
+
const suggestion: {
|
|
643
|
+
region: string;
|
|
644
|
+
source: "earlier-run" | "cli" | "eu-default";
|
|
645
|
+
note?: string;
|
|
646
|
+
} = decision.preselected
|
|
647
|
+
? { region: decision.preselected, source: "earlier-run" }
|
|
648
|
+
: suggestRegion(provider, await detectCliRegion(provider), regions);
|
|
649
|
+
if (suggestion.note) log.warn(suggestion.note);
|
|
650
|
+
const sourceHint = {
|
|
651
|
+
"earlier-run": "picked in your earlier run",
|
|
652
|
+
cli: "your gcloud default",
|
|
653
|
+
"eu-default": "EU default",
|
|
654
|
+
}[suggestion.source];
|
|
655
|
+
const choice = await select<string>({
|
|
656
|
+
message: `Region for Cloud SQL, storage and the services ${dim("(fixed after install)")}`,
|
|
657
|
+
initialValue: suggestion.region,
|
|
658
|
+
maxItems: 12,
|
|
659
|
+
options: regions.map((r) => ({
|
|
660
|
+
value: r.id,
|
|
661
|
+
label: r.id,
|
|
662
|
+
hint: r.id === suggestion.region ? `${r.name} — ${sourceHint}` : r.name,
|
|
663
|
+
})),
|
|
664
|
+
});
|
|
665
|
+
if (isCancel(choice)) {
|
|
666
|
+
cancel("Cancelled.");
|
|
667
|
+
process.exit(0);
|
|
668
|
+
}
|
|
669
|
+
return choice as string;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
async function stepProjectCreate(installId: string, projectId: string): Promise<void> {
|
|
673
|
+
const stepName = `project-create-${projectId}`;
|
|
674
|
+
if (isStepDone(installId, stepName)) {
|
|
675
|
+
log.success(`Project ${bold(projectId)} ${dim("(already created)")}`);
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
if (await projectExists(projectId)) {
|
|
679
|
+
log.success(`Project ${bold(projectId)} ${dim("(exists)")}`);
|
|
680
|
+
markStepDone(installId, stepName, { existed: true });
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
const s = spinner();
|
|
684
|
+
s.start(`Creating GCP project ${projectId}...`);
|
|
685
|
+
const r = await createProject(projectId, "Caelo CMS");
|
|
686
|
+
if (!r.ok) {
|
|
687
|
+
s.stop(red(`Failed: ${r.stderr.trim()}`));
|
|
688
|
+
log.error(
|
|
689
|
+
`gcloud projects create failed. Check that the id ${bold(projectId)} is globally unique + that you have project-create rights on the org.`,
|
|
690
|
+
);
|
|
691
|
+
cancel("Aborted.");
|
|
692
|
+
process.exit(1);
|
|
693
|
+
}
|
|
694
|
+
s.stop(green(`Project ${bold(projectId)} created`));
|
|
695
|
+
markStepDone(installId, stepName, { created: true });
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async function stepBillingLink(
|
|
699
|
+
installId: string,
|
|
700
|
+
projectId: string,
|
|
701
|
+
nonInteractive: boolean,
|
|
702
|
+
): Promise<void> {
|
|
703
|
+
const stepName = `billing-link-${projectId}`;
|
|
704
|
+
if (isStepDone(installId, stepName)) {
|
|
705
|
+
log.success(`Billing already linked ${dim("(checkpoint)")}`);
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
const accounts = await listBillingAccounts();
|
|
709
|
+
const open = accounts.filter((a) => a.open);
|
|
710
|
+
if (open.length === 0) {
|
|
711
|
+
log.error(red("No open billing accounts found."));
|
|
712
|
+
log.warn(
|
|
713
|
+
`Open one at https://console.cloud.google.com/billing then re-run ${bold(selfCommand())}.`,
|
|
714
|
+
);
|
|
715
|
+
cancel("Aborted.");
|
|
716
|
+
process.exit(1);
|
|
717
|
+
}
|
|
718
|
+
let chosen: BillingAccount;
|
|
719
|
+
if (open.length === 1) {
|
|
720
|
+
[chosen] = open as [BillingAccount];
|
|
721
|
+
log.info(`Billing account: ${bold(chosen.displayName)} ${dim(`(${chosen.id})`)}`);
|
|
722
|
+
} else if (nonInteractive) {
|
|
723
|
+
log.error(
|
|
724
|
+
red(
|
|
725
|
+
`Multiple billing accounts but --non-interactive passed. Re-run with --billing-account=<id> (one of: ${open.map((a) => a.id).join(", ")}).`,
|
|
726
|
+
),
|
|
727
|
+
);
|
|
728
|
+
cancel("Aborted.");
|
|
729
|
+
process.exit(1);
|
|
730
|
+
} else {
|
|
731
|
+
const choice = await select<string>({
|
|
732
|
+
message: "Pick a billing account",
|
|
733
|
+
options: open.map((a) => ({
|
|
734
|
+
value: a.id,
|
|
735
|
+
label: a.displayName,
|
|
736
|
+
hint: a.id,
|
|
737
|
+
})),
|
|
738
|
+
});
|
|
739
|
+
if (isCancel(choice)) {
|
|
740
|
+
cancel("Cancelled.");
|
|
741
|
+
process.exit(0);
|
|
742
|
+
}
|
|
743
|
+
const found = open.find((a) => a.id === (choice as string));
|
|
744
|
+
if (!found) {
|
|
745
|
+
cancel("Picked account not found in list — internal error.");
|
|
746
|
+
process.exit(1);
|
|
747
|
+
}
|
|
748
|
+
chosen = found;
|
|
749
|
+
}
|
|
750
|
+
const s = spinner();
|
|
751
|
+
s.start(`Linking billing ${chosen.id}...`);
|
|
752
|
+
const r = await linkBilling(projectId, chosen.id);
|
|
753
|
+
if (!r.ok) {
|
|
754
|
+
s.stop(red(`Failed: ${r.stderr.trim()}`));
|
|
755
|
+
cancel("Aborted.");
|
|
756
|
+
process.exit(1);
|
|
757
|
+
}
|
|
758
|
+
s.stop(green("Billing linked"));
|
|
759
|
+
markStepDone(installId, stepName, { billingAccountId: chosen.id });
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
async function stepEnableApis(
|
|
763
|
+
installId: string,
|
|
764
|
+
projectId: string,
|
|
765
|
+
provider: "gcp" | "gcp-firebase" = "gcp",
|
|
766
|
+
): Promise<void> {
|
|
767
|
+
// v0.3.1 — gcp-firebase needs the Firebase Management + Firebase
|
|
768
|
+
// Hosting APIs on top of the gcp baseline. Re-key the step name
|
|
769
|
+
// so installs that initially ran with `gcp` and then re-ran with
|
|
770
|
+
// `gcp-firebase` (re-key clears the checkpoint) re-enable the
|
|
771
|
+
// additional APIs. The base APIs are idempotent → re-enabling
|
|
772
|
+
// is a no-op.
|
|
773
|
+
const stepName = `enable-apis-${provider}-${projectId}`;
|
|
774
|
+
const apiCount =
|
|
775
|
+
provider === "gcp-firebase" ? REQUIRED_API_LIST.length + 2 : REQUIRED_API_LIST.length;
|
|
776
|
+
if (isStepDone(installId, stepName)) {
|
|
777
|
+
log.success(`APIs enabled ${dim(`(${apiCount} services, checkpointed)`)}`);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
const s = spinner();
|
|
781
|
+
s.start(`Enabling ${apiCount} GCP APIs (15-30s)...`);
|
|
782
|
+
const r = await enableApis(projectId, provider);
|
|
783
|
+
if (!r.ok) {
|
|
784
|
+
s.stop(red(`Failed: ${r.stderr.trim()}`));
|
|
785
|
+
cancel("Aborted.");
|
|
786
|
+
process.exit(1);
|
|
787
|
+
}
|
|
788
|
+
s.stop(green(`${apiCount} APIs enabled`));
|
|
789
|
+
markStepDone(installId, stepName, { count: apiCount });
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
async function stepServiceAccount(
|
|
793
|
+
installId: string,
|
|
794
|
+
projectId: string,
|
|
795
|
+
saEmail: string,
|
|
796
|
+
): Promise<void> {
|
|
797
|
+
const stepName = `service-account-${PROVISIONER_ACCOUNT_ID}`;
|
|
798
|
+
if (isStepDone(installId, stepName)) {
|
|
799
|
+
log.success(`Provisioner SA ${dim("(already created)")}`);
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
if (await serviceAccountExists(projectId, saEmail)) {
|
|
803
|
+
log.success(`Provisioner SA ${bold(saEmail)} ${dim("(exists)")}`);
|
|
804
|
+
markStepDone(installId, stepName, { existed: true, saEmail });
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
const s = spinner();
|
|
808
|
+
s.start(`Creating provisioner service account...`);
|
|
809
|
+
const r = await createServiceAccount(projectId, PROVISIONER_ACCOUNT_ID, "Caelo provisioner");
|
|
810
|
+
if (!r.ok) {
|
|
811
|
+
s.stop(red(`Failed: ${r.stderr.trim()}`));
|
|
812
|
+
cancel("Aborted.");
|
|
813
|
+
process.exit(1);
|
|
814
|
+
}
|
|
815
|
+
s.stop(green(`Provisioner SA ${bold(saEmail)} created`));
|
|
816
|
+
markStepDone(installId, stepName, { created: true, saEmail });
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
async function stepGrantRoles(
|
|
820
|
+
installId: string,
|
|
821
|
+
projectId: string,
|
|
822
|
+
saEmail: string,
|
|
823
|
+
runMode: RunMode,
|
|
824
|
+
): Promise<void> {
|
|
825
|
+
await stepGrantBaseRoles(installId, projectId, saEmail);
|
|
826
|
+
if (runMode === "job") await stepGrantInstallJobRoles(installId, projectId, saEmail);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* #696 — the roles the provisioner needs when it runs the install job
|
|
831
|
+
* itself (install-job.ts INSTALL_JOB_ROLES). Only for installs that use it.
|
|
832
|
+
*/
|
|
833
|
+
async function stepGrantInstallJobRoles(
|
|
834
|
+
installId: string,
|
|
835
|
+
projectId: string,
|
|
836
|
+
saEmail: string,
|
|
837
|
+
): Promise<void> {
|
|
838
|
+
const stepName = `grant-install-job-roles-${projectId}`;
|
|
839
|
+
if (isStepDone(installId, stepName)) {
|
|
840
|
+
log.success(`Install-job roles granted ${dim("(checkpointed)")}`);
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
const s = spinner();
|
|
844
|
+
s.start(
|
|
845
|
+
`Granting the provisioner ${INSTALL_JOB_ROLES.length} more roles to run the install job...`,
|
|
846
|
+
);
|
|
847
|
+
const { granted, failed } = await grantProvisionerRoles(projectId, saEmail, {
|
|
848
|
+
roles: INSTALL_JOB_ROLES,
|
|
849
|
+
});
|
|
850
|
+
if (failed.length > 0) {
|
|
851
|
+
s.stop(red(`Failed: ${failed.map((f) => `${f.role}: ${f.error}`).join("; ")}`));
|
|
852
|
+
cancel("Aborted — re-run the installer (idempotent).");
|
|
853
|
+
process.exit(1);
|
|
854
|
+
}
|
|
855
|
+
s.stop(green(`${granted} install-job roles granted`));
|
|
856
|
+
markStepDone(installId, stepName, { granted });
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
async function stepGrantBaseRoles(
|
|
860
|
+
installId: string,
|
|
861
|
+
projectId: string,
|
|
862
|
+
saEmail: string,
|
|
863
|
+
): Promise<void> {
|
|
864
|
+
const stepName = `grant-roles-${projectId}`;
|
|
865
|
+
if (isStepDone(installId, stepName)) {
|
|
866
|
+
log.success(
|
|
867
|
+
`IAM roles granted ${dim(`(${PROVISIONER_ROLE_LIST.length} roles, checkpointed)`)}`,
|
|
868
|
+
);
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
const s = spinner();
|
|
872
|
+
s.start(`Granting ${PROVISIONER_ROLE_LIST.length} IAM roles to the provisioner SA...`);
|
|
873
|
+
const { granted, failed } = await grantProvisionerRoles(projectId, saEmail);
|
|
874
|
+
if (failed.length > 0) {
|
|
875
|
+
s.stop(
|
|
876
|
+
red(`Granted ${granted}; failed ${failed.length}: ${failed.map((f) => f.role).join(", ")}`),
|
|
877
|
+
);
|
|
878
|
+
for (const f of failed) log.error(`${f.role}: ${f.error}`);
|
|
879
|
+
log.error(
|
|
880
|
+
`Some role bindings failed. You can re-run safely (idempotent), or grant the failed roles manually via gcloud.`,
|
|
881
|
+
);
|
|
882
|
+
cancel("Aborted.");
|
|
883
|
+
process.exit(1);
|
|
884
|
+
}
|
|
885
|
+
s.stop(green(`${granted} IAM roles granted`));
|
|
886
|
+
markStepDone(installId, stepName, { granted });
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Give `gateway_role` (created by migration 0248) its password from the
|
|
891
|
+
* `gateway-role-password` secret (#613). public_role already has its own:
|
|
892
|
+
* the stack set it from `public-role-password`. Idempotent, so it runs on
|
|
893
|
+
* every wizard pass.
|
|
894
|
+
*/
|
|
895
|
+
async function stepGatewayRolePassword(projectId: string): Promise<void> {
|
|
896
|
+
const s = spinner();
|
|
897
|
+
s.start("Setting the gateway's database login (gateway_role)...");
|
|
898
|
+
const install = { projectId, env: GCP_STACK_ENV };
|
|
899
|
+
const sqlInstance = await findSqlInstance(install);
|
|
900
|
+
const r = sqlInstance
|
|
901
|
+
? await ensureGatewayRolePassword({ ...install, sqlInstance })
|
|
902
|
+
: { status: "failed" as const, error: "the Cloud SQL instance was not found" };
|
|
903
|
+
if (r.status === "failed") {
|
|
904
|
+
s.stop(red(`Failed: ${r.error ?? ""}`));
|
|
905
|
+
log.error("Re-run the wizard once the cause is fixed (this step is idempotent).");
|
|
906
|
+
cancel("Aborted.");
|
|
907
|
+
process.exit(1);
|
|
908
|
+
}
|
|
909
|
+
s.stop(green("gateway_role ready"));
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* Set up the upgrader job (upgrader-setup.ts, #696): what the admin's
|
|
914
|
+
* "Install update" starts. After migrations, because the job's database
|
|
915
|
+
* user gets the role migration 0267 creates. A failure does not stop the
|
|
916
|
+
* install — the site works; updates then go through the CLI until a later
|
|
917
|
+
* `upgrade` sets the job up — so it only warns. Idempotent.
|
|
918
|
+
*
|
|
919
|
+
* @returns whether the job is ready
|
|
920
|
+
*/
|
|
921
|
+
async function stepUpgraderJob(
|
|
922
|
+
projectId: string,
|
|
923
|
+
region: string,
|
|
924
|
+
releaseVersion: string | null,
|
|
925
|
+
): Promise<boolean> {
|
|
926
|
+
// The upgrader of the release the install runs (a re-run keeps an older
|
|
927
|
+
// release — a newer upgrader would meet a schema it does not know yet).
|
|
928
|
+
const opsImageRef = releaseVersion ? await resolveOpsImageRef(releaseVersion) : null;
|
|
929
|
+
const digest = opsImageRef ? /@(sha256:[0-9a-f]{64})$/.exec(opsImageRef)?.[1] : undefined;
|
|
930
|
+
// The job runs with rights over the whole app: only a verified image.
|
|
931
|
+
if (digest && !(await verifyCosignAll([{ slug: "ops", digest }]))) {
|
|
932
|
+
log.warn(
|
|
933
|
+
`The upgrader is not set up: its image's signature did not verify. The install works; ${bold(selfCommand("upgrade"))} sets it up later.`,
|
|
934
|
+
);
|
|
935
|
+
return false;
|
|
936
|
+
}
|
|
937
|
+
const s = spinner();
|
|
938
|
+
s.start("Setting up the upgrader job (install updates from the admin)...");
|
|
939
|
+
const resolved = opsImageRef
|
|
940
|
+
? await resolveUpgraderTarget({ projectId, region, env: GCP_STACK_ENV, opsImageRef })
|
|
941
|
+
: {
|
|
942
|
+
ok: false as const,
|
|
943
|
+
error: releaseVersion
|
|
944
|
+
? `release ${releaseVersion} has no caelo-ops image in the registry`
|
|
945
|
+
: "the release this install runs could not be told from its images",
|
|
946
|
+
};
|
|
947
|
+
const r = resolved.ok
|
|
948
|
+
? await ensureUpgraderJob(resolved.target)
|
|
949
|
+
: { ok: false as const, done: [], error: resolved.error };
|
|
950
|
+
if (!r.ok) {
|
|
951
|
+
s.stop(yellow(`Upgrader job not set up: ${r.error}`));
|
|
952
|
+
log.warn(
|
|
953
|
+
`The install works; Settings → Updates shows the upgrade command instead of an Install button until ${bold(selfCommand("upgrade"))} sets the job up.`,
|
|
954
|
+
);
|
|
955
|
+
return false;
|
|
956
|
+
}
|
|
957
|
+
s.stop(green(`Upgrader job ready (${r.done.length} steps)`));
|
|
958
|
+
return true;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Whether an upgrader set up by an earlier run exists, so a failed setup
|
|
963
|
+
* this time still keeps its caelo-mcp signing grant (the operator-access
|
|
964
|
+
* job would otherwise remove it, breaking its health check).
|
|
965
|
+
*/
|
|
966
|
+
async function upgraderExists(projectId: string): Promise<boolean> {
|
|
967
|
+
const r = await gcloud([
|
|
968
|
+
"iam",
|
|
969
|
+
"service-accounts",
|
|
970
|
+
"describe",
|
|
971
|
+
upgraderServiceAccountEmail(projectId, GCP_STACK_ENV),
|
|
972
|
+
`--project=${projectId}`,
|
|
973
|
+
"--format=value(email)",
|
|
974
|
+
]);
|
|
975
|
+
return r.ok;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Set up the operator-access sync job (operator-access.ts): the only
|
|
980
|
+
* principal that may change who passes IAP, run as the operator's gcloud
|
|
981
|
+
* identity. After migrations, because the job's database user gets the role
|
|
982
|
+
* migration 0239 creates. Idempotent, so it runs on every wizard pass.
|
|
983
|
+
*/
|
|
984
|
+
async function stepOperatorAccessSync(
|
|
985
|
+
projectId: string,
|
|
986
|
+
region: string,
|
|
987
|
+
provider: "gcp" | "gcp-firebase",
|
|
988
|
+
ownerEmail: string,
|
|
989
|
+
mcpSigners: readonly string[],
|
|
990
|
+
): Promise<void> {
|
|
991
|
+
const s = spinner();
|
|
992
|
+
s.start("Setting up the operator-access sync job (Google IAP follows the user list)...");
|
|
993
|
+
const resolved = await resolveOperatorAccessTarget({
|
|
994
|
+
provider,
|
|
995
|
+
projectId,
|
|
996
|
+
region,
|
|
997
|
+
env: GCP_STACK_ENV,
|
|
998
|
+
ownerEmail,
|
|
999
|
+
});
|
|
1000
|
+
const r = resolved.ok
|
|
1001
|
+
? await ensureOperatorAccessSync({ ...resolved.target, mcpSigners })
|
|
1002
|
+
: { ok: false as const, done: [], error: resolved.error };
|
|
1003
|
+
if (!r.ok) {
|
|
1004
|
+
s.stop(red(`Failed: ${r.error}`));
|
|
1005
|
+
log.error("Re-run the wizard once the cause is fixed (this step is idempotent).");
|
|
1006
|
+
cancel("Aborted.");
|
|
1007
|
+
process.exit(1);
|
|
1008
|
+
}
|
|
1009
|
+
s.stop(green(`Operator-access sync job ready (${r.done.length} steps)`));
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Delete the firewall rules GCP pre-populates on the `default` network
|
|
1014
|
+
* (SSH, RDP, ICMP, internal; default-firewall.ts). A customised rule of the
|
|
1015
|
+
* same name, `default-allow-internal` while VMs sit on the default network,
|
|
1016
|
+
* or a failed delete is reported with the next step but does not stop
|
|
1017
|
+
* the install: the rules open nothing Caelo runs, and `upgrade` retries.
|
|
1018
|
+
*/
|
|
1019
|
+
async function stepRemoveDefaultFirewallRules(projectId: string): Promise<void> {
|
|
1020
|
+
const s = spinner();
|
|
1021
|
+
s.start("Removing the default network's pre-populated firewall rules...");
|
|
1022
|
+
const outcomes = await removeDefaultFirewallRules(projectId);
|
|
1023
|
+
const failed = outcomes.filter((o) => o.status === "failed");
|
|
1024
|
+
const deleted = outcomes.filter((o) => o.status === "applied");
|
|
1025
|
+
const summary =
|
|
1026
|
+
deleted.length > 0
|
|
1027
|
+
? `deleted ${deleted.length} default firewall rule(s)`
|
|
1028
|
+
: "no pre-populated default-network firewall rules present";
|
|
1029
|
+
if (failed.length > 0) s.stop(yellow(`Default firewall rules: ${failed.length} not removed`));
|
|
1030
|
+
else s.stop(green(`Default firewall rules ok (${summary})`));
|
|
1031
|
+
for (const o of deleted) log.info(` deleted: ${o.id} ${dim(`(${o.why})`)}`);
|
|
1032
|
+
for (const o of failed)
|
|
1033
|
+
log.warn(yellow(` not removed: ${o.id} — ${o.why}\n ${o.error ?? ""}`));
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* #696 — make the provisioner SA usable without a key: the operator's gcloud
|
|
1038
|
+
* account gets TokenCreator on it, an older install's `sa-key.json` is
|
|
1039
|
+
* deleted (locally and in IAM), and the credential chain Pulumi will use —
|
|
1040
|
+
* Application Default Credentials impersonating the SA — is verified before
|
|
1041
|
+
* anything billable runs. Idempotent; runs on every wizard pass.
|
|
1042
|
+
*/
|
|
1043
|
+
async function stepKeylessProvisioner(
|
|
1044
|
+
projectId: string,
|
|
1045
|
+
secretsDir: string,
|
|
1046
|
+
opts: { nonInteractive: boolean; checkAdc: boolean },
|
|
1047
|
+
): Promise<void> {
|
|
1048
|
+
const sa = provisionerServiceAccountEmail(projectId);
|
|
1049
|
+
const account = await activeAccount();
|
|
1050
|
+
if (!account) {
|
|
1051
|
+
cancel("No active gcloud account. Run `gcloud auth login`, then re-run.");
|
|
1052
|
+
process.exit(2);
|
|
1053
|
+
}
|
|
1054
|
+
const s = spinner();
|
|
1055
|
+
s.start(`Letting ${account} impersonate ${sa} (no key file)...`);
|
|
1056
|
+
const converged = await convergeKeylessProvisioner({
|
|
1057
|
+
projectId,
|
|
1058
|
+
secretsDir,
|
|
1059
|
+
operatorAccount: account,
|
|
1060
|
+
justCreated: true,
|
|
1061
|
+
});
|
|
1062
|
+
if (!converged.ok) {
|
|
1063
|
+
s.stop(red("Could not set up impersonation of the provisioner"));
|
|
1064
|
+
log.error(converged.error);
|
|
1065
|
+
cancel("Aborted.");
|
|
1066
|
+
process.exit(1);
|
|
1067
|
+
}
|
|
1068
|
+
if (converged.grant === "no-provisioner") {
|
|
1069
|
+
// stepServiceAccount ensured it and the lookup waited out propagation.
|
|
1070
|
+
s.stop(red(`${sa} does not exist`));
|
|
1071
|
+
cancel("Aborted — re-run the installer.");
|
|
1072
|
+
process.exit(1);
|
|
1073
|
+
}
|
|
1074
|
+
s.stop(
|
|
1075
|
+
green(
|
|
1076
|
+
converged.grant === "granted"
|
|
1077
|
+
? `${account} may now impersonate ${sa}`
|
|
1078
|
+
: `${account} may impersonate ${sa} ${dim("(already)")}`,
|
|
1079
|
+
),
|
|
1080
|
+
);
|
|
1081
|
+
if (converged.legacyKey === "removed") {
|
|
1082
|
+
log.warn(
|
|
1083
|
+
yellow("Removed the provisioner's long-lived key; this install now uses impersonation."),
|
|
1084
|
+
);
|
|
1085
|
+
for (const line of converged.lines) log.info(` ${line}`);
|
|
1086
|
+
}
|
|
1087
|
+
if (!opts.checkAdc) return;
|
|
1088
|
+
|
|
1089
|
+
const check = async () =>
|
|
1090
|
+
checkAdcImpersonation({
|
|
1091
|
+
projectId,
|
|
1092
|
+
operatorAccount: account,
|
|
1093
|
+
freshGrant: converged.grant === "granted",
|
|
1094
|
+
});
|
|
1095
|
+
const sAdc = spinner();
|
|
1096
|
+
sAdc.start("Checking that your Application Default Credentials can impersonate it...");
|
|
1097
|
+
let adc = await check();
|
|
1098
|
+
if (!adc.ok && adc.reason === "no-adc" && !opts.nonInteractive) {
|
|
1099
|
+
sAdc.stop(yellow("No Application Default Credentials yet"));
|
|
1100
|
+
const login = await confirm({
|
|
1101
|
+
message: `Run ${bold("gcloud auth application-default login")} now (opens your browser; sign in as ${account})?`,
|
|
1102
|
+
initialValue: true,
|
|
1103
|
+
});
|
|
1104
|
+
if (isCancel(login) || !login) {
|
|
1105
|
+
log.error(adc.error);
|
|
1106
|
+
cancel("Aborted.");
|
|
1107
|
+
process.exit(1);
|
|
1108
|
+
}
|
|
1109
|
+
const { spawnSync } = await import("node:child_process");
|
|
1110
|
+
spawnSync("gcloud", ["auth", "application-default", "login"], { stdio: "inherit" });
|
|
1111
|
+
sAdc.start("Checking again...");
|
|
1112
|
+
adc = await check();
|
|
1113
|
+
}
|
|
1114
|
+
if (!adc.ok) {
|
|
1115
|
+
sAdc.stop(red("Infrastructure credentials not ready"));
|
|
1116
|
+
log.error(adc.error);
|
|
1117
|
+
cancel("Aborted.");
|
|
1118
|
+
process.exit(1);
|
|
1119
|
+
}
|
|
1120
|
+
sAdc.stop(green(`Pulumi will run as ${sa} via impersonation`));
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* Decide and prepare where the install's Pulumi state lives (#696,
|
|
1125
|
+
* state-backend.ts chooseInstallBackend) and resolve it for Pulumi.
|
|
1126
|
+
*
|
|
1127
|
+
* For the ops bucket: ensures bucket + KMS key + IAM (idempotent, every
|
|
1128
|
+
* run), then records the backend in install.json and moves install.json +
|
|
1129
|
+
* progress.json into the bucket. A project whose bucket already holds
|
|
1130
|
+
* another install's install.json is refused — this machine would overwrite
|
|
1131
|
+
* that install's record; this install's own copy from an interrupted run is
|
|
1132
|
+
* taken over.
|
|
1133
|
+
*/
|
|
1134
|
+
async function stepStateBackend(
|
|
1135
|
+
installId: string,
|
|
1136
|
+
projectId: string,
|
|
1137
|
+
region: string,
|
|
1138
|
+
provisionerEmail: string,
|
|
1139
|
+
runMode: RunMode,
|
|
1140
|
+
): Promise<ResolvedBackend> {
|
|
1141
|
+
const root = installRoot(installId);
|
|
1142
|
+
const meta = readMetadata(installId);
|
|
1143
|
+
if (!meta) throw new Error(`No install.json for install '${installId}'.`);
|
|
1144
|
+
const kind = chooseInstallBackend(
|
|
1145
|
+
meta,
|
|
1146
|
+
hasLocalPulumiState(root),
|
|
1147
|
+
isStepDone(installId, `pulumi-up-${projectId}`),
|
|
1148
|
+
);
|
|
1149
|
+
if (kind === "local" && runMode === "job") {
|
|
1150
|
+
// The job reads the state from the project; a passphrase-encrypted
|
|
1151
|
+
// state on this machine is out of its reach.
|
|
1152
|
+
cancel(
|
|
1153
|
+
`This install's Pulumi state is on this machine (${describeBackend(meta, root)}), so the install job can't run it. Move it into project ${projectId} first — ${selfCommand("state", "migrate", "--install", installId)} — or re-run with --local.`,
|
|
1154
|
+
);
|
|
1155
|
+
process.exit(1);
|
|
1156
|
+
}
|
|
1157
|
+
if (kind === "local") {
|
|
1158
|
+
stepPulumiPassphrase(installId);
|
|
1159
|
+
log.info(
|
|
1160
|
+
`Pulumi state: ${dim(describeBackend(meta, root))} (this install predates state in project ${projectId}). Move it there, so any machine you sign in on can manage the install: ${bold(selfCommand("state", "migrate", "--install", installId))}`,
|
|
1161
|
+
);
|
|
1162
|
+
return resolveBackend(meta, {
|
|
1163
|
+
installRoot: root,
|
|
1164
|
+
readPassphrase: () => readSecret(installId, "pulumi-passphrase"),
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
const account = await activeAccount();
|
|
1169
|
+
if (!account) {
|
|
1170
|
+
cancel("No active gcloud account. Run `gcloud auth login`, then re-run.");
|
|
1171
|
+
process.exit(1);
|
|
1172
|
+
}
|
|
1173
|
+
const s = spinner();
|
|
1174
|
+
s.start(`Ensuring the install's state bucket + KMS key in ${projectId}...`);
|
|
1175
|
+
const r = await ensureOpsBackend({
|
|
1176
|
+
projectId,
|
|
1177
|
+
region,
|
|
1178
|
+
operatorAccount: account,
|
|
1179
|
+
provisionerEmail,
|
|
1180
|
+
});
|
|
1181
|
+
if (!r.ok) {
|
|
1182
|
+
s.stop(red("Could not set up where the install's state lives"));
|
|
1183
|
+
log.error(r.error);
|
|
1184
|
+
cancel("Aborted before anything else was provisioned; fix the above and re-run.");
|
|
1185
|
+
process.exit(1);
|
|
1186
|
+
}
|
|
1187
|
+
s.stop(
|
|
1188
|
+
green(
|
|
1189
|
+
r.actions.length > 0
|
|
1190
|
+
? `State bucket + KMS key ready ${dim(`(${r.actions.length} change(s))`)}`
|
|
1191
|
+
: `State bucket + KMS key ok ${dim("(already in place)")}`,
|
|
1192
|
+
),
|
|
1193
|
+
);
|
|
1194
|
+
for (const a of r.actions) log.info(dim(` ${a}`));
|
|
1195
|
+
|
|
1196
|
+
if (meta.stateBackend?.kind !== "gcs") {
|
|
1197
|
+
const record = gcsBackendRecord(projectId, region);
|
|
1198
|
+
const existing = new GcsStateStore(record.bucket, root).peek("install.json");
|
|
1199
|
+
const other = existing === null ? null : (JSON.parse(existing) as Partial<InstallMetadata>);
|
|
1200
|
+
// The same install (id + creation time) is this machine's own earlier
|
|
1201
|
+
// run, interrupted between writing the bucket and the local copy:
|
|
1202
|
+
// finishing the move is safe. Anything else is another install.
|
|
1203
|
+
if (other && !(other.installId === meta.installId && other.createdAt === meta.createdAt)) {
|
|
1204
|
+
cancel(
|
|
1205
|
+
`Project ${projectId} already has a Caelo install (${other.domain ?? "unknown domain"}) whose state lives in gs://${record.bucket}. This run would overwrite that install's record. To manage it from this machine run: ${selfCommand("attach", "--project", projectId)}`,
|
|
1206
|
+
);
|
|
1207
|
+
process.exit(1);
|
|
1208
|
+
}
|
|
1209
|
+
adoptStateBackend(installId, record);
|
|
1210
|
+
log.success(
|
|
1211
|
+
`Pulumi state + install metadata live in ${bold(describeBackend({ stateBackend: record }, root))}`,
|
|
1212
|
+
);
|
|
1213
|
+
}
|
|
1214
|
+
const current = readMetadata(installId);
|
|
1215
|
+
if (!current) throw new Error(`install.json for '${installId}' vanished from its store.`);
|
|
1216
|
+
return resolveBackend(current, { installRoot: root, readPassphrase: () => null });
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
function stepPulumiPassphrase(installId: string): string {
|
|
1220
|
+
const existing = readSecret(installId, "pulumi-passphrase");
|
|
1221
|
+
if (existing) {
|
|
1222
|
+
log.success(`Pulumi passphrase ${dim("(reused from secrets/, not regenerated)")}`);
|
|
1223
|
+
return existing;
|
|
1224
|
+
}
|
|
1225
|
+
const passphrase = randomBytes(32).toString("hex");
|
|
1226
|
+
writeSecret(installId, "pulumi-passphrase", passphrase);
|
|
1227
|
+
log.success(
|
|
1228
|
+
`Pulumi passphrase generated → ${dim(`~/.caelo-${installId}/secrets/pulumi-passphrase`)}`,
|
|
1229
|
+
);
|
|
1230
|
+
return passphrase;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
interface PulumiUpOpts {
|
|
1234
|
+
projectId: string;
|
|
1235
|
+
domain: string;
|
|
1236
|
+
/** #650 — the protected staging site's host (install setting). */
|
|
1237
|
+
stagingDomain: string;
|
|
1238
|
+
ownerEmail: string;
|
|
1239
|
+
region: string;
|
|
1240
|
+
/** #696 — the provisioner SA Pulumi impersonates (no key file); null in the install job. */
|
|
1241
|
+
impersonateServiceAccount: string | null;
|
|
1242
|
+
/** #696 — the install's state backend (state-backend.ts). */
|
|
1243
|
+
backend: ResolvedBackend;
|
|
1244
|
+
cloudSqlTier: string;
|
|
1245
|
+
cloudSqlHa: boolean;
|
|
1246
|
+
adminMinInstances: number;
|
|
1247
|
+
adminMemory: string;
|
|
1248
|
+
gatewayMinInstances: number;
|
|
1249
|
+
wafAdaptiveProtection: boolean;
|
|
1250
|
+
iapAllowlist: string[];
|
|
1251
|
+
imageDigests: ImageDigests;
|
|
1252
|
+
/** v0.3.1 — provider variant; routes pulumi up at the right stack. */
|
|
1253
|
+
provider?: "gcp" | "gcp-firebase";
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
async function stepPulumiUp(installId: string, opts: PulumiUpOpts): Promise<void> {
|
|
1257
|
+
const stepName = `pulumi-up-${opts.projectId}`;
|
|
1258
|
+
const deployed = isStepDone(installId, stepName);
|
|
1259
|
+
if (deployed) {
|
|
1260
|
+
log.success(`Pulumi up ${dim("(checkpointed — re-running for drift refresh)")}`);
|
|
1261
|
+
}
|
|
1262
|
+
const { secretsDir } = ensureInstallDir(installId);
|
|
1263
|
+
const root = installRoot(installId);
|
|
1264
|
+
// #696 — hold the install's state lock through `pulumi up` (`state
|
|
1265
|
+
// migrate` takes the same one), then re-check, uncached, that the state
|
|
1266
|
+
// still lives where this run resolved it: the bucket's install.json for a
|
|
1267
|
+
// bucket-backed install (another machine may have moved it back), the
|
|
1268
|
+
// file on disk for a local one.
|
|
1269
|
+
const opsBucket = gcsBackendRecord(opts.projectId, opts.region).bucket;
|
|
1270
|
+
let releaseLock: () => void;
|
|
1271
|
+
try {
|
|
1272
|
+
releaseLock = acquireStateLock(
|
|
1273
|
+
opts.backend.kind === "gcs" ? { kind: "gcs", bucket: opsBucket } : { kind: "local" },
|
|
1274
|
+
root,
|
|
1275
|
+
"installer pulumi up",
|
|
1276
|
+
);
|
|
1277
|
+
} catch (e) {
|
|
1278
|
+
cancel(e instanceof Error ? e.message : String(e));
|
|
1279
|
+
process.exit(1);
|
|
1280
|
+
}
|
|
1281
|
+
let moved: boolean;
|
|
1282
|
+
try {
|
|
1283
|
+
const nowText =
|
|
1284
|
+
opts.backend.kind === "gcs"
|
|
1285
|
+
? new GcsStateStore(opsBucket, root).peek("install.json")
|
|
1286
|
+
: JSON.stringify(readLocalMetadata(installId));
|
|
1287
|
+
const now = nowText ? (JSON.parse(nowText) as InstallMetadata | null) : null;
|
|
1288
|
+
moved =
|
|
1289
|
+
!now ||
|
|
1290
|
+
installBackend({ stateBackend: parseStateBackend(now.stateBackend) }).kind !==
|
|
1291
|
+
opts.backend.kind;
|
|
1292
|
+
} catch (e) {
|
|
1293
|
+
releaseLock();
|
|
1294
|
+
throw e;
|
|
1295
|
+
}
|
|
1296
|
+
if (moved) {
|
|
1297
|
+
releaseLock();
|
|
1298
|
+
cancel(
|
|
1299
|
+
"The install's Pulumi state has moved (state migrate) since this run started. Re-run the installer.",
|
|
1300
|
+
);
|
|
1301
|
+
process.exit(1);
|
|
1302
|
+
}
|
|
1303
|
+
log.info(`Pulumi up — wall-clock 8–15 min (Cloud SQL is the long pole). Streaming progress...`);
|
|
1304
|
+
|
|
1305
|
+
let resourceCount = 0;
|
|
1306
|
+
let result: Awaited<ReturnType<typeof pulumiUpGcp>>;
|
|
1307
|
+
try {
|
|
1308
|
+
result = await pulumiUpGcp(
|
|
1309
|
+
{
|
|
1310
|
+
installId,
|
|
1311
|
+
installRoot: root,
|
|
1312
|
+
secretsDir,
|
|
1313
|
+
...opts,
|
|
1314
|
+
// A deployed install's stack must exist: a missing one means the
|
|
1315
|
+
// state is elsewhere, and a fresh stack would try to recreate it all.
|
|
1316
|
+
stackMustExist: deployed,
|
|
1317
|
+
provider: opts.provider ?? "gcp",
|
|
1318
|
+
},
|
|
1319
|
+
(kind, message) => {
|
|
1320
|
+
if (kind === "resource") {
|
|
1321
|
+
resourceCount++;
|
|
1322
|
+
if (resourceCount % 5 === 0) {
|
|
1323
|
+
process.stdout.write(`\r${dim(` ${resourceCount} resources updated`)}`);
|
|
1324
|
+
}
|
|
1325
|
+
} else if (kind === "error") {
|
|
1326
|
+
process.stdout.write("\n");
|
|
1327
|
+
log.error(red(redactCredentials(message)));
|
|
1328
|
+
}
|
|
1329
|
+
// logs: silent — too noisy for a live progress UI; pulumi
|
|
1330
|
+
// diagnostics will print on failure via the SDK's onOutput.
|
|
1331
|
+
},
|
|
1332
|
+
);
|
|
1333
|
+
} finally {
|
|
1334
|
+
releaseLock();
|
|
1335
|
+
}
|
|
1336
|
+
process.stdout.write("\n");
|
|
1337
|
+
log.success(
|
|
1338
|
+
green(
|
|
1339
|
+
`Pulumi up complete — ${result.resourceCount.created} created, ${result.resourceCount.updated} updated, ${result.resourceCount.deleted} deleted`,
|
|
1340
|
+
),
|
|
1341
|
+
);
|
|
1342
|
+
markStepDone(installId, stepName, { outputs: result.outputs });
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/**
|
|
1346
|
+
* Poll the load balancer's managed certificates until one serves the admin
|
|
1347
|
+
* host (cert-wait.ts). Pulumi reports a certificate "created" the moment GCP
|
|
1348
|
+
* queues it; validation takes 5-60 min after DNS resolves. Without this step
|
|
1349
|
+
* the wizard would print the bootstrap URL while HTTPS still fails. Only the
|
|
1350
|
+
* admin host is waited for: the main domain validates after go-live.
|
|
1351
|
+
*/
|
|
1352
|
+
async function stepWaitForCertActive(projectId: string, adminHost: string): Promise<void> {
|
|
1353
|
+
// clack's spinner swallows SIGINT; exit explicitly so Ctrl-C stops the wait.
|
|
1354
|
+
const s = spinner({
|
|
1355
|
+
cancelMessage: "Stopped. Re-run the installer to resume waiting for the certificate.",
|
|
1356
|
+
onCancel: () => process.exit(130),
|
|
1357
|
+
});
|
|
1358
|
+
s.start(
|
|
1359
|
+
`Waiting for the TLS certificate of ${adminHost} (usually 15-60 min once DNS resolves)...`,
|
|
1360
|
+
);
|
|
1361
|
+
log.info(
|
|
1362
|
+
dim(
|
|
1363
|
+
"Safe to stop with Ctrl-C: re-running the installer resumes here; finished steps are skipped.",
|
|
1364
|
+
),
|
|
1365
|
+
);
|
|
1366
|
+
const r = await waitForCert({
|
|
1367
|
+
probe: () => lbCertProbe(gcloud, { projectId, hostname: adminHost }),
|
|
1368
|
+
timeoutMs: CERT_WAIT_TIMEOUT_MS,
|
|
1369
|
+
intervalMs: 30 * 1000,
|
|
1370
|
+
onUpdate: (detail) => s.message(`Certificate: ${detail}`),
|
|
1371
|
+
});
|
|
1372
|
+
if (r.ok) {
|
|
1373
|
+
s.stop(green(`TLS certificate for ${adminHost} is ACTIVE`));
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
if (r.reason === "error") {
|
|
1377
|
+
s.stop(red(r.error));
|
|
1378
|
+
cancel("Aborted.");
|
|
1379
|
+
process.exit(1);
|
|
1380
|
+
}
|
|
1381
|
+
s.stop(yellow(`The certificate for ${adminHost} is not ACTIVE after 60 min.`));
|
|
1382
|
+
log.warn(
|
|
1383
|
+
[
|
|
1384
|
+
"It may still finish on its own. Check its status with:",
|
|
1385
|
+
` ${bold(`gcloud compute ssl-certificates list --global --project=${projectId}`)}`,
|
|
1386
|
+
"Common causes if it never turns ACTIVE:",
|
|
1387
|
+
` - ${adminHost} does not resolve to the load balancer IP yet (check \`dig +short ${adminHost}\`)`,
|
|
1388
|
+
" - the load balancer's port 80/443 is not reachable from the internet",
|
|
1389
|
+
].join("\n"),
|
|
1390
|
+
);
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
/**
|
|
1394
|
+
* Run REASSIGN OWNED BY caelo_admin TO admin_role + DROP OWNED BY
|
|
1395
|
+
* caelo_admin in both cms_admin + cms_public, via a one-shot Cloud Run
|
|
1396
|
+
* Job. Idempotent: the SQL is wrapped in DO IF EXISTS so fresh installs
|
|
1397
|
+
* (no caelo_admin) + post-cleanup re-runs both pass through cleanly.
|
|
1398
|
+
*
|
|
1399
|
+
* Why this exists: the SQL-user rename in commit 3a81c37 left
|
|
1400
|
+
* `caelo_admin` in pg_roles owning N sequences/grants the prior
|
|
1401
|
+
* ALTER TABLE OWNER fix didn't reach. Pulumi's User-delete subsequently
|
|
1402
|
+
* fails with "role cannot be dropped because some objects depend on
|
|
1403
|
+
* it." REASSIGN/DROP OWNED clears them first.
|
|
1404
|
+
*
|
|
1405
|
+
* Skipped silently when no admin Cloud Run service exists (fresh
|
|
1406
|
+
* installs that haven't booted the admin yet).
|
|
1407
|
+
*/
|
|
1408
|
+
async function stepDrainLegacyCaeloAdminUser(projectId: string, region: string): Promise<void> {
|
|
1409
|
+
const s = spinner();
|
|
1410
|
+
s.start("Draining legacy caelo_admin postgres user (idempotent)...");
|
|
1411
|
+
|
|
1412
|
+
// Resolve current admin image; skip if missing (fresh install).
|
|
1413
|
+
const adminDescr = await gcloud([
|
|
1414
|
+
"run",
|
|
1415
|
+
"services",
|
|
1416
|
+
"describe",
|
|
1417
|
+
"caelo-production-admin-3efcfea",
|
|
1418
|
+
"--region",
|
|
1419
|
+
region,
|
|
1420
|
+
"--project",
|
|
1421
|
+
projectId,
|
|
1422
|
+
"--format=json",
|
|
1423
|
+
]);
|
|
1424
|
+
if (!adminDescr.ok) {
|
|
1425
|
+
s.stop(green("No admin service yet — nothing to drain (fresh install)"));
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
let adminImg = "";
|
|
1429
|
+
let networkRef = "";
|
|
1430
|
+
let subnetRef = "";
|
|
1431
|
+
try {
|
|
1432
|
+
const d = JSON.parse(adminDescr.stdout) as {
|
|
1433
|
+
spec: {
|
|
1434
|
+
template: {
|
|
1435
|
+
metadata?: { annotations?: Record<string, string> };
|
|
1436
|
+
spec: {
|
|
1437
|
+
containers: { image?: string }[];
|
|
1438
|
+
vpcAccess?: { networkInterfaces?: { network?: string; subnetwork?: string }[] };
|
|
1439
|
+
};
|
|
1440
|
+
};
|
|
1441
|
+
};
|
|
1442
|
+
};
|
|
1443
|
+
adminImg = d.spec.template.spec.containers[0]?.image ?? "";
|
|
1444
|
+
// Cloud Run v2 with Direct VPC stores network refs in the
|
|
1445
|
+
// `run.googleapis.com/network-interfaces` annotation (JSON-encoded
|
|
1446
|
+
// array), NOT in spec.template.spec.vpcAccess. Read both — newer
|
|
1447
|
+
// installs use the annotation; older ones use vpcAccess.
|
|
1448
|
+
const annotations = d.spec.template.metadata?.annotations ?? {};
|
|
1449
|
+
const niAnnotation = annotations["run.googleapis.com/network-interfaces"];
|
|
1450
|
+
if (niAnnotation) {
|
|
1451
|
+
try {
|
|
1452
|
+
const parsed = JSON.parse(niAnnotation) as { network?: string; subnetwork?: string }[];
|
|
1453
|
+
networkRef = parsed[0]?.network ?? "";
|
|
1454
|
+
subnetRef = parsed[0]?.subnetwork ?? "";
|
|
1455
|
+
} catch {
|
|
1456
|
+
// fall through to vpcAccess path
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
if (!networkRef || !subnetRef) {
|
|
1460
|
+
const ni = d.spec.template.spec.vpcAccess?.networkInterfaces?.[0];
|
|
1461
|
+
networkRef ||= ni?.network ?? "";
|
|
1462
|
+
subnetRef ||= ni?.subnetwork ?? "";
|
|
1463
|
+
}
|
|
1464
|
+
} catch {
|
|
1465
|
+
s.stop(yellow("Could not parse admin service describe — skipping drain"));
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
if (!adminImg) {
|
|
1469
|
+
s.stop(green("No admin image to spawn drain job — skipping"));
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
if (!networkRef || !subnetRef) {
|
|
1473
|
+
s.stop(yellow("Could not resolve VPC network from admin service — skipping drain"));
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
// Read postgres superuser password from Secret Manager.
|
|
1478
|
+
const secretFetch = await gcloud([
|
|
1479
|
+
"secrets",
|
|
1480
|
+
"versions",
|
|
1481
|
+
"access",
|
|
1482
|
+
"latest",
|
|
1483
|
+
"--secret",
|
|
1484
|
+
"caelo-production-postgres-password",
|
|
1485
|
+
"--project",
|
|
1486
|
+
projectId,
|
|
1487
|
+
]);
|
|
1488
|
+
if (!secretFetch.ok) {
|
|
1489
|
+
s.stop(yellow("Could not read postgres-password secret — skipping drain"));
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
const pgPass = secretFetch.stdout.trim();
|
|
1493
|
+
|
|
1494
|
+
// Sync the SQL `postgres` user's password to the Secret Manager value
|
|
1495
|
+
// (Pulumi may have rotated the secret without applying to the user yet).
|
|
1496
|
+
await gcloud([
|
|
1497
|
+
"sql",
|
|
1498
|
+
"users",
|
|
1499
|
+
"set-password",
|
|
1500
|
+
"postgres",
|
|
1501
|
+
"--instance",
|
|
1502
|
+
"caelo-production-pg-1d65811",
|
|
1503
|
+
"--project",
|
|
1504
|
+
projectId,
|
|
1505
|
+
"--password",
|
|
1506
|
+
pgPass,
|
|
1507
|
+
]);
|
|
1508
|
+
|
|
1509
|
+
// Resolve the SQL instance's private IP from gcloud (avoids reading
|
|
1510
|
+
// Pulumi outputs from inside the wizard).
|
|
1511
|
+
const sqlDescr = await gcloud([
|
|
1512
|
+
"sql",
|
|
1513
|
+
"instances",
|
|
1514
|
+
"describe",
|
|
1515
|
+
"caelo-production-pg-1d65811",
|
|
1516
|
+
"--project",
|
|
1517
|
+
projectId,
|
|
1518
|
+
"--format=value(ipAddresses[0].ipAddress)",
|
|
1519
|
+
]);
|
|
1520
|
+
const pgHost = (sqlDescr.stdout || "").trim();
|
|
1521
|
+
if (!pgHost) {
|
|
1522
|
+
s.stop(yellow("Could not resolve Cloud SQL private IP — skipping drain"));
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
const adminPg = `postgres://postgres:${pgPass}@${pgHost}:5432/cms_admin`;
|
|
1527
|
+
const publicPg = `postgres://postgres:${pgPass}@${pgHost}:5432/cms_public`;
|
|
1528
|
+
|
|
1529
|
+
// The cleanup script — uses bun's globalThis.Bun.SQL (already proven
|
|
1530
|
+
// pattern in hooks.server.ts + the migration runner). DO block makes
|
|
1531
|
+
// it safe to re-run.
|
|
1532
|
+
//
|
|
1533
|
+
// Per-step rationale (validated against caelo-website production):
|
|
1534
|
+
// - WITH INHERIT TRUE on both grants: PG 16 default is NOINHERIT,
|
|
1535
|
+
// so plain "GRANT caelo_admin TO postgres" makes postgres a member
|
|
1536
|
+
// but doesn't give it caelo_admin's privileges. REASSIGN OWNED
|
|
1537
|
+
// fails with "Only roles with privileges of role caelo_admin may
|
|
1538
|
+
// reassign objects owned by it." WITH INHERIT TRUE fixes it.
|
|
1539
|
+
// - ALTER SCHEMA public OWNER TO admin_role: in PG 16 the default
|
|
1540
|
+
// `public` schema is owned by cloudsqladmin (Cloud SQL) /
|
|
1541
|
+
// pg_database_owner (vanilla). The REASSIGN's per-function ACL
|
|
1542
|
+
// check ("permission denied for schema public") fails until
|
|
1543
|
+
// admin_role owns it.
|
|
1544
|
+
// - We count pg_proc (not pg_class): the legacy caelo_admin role
|
|
1545
|
+
// left behind owned FUNCTIONS, not tables. The before count was
|
|
1546
|
+
// 31 on the caelo-website install; after was 0.
|
|
1547
|
+
const script = `const SQL = globalThis.Bun.SQL; async function fix(name, u){ const s = new SQL(u); console.log(\`[\${name}] connected\`); const before = await s.unsafe(\`SELECT count(*)::int AS n FROM pg_proc p JOIN pg_roles r ON p.proowner=r.oid WHERE r.rolname = 'caelo_admin';\`); console.log(\`[\${name}] caelo_admin functions=\`, before); await s.unsafe(\`DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname='caelo_admin') THEN EXECUTE 'GRANT caelo_admin TO CURRENT_USER WITH INHERIT TRUE'; EXECUTE 'GRANT admin_role TO CURRENT_USER WITH INHERIT TRUE'; EXECUTE 'ALTER SCHEMA public OWNER TO admin_role'; EXECUTE 'REASSIGN OWNED BY caelo_admin TO admin_role'; EXECUTE 'DROP OWNED BY caelo_admin'; END IF; END $$;\`); const after = await s.unsafe(\`SELECT count(*)::int AS n FROM pg_proc p JOIN pg_roles r ON p.proowner=r.oid WHERE r.rolname = 'caelo_admin';\`); console.log(\`[\${name}] after functions=\`, after); await s.end(); } await fix('cms_admin', process.env.ADMIN_PG); await fix('cms_public', process.env.PUBLIC_PG); console.log('drained');`;
|
|
1548
|
+
|
|
1549
|
+
// Delete-then-create so we always run with the freshest config.
|
|
1550
|
+
await gcloud([
|
|
1551
|
+
"run",
|
|
1552
|
+
"jobs",
|
|
1553
|
+
"delete",
|
|
1554
|
+
"caelo-drain-legacy-user",
|
|
1555
|
+
"--region",
|
|
1556
|
+
region,
|
|
1557
|
+
"--project",
|
|
1558
|
+
projectId,
|
|
1559
|
+
"--quiet",
|
|
1560
|
+
]);
|
|
1561
|
+
const create = await gcloud([
|
|
1562
|
+
"run",
|
|
1563
|
+
"jobs",
|
|
1564
|
+
"create",
|
|
1565
|
+
"caelo-drain-legacy-user",
|
|
1566
|
+
`--image=${adminImg}`,
|
|
1567
|
+
"--region",
|
|
1568
|
+
region,
|
|
1569
|
+
"--project",
|
|
1570
|
+
projectId,
|
|
1571
|
+
"--service-account",
|
|
1572
|
+
`caelo-production-run-sa@${projectId}.iam.gserviceaccount.com`,
|
|
1573
|
+
"--network",
|
|
1574
|
+
networkRef.split("/").pop() ?? networkRef,
|
|
1575
|
+
"--subnet",
|
|
1576
|
+
subnetRef.split("/").pop() ?? subnetRef,
|
|
1577
|
+
"--vpc-egress=private-ranges-only",
|
|
1578
|
+
"--command=bun",
|
|
1579
|
+
// ^|^ delimiter form — JS contains commas so we can't use the default.
|
|
1580
|
+
// Wrap in async IIFE so top-level `await` in SCRIPT works (sync eval()
|
|
1581
|
+
// doesn't support TLA; new Function('return (async () => { ... })()')
|
|
1582
|
+
// does, and avoids needing to write SCRIPT to disk).
|
|
1583
|
+
`--args=^|^--bun|-e|(new Function('return (async () => { ' + process.env.SCRIPT + ' })()'))()`,
|
|
1584
|
+
`--set-env-vars=^|^SCRIPT=${script}|ADMIN_PG=${adminPg}|PUBLIC_PG=${publicPg}`,
|
|
1585
|
+
"--max-retries=0",
|
|
1586
|
+
"--task-timeout=2m",
|
|
1587
|
+
"--quiet",
|
|
1588
|
+
]);
|
|
1589
|
+
if (!create.ok) {
|
|
1590
|
+
s.stop(yellow(`Drain job create failed: ${create.stderr.trim()} — continuing`));
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
const exec = await gcloud([
|
|
1594
|
+
"run",
|
|
1595
|
+
"jobs",
|
|
1596
|
+
"execute",
|
|
1597
|
+
"caelo-drain-legacy-user",
|
|
1598
|
+
"--region",
|
|
1599
|
+
region,
|
|
1600
|
+
"--project",
|
|
1601
|
+
projectId,
|
|
1602
|
+
"--wait",
|
|
1603
|
+
]);
|
|
1604
|
+
if (!exec.ok) {
|
|
1605
|
+
s.stop(red(`Drain job failed: ${exec.stderr.trim()}`));
|
|
1606
|
+
cancel(
|
|
1607
|
+
"Could not drain legacy caelo_admin user. Inspect the Cloud Run Job logs " +
|
|
1608
|
+
"(`gcloud logging read 'resource.type=cloud_run_job AND " +
|
|
1609
|
+
"resource.labels.job_name=caelo-drain-legacy-user' --project=" +
|
|
1610
|
+
projectId +
|
|
1611
|
+
"`) and re-run the wizard. Aborting before pulumi-up to avoid the " +
|
|
1612
|
+
"predictable pg user-delete failure downstream.",
|
|
1613
|
+
);
|
|
1614
|
+
process.exit(1);
|
|
1615
|
+
}
|
|
1616
|
+
s.stop(green("Legacy caelo_admin user drained"));
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
/**
|
|
1620
|
+
* Upload the static placeholder so https://<domain>/ shows a friendly
|
|
1621
|
+
* "Coming soon" landing instead of GCS's raw NoSuchKey XML. Skipped
|
|
1622
|
+
* if anything is already in the bucket root (the static-generator's
|
|
1623
|
+
* first publish replaces this transparently).
|
|
1624
|
+
*/
|
|
1625
|
+
async function stepUploadStaticPlaceholder(
|
|
1626
|
+
installId: string,
|
|
1627
|
+
projectId: string,
|
|
1628
|
+
domain: string,
|
|
1629
|
+
): Promise<void> {
|
|
1630
|
+
const stepName = `static-placeholder-${projectId}`;
|
|
1631
|
+
if (isStepDone(installId, stepName)) {
|
|
1632
|
+
log.success(`Static placeholder uploaded ${dim("(checkpointed)")}`);
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
// Resolve bucket name from Pulumi outputs.
|
|
1636
|
+
const lsRoot = await gcloud([
|
|
1637
|
+
"storage",
|
|
1638
|
+
"buckets",
|
|
1639
|
+
"list",
|
|
1640
|
+
"--project",
|
|
1641
|
+
projectId,
|
|
1642
|
+
"--filter",
|
|
1643
|
+
"name~caelo-production-static",
|
|
1644
|
+
"--format=value(name)",
|
|
1645
|
+
]);
|
|
1646
|
+
const bucketName = (lsRoot.stdout || "").trim().split(/\s+/)[0];
|
|
1647
|
+
if (!bucketName) {
|
|
1648
|
+
log.warn(yellow("Static bucket not found; skipping placeholder upload."));
|
|
1649
|
+
return;
|
|
1650
|
+
}
|
|
1651
|
+
// Idempotency: skip if anything is in the bucket root already.
|
|
1652
|
+
const existing = await gcloud([
|
|
1653
|
+
"storage",
|
|
1654
|
+
"ls",
|
|
1655
|
+
`gs://${bucketName}/index.html`,
|
|
1656
|
+
"--project",
|
|
1657
|
+
projectId,
|
|
1658
|
+
]);
|
|
1659
|
+
if (existing.ok) {
|
|
1660
|
+
log.success(`Static bucket already populated ${dim("(skipping placeholder)")}`);
|
|
1661
|
+
markStepDone(installId, stepName, { skipped: "exists" });
|
|
1662
|
+
return;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
// Resolve the placeholder asset relative to this file (works in dev
|
|
1666
|
+
// from src/ and in the published tarball from dist/).
|
|
1667
|
+
const here = new URL(import.meta.url).pathname;
|
|
1668
|
+
const candidates = [
|
|
1669
|
+
join(here, "..", "..", "..", "static", "welcome.html"),
|
|
1670
|
+
join(here, "..", "..", "..", "..", "static", "welcome.html"),
|
|
1671
|
+
];
|
|
1672
|
+
let html = "";
|
|
1673
|
+
for (const path of candidates) {
|
|
1674
|
+
if (existsSync(path)) {
|
|
1675
|
+
html = readFileSync(path, "utf8");
|
|
1676
|
+
break;
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
if (!html) {
|
|
1680
|
+
log.warn(yellow("welcome.html template not found; skipping placeholder upload."));
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1683
|
+
// Substitute the admin URL.
|
|
1684
|
+
html = html.replaceAll("{{ADMIN_URL}}", `https://admin.${domain}`);
|
|
1685
|
+
|
|
1686
|
+
const s = spinner();
|
|
1687
|
+
s.start(`Uploading welcome page → gs://${bucketName}/index.html`);
|
|
1688
|
+
// Pipe via stdin so we don't write a temp file.
|
|
1689
|
+
const { spawn } = await import("node:child_process");
|
|
1690
|
+
const upload = await new Promise<{ ok: boolean; stderr: string }>((resolve) => {
|
|
1691
|
+
const child = spawn(
|
|
1692
|
+
"gcloud",
|
|
1693
|
+
[
|
|
1694
|
+
"storage",
|
|
1695
|
+
"cp",
|
|
1696
|
+
"--content-type",
|
|
1697
|
+
"text/html; charset=utf-8",
|
|
1698
|
+
"--cache-control",
|
|
1699
|
+
"no-cache, max-age=60",
|
|
1700
|
+
"-",
|
|
1701
|
+
`gs://${bucketName}/index.html`,
|
|
1702
|
+
"--project",
|
|
1703
|
+
projectId,
|
|
1704
|
+
],
|
|
1705
|
+
{ stdio: ["pipe", "inherit", "pipe"], env: gcloudEnv() },
|
|
1706
|
+
);
|
|
1707
|
+
let stderr = "";
|
|
1708
|
+
child.stderr.on("data", (d) => {
|
|
1709
|
+
stderr += d.toString();
|
|
1710
|
+
});
|
|
1711
|
+
child.on("close", (code) => resolve({ ok: code === 0, stderr }));
|
|
1712
|
+
child.stdin.write(html);
|
|
1713
|
+
child.stdin.end();
|
|
1714
|
+
});
|
|
1715
|
+
if (!upload.ok) {
|
|
1716
|
+
s.stop(red(`Upload failed: ${upload.stderr.trim()}`));
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
s.stop(green(`Welcome page live at https://${domain}/`));
|
|
1720
|
+
markStepDone(installId, stepName, { bucket: bucketName });
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
/**
|
|
1724
|
+
* The Owner setup link from the install's stack (`bootstrapUrlOut`). A
|
|
1725
|
+
* secret: read here, at the moment it is shown, never checkpointed (#656).
|
|
1726
|
+
* Fails loudly when the stack can't be read: the run's last step, so a
|
|
1727
|
+
* re-run picks it up.
|
|
1728
|
+
*/
|
|
1729
|
+
async function readBootstrapUrl(installId: string, provider: string): Promise<string> {
|
|
1730
|
+
const meta = readMetadata(installId);
|
|
1731
|
+
if (!meta) throw new Error(`No install.json for install '${installId}'.`);
|
|
1732
|
+
const value = await readStackOutput({
|
|
1733
|
+
backend: resolveBackend(meta, {
|
|
1734
|
+
installRoot: installRoot(installId),
|
|
1735
|
+
readPassphrase: () => readSecret(installId, "pulumi-passphrase"),
|
|
1736
|
+
}),
|
|
1737
|
+
provider: provider === "gcp-firebase" ? "gcp-firebase" : "gcp",
|
|
1738
|
+
name: "bootstrapUrlOut",
|
|
1739
|
+
});
|
|
1740
|
+
if (typeof value !== "string" || value === "") {
|
|
1741
|
+
throw new Error(
|
|
1742
|
+
"The stack has no bootstrapUrlOut output. Re-run the installer to refresh the stack.",
|
|
1743
|
+
);
|
|
1744
|
+
}
|
|
1745
|
+
return value;
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
/** The install's Pulumi outputs, as the `pulumi-up` step recorded them. */
|
|
1749
|
+
async function recordedStackOutputs(
|
|
1750
|
+
installId: string,
|
|
1751
|
+
projectId: string,
|
|
1752
|
+
): Promise<Record<string, unknown>> {
|
|
1753
|
+
const payload = (await import("../install-state.js")).getStepPayload<{
|
|
1754
|
+
outputs: Record<string, unknown>;
|
|
1755
|
+
}>(installId, `pulumi-up-${projectId}`);
|
|
1756
|
+
return payload?.outputs ?? {};
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
/** The stack's `dnsRecordsRequiredOut`, as records. */
|
|
1760
|
+
function stackDnsRecords(outputs: Record<string, unknown>): InstallDnsRecord[] {
|
|
1761
|
+
return Array.isArray(outputs.dnsRecordsRequiredOut)
|
|
1762
|
+
? (outputs.dnsRecordsRequiredOut as InstallDnsRecord[])
|
|
1763
|
+
: [];
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
const ADAPTER_RECORD_TYPES = new Set<string>(["A", "AAAA", "CNAME", "TXT"]);
|
|
1767
|
+
|
|
1768
|
+
/**
|
|
1769
|
+
* `gcp`: make the "set now" DNS records real through the DNS adapter
|
|
1770
|
+
* (Cloudflare API, or print + poll for a manual registrar) before the
|
|
1771
|
+
* certificate wait, which needs them. The main-domain records that switch a
|
|
1772
|
+
* live site are never applied here — the summary lists them for go-live.
|
|
1773
|
+
* Idempotent: a finished step is skipped on re-runs.
|
|
1774
|
+
*/
|
|
1775
|
+
async function stepGcpDnsRecords(
|
|
1776
|
+
installId: string,
|
|
1777
|
+
opts: { nonInteractive: boolean; onPhase: (phase: string, detail?: string) => void },
|
|
1778
|
+
): Promise<void> {
|
|
1779
|
+
const meta = readMetadata(installId);
|
|
1780
|
+
if (!meta?.projectId) return;
|
|
1781
|
+
const stepName = `dns-${meta.projectId}`;
|
|
1782
|
+
if (isStepDone(installId, stepName)) return;
|
|
1783
|
+
opts.onPhase("Setting up DNS");
|
|
1784
|
+
const records = stackDnsRecords(await recordedStackOutputs(installId, meta.projectId));
|
|
1785
|
+
if (records.length === 0) {
|
|
1786
|
+
cancel(
|
|
1787
|
+
"The stack reported no DNS records (dnsRecordsRequiredOut). Re-run the installer to refresh its outputs.",
|
|
1788
|
+
);
|
|
1789
|
+
process.exit(1);
|
|
1790
|
+
}
|
|
1791
|
+
const { setNow, atGoLive } = groupDnsRecords(records, meta.domain);
|
|
1792
|
+
if (atGoLive.length > 0) {
|
|
1793
|
+
log.warn(
|
|
1794
|
+
yellow(
|
|
1795
|
+
`Not touching ${meta.domain} itself: its records switch a live website to Caelo and are listed at the end for go-live.`,
|
|
1796
|
+
),
|
|
1797
|
+
);
|
|
1798
|
+
}
|
|
1799
|
+
const toApply = setNow.filter((r): r is InstallDnsRecord & { type: DnsRecord["type"] } =>
|
|
1800
|
+
ADAPTER_RECORD_TYPES.has(r.type),
|
|
1801
|
+
);
|
|
1802
|
+
const adapter = await pickDnsAdapter({
|
|
1803
|
+
domain: meta.domain,
|
|
1804
|
+
nonInteractive: opts.nonInteractive,
|
|
1805
|
+
});
|
|
1806
|
+
log.info(`DNS adapter: ${bold(adapter.name)}`);
|
|
1807
|
+
opts.onPhase(
|
|
1808
|
+
"Waiting for DNS",
|
|
1809
|
+
`Set at your registrar: ${toApply.map((r) => `${r.type} ${r.hostname} → ${r.value}`).join("; ")}`,
|
|
1810
|
+
);
|
|
1811
|
+
try {
|
|
1812
|
+
await adapter.applyRecords(
|
|
1813
|
+
toApply.map((r) => ({ hostname: r.hostname, type: r.type, value: r.value })),
|
|
1814
|
+
);
|
|
1815
|
+
markStepDone(installId, stepName, { adapter: adapter.name });
|
|
1816
|
+
} catch (e) {
|
|
1817
|
+
log.warn(
|
|
1818
|
+
yellow(`DNS records not confirmed yet: ${e instanceof Error ? e.message : String(e)}`),
|
|
1819
|
+
);
|
|
1820
|
+
log.warn(`Set them at your registrar, then re-run ${bold(selfCommand())} to resume.`);
|
|
1821
|
+
cancel("Stopped at the DNS step.");
|
|
1822
|
+
process.exit(1);
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
async function stepFinalize(installId: string, setupLink: "print" | "secret"): Promise<void> {
|
|
1827
|
+
const meta = readMetadata(installId);
|
|
1828
|
+
if (!meta?.projectId) return;
|
|
1829
|
+
const outputs = await recordedStackOutputs(installId, meta.projectId);
|
|
1830
|
+
const provider = String(outputs.providerOut ?? "gcp");
|
|
1831
|
+
const adminDomainOut = String(outputs.adminDomainOut ?? `admin.${meta.domain}`);
|
|
1832
|
+
const adminCloudRunUrl = String(outputs.adminCloudRunUrlOut ?? "");
|
|
1833
|
+
|
|
1834
|
+
// v0.3.15 — On gcp-firebase, the admin DomainMapping is opt-in
|
|
1835
|
+
// (`provisionAdminDomain=true` after Search Console verification);
|
|
1836
|
+
// without it, `admin.<domain>` doesn't resolve and the bootstrap
|
|
1837
|
+
// URL must point at the Cloud Run-generated *.run.app URL. The
|
|
1838
|
+
// bootstrap token in Pulumi's output already includes the right
|
|
1839
|
+
// path + query; we just swap the host.
|
|
1840
|
+
//
|
|
1841
|
+
// #656 — the link carries the setup token, so it is never checkpointed;
|
|
1842
|
+
// it is read from the stack here, where it is shown.
|
|
1843
|
+
const stackBootstrapUrl = await readBootstrapUrl(installId, provider);
|
|
1844
|
+
const bootstrapToken = stackBootstrapUrl.match(/token=([^&]+)/)?.[1] ?? "";
|
|
1845
|
+
const bootstrapUrl =
|
|
1846
|
+
provider === "gcp-firebase" && adminCloudRunUrl.length > 0
|
|
1847
|
+
? `${adminCloudRunUrl}/setup?token=${bootstrapToken}`
|
|
1848
|
+
: stackBootstrapUrl;
|
|
1849
|
+
// #696 — the install job's output lands in Cloud Logging: the link goes to
|
|
1850
|
+
// Secret Manager, where the operator's terminal reads it (install-job.ts).
|
|
1851
|
+
if (setupLink === "secret") {
|
|
1852
|
+
if (!meta.region) throw new Error(`install.json of ${installId} records no region.`);
|
|
1853
|
+
const stored = await storeSetupLink(
|
|
1854
|
+
gcloud,
|
|
1855
|
+
{ projectId: meta.projectId, region: meta.region },
|
|
1856
|
+
bootstrapUrl,
|
|
1857
|
+
);
|
|
1858
|
+
if (!stored.ok) {
|
|
1859
|
+
cancel(`Could not store the Owner setup link: ${stored.error}`);
|
|
1860
|
+
process.exit(1);
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
const setupLinkLine =
|
|
1864
|
+
setupLink === "print"
|
|
1865
|
+
? ` ${cyan(String(bootstrapUrl))}`
|
|
1866
|
+
: ` ${dim(`(shown in your Cloud Shell terminal; or: gcloud secrets versions access latest --secret=${INSTALL_SETUP_LINK_SECRET} --project=${meta.projectId})`)}`;
|
|
1867
|
+
|
|
1868
|
+
// Both providers report their records in `dnsRecordsRequiredOut`; the
|
|
1869
|
+
// summary lists them in two groups so the main-domain records that take a
|
|
1870
|
+
// live site offline are not set during the install (dns/groups.ts). On gcp
|
|
1871
|
+
// the "set now" group was already applied before the certificate wait.
|
|
1872
|
+
const dnsRecords = stackDnsRecords(outputs);
|
|
1873
|
+
const groups = groupDnsRecords(dnsRecords, meta.domain);
|
|
1874
|
+
const dnsLines: string[] = [];
|
|
1875
|
+
if (dnsRecords.length > 0) {
|
|
1876
|
+
dnsLines.push("", bold(`DNS records for ${meta.domain}:`));
|
|
1877
|
+
dnsLines.push(...formatDnsRecordGroups(groups, meta.domain).map((l) => ` ${l}`));
|
|
1878
|
+
if (groups.atGoLive.length > 0) {
|
|
1879
|
+
dnsLines.push(
|
|
1880
|
+
provider === "gcp-firebase"
|
|
1881
|
+
? ` ${dim("Firebase Hosting issues TLS for the main domain itself once its records point at it.")}`
|
|
1882
|
+
: ` ${dim(`The certificate for ${meta.domain} is issued after the switch (15-60 min of HTTPS errors on it).`)}`,
|
|
1883
|
+
);
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
const adminBase = provider === "gcp-firebase" ? adminCloudRunUrl : `https://${adminDomainOut}`;
|
|
1888
|
+
const adminNote: string[] =
|
|
1889
|
+
provider === "gcp-firebase"
|
|
1890
|
+
? [
|
|
1891
|
+
"",
|
|
1892
|
+
bold("Admin custom domain (admin.<domain>):"),
|
|
1893
|
+
` Not configured — using the Cloud Run URL above.`,
|
|
1894
|
+
` To bind ${cyan(`admin.${meta.domain}`)}, run`,
|
|
1895
|
+
` ${dim(selfCommand("admin-domain", "enable"))}`,
|
|
1896
|
+
` (it walks you through the one-time Search Console verification, prints the DNS record and waits for the certificate).`,
|
|
1897
|
+
"",
|
|
1898
|
+
bold("Protected staging site:"),
|
|
1899
|
+
` Stage builds render ${cyan(installStagingHost(meta))}; to serve it, run`,
|
|
1900
|
+
` ${dim(selfCommand("staging-domain", "enable"))}`,
|
|
1901
|
+
` It maps the host, turns on password protection and prints the staging login once;`,
|
|
1902
|
+
` add the DNS record it prints. The host serves pages after the first Stage.`,
|
|
1903
|
+
]
|
|
1904
|
+
: [];
|
|
1905
|
+
|
|
1906
|
+
note(
|
|
1907
|
+
[
|
|
1908
|
+
green(`✓ ${meta.domain} provisioned.`),
|
|
1909
|
+
...dnsLines,
|
|
1910
|
+
"",
|
|
1911
|
+
bold("Next steps:"),
|
|
1912
|
+
` 1. Owner setup — open in your browser (sign in with ${meta.ownerEmail}):`,
|
|
1913
|
+
setupLinkLine,
|
|
1914
|
+
` 2. AI provider — visit ${cyan(`${adminBase}/settings/ai`)} and paste your Anthropic API key`,
|
|
1915
|
+
` (encrypted under the project KEK and stored in the database, no env vars).`,
|
|
1916
|
+
` 3. Optional: connect Claude Code or another MCP client — at ${cyan(`${adminBase}/settings/mcp`)}`,
|
|
1917
|
+
` click ${bold("New token")} (scope ${bold("chat")}: Caelo's AI does the work; ${bold("admin")}: your agent drives Caelo's tools),`,
|
|
1918
|
+
" copy the bearer (shown once) and run the `claude mcp add` command the page shows.",
|
|
1919
|
+
" The admin is behind Google IAP, so run `gcloud auth application-default login` once on that machine.",
|
|
1920
|
+
` Details: ${cyan("https://caelo-cms.com/mcp")}`,
|
|
1921
|
+
...adminNote,
|
|
1922
|
+
"",
|
|
1923
|
+
bold("Lifecycle commands:"),
|
|
1924
|
+
` ${dim(selfCommand("status"))} — health check, and the exact upgrade command once a new release is out`,
|
|
1925
|
+
` ${dim(selfCommand("destroy"))} — tear everything down (irreversible)`,
|
|
1926
|
+
` New releases: ${cyan(`${adminBase}/settings/updates`)} shows the exact upgrade command for this install.`,
|
|
1927
|
+
].join("\n"),
|
|
1928
|
+
"Done",
|
|
1929
|
+
);
|
|
1930
|
+
void homedir; // unused-import guard
|
|
1931
|
+
void readFileSync;
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
// kleur unused-import guard for the yellow color helper kept for future warnings.
|
|
1935
|
+
void yellow;
|