@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
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Per-install state — every Caelo install gets its own
|
|
5
|
+
* `~/.caelo-<install-id>/` directory with:
|
|
6
|
+
*
|
|
7
|
+
* secrets/ — mode-700 dir for the install's secrets
|
|
8
|
+
* anthropic-api-key (mode 600) — Anthropic API key (one-time prompt)
|
|
9
|
+
* pulumi-passphrase (mode 600) — Pulumi local-backend passphrase
|
|
10
|
+
* (no service-account key: GCP installs impersonate `caelo-provisioner`
|
|
11
|
+
* with the operator's own Google login, CLAUDE.md §11.C. An
|
|
12
|
+
* `sa-key.json` minted by installers before #696 is deleted —
|
|
13
|
+
* locally and in IAM — on the next `upgrade` or installer run;
|
|
14
|
+
* provisioner-identity.ts)
|
|
15
|
+
* state/ — Pulumi local backend state (per-install isolated);
|
|
16
|
+
* absent once the install's state lives in its
|
|
17
|
+
* own cloud (#696, state-backend.ts)
|
|
18
|
+
* progress.json — wizard checkpoint so re-runs resume cleanly
|
|
19
|
+
* install.json — install metadata (provider, project id, region,
|
|
20
|
+
* domain, owner email, install id, created_at,
|
|
21
|
+
* where the state lives)
|
|
22
|
+
*
|
|
23
|
+
* #696 — for an install whose `stateBackend` is `gcs`, install.json and
|
|
24
|
+
* progress.json live in the install's ops bucket; the copies here are a
|
|
25
|
+
* mirror (state-store.ts). Every read/write below goes through
|
|
26
|
+
* {@link stateStoreFor}.
|
|
27
|
+
*
|
|
28
|
+
* Only `secrets/` (and Pulumi's encrypted state) hold secrets. progress.json
|
|
29
|
+
* and install.json are read by tooling, backups and agents, so writes to
|
|
30
|
+
* them refuse secret material (checkpoint-secrets.ts, #656).
|
|
31
|
+
*
|
|
32
|
+
* The CLAUDE.md §11.C contract: end-users never reach into this directory
|
|
33
|
+
* by hand. The wizard + lifecycle commands wrap every read + write.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import {
|
|
37
|
+
chmodSync,
|
|
38
|
+
existsSync,
|
|
39
|
+
mkdirSync,
|
|
40
|
+
readdirSync,
|
|
41
|
+
readFileSync,
|
|
42
|
+
writeFileSync,
|
|
43
|
+
} from "node:fs";
|
|
44
|
+
import { homedir } from "node:os";
|
|
45
|
+
import { join } from "node:path";
|
|
46
|
+
import {
|
|
47
|
+
assertNoSecrets,
|
|
48
|
+
rotationHints,
|
|
49
|
+
type ScrubReport,
|
|
50
|
+
scrubProgressFile,
|
|
51
|
+
} from "./checkpoint-secrets.js";
|
|
52
|
+
import { defaultStagingHost } from "./stack-contract.js";
|
|
53
|
+
import { parseStateBackend, type StateBackendRecord } from "./state-backend.js";
|
|
54
|
+
import {
|
|
55
|
+
GcsStateStore,
|
|
56
|
+
LocalStateStore,
|
|
57
|
+
realSyncGcloud,
|
|
58
|
+
type StateStore,
|
|
59
|
+
type SyncGcloud,
|
|
60
|
+
} from "./state-store.js";
|
|
61
|
+
|
|
62
|
+
export type Provider = "self-hosted" | "gcp" | "gcp-firebase" | "aws" | "azure";
|
|
63
|
+
|
|
64
|
+
export interface InstallMetadata {
|
|
65
|
+
installId: string;
|
|
66
|
+
provider: Provider;
|
|
67
|
+
/** Cloud-side project / account / subscription id. NULL for self-hosted. */
|
|
68
|
+
projectId: string | null;
|
|
69
|
+
domain: string;
|
|
70
|
+
ownerEmail: string;
|
|
71
|
+
region: string | null;
|
|
72
|
+
createdAt: string;
|
|
73
|
+
/**
|
|
74
|
+
* sha256 digests of the release images the install runs, recorded by the
|
|
75
|
+
* wizard after `pulumi up` and by `upgrade` after a roll. A wizard re-run
|
|
76
|
+
* deploys these instead of re-resolving `:latest`, so it never undoes a
|
|
77
|
+
* pinned or rc upgrade; versions change through `upgrade` only. Absent on
|
|
78
|
+
* installs that predate it.
|
|
79
|
+
*/
|
|
80
|
+
imageDigests?: ImageDigests;
|
|
81
|
+
/**
|
|
82
|
+
* #696 — the Caelo release (`0.11.0`) those digests belong to, recorded
|
|
83
|
+
* next to them by `upgrade` and the upgrader job. The upgrader job reads
|
|
84
|
+
* the installed version from here, not from the running admin: a
|
|
85
|
+
* compromised admin could claim an older version to get a downgrade
|
|
86
|
+
* past the "newer than installed" check. Absent until the first upgrade
|
|
87
|
+
* that knew it; the job refuses to run without it.
|
|
88
|
+
*/
|
|
89
|
+
releaseVersion?: string;
|
|
90
|
+
/**
|
|
91
|
+
* #650 — the protected staging site's host (wizard / `--staging-domain`,
|
|
92
|
+
* default `staging.<domain>`). Absent on installs that predate it;
|
|
93
|
+
* `upgrade` records the default ({@link installStagingHost}).
|
|
94
|
+
*/
|
|
95
|
+
stagingDomain?: string;
|
|
96
|
+
/**
|
|
97
|
+
* #696 — where Pulumi's state and this metadata live (state-backend.ts).
|
|
98
|
+
* Absent on installs that predate it: those are `local`.
|
|
99
|
+
*/
|
|
100
|
+
stateBackend?: StateBackendRecord;
|
|
101
|
+
/**
|
|
102
|
+
* #696 — the infrastructure contract version (infra-contract.ts) the last
|
|
103
|
+
* successful converge brought this install to. Absent until the first
|
|
104
|
+
* `upgrade` that converged completely; an app-roll-only upgrade refuses to
|
|
105
|
+
* run while it is lower than the release's (upgrade/converge.ts).
|
|
106
|
+
*/
|
|
107
|
+
infraContractVersion?: number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The install's staging host: its recorded setting, else the default. */
|
|
111
|
+
export function installStagingHost(
|
|
112
|
+
meta: Pick<InstallMetadata, "domain" | "stagingDomain">,
|
|
113
|
+
): string {
|
|
114
|
+
return meta.stagingDomain ?? defaultStagingHost(meta.domain);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A bare, lower-case fully-qualified host name: dot-separated labels of
|
|
119
|
+
* `a-z0-9-` (no leading/trailing hyphen, at most 63 characters each), a
|
|
120
|
+
* letter-led top-level label. Deliberately free of `.`-wildcards and
|
|
121
|
+
* lookaheads so the check reads as a strict allow-list.
|
|
122
|
+
*/
|
|
123
|
+
const RECORDABLE_HOST =
|
|
124
|
+
/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
125
|
+
|
|
126
|
+
/** DNS limit on a full host name. */
|
|
127
|
+
const MAX_HOST_LENGTH = 253;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The staging host exactly as install.json may record it, or null when
|
|
131
|
+
* `host` is anything but a bare host name. The host can come from the
|
|
132
|
+
* admin's HTTP answer (`staging-domain enable` reads the stored host), so
|
|
133
|
+
* only a value that passes this allow-list is written to disk — never a
|
|
134
|
+
* URL, path, port, whitespace or other payload.
|
|
135
|
+
*
|
|
136
|
+
* @example recordableStagingHost("staging.example.com") // → "staging.example.com"
|
|
137
|
+
* @example recordableStagingHost("https://x.example.com/") // → null
|
|
138
|
+
*/
|
|
139
|
+
export function recordableStagingHost(host: string): string | null {
|
|
140
|
+
if (host.length > MAX_HOST_LENGTH) return null;
|
|
141
|
+
return RECORDABLE_HOST.test(host) ? host : null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Release image digest per Cloud Run service. */
|
|
145
|
+
export interface ImageDigests {
|
|
146
|
+
readonly admin: string;
|
|
147
|
+
readonly gateway: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The image digests recorded for an install, or null when none are. Throws
|
|
154
|
+
* on a malformed record rather than deploying a guess.
|
|
155
|
+
*/
|
|
156
|
+
export function recordedImageDigests(meta: InstallMetadata): ImageDigests | null {
|
|
157
|
+
const d = meta.imageDigests;
|
|
158
|
+
if (d === undefined) return null;
|
|
159
|
+
if (!DIGEST.test(d.admin ?? "") || !DIGEST.test(d.gateway ?? "")) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`install.json imageDigests is malformed (${JSON.stringify(d)}); fix or remove it, then re-run.`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
return d;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The metadata a non-interactive re-run of an existing install writes: the
|
|
169
|
+
* inputs it was given over what install.json already holds. Everything else
|
|
170
|
+
* (createdAt, region, the recorded `imageDigests`) survives, so the re-run
|
|
171
|
+
* keeps the release the install runs instead of looking like a new install.
|
|
172
|
+
*/
|
|
173
|
+
export function resumedMetadata(
|
|
174
|
+
existing: InstallMetadata,
|
|
175
|
+
inputs: { domain: string; ownerEmail: string; projectId: string | null },
|
|
176
|
+
): InstallMetadata {
|
|
177
|
+
return {
|
|
178
|
+
...existing,
|
|
179
|
+
domain: inputs.domain,
|
|
180
|
+
ownerEmail: inputs.ownerEmail,
|
|
181
|
+
projectId: inputs.projectId ?? existing.projectId,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Record the image digests an install now runs (see {@link InstallMetadata.imageDigests}). */
|
|
186
|
+
export function recordImageDigests(
|
|
187
|
+
installId: string,
|
|
188
|
+
digests: ImageDigests,
|
|
189
|
+
releaseVersion?: string | null,
|
|
190
|
+
): void {
|
|
191
|
+
const meta = readMetadata(installId);
|
|
192
|
+
if (!meta) throw new Error(`No install.json for install '${installId}'.`);
|
|
193
|
+
// A version belongs to its digests: unchanged digests keep the recorded
|
|
194
|
+
// version unless a new one is given; new digests without a known version
|
|
195
|
+
// drop it — a stale one would be worse than none (the upgrader job
|
|
196
|
+
// refuses on none and judges downgrades by it).
|
|
197
|
+
const same =
|
|
198
|
+
meta.imageDigests?.admin === digests.admin && meta.imageDigests?.gateway === digests.gateway;
|
|
199
|
+
const { releaseVersion: previous, ...rest } = meta;
|
|
200
|
+
const version = releaseVersion ?? (same ? previous : undefined);
|
|
201
|
+
writeMetadata(installId, {
|
|
202
|
+
...rest,
|
|
203
|
+
imageDigests: { admin: digests.admin, gateway: digests.gateway },
|
|
204
|
+
...(version ? { releaseVersion: version } : {}),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Record the infrastructure contract version a converge just brought the
|
|
210
|
+
* install to (see {@link InstallMetadata.infraContractVersion}).
|
|
211
|
+
*/
|
|
212
|
+
export function recordInfraContractVersion(installId: string, version: number): void {
|
|
213
|
+
const meta = readMetadata(installId);
|
|
214
|
+
if (!meta) throw new Error(`No install.json for install '${installId}'.`);
|
|
215
|
+
writeMetadata(installId, { ...meta, infraContractVersion: version });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface ProgressCheckpoint {
|
|
219
|
+
/** Last completed wizard step. Used for resume-after-failure. */
|
|
220
|
+
lastCompletedStep: string | null;
|
|
221
|
+
/** Per-step state (e.g. createdProjectId, billingAccountId, etc.). */
|
|
222
|
+
steps: Record<string, unknown>;
|
|
223
|
+
/** ISO timestamp of last successful update. */
|
|
224
|
+
updatedAt: string;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const ROOT_PREFIX = ".caelo-";
|
|
228
|
+
|
|
229
|
+
export function installRoot(installId: string): string {
|
|
230
|
+
return join(homedir(), `${ROOT_PREFIX}${installId}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Stable install id from the (provider, projectId-or-domain) pair so a re-run
|
|
235
|
+
* with the same inputs always lands on the same `~/.caelo-<id>/` directory.
|
|
236
|
+
* The id is short + readable — `gcp-caelo-website` / `self-hosted-mysite-com`.
|
|
237
|
+
*/
|
|
238
|
+
export function deriveInstallId(provider: Provider, projectIdOrDomain: string): string {
|
|
239
|
+
const slug = projectIdOrDomain
|
|
240
|
+
.toLowerCase()
|
|
241
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
242
|
+
.replace(/^-+|-+$/g, "")
|
|
243
|
+
.slice(0, 40);
|
|
244
|
+
return `${provider}-${slug}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Ensure the install directory exists with the right mode + sub-dirs.
|
|
249
|
+
* Idempotent.
|
|
250
|
+
*/
|
|
251
|
+
export function ensureInstallDir(installId: string): {
|
|
252
|
+
root: string;
|
|
253
|
+
secretsDir: string;
|
|
254
|
+
stateDir: string;
|
|
255
|
+
} {
|
|
256
|
+
const root = installRoot(installId);
|
|
257
|
+
const secretsDir = join(root, "secrets");
|
|
258
|
+
const stateDir = join(root, "state");
|
|
259
|
+
|
|
260
|
+
if (!existsSync(root)) mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
261
|
+
if (!existsSync(secretsDir)) mkdirSync(secretsDir, { recursive: true, mode: 0o700 });
|
|
262
|
+
// An install whose state lives in its ops bucket gets no local state dir:
|
|
263
|
+
// an empty one would read as "the state is here".
|
|
264
|
+
const remote = readLocalMetadata(installId)?.stateBackend?.kind === "gcs";
|
|
265
|
+
if (!remote && !existsSync(stateDir)) mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
266
|
+
|
|
267
|
+
// chmod every time in case the dirs existed with looser perms.
|
|
268
|
+
chmodSync(root, 0o700);
|
|
269
|
+
chmodSync(secretsDir, 0o700);
|
|
270
|
+
if (existsSync(stateDir)) chmodSync(stateDir, 0o700);
|
|
271
|
+
|
|
272
|
+
return { root, secretsDir, stateDir };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Scan `~/.caelo-*` for every install with a readable `install.json` (the
|
|
277
|
+
* local copy; see {@link currentMetadata}).
|
|
278
|
+
* Used by the wizard to offer "resume X" before prompting for the
|
|
279
|
+
* domain / owner / project again. Silently skips dirs that fail to
|
|
280
|
+
* parse — partial / orphaned dirs don't block the new-install flow.
|
|
281
|
+
*/
|
|
282
|
+
export function listInstalls(): InstallMetadata[] {
|
|
283
|
+
const home = homedir();
|
|
284
|
+
let entries: string[];
|
|
285
|
+
try {
|
|
286
|
+
entries = readdirSync(home);
|
|
287
|
+
} catch {
|
|
288
|
+
return [];
|
|
289
|
+
}
|
|
290
|
+
const out: InstallMetadata[] = [];
|
|
291
|
+
for (const entry of entries) {
|
|
292
|
+
if (!entry.startsWith(ROOT_PREFIX)) continue;
|
|
293
|
+
const installId = entry.slice(ROOT_PREFIX.length);
|
|
294
|
+
// The local copy, so one unreachable bucket (a revoked grant, a deleted
|
|
295
|
+
// project) can't block commands for every other install. A command
|
|
296
|
+
// re-reads the install it picked with {@link currentMetadata}.
|
|
297
|
+
const meta = readLocalMetadata(installId);
|
|
298
|
+
if (meta) out.push(meta);
|
|
299
|
+
}
|
|
300
|
+
out.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The install a command names on its argv: `--install <id or domain>` or
|
|
306
|
+
* `--install=<…>`. `--install-id` is accepted as an alias, the spelling
|
|
307
|
+
* older docs and code comments used.
|
|
308
|
+
*
|
|
309
|
+
* @returns the value, or undefined when no flag is given
|
|
310
|
+
* @throws when the flag is given without a value
|
|
311
|
+
*/
|
|
312
|
+
export function installFlag(argv: readonly string[]): string | undefined {
|
|
313
|
+
for (let i = 0; i < argv.length; i++) {
|
|
314
|
+
const a = argv[i] ?? "";
|
|
315
|
+
for (const name of ["--install", "--install-id"]) {
|
|
316
|
+
if (a === name) {
|
|
317
|
+
const v = argv[i + 1];
|
|
318
|
+
if (v === undefined || v.startsWith("-")) {
|
|
319
|
+
throw new Error(
|
|
320
|
+
`${name} needs a value: the install id or domain (e.g. ${name} example.com)`,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
return v;
|
|
324
|
+
}
|
|
325
|
+
if (a.startsWith(`${name}=`)) {
|
|
326
|
+
const v = a.slice(name.length + 1);
|
|
327
|
+
if (v === "") throw new Error(`${name} needs a value: the install id or domain`);
|
|
328
|
+
return v;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return undefined;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Outcome of {@link selectInstall}. */
|
|
336
|
+
export type InstallSelection = { ok: true; meta: InstallMetadata } | { ok: false; message: string };
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Pick the install a lifecycle command acts on. `wanted` (from `--install`)
|
|
340
|
+
* matches an install id or its domain. Without it, exactly one install may
|
|
341
|
+
* exist: with several, guessing would upgrade, back up or destroy the wrong
|
|
342
|
+
* site, so the operator is asked to name one instead.
|
|
343
|
+
*
|
|
344
|
+
* @param installs every install on this machine (see {@link listInstalls})
|
|
345
|
+
* @param wanted the `--install` value, if given
|
|
346
|
+
*/
|
|
347
|
+
export function selectInstall(
|
|
348
|
+
installs: readonly InstallMetadata[],
|
|
349
|
+
wanted: string | undefined,
|
|
350
|
+
): InstallSelection {
|
|
351
|
+
const listed = (ms: readonly InstallMetadata[]): string =>
|
|
352
|
+
ms.map((m) => ` ${m.installId} (${m.domain}, ${m.provider})`).join("\n");
|
|
353
|
+
if (wanted !== undefined) {
|
|
354
|
+
const hits = installs.filter((m) => m.installId === wanted || m.domain === wanted);
|
|
355
|
+
if (hits.length === 1 && hits[0]) return { ok: true, meta: hits[0] };
|
|
356
|
+
if (hits.length === 0) {
|
|
357
|
+
return {
|
|
358
|
+
ok: false,
|
|
359
|
+
message:
|
|
360
|
+
installs.length === 0
|
|
361
|
+
? `No install "${wanted}" on this machine (no ~/.caelo-<install-id>/install.json found).`
|
|
362
|
+
: `No install "${wanted}" on this machine. Installs found:\n${listed(installs)}\nPass one of these ids (or its domain) with --install.`,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
return {
|
|
366
|
+
ok: false,
|
|
367
|
+
message: `"${wanted}" matches several installs:\n${listed(hits)}\nPass the install id with --install.`,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
if (installs.length === 1 && installs[0]) return { ok: true, meta: installs[0] };
|
|
371
|
+
if (installs.length === 0) {
|
|
372
|
+
return { ok: false, message: "No Caelo install found on this machine." };
|
|
373
|
+
}
|
|
374
|
+
return {
|
|
375
|
+
ok: false,
|
|
376
|
+
message: `Several Caelo installs on this machine:\n${listed(installs)}\nPick one with --install <install-id or domain>.`,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
let syncGcloud: SyncGcloud = realSyncGcloud;
|
|
381
|
+
const gcsStores = new Map<string, GcsStateStore>();
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Replace the gcloud runner bucket-backed stores use. Test seam only: the
|
|
385
|
+
* CLI always uses the real `gcloud`.
|
|
386
|
+
*/
|
|
387
|
+
export function setStateStoreGcloud(run: SyncGcloud): void {
|
|
388
|
+
syncGcloud = run;
|
|
389
|
+
gcsStores.clear();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function parseMetadata(text: string, where: string): InstallMetadata {
|
|
393
|
+
const meta = JSON.parse(text) as InstallMetadata;
|
|
394
|
+
if (typeof meta !== "object" || meta === null || typeof meta.installId !== "string") {
|
|
395
|
+
throw new Error(`${where} is not an install.json (no installId).`);
|
|
396
|
+
}
|
|
397
|
+
const stateBackend = parseStateBackend(meta.stateBackend);
|
|
398
|
+
return stateBackend ? { ...meta, stateBackend } : meta;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* The install.json copy in `~/.caelo-<id>/`, without consulting the bucket.
|
|
403
|
+
* For listing installs and for deciding which store to use.
|
|
404
|
+
*/
|
|
405
|
+
export function readLocalMetadata(installId: string): InstallMetadata | null {
|
|
406
|
+
const text = new LocalStateStore(installRoot(installId)).read("install.json");
|
|
407
|
+
return text === null ? null : parseMetadata(text, `~/.caelo-${installId}/install.json`);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* The store holding the install's metadata: its ops bucket when the local
|
|
412
|
+
* install.json says the install's state lives there, else `~/.caelo-<id>/`.
|
|
413
|
+
* One bucket store per install and process, so a run reads each document
|
|
414
|
+
* from the bucket once.
|
|
415
|
+
*/
|
|
416
|
+
export function stateStoreFor(installId: string): StateStore {
|
|
417
|
+
const root = installRoot(installId);
|
|
418
|
+
const backend = readLocalMetadata(installId)?.stateBackend;
|
|
419
|
+
if (backend?.kind !== "gcs") return new LocalStateStore(root);
|
|
420
|
+
const key = `${installId}\0${backend.bucket}`;
|
|
421
|
+
let store = gcsStores.get(key);
|
|
422
|
+
if (!store) {
|
|
423
|
+
store = new GcsStateStore(backend.bucket, root, syncGcloud);
|
|
424
|
+
gcsStores.set(key, store);
|
|
425
|
+
}
|
|
426
|
+
return store;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* The authoritative metadata of an install picked from {@link listInstalls}:
|
|
431
|
+
* for an install whose state lives in its ops bucket, the bucket's copy
|
|
432
|
+
* (another machine may have upgraded it since). Throws when that copy can't
|
|
433
|
+
* be read — acting on a stale mirror could roll an install back.
|
|
434
|
+
*/
|
|
435
|
+
export function currentMetadata(meta: InstallMetadata): InstallMetadata {
|
|
436
|
+
if (meta.stateBackend?.kind !== "gcs") return meta;
|
|
437
|
+
const fresh = readMetadata(meta.installId);
|
|
438
|
+
if (!fresh) {
|
|
439
|
+
throw new Error(
|
|
440
|
+
`gs://${meta.stateBackend.bucket} has no install.json for ${meta.installId}; the install may have been destroyed.`,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
return fresh;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export function readMetadata(installId: string): InstallMetadata | null {
|
|
447
|
+
const store = stateStoreFor(installId);
|
|
448
|
+
const text = store.read("install.json");
|
|
449
|
+
return text === null ? null : parseMetadata(text, `${store.kind} install.json of ${installId}`);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Write install.json. Never changes where the state lives — only
|
|
454
|
+
* {@link adoptStateBackend} (behind `state migrate`'s checks) does: the
|
|
455
|
+
* recorded `stateBackend` is kept whatever `meta` carries, so a caller
|
|
456
|
+
* holding metadata read before a migration can't switch it back. For a
|
|
457
|
+
* bucket-backed install the bucket's current record is checked first
|
|
458
|
+
* (uncached): if another machine moved the state off the bucket meanwhile,
|
|
459
|
+
* the write is refused.
|
|
460
|
+
*/
|
|
461
|
+
export function writeMetadata(installId: string, meta: InstallMetadata): void {
|
|
462
|
+
const store = stateStoreFor(installId);
|
|
463
|
+
const recorded = readLocalMetadata(installId)?.stateBackend;
|
|
464
|
+
const { stateBackend: _ignored, ...rest } = meta;
|
|
465
|
+
const next: InstallMetadata = recorded ? { ...rest, stateBackend: recorded } : rest;
|
|
466
|
+
if (store instanceof GcsStateStore) {
|
|
467
|
+
const live = store.peek("install.json");
|
|
468
|
+
const liveBackend =
|
|
469
|
+
live === null ? undefined : parseStateBackend(JSON.parse(live).stateBackend);
|
|
470
|
+
if (live !== null && liveBackend?.kind !== "gcs") {
|
|
471
|
+
throw new Error(
|
|
472
|
+
`${installId}'s state was moved off its bucket by another machine (state migrate --to local); this run's metadata is out of date. Nothing was written; manage the install from that machine.`,
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
store.write("install.json", next);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Move the install's metadata to `backend`'s store and record the backend
|
|
481
|
+
* in install.json (#696). progress.json is written first and install.json
|
|
482
|
+
* last: install.json naming the new backend is the commit point, so an
|
|
483
|
+
* interrupted switch leaves the install on its old store. When the install
|
|
484
|
+
* leaves the bucket, the bucket's install.json is updated too, so a machine
|
|
485
|
+
* that attaches later learns where the state went.
|
|
486
|
+
*
|
|
487
|
+
* @returns the metadata as now recorded
|
|
488
|
+
*/
|
|
489
|
+
export function adoptStateBackend(installId: string, backend: StateBackendRecord): InstallMetadata {
|
|
490
|
+
const current = readMetadata(installId);
|
|
491
|
+
if (!current) throw new Error(`No install.json for install '${installId}'.`);
|
|
492
|
+
const progress = readProgress(installId);
|
|
493
|
+
const next: InstallMetadata = { ...current, stateBackend: backend };
|
|
494
|
+
const root = installRoot(installId);
|
|
495
|
+
const previous = current.stateBackend;
|
|
496
|
+
gcsStores.clear();
|
|
497
|
+
if (backend.kind === "gcs") {
|
|
498
|
+
const gcs = new GcsStateStore(backend.bucket, root, syncGcloud);
|
|
499
|
+
gcs.write("progress.json", progress);
|
|
500
|
+
gcs.write("install.json", next);
|
|
501
|
+
} else {
|
|
502
|
+
if (previous?.kind === "gcs") {
|
|
503
|
+
new GcsStateStore(previous.bucket, root, syncGcloud).write("install.json", next);
|
|
504
|
+
}
|
|
505
|
+
const local = new LocalStateStore(root);
|
|
506
|
+
local.write("progress.json", progress);
|
|
507
|
+
local.write("install.json", next);
|
|
508
|
+
}
|
|
509
|
+
return next;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export function readProgress(installId: string): ProgressCheckpoint {
|
|
513
|
+
const text = stateStoreFor(installId).read("progress.json");
|
|
514
|
+
if (text === null) {
|
|
515
|
+
return { lastCompletedStep: null, steps: {}, updatedAt: new Date().toISOString() };
|
|
516
|
+
}
|
|
517
|
+
return JSON.parse(text) as ProgressCheckpoint;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export function writeProgress(installId: string, checkpoint: ProgressCheckpoint): void {
|
|
521
|
+
stateStoreFor(installId).write("progress.json", {
|
|
522
|
+
...checkpoint,
|
|
523
|
+
updatedAt: new Date().toISOString(),
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Mark a step complete. Wizard re-runs check `isStepDone(installId, name)` to
|
|
529
|
+
* skip already-done steps. The payload is bookkeeping only: one holding a
|
|
530
|
+
* password, a credential URL or a secret-named key is refused (#656) —
|
|
531
|
+
* store an identifier and read the secret at the moment of use.
|
|
532
|
+
*/
|
|
533
|
+
export function markStepDone(installId: string, stepName: string, payload?: unknown): void {
|
|
534
|
+
if (payload !== undefined) assertNoSecrets(payload, `progress.json step "${stepName}"`);
|
|
535
|
+
const cur = readProgress(installId);
|
|
536
|
+
cur.lastCompletedStep = stepName;
|
|
537
|
+
if (payload !== undefined) cur.steps[stepName] = payload;
|
|
538
|
+
writeProgress(installId, cur);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
export function isStepDone(installId: string, stepName: string): boolean {
|
|
542
|
+
return readProgress(installId).steps[stepName] !== undefined;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
export function getStepPayload<T>(installId: string, stepName: string): T | null {
|
|
546
|
+
const v = readProgress(installId).steps[stepName];
|
|
547
|
+
return (v ?? null) as T | null;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Remove secret material an older CLI checkpointed into the install's
|
|
552
|
+
* progress.json (#656). Idempotent; completed steps stay completed.
|
|
553
|
+
*
|
|
554
|
+
* @returns the lines to show the operator: what was removed and which
|
|
555
|
+
* secret to rotate with which command; empty when the file was clean
|
|
556
|
+
*/
|
|
557
|
+
export function scrubInstallProgress(installId: string): string[] {
|
|
558
|
+
const { removed }: ScrubReport = scrubProgressFile(join(installRoot(installId), "progress.json"));
|
|
559
|
+
if (removed.length === 0) return [];
|
|
560
|
+
return [
|
|
561
|
+
`Removed ${removed.length} secret value(s) an earlier installer stored in ~/.caelo-${installId}/progress.json. Copies may remain in backups or synced copies of that file.`,
|
|
562
|
+
...rotationHints(removed, installId),
|
|
563
|
+
];
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Read a secret file from the install's `secrets/` dir. Returns null if the
|
|
568
|
+
* file doesn't exist; throws if the file exists but has the wrong mode (a
|
|
569
|
+
* defence against the user copy-pasting a key into a 644 file by mistake).
|
|
570
|
+
*/
|
|
571
|
+
export function readSecret(installId: string, name: string): string | null {
|
|
572
|
+
const path = join(installRoot(installId), "secrets", name);
|
|
573
|
+
if (!existsSync(path)) return null;
|
|
574
|
+
const contents = readFileSync(path, "utf8").trim();
|
|
575
|
+
if (contents.length === 0) return null;
|
|
576
|
+
return contents;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
export function writeSecret(installId: string, name: string, value: string): void {
|
|
580
|
+
ensureInstallDir(installId);
|
|
581
|
+
const path = join(installRoot(installId), "secrets", name);
|
|
582
|
+
writeFileSync(path, value.endsWith("\n") ? value : `${value}\n`, { mode: 0o600 });
|
|
583
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The CLI's side of the admin's /api/internal/* token
|
|
5
|
+
* (apps/admin/src/lib/server/internal-jwt.ts): HMAC-SHA256 over
|
|
6
|
+
* `"<iat>:<exp>:<scope>"` with the install's internal secret, valid for the
|
|
7
|
+
* admin's 5-minute replay window. One token per call.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Replay window the admin enforces; tokens never outlive it. */
|
|
11
|
+
const TOKEN_TTL_MS = 5 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Mint a token for `scope`.
|
|
15
|
+
*
|
|
16
|
+
* @param secret - the install's internal secret (≥32 chars; Secret Manager
|
|
17
|
+
* `<prefix>-internal-secret` on cloud installs, never written to disk).
|
|
18
|
+
* @param now - ms since epoch; tests pin it.
|
|
19
|
+
*/
|
|
20
|
+
export async function mintInternalToken(
|
|
21
|
+
secret: string,
|
|
22
|
+
scope: string,
|
|
23
|
+
now: number = Date.now(),
|
|
24
|
+
): Promise<string> {
|
|
25
|
+
const message = `${now}:${now + TOKEN_TTL_MS}:${scope}`;
|
|
26
|
+
const key = await crypto.subtle.importKey(
|
|
27
|
+
"raw",
|
|
28
|
+
new TextEncoder().encode(secret),
|
|
29
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
30
|
+
false,
|
|
31
|
+
["sign"],
|
|
32
|
+
);
|
|
33
|
+
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message));
|
|
34
|
+
return `${Buffer.from(message).toString("base64url")}.${Buffer.from(sig).toString("base64url")}`;
|
|
35
|
+
}
|