@caelo-cms/provisioning 0.10.47 → 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/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 +119 -16
- package/dist/cli.js.map +1 -1
- package/dist/cloudsql-iam-user.d.ts +39 -0
- package/dist/cloudsql-iam-user.d.ts.map +1 -0
- package/dist/cloudsql-iam-user.js +119 -0
- package/dist/cloudsql-iam-user.js.map +1 -0
- package/dist/compose.d.ts +16 -0
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +26 -1
- package/dist/compose.js.map +1 -1
- package/dist/dns/index.d.ts +2 -0
- package/dist/dns/index.d.ts.map +1 -1
- package/dist/dns/index.js +3 -2
- package/dist/dns/index.js.map +1 -1
- package/dist/dns/manual.d.ts +5 -0
- package/dist/dns/manual.d.ts.map +1 -1
- package/dist/dns/manual.js +28 -12
- package/dist/dns/manual.js.map +1 -1
- package/dist/gcloud.d.ts +2 -0
- package/dist/gcloud.d.ts.map +1 -1
- package/dist/gcloud.js +1 -1
- package/dist/gcloud.js.map +1 -1
- package/dist/gcp-names.d.ts +15 -0
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +23 -0
- package/dist/gcp-names.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/infra-contract.d.ts +1 -1
- package/dist/infra-contract.js +7 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/install-job-runner.d.ts +13 -0
- package/dist/install-job-runner.d.ts.map +1 -0
- package/dist/install-job-runner.js +155 -0
- package/dist/install-job-runner.js.map +1 -0
- package/dist/install-job.d.ts +210 -0
- package/dist/install-job.d.ts.map +1 -0
- package/dist/install-job.js +449 -0
- package/dist/install-job.js.map +1 -0
- package/dist/install-state.d.ts +10 -1
- package/dist/install-state.d.ts.map +1 -1
- package/dist/install-state.js +10 -2
- package/dist/install-state.js.map +1 -1
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +19 -4
- package/dist/lifecycle.js.map +1 -1
- package/dist/migration-runner.d.ts +5 -0
- package/dist/migration-runner.d.ts.map +1 -1
- package/dist/migration-runner.js +1 -1
- package/dist/migration-runner.js.map +1 -1
- package/dist/operator-access.d.ts +14 -8
- package/dist/operator-access.d.ts.map +1 -1
- package/dist/operator-access.js +16 -102
- package/dist/operator-access.js.map +1 -1
- package/dist/run-mode.d.ts +27 -0
- package/dist/run-mode.d.ts.map +1 -0
- package/dist/run-mode.js +22 -0
- package/dist/run-mode.js.map +1 -0
- package/dist/runtime-secrets.d.ts +19 -0
- package/dist/runtime-secrets.d.ts.map +1 -1
- package/dist/runtime-secrets.js +44 -0
- package/dist/runtime-secrets.js.map +1 -1
- package/dist/self-hosted-roles.d.ts +22 -1
- package/dist/self-hosted-roles.d.ts.map +1 -1
- package/dist/self-hosted-roles.js +81 -12
- package/dist/self-hosted-roles.js.map +1 -1
- package/dist/self-hosted-upgrade.d.ts +17 -0
- package/dist/self-hosted-upgrade.d.ts.map +1 -1
- package/dist/self-hosted-upgrade.js +16 -1
- package/dist/self-hosted-upgrade.js.map +1 -1
- package/dist/stack-contract.d.ts +6 -0
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +10 -1
- package/dist/stack-contract.js.map +1 -1
- package/dist/upgrade/converge.d.ts +6 -0
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +59 -2
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrade/gcp-lookup.d.ts +6 -0
- package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
- package/dist/upgrade/gcp-lookup.js +25 -0
- package/dist/upgrade/gcp-lookup.js.map +1 -1
- package/dist/upgrade/release-images.d.ts +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 +4 -2
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +1 -1
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts +40 -0
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +223 -59
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +6 -4
- package/src/adapter.ts +97 -0
- package/src/adc-impersonation.ts +176 -0
- package/src/admin-domain.test.ts +254 -0
- package/src/admin-domain.ts +245 -0
- package/src/admin-memory.test.ts +151 -0
- package/src/agent/apply.test.ts +185 -0
- package/src/agent/apply.ts +124 -0
- package/src/agent/cli-provenance.test.ts +159 -0
- package/src/agent/cli-provenance.ts +191 -0
- package/src/agent/command.test.ts +77 -0
- package/src/agent/command.ts +564 -0
- package/src/agent/config.ts +86 -0
- package/src/agent/lock-config.test.ts +97 -0
- package/src/agent/lock.ts +110 -0
- package/src/agent/queue.test.ts +10 -0
- package/src/agent/queue.ts +91 -0
- package/src/agent/run.test.ts +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
package/src/lifecycle.ts
ADDED
|
@@ -0,0 +1,900 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lifecycle commands per CLAUDE.md §11.C: every install gets a
|
|
5
|
+
* `caelo-cms` CLI binary with first-class `upgrade / backup / restore
|
|
6
|
+
* / rotate-secret / status / destroy` operations. These are the
|
|
7
|
+
* day-2 operations operators do regularly — they shouldn't drop
|
|
8
|
+
* into provider tools for any of them.
|
|
9
|
+
*
|
|
10
|
+
* Each command:
|
|
11
|
+
* - acts on the install named by `--install <install-id or domain>`, or
|
|
12
|
+
* on the only install on this machine; with several and no flag it
|
|
13
|
+
* stops and lists them (selectInstall in install-state.ts)
|
|
14
|
+
* - dispatches to provider-specific implementations (gcp / aws /
|
|
15
|
+
* azure / self-hosted)
|
|
16
|
+
* - emits human-readable progress + a final summary
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { join, resolve as resolvePath } from "node:path";
|
|
20
|
+
import { provisioningUpgradeCommand } from "@caelo-cms/shared";
|
|
21
|
+
import { cancel, confirm, isCancel, log, note, select, spinner } from "@clack/prompts";
|
|
22
|
+
import { bold, cyan, dim, green, red, yellow } from "kleur/colors";
|
|
23
|
+
import { activeAccount, gcloud } from "./gcloud.js";
|
|
24
|
+
import { type GcloudRunner, realSleep } from "./gcloud-retry.js";
|
|
25
|
+
import { GCP_STACK_ENV } from "./gcp-names.js";
|
|
26
|
+
import { INFRA_CONTRACT_VERSION } from "./infra-contract.js";
|
|
27
|
+
import {
|
|
28
|
+
currentMetadata,
|
|
29
|
+
type ImageDigests,
|
|
30
|
+
type InstallMetadata,
|
|
31
|
+
installFlag,
|
|
32
|
+
installRoot,
|
|
33
|
+
installStagingHost,
|
|
34
|
+
listInstalls,
|
|
35
|
+
readMetadata,
|
|
36
|
+
readSecret,
|
|
37
|
+
recordImageDigests,
|
|
38
|
+
recordInfraContractVersion,
|
|
39
|
+
scrubInstallProgress,
|
|
40
|
+
selectInstall,
|
|
41
|
+
writeMetadata,
|
|
42
|
+
} from "./install-state.js";
|
|
43
|
+
import { runMigrationsViaCloudRunJob } from "./migration-runner.js";
|
|
44
|
+
import {
|
|
45
|
+
convergeKeylessProvisioner,
|
|
46
|
+
provisionerServiceAccountEmail,
|
|
47
|
+
} from "./provisioner-identity.js";
|
|
48
|
+
import { assertRegionUnchanged } from "./regions.js";
|
|
49
|
+
import {
|
|
50
|
+
type HttpFetch,
|
|
51
|
+
ROTATABLE_SECRETS,
|
|
52
|
+
type RotatableSecret,
|
|
53
|
+
rolesOfSecret,
|
|
54
|
+
rotateRuntimeSecret,
|
|
55
|
+
rotationRefusal,
|
|
56
|
+
} from "./runtime-secrets.js";
|
|
57
|
+
import { selfCommand } from "./self-command.js";
|
|
58
|
+
import {
|
|
59
|
+
backendShellEnv,
|
|
60
|
+
localStateNotice,
|
|
61
|
+
type ResolvedBackend,
|
|
62
|
+
resolveBackend,
|
|
63
|
+
} from "./state-backend.js";
|
|
64
|
+
import {
|
|
65
|
+
type AppRollPlan,
|
|
66
|
+
type ReleaseImage,
|
|
67
|
+
ROLL_ORDER,
|
|
68
|
+
type RollSlug,
|
|
69
|
+
} from "./upgrade/app-roll.js";
|
|
70
|
+
import { restoreCommand, takeSqlBackup } from "./upgrade/backup.js";
|
|
71
|
+
import { findServiceUrl, resolveGcpResourceName } from "./upgrade/gcp-lookup.js";
|
|
72
|
+
import { createIapHealthProbe, type HealthProbe } from "./upgrade/health.js";
|
|
73
|
+
import {
|
|
74
|
+
canonicalReleaseTag,
|
|
75
|
+
fetchLatestReleaseTag,
|
|
76
|
+
RELEASE_REGISTRY,
|
|
77
|
+
releaseImageRef,
|
|
78
|
+
releaseTag,
|
|
79
|
+
releaseVersionForDigest,
|
|
80
|
+
resolveOpsImageRef,
|
|
81
|
+
resolveTagDigest,
|
|
82
|
+
verifyCosignAll,
|
|
83
|
+
} from "./upgrade/release-images.js";
|
|
84
|
+
import { clackReporter } from "./upgrade/reporter.js";
|
|
85
|
+
import { upgradeGcpInstall } from "./upgrade/upgrade.js";
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The installs a command acts on. `--install <id or domain>` names one
|
|
89
|
+
* (`--install all` every one, where the command allows it). Without the flag:
|
|
90
|
+
* the only install on this machine; with several, an interactive terminal
|
|
91
|
+
* asks which one (or all), and a non-interactive run stops with the list.
|
|
92
|
+
* Never guesses — acting on the wrong install upgrades or wipes the wrong
|
|
93
|
+
* site (see selectInstall).
|
|
94
|
+
*/
|
|
95
|
+
async function chooseInstalls(opts: {
|
|
96
|
+
readonly verb: string;
|
|
97
|
+
readonly allowAll: boolean;
|
|
98
|
+
}): Promise<InstallMetadata[]> {
|
|
99
|
+
const installs = listInstalls();
|
|
100
|
+
let wanted: string | undefined;
|
|
101
|
+
try {
|
|
102
|
+
wanted = installFlag(process.argv);
|
|
103
|
+
} catch (e) {
|
|
104
|
+
log.error(red(e instanceof Error ? e.message : String(e)));
|
|
105
|
+
process.exit(2);
|
|
106
|
+
}
|
|
107
|
+
if (wanted === "all") {
|
|
108
|
+
if (!opts.allowAll) {
|
|
109
|
+
log.error(
|
|
110
|
+
red(`${opts.verb} runs on one install at a time; pass --install <install-id or domain>.`),
|
|
111
|
+
);
|
|
112
|
+
process.exit(2);
|
|
113
|
+
}
|
|
114
|
+
if (installs.length === 0) return exitNoInstall("No Caelo install found on this machine.");
|
|
115
|
+
return installs.map(currentMetadata);
|
|
116
|
+
}
|
|
117
|
+
if (wanted === undefined && installs.length > 1 && process.stdin.isTTY) {
|
|
118
|
+
const choice = await select<string>({
|
|
119
|
+
message: `Which install should ${opts.verb} act on?`,
|
|
120
|
+
options: [
|
|
121
|
+
...installs.map((m) => ({
|
|
122
|
+
value: m.installId,
|
|
123
|
+
label: m.domain,
|
|
124
|
+
hint: `${m.installId}, ${m.provider}`,
|
|
125
|
+
})),
|
|
126
|
+
...(opts.allowAll
|
|
127
|
+
? [{ value: "all", label: `All ${installs.length} installs`, hint: "one after another" }]
|
|
128
|
+
: []),
|
|
129
|
+
],
|
|
130
|
+
});
|
|
131
|
+
if (isCancel(choice)) {
|
|
132
|
+
cancel("Aborted.");
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
if (choice === "all") return installs.map(currentMetadata);
|
|
136
|
+
wanted = choice;
|
|
137
|
+
}
|
|
138
|
+
const selected = selectInstall(installs, wanted);
|
|
139
|
+
if (!selected.ok) return exitNoInstall(selected.message);
|
|
140
|
+
return [currentMetadata(selected.meta)];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function exitNoInstall(message: string): never {
|
|
144
|
+
log.error(red(message));
|
|
145
|
+
if (listInstalls().length === 0) {
|
|
146
|
+
log.warn(
|
|
147
|
+
`Run ${bold(selfCommand())} first to provision an install, OR copy ${dim("~/.caelo-<install-id>/")} from the provisioning machine.`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The one install a single-install command (rotate-secret, truncate, destroy) acts on. */
|
|
154
|
+
async function requireInstall(verb: string): Promise<{ installId: string; meta: InstallMetadata }> {
|
|
155
|
+
const [meta] = await chooseInstalls({ verb, allowAll: false });
|
|
156
|
+
if (!meta) return exitNoInstall("No Caelo install found on this machine.");
|
|
157
|
+
return { installId: meta.installId, meta };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Run `run` for each chosen install in turn, with a header when there are
|
|
162
|
+
* several. `run` reports whether the install succeeded; the first failure
|
|
163
|
+
* stops the batch (the installs after it are left untouched) and sets a
|
|
164
|
+
* non-zero exit code, so scripts see it too.
|
|
165
|
+
*
|
|
166
|
+
* @param check validates the chosen set before anything runs (e.g. a
|
|
167
|
+
* --region that cannot match several installs); returns an error or null
|
|
168
|
+
*/
|
|
169
|
+
async function forEachInstall(
|
|
170
|
+
verb: string,
|
|
171
|
+
run: (installId: string, meta: InstallMetadata) => Promise<boolean>,
|
|
172
|
+
check?: (chosen: readonly InstallMetadata[]) => string | null,
|
|
173
|
+
): Promise<void> {
|
|
174
|
+
const chosen = await chooseInstalls({ verb, allowAll: true });
|
|
175
|
+
const problem = check?.(chosen) ?? null;
|
|
176
|
+
if (problem) {
|
|
177
|
+
log.error(red(problem));
|
|
178
|
+
process.exit(2);
|
|
179
|
+
}
|
|
180
|
+
for (const [i, meta] of chosen.entries()) {
|
|
181
|
+
if (chosen.length > 1) {
|
|
182
|
+
note(`${meta.domain} ${dim(`(${meta.installId})`)}`, `${verb} ${i + 1}/${chosen.length}`);
|
|
183
|
+
}
|
|
184
|
+
if (await run(meta.installId, meta)) continue;
|
|
185
|
+
process.exitCode = 1;
|
|
186
|
+
const skipped = chosen.slice(i + 1);
|
|
187
|
+
if (skipped.length > 0) {
|
|
188
|
+
log.warn(
|
|
189
|
+
`${verb} failed for ${meta.domain}; stopped before ${skipped.map((m) => m.domain).join(", ")}.`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The install's recorded region (#607); exits with the actionable reason
|
|
198
|
+
* when install.json has none, or when `requested` names a different one.
|
|
199
|
+
*/
|
|
200
|
+
function installRegion(meta: InstallMetadata, requested?: string): string {
|
|
201
|
+
try {
|
|
202
|
+
return assertRegionUnchanged(meta, requested);
|
|
203
|
+
} catch (e) {
|
|
204
|
+
log.error(red(e instanceof Error ? e.message : String(e)));
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// =========================================================================
|
|
210
|
+
// status — health check + monthly cost
|
|
211
|
+
// =========================================================================
|
|
212
|
+
|
|
213
|
+
export async function statusCommand(): Promise<void> {
|
|
214
|
+
await forEachInstall("status", statusOf);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function statusOf(installId: string, meta: InstallMetadata): Promise<boolean> {
|
|
218
|
+
log.info(`Install: ${bold(installId)} ${dim(`(${meta.provider})`)}`);
|
|
219
|
+
log.info(`Domain: ${bold(meta.domain)}`);
|
|
220
|
+
log.info(`Project: ${bold(meta.projectId ?? "<self-hosted>")}`);
|
|
221
|
+
|
|
222
|
+
// #696 — an install made (or resumed) through the install job: how that
|
|
223
|
+
// job stands, from the same record the installer attaches with.
|
|
224
|
+
if (
|
|
225
|
+
(meta.provider === "gcp" || meta.provider === "gcp-firebase") &&
|
|
226
|
+
meta.stateBackend?.kind === "gcs"
|
|
227
|
+
) {
|
|
228
|
+
const { installJobStatusLines } = await import("./wizards/gcp-install-job.js");
|
|
229
|
+
for (const line of await installJobStatusLines(meta)) log.info(line);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (meta.provider === "gcp") {
|
|
233
|
+
await gcpStatus(meta);
|
|
234
|
+
} else {
|
|
235
|
+
log.warn(`status command for provider ${meta.provider} not yet implemented.`);
|
|
236
|
+
}
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* P20 — fetch the latest stable Caelo release tag from GitHub.
|
|
242
|
+
* Returns null on any failure (network, rate limit, no releases yet)
|
|
243
|
+
* — status output skips the "newer available" line in that case.
|
|
244
|
+
* Caches in-process for 10 minutes to keep repeated `status` calls
|
|
245
|
+
* polite to GitHub's unauthenticated rate limit (60 req/hr).
|
|
246
|
+
*/
|
|
247
|
+
let releaseCheckCache: { fetchedAt: number; latest: string | null } | null = null;
|
|
248
|
+
async function getLatestReleaseTag(): Promise<string | null> {
|
|
249
|
+
const now = Date.now();
|
|
250
|
+
if (releaseCheckCache && now - releaseCheckCache.fetchedAt < 10 * 60 * 1000) {
|
|
251
|
+
return releaseCheckCache.latest;
|
|
252
|
+
}
|
|
253
|
+
const r = await fetchLatestReleaseTag();
|
|
254
|
+
const latest = r.ok ? r.tag : null;
|
|
255
|
+
releaseCheckCache = { fetchedAt: now, latest };
|
|
256
|
+
return latest;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function gcpStatus(meta: InstallMetadata): Promise<void> {
|
|
260
|
+
if (!meta.projectId) return;
|
|
261
|
+
const s = spinner();
|
|
262
|
+
s.start("Resolving deployed resources + checking health...");
|
|
263
|
+
|
|
264
|
+
const region = installRegion(meta);
|
|
265
|
+
const adminName = await resolveGcpResourceName(
|
|
266
|
+
gcloud,
|
|
267
|
+
"run-service",
|
|
268
|
+
"caelo-production-admin",
|
|
269
|
+
meta.projectId,
|
|
270
|
+
region,
|
|
271
|
+
);
|
|
272
|
+
const sqlName = await resolveGcpResourceName(
|
|
273
|
+
gcloud,
|
|
274
|
+
"sql-instance",
|
|
275
|
+
"caelo-production-pg",
|
|
276
|
+
meta.projectId,
|
|
277
|
+
);
|
|
278
|
+
if (!adminName || !sqlName) {
|
|
279
|
+
s.stop(red("Could not resolve deployed resource names — is the install live?"));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const adminUri = await gcloud([
|
|
284
|
+
"run",
|
|
285
|
+
"services",
|
|
286
|
+
"describe",
|
|
287
|
+
adminName,
|
|
288
|
+
"--region",
|
|
289
|
+
region,
|
|
290
|
+
"--project",
|
|
291
|
+
meta.projectId,
|
|
292
|
+
"--format=value(status.url)",
|
|
293
|
+
]);
|
|
294
|
+
const sqlState = await gcloud([
|
|
295
|
+
"sql",
|
|
296
|
+
"instances",
|
|
297
|
+
"describe",
|
|
298
|
+
sqlName,
|
|
299
|
+
"--project",
|
|
300
|
+
meta.projectId,
|
|
301
|
+
"--format=value(state)",
|
|
302
|
+
]);
|
|
303
|
+
s.stop(green("Health check complete"));
|
|
304
|
+
|
|
305
|
+
// P20 — show running version vs latest available release. Pulled
|
|
306
|
+
// from @caelo-cms/shared (kept in lockstep by scripts/release.ts).
|
|
307
|
+
const { CAELO_VERSION } = await import("@caelo-cms/shared");
|
|
308
|
+
const latestTag = await getLatestReleaseTag();
|
|
309
|
+
const latestStable = latestTag?.replace(/^v/, "") ?? null;
|
|
310
|
+
// #696 — the upgrade runs as the new release's CLI and rolls exactly that
|
|
311
|
+
// release, on exactly this install (same command Settings → Updates shows).
|
|
312
|
+
const upgradeHint =
|
|
313
|
+
latestStable && latestStable !== CAELO_VERSION
|
|
314
|
+
? `${yellow(`v${latestStable} available`)} — run \`${provisioningUpgradeCommand({ version: latestStable, install: meta.domain })}\``
|
|
315
|
+
: latestStable === CAELO_VERSION
|
|
316
|
+
? green("up to date")
|
|
317
|
+
: dim("(latest unknown)");
|
|
318
|
+
|
|
319
|
+
note(
|
|
320
|
+
[
|
|
321
|
+
`${dim("Admin Cloud Run URL")} ${adminUri.ok ? bold(adminUri.stdout.trim()) : red("error")}`,
|
|
322
|
+
`${dim("Cloud SQL state")} ${sqlState.ok ? bold(sqlState.stdout.trim()) : red("error")}`,
|
|
323
|
+
`${dim("Public site")} ${cyan(`https://${meta.domain}`)}`,
|
|
324
|
+
`${dim("Admin (IAP-gated)")} ${cyan(`https://admin.${meta.domain}`)}`,
|
|
325
|
+
`${dim("CLI version")} v${CAELO_VERSION} ${upgradeHint}`,
|
|
326
|
+
].join("\n"),
|
|
327
|
+
"Status",
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// =========================================================================
|
|
332
|
+
// upgrade — converge the infrastructure, then roll Cloud Run to a release
|
|
333
|
+
// =========================================================================
|
|
334
|
+
|
|
335
|
+
interface UpgradeOpts {
|
|
336
|
+
/** Explicit semver to roll to (e.g. "0.5.3"). Defaults to "latest". */
|
|
337
|
+
readonly version?: string;
|
|
338
|
+
/** Pre-release channel: "stable" (default), "rc", "beta". */
|
|
339
|
+
readonly channel?: "stable" | "rc" | "beta";
|
|
340
|
+
/**
|
|
341
|
+
* P21 ship 4 — escape hatch for forks / staging environments using
|
|
342
|
+
* unsigned images. Default = verify with cosign; refuse to roll on
|
|
343
|
+
* mismatch.
|
|
344
|
+
*/
|
|
345
|
+
readonly skipVerify?: boolean;
|
|
346
|
+
/**
|
|
347
|
+
* #696 — roll with Cloud Run's readiness check only, without the deep
|
|
348
|
+
* health check (for installs the probe cannot reach, or a roll to a
|
|
349
|
+
* release that predates the deep health endpoint).
|
|
350
|
+
*/
|
|
351
|
+
readonly skipHealthCheck?: boolean;
|
|
352
|
+
/**
|
|
353
|
+
* #607 — the region the operator expects the install in. Optional; when
|
|
354
|
+
* given it must match install.json, because upgrade never moves regions.
|
|
355
|
+
*/
|
|
356
|
+
readonly region?: string;
|
|
357
|
+
/**
|
|
358
|
+
* The install to upgrade, already chosen by the caller (the wizard's
|
|
359
|
+
* "Upgrade <install>" entry). Without it the install comes from
|
|
360
|
+
* `--install` or the picker (chooseInstalls).
|
|
361
|
+
*/
|
|
362
|
+
readonly installId?: string;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export async function upgradeCommand(opts: UpgradeOpts = {}): Promise<void> {
|
|
366
|
+
if (opts.installId) {
|
|
367
|
+
const selected = selectInstall(listInstalls(), opts.installId);
|
|
368
|
+
if (!selected.ok) return exitNoInstall(selected.message);
|
|
369
|
+
const meta = currentMetadata(selected.meta);
|
|
370
|
+
if (!(await upgradeInstall(meta.installId, meta, opts))) process.exitCode = 1;
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
await forEachInstall(
|
|
374
|
+
"upgrade",
|
|
375
|
+
(installId, meta) => upgradeInstall(installId, meta, opts),
|
|
376
|
+
// Each install has its own recorded region (#607); one --region can't
|
|
377
|
+
// match several, and finding out halfway would leave a partial batch.
|
|
378
|
+
(chosen) =>
|
|
379
|
+
opts.region && chosen.length > 1
|
|
380
|
+
? "--region names one install's region; drop it when upgrading several installs."
|
|
381
|
+
: null,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Resolve both release images' digests from the public registry. Via the
|
|
387
|
+
* registry's anonymous V2 API, not `gcloud artifacts docker tags list`: that
|
|
388
|
+
* needs IAM read on Caelo's registry project, which operators don't have.
|
|
389
|
+
*/
|
|
390
|
+
async function resolveReleaseImages(
|
|
391
|
+
tag: string,
|
|
392
|
+
): Promise<{ ok: true; images: Record<RollSlug, ReleaseImage> } | { ok: false; error: string }> {
|
|
393
|
+
const images: Partial<Record<RollSlug, ReleaseImage>> = {};
|
|
394
|
+
for (const slug of ROLL_ORDER) {
|
|
395
|
+
const res = await resolveTagDigest(slug, tag);
|
|
396
|
+
if (!res.ok) {
|
|
397
|
+
const r = RELEASE_REGISTRY;
|
|
398
|
+
return {
|
|
399
|
+
ok: false,
|
|
400
|
+
error:
|
|
401
|
+
`Couldn't resolve image digest for ${slug}:${tag}: the public registry returned ${res.reason}.\n` +
|
|
402
|
+
`Verify the tag exists at https://${r.region}-docker.pkg.dev/${r.project}/${r.repo}/${slug}:${tag}\n` +
|
|
403
|
+
"(latest releases live at https://github.com/caelo-cms/caelo-cms/releases — pass --version vX.Y.Z to pin.)",
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
images[slug] = { digest: res.digest, imageRef: releaseImageRef(slug, res.digest) };
|
|
407
|
+
}
|
|
408
|
+
return { ok: true, images: images as Record<RollSlug, ReleaseImage> };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* `upgrade`'s step of the keyless provisioner migration (#696,
|
|
413
|
+
* provisioner-identity.ts) — a converge step (upgrade/converge.ts runs it
|
|
414
|
+
* first). Idempotent: on an install that already runs keyless it only
|
|
415
|
+
* confirms the grant.
|
|
416
|
+
*
|
|
417
|
+
* @returns false when the upgrade must stop (the message is printed)
|
|
418
|
+
*/
|
|
419
|
+
async function upgradeKeylessProvisioner(installId: string, projectId: string): Promise<boolean> {
|
|
420
|
+
const account = await activeAccount();
|
|
421
|
+
if (!account) {
|
|
422
|
+
log.error(red("No active gcloud account. Run `gcloud auth login`, then re-run upgrade."));
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
const s = spinner();
|
|
426
|
+
s.start("Ensuring the provisioner service account is used without a key...");
|
|
427
|
+
const r = await convergeKeylessProvisioner({
|
|
428
|
+
projectId,
|
|
429
|
+
secretsDir: join(installRoot(installId), "secrets"),
|
|
430
|
+
operatorAccount: account,
|
|
431
|
+
});
|
|
432
|
+
if (!r.ok) {
|
|
433
|
+
s.stop(red("Could not move the provisioner to impersonation. Aborting upgrade."));
|
|
434
|
+
log.error(r.error);
|
|
435
|
+
log.warn("Nothing was rolled and no migrations ran. Fix the above and re-run.");
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
if (r.grant === "no-provisioner") {
|
|
439
|
+
s.stop(
|
|
440
|
+
green(`No ${provisionerServiceAccountEmail(projectId)} in this project — nothing to do`),
|
|
441
|
+
);
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
s.stop(
|
|
445
|
+
green(
|
|
446
|
+
r.legacyKey === "removed"
|
|
447
|
+
? "Provisioner moved to impersonation; its long-lived key is deleted"
|
|
448
|
+
: `Provisioner keyless ok ${dim(`(${account} may impersonate it)`)}`,
|
|
449
|
+
),
|
|
450
|
+
);
|
|
451
|
+
if (r.legacyKey === "removed") {
|
|
452
|
+
note(
|
|
453
|
+
[
|
|
454
|
+
`This install no longer keeps a service-account key on your machine (#696).`,
|
|
455
|
+
...r.lines.map((l) => ` ${l}`),
|
|
456
|
+
"",
|
|
457
|
+
`Infrastructure runs (re-running the installer) now use your own Google login:`,
|
|
458
|
+
`${account} impersonates ${provisionerServiceAccountEmail(projectId)} with short-lived tokens.`,
|
|
459
|
+
`They need Application Default Credentials once on this machine: ${bold("gcloud auth application-default login")}`,
|
|
460
|
+
].join("\n"),
|
|
461
|
+
"Service-account key removed",
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Upgrade one install: converge, then app-roll (upgrade/upgrade.ts holds the
|
|
469
|
+
* sequence; this wires it to gcloud, the registry, cosign and install.json).
|
|
470
|
+
*/
|
|
471
|
+
async function upgradeInstall(
|
|
472
|
+
installId: string,
|
|
473
|
+
meta: InstallMetadata,
|
|
474
|
+
opts: UpgradeOpts,
|
|
475
|
+
): Promise<boolean> {
|
|
476
|
+
// #656 — earlier installers checkpointed database URLs with role
|
|
477
|
+
// passwords (and the setup link) into progress.json. Scrub them first,
|
|
478
|
+
// on every provider and whether or not the roll below succeeds.
|
|
479
|
+
for (const line of scrubInstallProgress(installId)) log.warn(yellow(line));
|
|
480
|
+
|
|
481
|
+
// gcp and gcp-firebase share the admin + gateway shape on Cloud Run; only
|
|
482
|
+
// the static-site layer differs, which upgrade doesn't touch.
|
|
483
|
+
if (meta.provider !== "gcp" && meta.provider !== "gcp-firebase") {
|
|
484
|
+
log.warn(`upgrade for provider ${meta.provider} not yet implemented.`);
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
if (!meta.projectId) return false;
|
|
488
|
+
const provider = meta.provider;
|
|
489
|
+
const projectId = meta.projectId;
|
|
490
|
+
|
|
491
|
+
// #607 — upgrade never moves an install: a `--region` other than the
|
|
492
|
+
// recorded one is refused before anything rolls.
|
|
493
|
+
const region = installRegion(meta, opts.region);
|
|
494
|
+
const targetTag = releaseTag(opts);
|
|
495
|
+
log.info(`Upgrading admin + gateway to ${bold(targetTag)}`);
|
|
496
|
+
// #650 — an install from before the staging host setting converges to
|
|
497
|
+
// the default (staging.<domain>) without operator action: recorded here,
|
|
498
|
+
// handed to the admin as CAELO_STAGING_HOST, which seeds its setting.
|
|
499
|
+
const stagingHost = installStagingHost(meta);
|
|
500
|
+
if (!meta.stagingDomain) writeMetadata(installId, { ...meta, stagingDomain: stagingHost });
|
|
501
|
+
|
|
502
|
+
const run: GcloudRunner = gcloud;
|
|
503
|
+
const http = fetch as unknown as HttpFetch;
|
|
504
|
+
const ok = await upgradeGcpInstall(
|
|
505
|
+
{
|
|
506
|
+
provider,
|
|
507
|
+
projectId,
|
|
508
|
+
region,
|
|
509
|
+
env: GCP_STACK_ENV,
|
|
510
|
+
domain: meta.domain,
|
|
511
|
+
stagingHost,
|
|
512
|
+
ownerEmail: meta.ownerEmail,
|
|
513
|
+
targetTag,
|
|
514
|
+
infraContractVersion: INFRA_CONTRACT_VERSION,
|
|
515
|
+
skipVerify: opts.skipVerify === true,
|
|
516
|
+
skipHealthCheck: opts.skipHealthCheck === true,
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
run,
|
|
520
|
+
http,
|
|
521
|
+
reporter: clackReporter(),
|
|
522
|
+
resolveImages: resolveReleaseImages,
|
|
523
|
+
verifyImages: verifyCosignAll,
|
|
524
|
+
migrate: (imageRef) =>
|
|
525
|
+
runMigrationsViaCloudRunJob({ projectId, region, imageOverride: imageRef }, { run }),
|
|
526
|
+
healthProbe: (plan) => gcpHealthProbe(run, http, plan),
|
|
527
|
+
recordDigests: async (digests) =>
|
|
528
|
+
recordRolledDigests(
|
|
529
|
+
installId,
|
|
530
|
+
provider,
|
|
531
|
+
digests,
|
|
532
|
+
// #696 — the version the upgrader job judges later updates by.
|
|
533
|
+
canonicalReleaseTag(targetTag)?.replace(/^v/, "") ??
|
|
534
|
+
(await releaseVersionForDigest(run, "admin", digests.admin)),
|
|
535
|
+
),
|
|
536
|
+
resolveOpsImage: resolveOpsImageRef,
|
|
537
|
+
recordInfraContractVersion: (version) => recordInfraContractVersion(installId, version),
|
|
538
|
+
provisionerIdentity: () => upgradeKeylessProvisioner(installId, projectId),
|
|
539
|
+
},
|
|
540
|
+
);
|
|
541
|
+
// #696 — upgrade never moves Pulumi state; it says how to.
|
|
542
|
+
const stateNotice = localStateNotice(meta);
|
|
543
|
+
if (stateNotice) note(stateNotice, "Pulumi state is on this machine");
|
|
544
|
+
if (ok && provider === "gcp-firebase") {
|
|
545
|
+
// #650 — staging builds now render the staging host; the gateway
|
|
546
|
+
// serves it once it is mapped. The command is idempotent, so this
|
|
547
|
+
// reminder is safe to repeat on every upgrade.
|
|
548
|
+
log.info(
|
|
549
|
+
`Protected staging site: run ${bold("cms-provision staging-domain enable")} once to serve the staging host (it maps the host the admin uses — ${bold(stagingHost)} unless changed there —, turns on password protection, prints the login and the DNS record). Host and access: admin → Settings → Domains / Staging access.`,
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
return ok;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** The deep health probe for `plan`'s install, through IAP. */
|
|
556
|
+
async function gcpHealthProbe(
|
|
557
|
+
run: GcloudRunner,
|
|
558
|
+
http: HttpFetch,
|
|
559
|
+
plan: AppRollPlan,
|
|
560
|
+
): Promise<{ ok: true; probe: HealthProbe } | { ok: false; error: string }> {
|
|
561
|
+
const { install } = plan;
|
|
562
|
+
const urls: Partial<Record<RollSlug, string>> = {};
|
|
563
|
+
for (const s of plan.services) {
|
|
564
|
+
const url = await findServiceUrl(run, install.projectId, install.region, s.serviceName);
|
|
565
|
+
if (!url) {
|
|
566
|
+
return {
|
|
567
|
+
ok: false,
|
|
568
|
+
error: `Could not read the ${s.slug} service's URL for the health check.`,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
urls[s.slug] = url;
|
|
572
|
+
}
|
|
573
|
+
return {
|
|
574
|
+
ok: true,
|
|
575
|
+
probe: createIapHealthProbe(
|
|
576
|
+
{
|
|
577
|
+
provider: install.provider,
|
|
578
|
+
projectId: install.projectId,
|
|
579
|
+
domain: install.domain,
|
|
580
|
+
adminServiceUrl: urls.admin as string,
|
|
581
|
+
gatewayServiceUrl: urls.gateway as string,
|
|
582
|
+
},
|
|
583
|
+
{ run, http, sleep: realSleep },
|
|
584
|
+
),
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Record the digests just rolled so nothing re-deploys an older release:
|
|
590
|
+
* install.json (what a wizard re-run deploys) and the Pulumi stack config
|
|
591
|
+
* (what a plain `pulumi up` deploys). The roll already succeeded, so each
|
|
592
|
+
* write that fails warns with the exact manual fix instead of failing the
|
|
593
|
+
* upgrade, and one failing never skips the other.
|
|
594
|
+
*/
|
|
595
|
+
async function recordRolledDigests(
|
|
596
|
+
installId: string,
|
|
597
|
+
provider: "gcp" | "gcp-firebase",
|
|
598
|
+
digests: ImageDigests,
|
|
599
|
+
releaseVersion: string | null,
|
|
600
|
+
): Promise<void> {
|
|
601
|
+
const reason = (e: unknown) => (e instanceof Error ? e.message.split("\n")[0] : String(e));
|
|
602
|
+
if (!releaseVersion) {
|
|
603
|
+
log.warn(
|
|
604
|
+
yellow(
|
|
605
|
+
"Could not tell which Caelo release these images are (no version tag points at them), so install.json records none; in-product updates stay unavailable until an upgrade to a released version.",
|
|
606
|
+
),
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
try {
|
|
610
|
+
recordImageDigests(installId, digests, releaseVersion);
|
|
611
|
+
} catch (e) {
|
|
612
|
+
log.warn(
|
|
613
|
+
yellow(
|
|
614
|
+
`Could not record the rolled digests in the install.json of ${installId} (${reason(e)}). Add this before re-running the installer, or it deploys the newest release instead:\n "imageDigests": ${JSON.stringify(digests)}`,
|
|
615
|
+
),
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
const meta = readMetadata(installId);
|
|
619
|
+
const manual = stackConfigRecovery(installId, meta ?? {}, provider, digests);
|
|
620
|
+
let backend: ResolvedBackend;
|
|
621
|
+
try {
|
|
622
|
+
if (!meta) throw new Error(`no install.json for '${installId}'`);
|
|
623
|
+
backend = resolveBackend(meta, {
|
|
624
|
+
installRoot: installRoot(installId),
|
|
625
|
+
readPassphrase: () => readSecret(installId, "pulumi-passphrase"),
|
|
626
|
+
});
|
|
627
|
+
} catch (e) {
|
|
628
|
+
log.warn(
|
|
629
|
+
yellow(
|
|
630
|
+
`Stack config not updated (${reason(e)}). Before any manual \`pulumi up\`, run:\n${manual}`,
|
|
631
|
+
),
|
|
632
|
+
);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
try {
|
|
636
|
+
const { writeImageDigestsToStack } = await import("./wizards/gcp-pulumi.js");
|
|
637
|
+
const { removedOverrides } = await writeImageDigestsToStack({
|
|
638
|
+
backend,
|
|
639
|
+
provider,
|
|
640
|
+
digests,
|
|
641
|
+
});
|
|
642
|
+
log.info(dim("Pinned the rolled image digests in the Pulumi stack config."));
|
|
643
|
+
if (removedOverrides.length > 0) {
|
|
644
|
+
log.warn(
|
|
645
|
+
yellow(
|
|
646
|
+
`Removed ${removedOverrides.join(", ")} from the stack config: those image overrides win over the digest pins, so a later \`pulumi up\` would have rolled back to them.`,
|
|
647
|
+
),
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
} catch (e) {
|
|
651
|
+
log.warn(
|
|
652
|
+
yellow(
|
|
653
|
+
`Could not pin the rolled digests in the Pulumi stack config (${reason(e)}). Before any manual \`pulumi up\`, run:\n${manual}`,
|
|
654
|
+
),
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Shell commands that pin `digests` in the install's own Pulumi stack, with
|
|
661
|
+
* the same workspace, backend, stack and passphrase the CLI uses — run from
|
|
662
|
+
* any directory, they cannot hit an unrelated stack.
|
|
663
|
+
*/
|
|
664
|
+
function stackConfigRecovery(
|
|
665
|
+
installId: string,
|
|
666
|
+
meta: Pick<InstallMetadata, "stateBackend">,
|
|
667
|
+
provider: "gcp" | "gcp-firebase",
|
|
668
|
+
digests: ImageDigests,
|
|
669
|
+
): string {
|
|
670
|
+
const env = backendShellEnv(meta, installRoot(installId));
|
|
671
|
+
const cwd = `--cwd ${resolvePath(import.meta.dir, "../stacks", provider)} --stack ${GCP_STACK_ENV}`;
|
|
672
|
+
const lines = Object.entries(digests).map(
|
|
673
|
+
([svc, d]) => ` ${env} pulumi config set ${cwd} caelo-${provider}:image-digest-${svc} ${d}`,
|
|
674
|
+
);
|
|
675
|
+
if (provider === "gcp") {
|
|
676
|
+
lines.push(
|
|
677
|
+
` ${env} pulumi config rm ${cwd} caelo-gcp:image-admin # only if set`,
|
|
678
|
+
` ${env} pulumi config rm ${cwd} caelo-gcp:image-gateway # only if set`,
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
return lines.join("\n");
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// =========================================================================
|
|
685
|
+
// backup — Cloud SQL on-demand backup
|
|
686
|
+
// =========================================================================
|
|
687
|
+
|
|
688
|
+
export async function backupCommand(): Promise<void> {
|
|
689
|
+
await forEachInstall("backup", (_installId, meta) => backupOf(meta));
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** Both GCP providers keep their data in the install's Cloud SQL instance. */
|
|
693
|
+
async function backupOf(meta: InstallMetadata): Promise<boolean> {
|
|
694
|
+
if (meta.provider !== "gcp" && meta.provider !== "gcp-firebase") {
|
|
695
|
+
log.warn(`backup for provider ${meta.provider} not yet implemented.`);
|
|
696
|
+
return false;
|
|
697
|
+
}
|
|
698
|
+
if (!meta.projectId) return false;
|
|
699
|
+
|
|
700
|
+
const s = spinner();
|
|
701
|
+
s.start("Resolving Cloud SQL instance + triggering on-demand backup...");
|
|
702
|
+
const r = await takeSqlBackup(
|
|
703
|
+
gcloud,
|
|
704
|
+
{ projectId: meta.projectId, env: GCP_STACK_ENV },
|
|
705
|
+
"backup",
|
|
706
|
+
);
|
|
707
|
+
if (!r.ok) {
|
|
708
|
+
s.stop(red(`Failed: ${r.error}`));
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
711
|
+
s.stop(green(`Backup ${r.backup.backupId} of ${r.backup.instance} created.`));
|
|
712
|
+
log.info(`Restore it (overwrites all data written since): ${bold(restoreCommand(r.backup))}`);
|
|
713
|
+
return true;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// =========================================================================
|
|
717
|
+
// rotate-secret <name>
|
|
718
|
+
// =========================================================================
|
|
719
|
+
|
|
720
|
+
export async function rotateSecretCommand(name: string | undefined): Promise<void> {
|
|
721
|
+
if (!name) {
|
|
722
|
+
log.error(red("Usage: caelo-cms rotate-secret <name>"));
|
|
723
|
+
log.warn(`Names: ${ROTATABLE_SECRETS.join(", ")}`);
|
|
724
|
+
process.exit(2);
|
|
725
|
+
}
|
|
726
|
+
const refusal = rotationRefusal(name);
|
|
727
|
+
if (refusal) {
|
|
728
|
+
log.error(red(refusal));
|
|
729
|
+
process.exit(2);
|
|
730
|
+
}
|
|
731
|
+
const secret = name as RotatableSecret;
|
|
732
|
+
const { meta } = await requireInstall("rotate-secret");
|
|
733
|
+
if (meta.provider !== "gcp" && meta.provider !== "gcp-firebase") {
|
|
734
|
+
log.warn(`rotate-secret for provider ${meta.provider} not yet implemented.`);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
if (!meta.projectId) return;
|
|
738
|
+
const region = installRegion(meta);
|
|
739
|
+
|
|
740
|
+
const s = spinner();
|
|
741
|
+
s.start(`Rotating ${secret}...`);
|
|
742
|
+
const services: Partial<Record<"admin" | "gateway", string>> = {};
|
|
743
|
+
for (const slug of ["admin", "gateway"] as const) {
|
|
744
|
+
const serviceName = await resolveGcpResourceName(
|
|
745
|
+
gcloud,
|
|
746
|
+
"run-service",
|
|
747
|
+
`caelo-production-${slug}`,
|
|
748
|
+
meta.projectId,
|
|
749
|
+
region,
|
|
750
|
+
);
|
|
751
|
+
if (!serviceName) {
|
|
752
|
+
s.stop(red(`Could not find caelo-production-${slug}* Cloud Run service — nothing rotated`));
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
services[slug] = serviceName;
|
|
756
|
+
}
|
|
757
|
+
const sqlInstance =
|
|
758
|
+
rolesOfSecret(secret).length > 0
|
|
759
|
+
? await resolveGcpResourceName(gcloud, "sql-instance", "caelo-production-pg", meta.projectId)
|
|
760
|
+
: null;
|
|
761
|
+
const report = await rotateRuntimeSecret(
|
|
762
|
+
{
|
|
763
|
+
projectId: meta.projectId,
|
|
764
|
+
region,
|
|
765
|
+
env: GCP_STACK_ENV,
|
|
766
|
+
services: services as Record<"admin" | "gateway", string>,
|
|
767
|
+
...(sqlInstance ? { sqlInstance } : {}),
|
|
768
|
+
},
|
|
769
|
+
secret,
|
|
770
|
+
);
|
|
771
|
+
if (report.ok) {
|
|
772
|
+
s.stop(green(`${secret} rotated`));
|
|
773
|
+
} else {
|
|
774
|
+
s.stop(red(`Rotating ${secret} failed`));
|
|
775
|
+
}
|
|
776
|
+
for (const step of report.steps) log.info(` ${step}`);
|
|
777
|
+
if (report.error) log.error(red(report.error));
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// =========================================================================
|
|
781
|
+
// destroy — pulumi destroy + gcloud projects delete
|
|
782
|
+
// =========================================================================
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* v0.4.0 — Truncate all content + history tables on the install's Cloud
|
|
786
|
+
* SQL, preserving identity (users / roles / providers / domains /
|
|
787
|
+
* site_defaults / provisioning_outputs / site_ai_memory). The operator
|
|
788
|
+
* can re-author from scratch against the current schema without
|
|
789
|
+
* destroying infra.
|
|
790
|
+
*
|
|
791
|
+
* Useful after a schema-changing release (e.g. v0.4.0's module/content
|
|
792
|
+
* split) when AI-authored content built against the old shape no
|
|
793
|
+
* longer renders cleanly.
|
|
794
|
+
*/
|
|
795
|
+
export async function truncateCommand(): Promise<void> {
|
|
796
|
+
const { meta } = await requireInstall("truncate");
|
|
797
|
+
|
|
798
|
+
log.warn(
|
|
799
|
+
yellow(
|
|
800
|
+
`${bold("Truncate")} wipes ALL pages, modules, templates, layouts, redirects, nav menus, theme tokens, media assets, chat history, audit log, snapshots, and site memory on ${bold(meta.domain)}.\n` +
|
|
801
|
+
`Preserved: users / roles / AI providers / domains / site_defaults / provisioning_outputs.`,
|
|
802
|
+
),
|
|
803
|
+
);
|
|
804
|
+
const confirm1 = await confirm({
|
|
805
|
+
message: `Truncate content on ${bold(meta.domain)} (${bold(meta.projectId ?? "self-hosted")})?`,
|
|
806
|
+
initialValue: false,
|
|
807
|
+
});
|
|
808
|
+
if (isCancel(confirm1) || !confirm1) {
|
|
809
|
+
cancel("Cancelled.");
|
|
810
|
+
process.exit(0);
|
|
811
|
+
}
|
|
812
|
+
const typed = await import("@clack/prompts").then((m) =>
|
|
813
|
+
m.text({
|
|
814
|
+
message: `Type the domain to confirm: ${bold(meta.domain)}`,
|
|
815
|
+
validate: (v) => (v === meta.domain ? undefined : "Domain doesn't match — aborting"),
|
|
816
|
+
}),
|
|
817
|
+
);
|
|
818
|
+
if (isCancel(typed)) {
|
|
819
|
+
cancel("Cancelled.");
|
|
820
|
+
process.exit(0);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
if (meta.provider === "self-hosted") {
|
|
824
|
+
log.warn(
|
|
825
|
+
"Self-hosted truncate not yet wired. Run the SQL directly via " +
|
|
826
|
+
`${cyan("docker compose -f .caelo/docker-compose.yml exec postgres psql -U admin_role cms_admin")} ` +
|
|
827
|
+
"and TRUNCATE the content tables manually.",
|
|
828
|
+
);
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
if ((meta.provider === "gcp" || meta.provider === "gcp-firebase") && meta.projectId) {
|
|
833
|
+
const { truncateViaCloudRunJob } = await import("./migration-runner.js");
|
|
834
|
+
const region = installRegion(meta);
|
|
835
|
+
const r = await truncateViaCloudRunJob({ projectId: meta.projectId, region });
|
|
836
|
+
if (!r.ok) {
|
|
837
|
+
cancel(`Truncate failed: ${r.error}.`);
|
|
838
|
+
process.exit(1);
|
|
839
|
+
}
|
|
840
|
+
note(
|
|
841
|
+
[
|
|
842
|
+
green(`✓ Content truncated on ${meta.domain}.`),
|
|
843
|
+
"",
|
|
844
|
+
"Next: visit the admin and start a fresh chat. The AI will author against",
|
|
845
|
+
"the current schema (v0.4.0 → module = code + fields; content per page).",
|
|
846
|
+
].join("\n"),
|
|
847
|
+
"Done",
|
|
848
|
+
);
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
log.warn(`Truncate not implemented for provider=${meta.provider}.`);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
export async function destroyCommand(): Promise<void> {
|
|
856
|
+
const { installId, meta } = await requireInstall("destroy");
|
|
857
|
+
|
|
858
|
+
log.warn(
|
|
859
|
+
red(
|
|
860
|
+
`${bold("Destroy will PERMANENTLY delete")} the GCP project + every Caelo resource. This is irreversible after the 30-day undelete window.`,
|
|
861
|
+
),
|
|
862
|
+
);
|
|
863
|
+
const confirm1 = await confirm({
|
|
864
|
+
message: `Destroy the install for ${bold(meta.domain)} (${bold(meta.projectId ?? "self-hosted")})?`,
|
|
865
|
+
initialValue: false,
|
|
866
|
+
});
|
|
867
|
+
if (isCancel(confirm1) || !confirm1) {
|
|
868
|
+
cancel("Cancelled.");
|
|
869
|
+
process.exit(0);
|
|
870
|
+
}
|
|
871
|
+
const typed = await import("@clack/prompts").then((m) =>
|
|
872
|
+
m.text({
|
|
873
|
+
message: `Type the domain to confirm: ${bold(meta.domain)}`,
|
|
874
|
+
validate: (v) => (v === meta.domain ? undefined : "Domain doesn't match — aborting"),
|
|
875
|
+
}),
|
|
876
|
+
);
|
|
877
|
+
if (isCancel(typed)) {
|
|
878
|
+
cancel("Cancelled.");
|
|
879
|
+
process.exit(0);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
if (meta.provider === "gcp" && meta.projectId) {
|
|
883
|
+
const s = spinner();
|
|
884
|
+
s.start(`Deleting GCP project ${meta.projectId}...`);
|
|
885
|
+
const r = await gcloud(["projects", "delete", meta.projectId, "--quiet"]);
|
|
886
|
+
if (!r.ok) {
|
|
887
|
+
s.stop(red(`Failed: ${r.stderr.trim()}`));
|
|
888
|
+
log.warn(
|
|
889
|
+
`You can delete the project manually via the Cloud Console: https://console.cloud.google.com/iam-admin/settings?project=${meta.projectId}`,
|
|
890
|
+
);
|
|
891
|
+
} else {
|
|
892
|
+
s.stop(green(`Project ${meta.projectId} marked for deletion (30-day undelete window).`));
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
log.info(
|
|
897
|
+
`Local state at ${dim(installRoot(installId))} preserved. Remove manually if you want a clean slate: ${bold(`rm -rf ${installRoot(installId)}`)}.`,
|
|
898
|
+
);
|
|
899
|
+
void readSecret; // unused-import guard
|
|
900
|
+
}
|