@caelo-cms/provisioning 0.10.34 → 0.10.37
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/adapter.d.ts +7 -1
- package/dist/adapter.d.ts.map +1 -1
- package/dist/cli.js +81 -12
- package/dist/cli.js.map +1 -1
- package/dist/compose.d.ts +12 -0
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +12 -4
- package/dist/compose.js.map +1 -1
- package/dist/default-firewall.d.ts +45 -0
- package/dist/default-firewall.d.ts.map +1 -0
- package/dist/default-firewall.js +171 -0
- package/dist/default-firewall.js.map +1 -0
- package/dist/deployed-release.d.ts +5 -3
- package/dist/deployed-release.d.ts.map +1 -1
- package/dist/deployed-release.js +6 -4
- package/dist/deployed-release.js.map +1 -1
- package/dist/gateway-credentials.d.ts +42 -0
- package/dist/gateway-credentials.d.ts.map +1 -0
- package/dist/gateway-credentials.js +95 -0
- package/dist/gateway-credentials.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/install-state.d.ts +27 -0
- package/dist/install-state.d.ts.map +1 -1
- package/dist/install-state.js +67 -0
- package/dist/install-state.js.map +1 -1
- package/dist/lifecycle.d.ts +6 -0
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +225 -56
- package/dist/lifecycle.js.map +1 -1
- package/dist/runtime-secrets.d.ts +39 -2
- package/dist/runtime-secrets.d.ts.map +1 -1
- package/dist/runtime-secrets.js +77 -8
- package/dist/runtime-secrets.js.map +1 -1
- package/dist/self-hosted-roles.d.ts +64 -0
- package/dist/self-hosted-roles.d.ts.map +1 -0
- package/dist/self-hosted-roles.js +77 -0
- package/dist/self-hosted-roles.js.map +1 -0
- package/dist/stack-contract.d.ts +68 -3
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +69 -12
- package/dist/stack-contract.js.map +1 -1
- package/dist/stack-converge.d.ts +24 -18
- package/dist/stack-converge.d.ts.map +1 -1
- package/dist/stack-converge.js +47 -2
- package/dist/stack-converge.js.map +1 -1
- package/dist/wizard.d.ts.map +1 -1
- package/dist/wizard.js +4 -2
- package/dist/wizard.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +64 -2
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +3 -3
- package/stacks/aws/README.md +1 -1
- package/stacks/aws/index.ts +20 -2
- package/stacks/azure/README.md +1 -1
- package/stacks/azure/index.ts +46 -7
- package/stacks/gcp/README.md +1 -1
- package/stacks/gcp/index.ts +25 -5
- package/stacks/gcp-firebase/index.ts +23 -2
- package/stacks/self-hosted/index.ts +26 -12
package/dist/lifecycle.js
CHANGED
|
@@ -7,49 +7,123 @@
|
|
|
7
7
|
* into provider tools for any of them.
|
|
8
8
|
*
|
|
9
9
|
* Each command:
|
|
10
|
-
* -
|
|
11
|
-
*
|
|
10
|
+
* - acts on the install named by `--install <install-id or domain>`, or
|
|
11
|
+
* on the only install on this machine; with several and no flag it
|
|
12
|
+
* stops and lists them (selectInstall in install-state.ts)
|
|
12
13
|
* - dispatches to provider-specific implementations (gcp / aws /
|
|
13
14
|
* azure / self-hosted)
|
|
14
15
|
* - emits human-readable progress + a final summary
|
|
15
16
|
*/
|
|
16
|
-
import { existsSync, readdirSync } from "node:fs";
|
|
17
|
-
import { homedir } from "node:os";
|
|
18
17
|
import { join, resolve as resolvePath } from "node:path";
|
|
19
|
-
import { cancel, confirm, isCancel, log, note, spinner } from "@clack/prompts";
|
|
18
|
+
import { cancel, confirm, isCancel, log, note, select, spinner } from "@clack/prompts";
|
|
20
19
|
import { bold, cyan, dim, green, red, yellow } from "kleur/colors";
|
|
20
|
+
import { ensureGatewayRolePassword, planPublicRoleSwitch } from "./gateway-credentials.js";
|
|
21
21
|
import { gcloud } from "./gcloud.js";
|
|
22
22
|
import { GCP_STACK_ENV, gatewayServiceAccountEmail, runServiceAccountEmail } from "./gcp-names.js";
|
|
23
|
-
import { installRoot,
|
|
23
|
+
import { installFlag, installRoot, listInstalls, readSecret, recordImageDigests, selectInstall, } from "./install-state.js";
|
|
24
24
|
import { ensureMcpIapAccess } from "./mcp-iap.js";
|
|
25
25
|
import { ensureOperatorAccessSync, resolveOperatorAccessTarget } from "./operator-access.js";
|
|
26
26
|
import { assertRegionUnchanged } from "./regions.js";
|
|
27
|
-
import { ensureGatewayServiceAccount, ensureGeneratedSecrets, plainGeneratedSecretSeed, ROTATABLE_SECRETS, readSecretReplication, rotateRuntimeSecret, rotationRefusal, } from "./runtime-secrets.js";
|
|
27
|
+
import { ensureGatewayServiceAccount, ensureGeneratedSecrets, plainGeneratedSecretSeed, ROTATABLE_SECRETS, readSecretReplication, rolesOfSecret, rotateRuntimeSecret, rotationRefusal, } from "./runtime-secrets.js";
|
|
28
28
|
import { adminMediaVolume, MCP_ENV_VAR, OPERATOR_ACCESS_JOB_ENV_VAR } from "./stack-contract.js";
|
|
29
|
-
import { ensureStackInvariants, liveContainerEnv, liveContainerMemory, liveEnvHasInlinePassword, liveVolumes, planAdminMemory, planContractEnv, planMediaVolume, rollService, serviceRollArgs, } from "./stack-converge.js";
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
29
|
+
import { ensureStackInvariants, liveContainerEnv, liveContainerMemory, liveEnvHasInlinePassword, liveVolumes, planAdminMemory, planContractEnv, planMediaVolume, removeRetiredIamBindings, rollService, serviceRollArgs, } from "./stack-converge.js";
|
|
30
|
+
/**
|
|
31
|
+
* The installs a command acts on. `--install <id or domain>` names one
|
|
32
|
+
* (`--install all` every one, where the command allows it). Without the flag:
|
|
33
|
+
* the only install on this machine; with several, an interactive terminal
|
|
34
|
+
* asks which one (or all), and a non-interactive run stops with the list.
|
|
35
|
+
* Never guesses — acting on the wrong install upgrades or wipes the wrong
|
|
36
|
+
* site (see selectInstall).
|
|
37
|
+
*/
|
|
38
|
+
async function chooseInstalls(opts) {
|
|
39
|
+
const installs = listInstalls();
|
|
40
|
+
let wanted;
|
|
41
|
+
try {
|
|
42
|
+
wanted = installFlag(process.argv);
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
log.error(red(e instanceof Error ? e.message : String(e)));
|
|
46
|
+
process.exit(2);
|
|
47
|
+
}
|
|
48
|
+
if (wanted === "all") {
|
|
49
|
+
if (!opts.allowAll) {
|
|
50
|
+
log.error(red(`${opts.verb} runs on one install at a time; pass --install <install-id or domain>.`));
|
|
51
|
+
process.exit(2);
|
|
52
|
+
}
|
|
53
|
+
if (installs.length === 0)
|
|
54
|
+
return exitNoInstall("No Caelo install found on this machine.");
|
|
55
|
+
return installs;
|
|
56
|
+
}
|
|
57
|
+
if (wanted === undefined && installs.length > 1 && process.stdin.isTTY) {
|
|
58
|
+
const choice = await select({
|
|
59
|
+
message: `Which install should ${opts.verb} act on?`,
|
|
60
|
+
options: [
|
|
61
|
+
...installs.map((m) => ({
|
|
62
|
+
value: m.installId,
|
|
63
|
+
label: m.domain,
|
|
64
|
+
hint: `${m.installId}, ${m.provider}`,
|
|
65
|
+
})),
|
|
66
|
+
...(opts.allowAll
|
|
67
|
+
? [{ value: "all", label: `All ${installs.length} installs`, hint: "one after another" }]
|
|
68
|
+
: []),
|
|
69
|
+
],
|
|
70
|
+
});
|
|
71
|
+
if (isCancel(choice)) {
|
|
72
|
+
cancel("Aborted.");
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
if (choice === "all")
|
|
76
|
+
return installs;
|
|
77
|
+
wanted = choice;
|
|
78
|
+
}
|
|
79
|
+
const selected = selectInstall(installs, wanted);
|
|
80
|
+
if (!selected.ok)
|
|
81
|
+
return exitNoInstall(selected.message);
|
|
82
|
+
return [selected.meta];
|
|
44
83
|
}
|
|
45
|
-
function
|
|
46
|
-
|
|
47
|
-
if (
|
|
48
|
-
log.error(red("No Caelo install found on this machine."));
|
|
84
|
+
function exitNoInstall(message) {
|
|
85
|
+
log.error(red(message));
|
|
86
|
+
if (listInstalls().length === 0) {
|
|
49
87
|
log.warn(`Run ${bold("bunx @caelo-cms/provisioning")} first to provision an install, OR copy ${dim("~/.caelo-<install-id>/")} from the provisioning machine.`);
|
|
50
|
-
process.exit(1);
|
|
51
88
|
}
|
|
52
|
-
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
/** The one install a single-install command (rotate-secret, truncate, destroy) acts on. */
|
|
92
|
+
async function requireInstall(verb) {
|
|
93
|
+
const [meta] = await chooseInstalls({ verb, allowAll: false });
|
|
94
|
+
if (!meta)
|
|
95
|
+
return exitNoInstall("No Caelo install found on this machine.");
|
|
96
|
+
return { installId: meta.installId, meta };
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Run `run` for each chosen install in turn, with a header when there are
|
|
100
|
+
* several. `run` reports whether the install succeeded; the first failure
|
|
101
|
+
* stops the batch (the installs after it are left untouched) and sets a
|
|
102
|
+
* non-zero exit code, so scripts see it too.
|
|
103
|
+
*
|
|
104
|
+
* @param check validates the chosen set before anything runs (e.g. a
|
|
105
|
+
* --region that cannot match several installs); returns an error or null
|
|
106
|
+
*/
|
|
107
|
+
async function forEachInstall(verb, run, check) {
|
|
108
|
+
const chosen = await chooseInstalls({ verb, allowAll: true });
|
|
109
|
+
const problem = check?.(chosen) ?? null;
|
|
110
|
+
if (problem) {
|
|
111
|
+
log.error(red(problem));
|
|
112
|
+
process.exit(2);
|
|
113
|
+
}
|
|
114
|
+
for (const [i, meta] of chosen.entries()) {
|
|
115
|
+
if (chosen.length > 1) {
|
|
116
|
+
note(`${meta.domain} ${dim(`(${meta.installId})`)}`, `${verb} ${i + 1}/${chosen.length}`);
|
|
117
|
+
}
|
|
118
|
+
if (await run(meta.installId, meta))
|
|
119
|
+
continue;
|
|
120
|
+
process.exitCode = 1;
|
|
121
|
+
const skipped = chosen.slice(i + 1);
|
|
122
|
+
if (skipped.length > 0) {
|
|
123
|
+
log.warn(`${verb} failed for ${meta.domain}; stopped before ${skipped.map((m) => m.domain).join(", ")}.`);
|
|
124
|
+
}
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
53
127
|
}
|
|
54
128
|
/**
|
|
55
129
|
* The install's recorded region (#607); exits with the actionable reason
|
|
@@ -101,7 +175,9 @@ async function resolveGcpResourceName(kind, prefix, projectId, region) {
|
|
|
101
175
|
// status — health check + monthly cost
|
|
102
176
|
// =========================================================================
|
|
103
177
|
export async function statusCommand() {
|
|
104
|
-
|
|
178
|
+
await forEachInstall("status", statusOf);
|
|
179
|
+
}
|
|
180
|
+
async function statusOf(installId, meta) {
|
|
105
181
|
log.info(`Install: ${bold(installId)} ${dim(`(${meta.provider})`)}`);
|
|
106
182
|
log.info(`Domain: ${bold(meta.domain)}`);
|
|
107
183
|
log.info(`Project: ${bold(meta.projectId ?? "<self-hosted>")}`);
|
|
@@ -111,6 +187,7 @@ export async function statusCommand() {
|
|
|
111
187
|
else {
|
|
112
188
|
log.warn(`status command for provider ${meta.provider} not yet implemented.`);
|
|
113
189
|
}
|
|
190
|
+
return true;
|
|
114
191
|
}
|
|
115
192
|
/**
|
|
116
193
|
* P20 — fetch the latest stable Caelo release tag from GitHub.
|
|
@@ -315,7 +392,22 @@ async function resolveAdminIapResource(meta, region, adminServiceName) {
|
|
|
315
392
|
return name ? { kind: "backend-services", service: name } : null;
|
|
316
393
|
}
|
|
317
394
|
export async function upgradeCommand(opts = {}) {
|
|
318
|
-
|
|
395
|
+
if (opts.installId) {
|
|
396
|
+
const selected = selectInstall(listInstalls(), opts.installId);
|
|
397
|
+
if (!selected.ok)
|
|
398
|
+
return exitNoInstall(selected.message);
|
|
399
|
+
if (!(await upgradeInstall(selected.meta.installId, selected.meta, opts)))
|
|
400
|
+
process.exitCode = 1;
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
await forEachInstall("upgrade", (installId, meta) => upgradeInstall(installId, meta, opts),
|
|
404
|
+
// Each install has its own recorded region (#607); one --region can't
|
|
405
|
+
// match several, and finding out halfway would leave a partial batch.
|
|
406
|
+
(chosen) => opts.region && chosen.length > 1
|
|
407
|
+
? "--region names one install's region; drop it when upgrading several installs."
|
|
408
|
+
: null);
|
|
409
|
+
}
|
|
410
|
+
async function upgradeInstall(installId, meta, opts) {
|
|
319
411
|
// v0.5.15 — extended to cover gcp-firebase too. Both providers share
|
|
320
412
|
// the identical admin + gateway shape on Cloud Run (Artifact
|
|
321
413
|
// Registry image, `caelo-production-<slug>` service naming, the same
|
|
@@ -325,10 +417,10 @@ export async function upgradeCommand(opts = {}) {
|
|
|
325
417
|
// provider adapters land.
|
|
326
418
|
if (meta.provider !== "gcp" && meta.provider !== "gcp-firebase") {
|
|
327
419
|
log.warn(`upgrade for provider ${meta.provider} not yet implemented.`);
|
|
328
|
-
return;
|
|
420
|
+
return false;
|
|
329
421
|
}
|
|
330
422
|
if (!meta.projectId)
|
|
331
|
-
return;
|
|
423
|
+
return false;
|
|
332
424
|
// #607 — upgrade never moves an install: a `--region` other than the
|
|
333
425
|
// recorded one is refused before anything rolls.
|
|
334
426
|
const region = installRegion(meta, opts.region);
|
|
@@ -357,7 +449,7 @@ export async function upgradeCommand(opts = {}) {
|
|
|
357
449
|
const serviceName = await resolveGcpResourceName("run-service", `caelo-production-${slug}`, meta.projectId, region);
|
|
358
450
|
if (!serviceName) {
|
|
359
451
|
sPre.stop(red(`Could not find caelo-production-${slug}* Cloud Run service`));
|
|
360
|
-
return;
|
|
452
|
+
return false;
|
|
361
453
|
}
|
|
362
454
|
// Resolve the tag via the public Docker Registry V2 API directly,
|
|
363
455
|
// not `gcloud artifacts docker tags list`. The gcloud path requires
|
|
@@ -374,18 +466,18 @@ export async function upgradeCommand(opts = {}) {
|
|
|
374
466
|
log.error(`Public registry returned ${digestRes.reason}.\n` +
|
|
375
467
|
`Verify the tag exists at https://${registryRegion}-docker.pkg.dev/${registryProject}/${registryRepo}/${slug}:${targetTag}\n` +
|
|
376
468
|
`(latest releases live at https://github.com/caelo-cms/caelo-cms/releases — pass --version vX.Y.Z to pin.)`);
|
|
377
|
-
return;
|
|
469
|
+
return false;
|
|
378
470
|
}
|
|
379
471
|
const digest = digestRes.digest;
|
|
380
472
|
const priorRevision = await findCurrentRevision(meta.projectId, region, serviceName);
|
|
381
473
|
if (!priorRevision) {
|
|
382
474
|
sPre.stop(red(`Could not capture current revision for ${slug} — refusing to roll`));
|
|
383
|
-
return;
|
|
475
|
+
return false;
|
|
384
476
|
}
|
|
385
477
|
const serviceJson = await describeServiceJson(meta.projectId, region, serviceName);
|
|
386
478
|
if (!serviceJson) {
|
|
387
479
|
sPre.stop(red(`Could not read the ${slug} service's configuration — refusing to roll`));
|
|
388
|
-
return;
|
|
480
|
+
return false;
|
|
389
481
|
}
|
|
390
482
|
plans.push({
|
|
391
483
|
slug,
|
|
@@ -410,7 +502,7 @@ export async function upgradeCommand(opts = {}) {
|
|
|
410
502
|
if (!envPlan.ok) {
|
|
411
503
|
sPre.stop(red("Pre-flight failed: the install's env contract can't be applied"));
|
|
412
504
|
log.error(envPlan.error);
|
|
413
|
-
return;
|
|
505
|
+
return false;
|
|
414
506
|
}
|
|
415
507
|
// The admin's media lives in the media bucket, mounted as a Cloud Storage
|
|
416
508
|
// volume. Without it every upload is lost on the next revision or
|
|
@@ -422,7 +514,7 @@ export async function upgradeCommand(opts = {}) {
|
|
|
422
514
|
if (!mediaVolume.ok) {
|
|
423
515
|
sPre.stop(red("Pre-flight failed: the admin's media volume can't be set up"));
|
|
424
516
|
log.error(mediaVolume.error);
|
|
425
|
-
return;
|
|
517
|
+
return false;
|
|
426
518
|
}
|
|
427
519
|
let rolls = plans.map((p) => {
|
|
428
520
|
// #553 — the admin runs the Lighthouse quality audit; raise it to the
|
|
@@ -464,7 +556,7 @@ export async function upgradeCommand(opts = {}) {
|
|
|
464
556
|
if (!opts.skipVerify) {
|
|
465
557
|
const verified = await verifyCosignAll(plans, registryRegion, registryProject, registryRepo);
|
|
466
558
|
if (!verified)
|
|
467
|
-
return;
|
|
559
|
+
return false;
|
|
468
560
|
}
|
|
469
561
|
else {
|
|
470
562
|
log.warn(yellow("--skip-verify set — image signatures NOT verified."));
|
|
@@ -496,7 +588,7 @@ export async function upgradeCommand(opts = {}) {
|
|
|
496
588
|
for (const o of runtimeFailed)
|
|
497
589
|
log.error(red(` FAILED: ${o.id}\n ${o.error ?? ""}`));
|
|
498
590
|
log.warn("No traffic was shifted and no migrations ran. Fix the above and re-run.");
|
|
499
|
-
return;
|
|
591
|
+
return false;
|
|
500
592
|
}
|
|
501
593
|
sRt.stop(green("Gateway service account + runtime secrets ok"));
|
|
502
594
|
for (const o of runtime.filter((o) => o.status === "applied"))
|
|
@@ -506,10 +598,11 @@ export async function upgradeCommand(opts = {}) {
|
|
|
506
598
|
// from an image roll: IAM bindings + CDN policy added to the stacks after
|
|
507
599
|
// this install was provisioned (stack-contract.ts). Additive + idempotent.
|
|
508
600
|
// A failure the install can't work without aborts here, before
|
|
509
|
-
// migrations or any traffic shift; the rest warn.
|
|
601
|
+
// migrations or any traffic shift; the rest warn. Also deletes GCP's
|
|
602
|
+
// auto-created SSH/RDP-from-anywhere firewall rules (default-firewall.ts).
|
|
510
603
|
// ────────────────────────────────────────────────────────────────
|
|
511
604
|
const sInv = spinner();
|
|
512
|
-
sInv.start("Ensuring the IAM bindings + CDN settings the stack declares...");
|
|
605
|
+
sInv.start("Ensuring the IAM bindings + CDN settings the stack declares, removing default SSH/RDP firewall rules...");
|
|
513
606
|
const invariants = await ensureStackInvariants({
|
|
514
607
|
provider: meta.provider,
|
|
515
608
|
projectId: meta.projectId,
|
|
@@ -536,7 +629,7 @@ export async function upgradeCommand(opts = {}) {
|
|
|
536
629
|
}
|
|
537
630
|
if (invariants.mustAbort) {
|
|
538
631
|
log.warn("No traffic was shifted and no migrations ran. Fix the bindings above and re-run.");
|
|
539
|
-
return;
|
|
632
|
+
return false;
|
|
540
633
|
}
|
|
541
634
|
// ────────────────────────────────────────────────────────────────
|
|
542
635
|
// P21 ship 3 — DB migrations BEFORE traffic shifts. Idempotent
|
|
@@ -565,10 +658,38 @@ export async function upgradeCommand(opts = {}) {
|
|
|
565
658
|
" gcloud logging read 'resource.type=cloud_run_job AND " +
|
|
566
659
|
'resource.labels.job_name=~"caelo-migrate-.*"\' ' +
|
|
567
660
|
`--project=${meta.projectId} --limit=50`);
|
|
568
|
-
return;
|
|
661
|
+
return false;
|
|
569
662
|
}
|
|
570
663
|
sMig.stop(green("Migrations applied"));
|
|
571
664
|
// ────────────────────────────────────────────────────────────────
|
|
665
|
+
// #613 — the gateway's own database login. Migration 0248 created
|
|
666
|
+
// gateway_role without a password; give it the gateway-role-password
|
|
667
|
+
// secret's value now, before the gateway rolls onto it. Without it the
|
|
668
|
+
// new gateway could not reach cms_admin, so a failure aborts here.
|
|
669
|
+
// public_role moves onto its own password right before the gateway's
|
|
670
|
+
// roll (gateway-credentials.ts), so the old revision keeps working
|
|
671
|
+
// until then.
|
|
672
|
+
// ────────────────────────────────────────────────────────────────
|
|
673
|
+
const sRole = spinner();
|
|
674
|
+
sRole.start("Ensuring the gateway's database login (gateway_role)...");
|
|
675
|
+
const sqlInstance = await resolveGcpResourceName("sql-instance", "caelo-production-pg", meta.projectId);
|
|
676
|
+
if (!sqlInstance) {
|
|
677
|
+
sRole.stop(red("Could not find the Cloud SQL instance. Aborting upgrade."));
|
|
678
|
+
log.warn("Migrations ran; no traffic was shifted. Re-run upgrade once the instance is found.");
|
|
679
|
+
return false;
|
|
680
|
+
}
|
|
681
|
+
const roleTarget = { projectId: meta.projectId, env: GCP_STACK_ENV, sqlInstance };
|
|
682
|
+
const gatewayRole = await ensureGatewayRolePassword(roleTarget);
|
|
683
|
+
if (gatewayRole.status === "failed") {
|
|
684
|
+
sRole.stop(red("gateway_role could not be given its password. Aborting upgrade."));
|
|
685
|
+
log.error(red(` ${gatewayRole.error ?? ""}`));
|
|
686
|
+
log.warn("Migrations ran (0248 creates gateway_role); no traffic was shifted. Fix the above and re-run upgrade.");
|
|
687
|
+
return false;
|
|
688
|
+
}
|
|
689
|
+
sRole.stop(green("gateway_role ready"));
|
|
690
|
+
const gatewayLive = plans.find((p) => p.slug === "gateway");
|
|
691
|
+
const publicRole = planPublicRoleSwitch(roleTarget, gatewayLive?.liveEnv ?? new Map());
|
|
692
|
+
// ────────────────────────────────────────────────────────────────
|
|
572
693
|
// Issue #37 — MCP through IAP. Idempotently ensure the MCP service
|
|
573
694
|
// account + its IAP/token-creator bindings; the admin learns the SA's
|
|
574
695
|
// email from CAELO_MCP_IAP_SERVICE_ACCOUNT in the env contract. Installs
|
|
@@ -648,9 +769,27 @@ export async function upgradeCommand(opts = {}) {
|
|
|
648
769
|
// service's run SA: the gateway moves to its own SA here.
|
|
649
770
|
// ────────────────────────────────────────────────────────────────
|
|
650
771
|
const rolled = [];
|
|
772
|
+
/** A failed gateway roll goes back to a revision that reads public_role's old password. */
|
|
773
|
+
const restorePublicRole = async (slug) => {
|
|
774
|
+
if (slug !== "gateway")
|
|
775
|
+
return;
|
|
776
|
+
const outcome = await publicRole.afterGatewayRollFailed();
|
|
777
|
+
if (outcome.startsWith("could NOT"))
|
|
778
|
+
log.error(red(` ${outcome}`));
|
|
779
|
+
else
|
|
780
|
+
log.info(` ${outcome}`);
|
|
781
|
+
};
|
|
651
782
|
for (const plan of rolls) {
|
|
652
783
|
const s = spinner();
|
|
653
784
|
s.start(`Rolling ${plan.slug} → ${plan.digest.slice(0, 19)}...`);
|
|
785
|
+
if (plan.slug === "gateway") {
|
|
786
|
+
const moved = await publicRole.beforeGatewayRoll();
|
|
787
|
+
if (moved.status === "failed") {
|
|
788
|
+
s.stop(red(`public_role could not be moved onto its own password: ${moved.error ?? ""}`));
|
|
789
|
+
await rollbackPriorlyRolled(meta.projectId, region, rolled);
|
|
790
|
+
return false;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
654
793
|
const upd = await rollService(serviceRollArgs({
|
|
655
794
|
serviceName: plan.serviceName,
|
|
656
795
|
region,
|
|
@@ -665,8 +804,9 @@ export async function upgradeCommand(opts = {}) {
|
|
|
665
804
|
}));
|
|
666
805
|
if (!upd.ok) {
|
|
667
806
|
s.stop(red(`Failed: ${upd.stderr.trim()}`));
|
|
807
|
+
await restorePublicRole(plan.slug);
|
|
668
808
|
await rollbackPriorlyRolled(meta.projectId, region, rolled);
|
|
669
|
-
return;
|
|
809
|
+
return false;
|
|
670
810
|
}
|
|
671
811
|
// Force traffic onto the new revision. `update --image` only auto-
|
|
672
812
|
// flips when the service has no explicit traffic config — but our
|
|
@@ -691,14 +831,16 @@ export async function upgradeCommand(opts = {}) {
|
|
|
691
831
|
]);
|
|
692
832
|
if (!flip.ok) {
|
|
693
833
|
s.stop(red(`Traffic flip to latest failed: ${flip.stderr.trim()}`));
|
|
834
|
+
await restorePublicRole(plan.slug);
|
|
694
835
|
await rollbackPriorlyRolled(meta.projectId, region, rolled);
|
|
695
|
-
return;
|
|
836
|
+
return false;
|
|
696
837
|
}
|
|
697
838
|
if (!(await findServiceUrl(meta.projectId, region, plan.serviceName))) {
|
|
698
839
|
s.stop(red(`Could not resolve service URL for ${plan.slug} — rolling back`));
|
|
699
840
|
await rollbackTraffic(meta.projectId, region, plan.serviceName, plan.priorRevision);
|
|
841
|
+
await restorePublicRole(plan.slug);
|
|
700
842
|
await rollbackPriorlyRolled(meta.projectId, region, rolled);
|
|
701
|
-
return;
|
|
843
|
+
return false;
|
|
702
844
|
}
|
|
703
845
|
// Cloud Run's `update --image` (with default --quiet) returns only
|
|
704
846
|
// after the new revision passes its container-readiness probe AND
|
|
@@ -717,6 +859,29 @@ export async function upgradeCommand(opts = {}) {
|
|
|
717
859
|
rolled.push(plan);
|
|
718
860
|
}
|
|
719
861
|
log.success(`Upgrade to ${bold(targetTag)} complete (admin + gateway revisions Ready).`);
|
|
862
|
+
// #613 — with both services on their new revisions, drop the bindings
|
|
863
|
+
// nothing reads through any more: above all the gateway SA's access to
|
|
864
|
+
// admin_role's password. Loud on failure — until it is gone, a
|
|
865
|
+
// compromised gateway could still read that password.
|
|
866
|
+
const retired = await removeRetiredIamBindings({
|
|
867
|
+
provider: meta.provider,
|
|
868
|
+
projectId: meta.projectId,
|
|
869
|
+
region,
|
|
870
|
+
env: GCP_STACK_ENV,
|
|
871
|
+
services: Object.fromEntries(rolls.map((r) => [r.slug, r.serviceName])),
|
|
872
|
+
});
|
|
873
|
+
for (const o of retired) {
|
|
874
|
+
if (o.status === "removed")
|
|
875
|
+
log.info(` removed: ${o.id} ${dim(`(${o.why})`)}`);
|
|
876
|
+
if (o.status === "failed") {
|
|
877
|
+
log.error(red(` NOT removed: ${o.id} — ${o.why}\n ${o.error ?? ""}\n Re-run upgrade with an account that may change IAM, or remove the binding by hand.`));
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
if (publicRole.moves) {
|
|
881
|
+
// Until this upgrade the gateway held admin_role's password; anyone who
|
|
882
|
+
// ever compromised it could have kept a copy.
|
|
883
|
+
log.warn(yellow(`The gateway no longer holds admin_role's password, but it did until now. Rotate it: ${bold("bunx @caelo-cms/provisioning rotate-secret postgres-password")}`));
|
|
884
|
+
}
|
|
720
885
|
if (rolls.some((r) => liveEnvHasInlinePassword(r.liveEnv))) {
|
|
721
886
|
// The services just moved their database password to Secret Manager,
|
|
722
887
|
// but every earlier revision still shows it in its env.
|
|
@@ -732,6 +897,7 @@ export async function upgradeCommand(opts = {}) {
|
|
|
732
897
|
admin: digestOf("admin"),
|
|
733
898
|
gateway: digestOf("gateway"),
|
|
734
899
|
});
|
|
900
|
+
return true;
|
|
735
901
|
}
|
|
736
902
|
/**
|
|
737
903
|
* Record the digests just rolled so nothing re-deploys an older release:
|
|
@@ -909,19 +1075,21 @@ async function rollbackPriorlyRolled(projectId, region, rolled) {
|
|
|
909
1075
|
// backup — Cloud SQL on-demand backup + bundle Pulumi state
|
|
910
1076
|
// =========================================================================
|
|
911
1077
|
export async function backupCommand() {
|
|
912
|
-
|
|
1078
|
+
await forEachInstall("backup", (_installId, meta) => backupOf(meta));
|
|
1079
|
+
}
|
|
1080
|
+
async function backupOf(meta) {
|
|
913
1081
|
if (meta.provider !== "gcp") {
|
|
914
1082
|
log.warn(`backup for provider ${meta.provider} not yet implemented.`);
|
|
915
|
-
return;
|
|
1083
|
+
return false;
|
|
916
1084
|
}
|
|
917
1085
|
if (!meta.projectId)
|
|
918
|
-
return;
|
|
1086
|
+
return false;
|
|
919
1087
|
const s = spinner();
|
|
920
1088
|
s.start("Resolving Cloud SQL instance + triggering on-demand backup...");
|
|
921
1089
|
const sqlName = await resolveGcpResourceName("sql-instance", "caelo-production-pg", meta.projectId);
|
|
922
1090
|
if (!sqlName) {
|
|
923
1091
|
s.stop(red("Could not find caelo-production-pg* Cloud SQL instance"));
|
|
924
|
-
return;
|
|
1092
|
+
return false;
|
|
925
1093
|
}
|
|
926
1094
|
const r = await gcloud([
|
|
927
1095
|
"sql",
|
|
@@ -936,9 +1104,10 @@ export async function backupCommand() {
|
|
|
936
1104
|
]);
|
|
937
1105
|
if (!r.ok) {
|
|
938
1106
|
s.stop(red(`Failed: ${r.stderr.trim()}`));
|
|
939
|
-
return;
|
|
1107
|
+
return false;
|
|
940
1108
|
}
|
|
941
1109
|
s.stop(green(`Backup created. List with \`gcloud sql backups list --instance=${sqlName}\`.`));
|
|
1110
|
+
return true;
|
|
942
1111
|
}
|
|
943
1112
|
// =========================================================================
|
|
944
1113
|
// rotate-secret <name>
|
|
@@ -955,7 +1124,7 @@ export async function rotateSecretCommand(name) {
|
|
|
955
1124
|
process.exit(2);
|
|
956
1125
|
}
|
|
957
1126
|
const secret = name;
|
|
958
|
-
const { meta } = requireInstall();
|
|
1127
|
+
const { meta } = await requireInstall("rotate-secret");
|
|
959
1128
|
if (meta.provider !== "gcp" && meta.provider !== "gcp-firebase") {
|
|
960
1129
|
log.warn(`rotate-secret for provider ${meta.provider} not yet implemented.`);
|
|
961
1130
|
return;
|
|
@@ -974,7 +1143,7 @@ export async function rotateSecretCommand(name) {
|
|
|
974
1143
|
}
|
|
975
1144
|
services[slug] = serviceName;
|
|
976
1145
|
}
|
|
977
|
-
const sqlInstance = secret
|
|
1146
|
+
const sqlInstance = rolesOfSecret(secret).length > 0
|
|
978
1147
|
? await resolveGcpResourceName("sql-instance", "caelo-production-pg", meta.projectId)
|
|
979
1148
|
: null;
|
|
980
1149
|
const report = await rotateRuntimeSecret({
|
|
@@ -1010,7 +1179,7 @@ export async function rotateSecretCommand(name) {
|
|
|
1010
1179
|
* longer renders cleanly.
|
|
1011
1180
|
*/
|
|
1012
1181
|
export async function truncateCommand() {
|
|
1013
|
-
const { meta } = requireInstall();
|
|
1182
|
+
const { meta } = await requireInstall("truncate");
|
|
1014
1183
|
log.warn(yellow(`${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` +
|
|
1015
1184
|
`Preserved: users / roles / AI providers / domains / site_defaults / provisioning_outputs.`));
|
|
1016
1185
|
const confirm1 = await confirm({
|
|
@@ -1054,7 +1223,7 @@ export async function truncateCommand() {
|
|
|
1054
1223
|
log.warn(`Truncate not implemented for provider=${meta.provider}.`);
|
|
1055
1224
|
}
|
|
1056
1225
|
export async function destroyCommand() {
|
|
1057
|
-
const { installId, meta } = requireInstall();
|
|
1226
|
+
const { installId, meta } = await requireInstall("destroy");
|
|
1058
1227
|
log.warn(red(`${bold("Destroy will PERMANENTLY delete")} the GCP project + every Caelo resource. This is irreversible after the 30-day undelete window.`));
|
|
1059
1228
|
const confirm1 = await confirm({
|
|
1060
1229
|
message: `Destroy the install for ${bold(meta.domain)} (${bold(meta.projectId ?? "self-hosted")})?`,
|