@caelo-cms/provisioning 0.10.29 → 0.10.31
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/admin-domain.d.ts +54 -0
- package/dist/admin-domain.d.ts.map +1 -0
- package/dist/admin-domain.js +122 -0
- package/dist/admin-domain.js.map +1 -0
- package/dist/cli.js +71 -7
- package/dist/cli.js.map +1 -1
- package/dist/compose.d.ts +7 -9
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +17 -1
- package/dist/compose.js.map +1 -1
- package/dist/deployed-release.d.ts +84 -0
- package/dist/deployed-release.d.ts.map +1 -0
- package/dist/deployed-release.js +182 -0
- package/dist/deployed-release.js.map +1 -0
- package/dist/gcloud-retry.d.ts +16 -0
- package/dist/gcloud-retry.d.ts.map +1 -0
- package/dist/gcloud-retry.js +17 -0
- package/dist/gcloud-retry.js.map +1 -0
- package/dist/gcloud.d.ts.map +1 -1
- package/dist/gcloud.js +2 -0
- package/dist/gcloud.js.map +1 -1
- package/dist/gcp-names.d.ts +58 -0
- package/dist/gcp-names.d.ts.map +1 -0
- package/dist/gcp-names.js +88 -0
- package/dist/gcp-names.js.map +1 -0
- package/dist/install-state.d.ts +31 -0
- package/dist/install-state.d.ts.map +1 -1
- package/dist/install-state.js +38 -0
- package/dist/install-state.js.map +1 -1
- package/dist/lifecycle.d.ts +5 -0
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +340 -50
- package/dist/lifecycle.js.map +1 -1
- package/dist/mcp-iap.d.ts +5 -26
- package/dist/mcp-iap.d.ts.map +1 -1
- package/dist/mcp-iap.js +6 -18
- package/dist/mcp-iap.js.map +1 -1
- package/dist/migration-runner.d.ts +25 -0
- package/dist/migration-runner.d.ts.map +1 -1
- package/dist/migration-runner.js +60 -19
- package/dist/migration-runner.js.map +1 -1
- package/dist/operator-access.d.ts +83 -0
- package/dist/operator-access.d.ts.map +1 -0
- package/dist/operator-access.js +562 -0
- package/dist/operator-access.js.map +1 -0
- package/dist/region-data.d.ts +30 -0
- package/dist/region-data.d.ts.map +1 -0
- package/dist/region-data.js +406 -0
- package/dist/region-data.js.map +1 -0
- package/dist/regions.d.ts +130 -0
- package/dist/regions.d.ts.map +1 -0
- package/dist/regions.js +247 -0
- package/dist/regions.js.map +1 -0
- package/dist/runtime-secrets.d.ts +128 -0
- package/dist/runtime-secrets.d.ts.map +1 -0
- package/dist/runtime-secrets.js +444 -0
- package/dist/runtime-secrets.js.map +1 -0
- package/dist/stack-contract.d.ts +231 -0
- package/dist/stack-contract.d.ts.map +1 -0
- package/dist/stack-contract.js +455 -0
- package/dist/stack-contract.js.map +1 -0
- package/dist/stack-converge.d.ts +183 -0
- package/dist/stack-converge.d.ts.map +1 -0
- package/dist/stack-converge.js +513 -0
- package/dist/stack-converge.js.map +1 -0
- package/dist/wizard.d.ts +7 -0
- package/dist/wizard.d.ts.map +1 -1
- package/dist/wizard.js +11 -1
- package/dist/wizard.js.map +1 -1
- package/dist/wizards/gcp-cost.d.ts +10 -10
- package/dist/wizards/gcp-cost.d.ts.map +1 -1
- package/dist/wizards/gcp-cost.js +40 -3
- package/dist/wizards/gcp-cost.js.map +1 -1
- package/dist/wizards/gcp-firebase.d.ts +2 -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-pulumi.d.ts +51 -1
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +89 -6
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts +2 -0
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +171 -23
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +3 -3
- package/stacks/aws/Pulumi.yaml +5 -2
- package/stacks/aws/README.md +3 -1
- package/stacks/aws/index.ts +21 -1
- package/stacks/azure/Pulumi.yaml +6 -3
- package/stacks/azure/README.md +1 -1
- package/stacks/azure/index.ts +22 -3
- package/stacks/gcp/Pulumi.yaml +5 -5
- package/stacks/gcp/README.md +15 -3
- package/stacks/gcp/index.ts +204 -141
- package/stacks/gcp-firebase/Pulumi.yaml +5 -2
- package/stacks/gcp-firebase/index.ts +169 -76
- package/stacks/self-hosted/Pulumi.yaml +4 -0
- package/stacks/self-hosted/index.ts +3 -0
package/dist/lifecycle.js
CHANGED
|
@@ -15,11 +15,18 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { existsSync, readdirSync } from "node:fs";
|
|
17
17
|
import { homedir } from "node:os";
|
|
18
|
+
import { join, resolve as resolvePath } from "node:path";
|
|
18
19
|
import { cancel, confirm, isCancel, log, note, spinner } from "@clack/prompts";
|
|
19
20
|
import { bold, cyan, dim, green, red, yellow } from "kleur/colors";
|
|
20
21
|
import { gcloud } from "./gcloud.js";
|
|
21
|
-
import {
|
|
22
|
+
import { GCP_STACK_ENV, gatewayServiceAccountEmail, runServiceAccountEmail } from "./gcp-names.js";
|
|
23
|
+
import { installRoot, readMetadata, readSecret, recordImageDigests, } from "./install-state.js";
|
|
22
24
|
import { ensureMcpIapAccess } from "./mcp-iap.js";
|
|
25
|
+
import { ensureOperatorAccessSync, resolveOperatorAccessTarget } from "./operator-access.js";
|
|
26
|
+
import { assertRegionUnchanged } from "./regions.js";
|
|
27
|
+
import { ensureGatewayServiceAccount, ensureGeneratedSecrets, plainGeneratedSecretSeed, ROTATABLE_SECRETS, readSecretReplication, rotateRuntimeSecret, rotationRefusal, } from "./runtime-secrets.js";
|
|
28
|
+
import { MCP_ENV_VAR, OPERATOR_ACCESS_JOB_ENV_VAR } from "./stack-contract.js";
|
|
29
|
+
import { ensureStackInvariants, liveContainerEnv, liveContainerMemory, liveEnvHasInlinePassword, planAdminMemory, planContractEnv, rollService, serviceRollArgs, } from "./stack-converge.js";
|
|
23
30
|
/** Find the single install on this machine — or warn if 0/multiple. */
|
|
24
31
|
function findActiveInstall() {
|
|
25
32
|
const home = homedir();
|
|
@@ -44,6 +51,25 @@ function requireInstall() {
|
|
|
44
51
|
}
|
|
45
52
|
return found;
|
|
46
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* The install's recorded region (#607); exits with the actionable reason
|
|
56
|
+
* when install.json has none, or when `requested` names a different one.
|
|
57
|
+
*/
|
|
58
|
+
function installRegion(meta, requested) {
|
|
59
|
+
try {
|
|
60
|
+
return assertRegionUnchanged(meta, requested);
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
log.error(red(e instanceof Error ? e.message : String(e)));
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** A Cloud Run lookup is regional; there is no default region to guess. */
|
|
68
|
+
function requiredRegion(region) {
|
|
69
|
+
if (!region)
|
|
70
|
+
throw new Error("a Cloud Run lookup needs the install region (install.json)");
|
|
71
|
+
return region;
|
|
72
|
+
}
|
|
47
73
|
/**
|
|
48
74
|
* Pulumi auto-naming appends a random 7-char suffix to every resource
|
|
49
75
|
* (e.g. `caelo-production-admin-3efcfea`). Lifecycle commands need the
|
|
@@ -56,7 +82,7 @@ function requireInstall() {
|
|
|
56
82
|
async function resolveGcpResourceName(kind, prefix, projectId, region) {
|
|
57
83
|
const args = [];
|
|
58
84
|
if (kind === "run-service") {
|
|
59
|
-
args.push("run", "services", "list", "--region", region
|
|
85
|
+
args.push("run", "services", "list", "--region", requiredRegion(region), "--project", projectId, "--filter", `metadata.name~^${prefix}`, "--format=value(metadata.name)");
|
|
60
86
|
}
|
|
61
87
|
else {
|
|
62
88
|
args.push("sql", "instances", "list", "--project", projectId, "--filter", `name~^${prefix}`, "--format=value(name)");
|
|
@@ -122,7 +148,7 @@ async function gcpStatus(meta) {
|
|
|
122
148
|
return;
|
|
123
149
|
const s = spinner();
|
|
124
150
|
s.start("Resolving deployed resources + checking health...");
|
|
125
|
-
const region = meta
|
|
151
|
+
const region = installRegion(meta);
|
|
126
152
|
const adminName = await resolveGcpResourceName("run-service", "caelo-production-admin", meta.projectId, region);
|
|
127
153
|
const sqlName = await resolveGcpResourceName("sql-instance", "caelo-production-pg", meta.projectId);
|
|
128
154
|
if (!adminName || !sqlName) {
|
|
@@ -202,6 +228,21 @@ async function resolveTagDigest(region, project, repo, image, tag) {
|
|
|
202
228
|
return { ok: false, reason: e instanceof Error ? e.message : String(e) };
|
|
203
229
|
}
|
|
204
230
|
}
|
|
231
|
+
/** The deployed service as `gcloud run services describe --format=json` returns it. */
|
|
232
|
+
async function describeServiceJson(projectId, region, serviceName) {
|
|
233
|
+
const r = await gcloud([
|
|
234
|
+
"run",
|
|
235
|
+
"services",
|
|
236
|
+
"describe",
|
|
237
|
+
serviceName,
|
|
238
|
+
"--region",
|
|
239
|
+
region,
|
|
240
|
+
"--project",
|
|
241
|
+
projectId,
|
|
242
|
+
"--format=json",
|
|
243
|
+
]);
|
|
244
|
+
return r.ok ? r.stdout : null;
|
|
245
|
+
}
|
|
205
246
|
/** Look up the currently-serving Cloud Run revision so we can roll back to it. */
|
|
206
247
|
async function findCurrentRevision(projectId, region, serviceName) {
|
|
207
248
|
const r = await gcloud([
|
|
@@ -274,7 +315,7 @@ async function resolveAdminIapResource(meta, region, adminServiceName) {
|
|
|
274
315
|
return name ? { kind: "backend-services", service: name } : null;
|
|
275
316
|
}
|
|
276
317
|
export async function upgradeCommand(opts = {}) {
|
|
277
|
-
const { meta } = requireInstall();
|
|
318
|
+
const { installId, meta } = requireInstall();
|
|
278
319
|
// v0.5.15 — extended to cover gcp-firebase too. Both providers share
|
|
279
320
|
// the identical admin + gateway shape on Cloud Run (Artifact
|
|
280
321
|
// Registry image, `caelo-production-<slug>` service naming, the same
|
|
@@ -288,7 +329,9 @@ export async function upgradeCommand(opts = {}) {
|
|
|
288
329
|
}
|
|
289
330
|
if (!meta.projectId)
|
|
290
331
|
return;
|
|
291
|
-
|
|
332
|
+
// #607 — upgrade never moves an install: a `--region` other than the
|
|
333
|
+
// recorded one is refused before anything rolls.
|
|
334
|
+
const region = installRegion(meta, opts.region);
|
|
292
335
|
const registryProject = "caelo-website";
|
|
293
336
|
const registryRegion = "europe-west1";
|
|
294
337
|
const registryRepo = "caelo-cms-images";
|
|
@@ -339,15 +382,57 @@ export async function upgradeCommand(opts = {}) {
|
|
|
339
382
|
sPre.stop(red(`Could not capture current revision for ${slug} — refusing to roll`));
|
|
340
383
|
return;
|
|
341
384
|
}
|
|
385
|
+
const serviceJson = await describeServiceJson(meta.projectId, region, serviceName);
|
|
386
|
+
if (!serviceJson) {
|
|
387
|
+
sPre.stop(red(`Could not read the ${slug} service's configuration — refusing to roll`));
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
342
390
|
plans.push({
|
|
343
391
|
slug,
|
|
344
392
|
serviceName,
|
|
345
393
|
digest,
|
|
346
394
|
imageRef: `${registryRegion}-docker.pkg.dev/${registryProject}/${registryRepo}/${slug}@${digest}`,
|
|
347
395
|
priorRevision,
|
|
396
|
+
liveEnv: liveContainerEnv(serviceJson),
|
|
397
|
+
liveMemory: liveContainerMemory(serviceJson),
|
|
348
398
|
});
|
|
349
399
|
}
|
|
350
|
-
|
|
400
|
+
const install = {
|
|
401
|
+
provider: meta.provider,
|
|
402
|
+
projectId: meta.projectId,
|
|
403
|
+
env: GCP_STACK_ENV,
|
|
404
|
+
domain: meta.domain,
|
|
405
|
+
region,
|
|
406
|
+
};
|
|
407
|
+
const deployed = Object.fromEntries(plans.map((p) => [p.slug, { serviceName: p.serviceName, liveEnv: p.liveEnv }]));
|
|
408
|
+
const envPlan = planContractEnv(install, deployed);
|
|
409
|
+
if (!envPlan.ok) {
|
|
410
|
+
sPre.stop(red("Pre-flight failed: the install's env contract can't be applied"));
|
|
411
|
+
log.error(envPlan.error);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
let rolls = plans.map((p) => {
|
|
415
|
+
// #553 — the admin runs the Lighthouse quality audit; raise it to the
|
|
416
|
+
// stack's memory default (never lowered; an unparsable value is kept).
|
|
417
|
+
const memory = p.slug === "admin" ? planAdminMemory(p.liveMemory) : null;
|
|
418
|
+
if (memory && !memory.ok)
|
|
419
|
+
log.warn(yellow(`admin memory left as is: ${memory.error}`));
|
|
420
|
+
return {
|
|
421
|
+
...p,
|
|
422
|
+
envFlags: envPlan.services[p.slug].flags,
|
|
423
|
+
envChanges: envPlan.services[p.slug].changes,
|
|
424
|
+
resourceFlags: memory?.ok ? memory.flags : [],
|
|
425
|
+
};
|
|
426
|
+
});
|
|
427
|
+
sPre.stop(green(`Pre-flight ok — ${rolls.length} services planned`));
|
|
428
|
+
for (const roll of rolls) {
|
|
429
|
+
for (const f of roll.resourceFlags) {
|
|
430
|
+
log.info(`${roll.slug} ${bold("resources")}: ${roll.liveMemory ?? dim("(default)")} → ${f}`);
|
|
431
|
+
}
|
|
432
|
+
for (const c of roll.envChanges) {
|
|
433
|
+
log.info(`${roll.slug} env ${bold(c.name)}: ${c.from === undefined ? dim("(unset)") : c.from} → ${c.to === undefined ? dim("(removed)") : c.to}`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
351
436
|
// ────────────────────────────────────────────────────────────────
|
|
352
437
|
// P21 ship 4 — cosign verify each resolved digest against the
|
|
353
438
|
// Caelo release workflow's keyless OIDC identity. Refuses to roll
|
|
@@ -368,6 +453,75 @@ export async function upgradeCommand(opts = {}) {
|
|
|
368
453
|
log.warn(yellow("--skip-verify set — image signatures NOT verified."));
|
|
369
454
|
}
|
|
370
455
|
// ────────────────────────────────────────────────────────────────
|
|
456
|
+
// Runtime identities + secrets the env contract references: the
|
|
457
|
+
// gateway's own run SA and the CLI-generated secrets
|
|
458
|
+
// (runtime-secrets.ts). Created once, never overwritten. Must exist
|
|
459
|
+
// before the IAM invariants bind them and before the rolls reference
|
|
460
|
+
// them, so any failure aborts here.
|
|
461
|
+
// ────────────────────────────────────────────────────────────────
|
|
462
|
+
const sRt = spinner();
|
|
463
|
+
sRt.start("Ensuring the gateway service account + generated runtime secrets...");
|
|
464
|
+
const secretsTarget = { projectId: meta.projectId, env: GCP_STACK_ENV };
|
|
465
|
+
const replication = await readSecretReplication(secretsTarget);
|
|
466
|
+
const runtime = [
|
|
467
|
+
await ensureGatewayServiceAccount(secretsTarget),
|
|
468
|
+
...(replication.ok
|
|
469
|
+
? await ensureGeneratedSecrets({
|
|
470
|
+
...secretsTarget,
|
|
471
|
+
replication: replication.replication,
|
|
472
|
+
seed: plainGeneratedSecretSeed(deployed.admin.liveEnv),
|
|
473
|
+
})
|
|
474
|
+
: [{ id: "secret replication", status: "failed", error: replication.error }]),
|
|
475
|
+
];
|
|
476
|
+
const runtimeFailed = runtime.filter((o) => o.status === "failed");
|
|
477
|
+
if (runtimeFailed.length > 0) {
|
|
478
|
+
sRt.stop(red("Runtime identities/secrets could not be ensured. Aborting upgrade."));
|
|
479
|
+
for (const o of runtimeFailed)
|
|
480
|
+
log.error(red(` FAILED: ${o.id}\n ${o.error ?? ""}`));
|
|
481
|
+
log.warn("No traffic was shifted and no migrations ran. Fix the above and re-run.");
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
sRt.stop(green("Gateway service account + runtime secrets ok"));
|
|
485
|
+
for (const o of runtime.filter((o) => o.status === "applied"))
|
|
486
|
+
log.info(` created: ${o.id}`);
|
|
487
|
+
// ────────────────────────────────────────────────────────────────
|
|
488
|
+
// Converge the infrastructure the stack declares but upgrade can't get
|
|
489
|
+
// from an image roll: IAM bindings + CDN policy added to the stacks after
|
|
490
|
+
// this install was provisioned (stack-contract.ts). Additive + idempotent.
|
|
491
|
+
// A failure the install can't work without aborts here, before
|
|
492
|
+
// migrations or any traffic shift; the rest warn.
|
|
493
|
+
// ────────────────────────────────────────────────────────────────
|
|
494
|
+
const sInv = spinner();
|
|
495
|
+
sInv.start("Ensuring the IAM bindings + CDN settings the stack declares...");
|
|
496
|
+
const invariants = await ensureStackInvariants({
|
|
497
|
+
provider: meta.provider,
|
|
498
|
+
projectId: meta.projectId,
|
|
499
|
+
region,
|
|
500
|
+
env: GCP_STACK_ENV,
|
|
501
|
+
services: Object.fromEntries(rolls.map((r) => [r.slug, r.serviceName])),
|
|
502
|
+
});
|
|
503
|
+
const applied = invariants.outcomes.filter((o) => o.status === "applied");
|
|
504
|
+
const failed = invariants.outcomes.filter((o) => o.status === "failed");
|
|
505
|
+
if (invariants.mustAbort) {
|
|
506
|
+
sInv.stop(red("Stack invariants could not be ensured. Aborting upgrade."));
|
|
507
|
+
}
|
|
508
|
+
else {
|
|
509
|
+
sInv.stop(green(`Stack invariants ok (${invariants.outcomes.length - failed.length}/${invariants.outcomes.length}, ${applied.length} applied)`));
|
|
510
|
+
}
|
|
511
|
+
for (const o of applied)
|
|
512
|
+
log.info(` applied: ${o.id} ${dim(`(${o.why})`)}`);
|
|
513
|
+
for (const o of failed) {
|
|
514
|
+
const line = ` ${o.onFailure === "abort" ? "FAILED" : "not applied"}: ${o.id} — ${o.why}\n ${o.error ?? ""}`;
|
|
515
|
+
if (o.onFailure === "abort")
|
|
516
|
+
log.error(red(line));
|
|
517
|
+
else
|
|
518
|
+
log.warn(yellow(line));
|
|
519
|
+
}
|
|
520
|
+
if (invariants.mustAbort) {
|
|
521
|
+
log.warn("No traffic was shifted and no migrations ran. Fix the bindings above and re-run.");
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
// ────────────────────────────────────────────────────────────────
|
|
371
525
|
// P21 ship 3 — DB migrations BEFORE traffic shifts. Idempotent
|
|
372
526
|
// (drizzle bookkeeping table); a failure here aborts the upgrade
|
|
373
527
|
// before the new image touches traffic, so the admin keeps
|
|
@@ -382,7 +536,7 @@ export async function upgradeCommand(opts = {}) {
|
|
|
382
536
|
// so new migrations were silently skipped. Production hit this on
|
|
383
537
|
// every upgrade that introduced schema changes — the symptom was
|
|
384
538
|
// post-rollout queries failing with "column does not exist".
|
|
385
|
-
const adminPlan =
|
|
539
|
+
const adminPlan = rolls.find((p) => p.slug === "admin");
|
|
386
540
|
const mig = await runMigrationsViaCloudRunJob({
|
|
387
541
|
projectId: meta.projectId,
|
|
388
542
|
region,
|
|
@@ -399,13 +553,14 @@ export async function upgradeCommand(opts = {}) {
|
|
|
399
553
|
sMig.stop(green("Migrations applied"));
|
|
400
554
|
// ────────────────────────────────────────────────────────────────
|
|
401
555
|
// Issue #37 — MCP through IAP. Idempotently ensure the MCP service
|
|
402
|
-
// account + its IAP/token-creator bindings
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
// costs MCP access, so it warns instead of aborting the upgrade.
|
|
556
|
+
// account + its IAP/token-creator bindings; the admin learns the SA's
|
|
557
|
+
// email from CAELO_MCP_IAP_SERVICE_ACCOUNT in the env contract. Installs
|
|
558
|
+
// provisioned before this existed get it here, with no operator config.
|
|
559
|
+
// A failure only costs MCP access, so it warns instead of aborting.
|
|
407
560
|
// ────────────────────────────────────────────────────────────────
|
|
408
|
-
|
|
561
|
+
// Env vars the rolls must leave as the service has them, because the
|
|
562
|
+
// thing they point at could not be set up.
|
|
563
|
+
const leaveUntouched = [];
|
|
409
564
|
if (adminPlan) {
|
|
410
565
|
const sMcp = spinner();
|
|
411
566
|
sMcp.start("Ensuring MCP access through IAP...");
|
|
@@ -414,39 +569,82 @@ export async function upgradeCommand(opts = {}) {
|
|
|
414
569
|
? await ensureMcpIapAccess({ projectId: meta.projectId, resource })
|
|
415
570
|
: { ok: false, error: "admin IAP backend service not found" };
|
|
416
571
|
if (mcp.ok) {
|
|
417
|
-
mcpServiceAccount = mcp.serviceAccount;
|
|
418
572
|
sMcp.stop(green(`MCP access ready (${mcp.serviceAccount}; ${mcp.operators.length} operator(s))`));
|
|
419
573
|
}
|
|
420
574
|
else {
|
|
421
575
|
sMcp.stop(yellow(`MCP access not configured: ${mcp.error}`));
|
|
422
576
|
log.warn("The upgrade continues; external MCP clients stay blocked by IAP until this succeeds.");
|
|
577
|
+
// Don't hand the admin an MCP service account that may not exist or
|
|
578
|
+
// lacks its bindings: /security/mcp would print a `claude mcp add`
|
|
579
|
+
// command that can't work. Leave the var as the service has it.
|
|
580
|
+
leaveUntouched.push(MCP_ENV_VAR);
|
|
581
|
+
}
|
|
582
|
+
// ──────────────────────────────────────────────────────────────
|
|
583
|
+
// Operator access: the sync job that keeps Google IAP in step with the
|
|
584
|
+
// user list (operator-access.ts) — its own SA, the only principal that
|
|
585
|
+
// may change the IAP binding; the admin may only start it. Also removes
|
|
586
|
+
// the operator-access rights an earlier revision gave the admin's SA.
|
|
587
|
+
// After migrations: the job's database user gets the role migration
|
|
588
|
+
// 0239 creates. A failure only costs IAP following the user list, so it
|
|
589
|
+
// warns; the admin then reports the sync as not set up, loudly.
|
|
590
|
+
// ──────────────────────────────────────────────────────────────
|
|
591
|
+
const sOa = spinner();
|
|
592
|
+
sOa.start("Ensuring the operator-access sync job...");
|
|
593
|
+
const resolved = await resolveOperatorAccessTarget({
|
|
594
|
+
provider: meta.provider,
|
|
595
|
+
projectId: meta.projectId,
|
|
596
|
+
region,
|
|
597
|
+
env: GCP_STACK_ENV,
|
|
598
|
+
ownerEmail: meta.ownerEmail,
|
|
599
|
+
imageRef: adminPlan.imageRef,
|
|
600
|
+
});
|
|
601
|
+
const oa = resolved.ok
|
|
602
|
+
? await ensureOperatorAccessSync(resolved.target)
|
|
603
|
+
: { ok: false, done: [], error: resolved.error };
|
|
604
|
+
for (const step of oa.done)
|
|
605
|
+
log.info(` ${dim(step)}`);
|
|
606
|
+
if (oa.ok) {
|
|
607
|
+
sOa.stop(green("Operator-access sync job ready (IAP follows the user list)"));
|
|
608
|
+
}
|
|
609
|
+
else {
|
|
610
|
+
sOa.stop(yellow(`Operator-access sync job not set up: ${oa.error}`));
|
|
611
|
+
log.warn("The upgrade continues; user changes will report that Google IAP could not be updated until this succeeds. Re-run upgrade once the cause is fixed.");
|
|
612
|
+
leaveUntouched.push(OPERATOR_ACCESS_JOB_ENV_VAR);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
if (leaveUntouched.length > 0) {
|
|
616
|
+
const replan = planContractEnv(install, deployed, { leaveUntouched });
|
|
617
|
+
if (replan.ok) {
|
|
618
|
+
rolls = rolls.map((r) => ({
|
|
619
|
+
...r,
|
|
620
|
+
envFlags: replan.services[r.slug].flags,
|
|
621
|
+
envChanges: replan.services[r.slug].changes,
|
|
622
|
+
}));
|
|
423
623
|
}
|
|
424
624
|
}
|
|
425
625
|
// ────────────────────────────────────────────────────────────────
|
|
426
626
|
// Phase 2: roll each service, probe health, auto-rollback on fail.
|
|
427
627
|
// If admin succeeds but gateway fails, also roll admin back so the
|
|
428
|
-
// operator never ends up on a mismatched-version pair.
|
|
628
|
+
// operator never ends up on a mismatched-version pair. The env
|
|
629
|
+
// contract's changes ride the same `services update` as the image, so
|
|
630
|
+
// they land in the new revision (and roll back with it). So does the
|
|
631
|
+
// service's run SA: the gateway moves to its own SA here.
|
|
429
632
|
// ────────────────────────────────────────────────────────────────
|
|
430
633
|
const rolled = [];
|
|
431
|
-
for (const plan of
|
|
634
|
+
for (const plan of rolls) {
|
|
432
635
|
const s = spinner();
|
|
433
636
|
s.start(`Rolling ${plan.slug} → ${plan.digest.slice(0, 19)}...`);
|
|
434
|
-
const upd = await
|
|
435
|
-
|
|
436
|
-
"services",
|
|
437
|
-
"update",
|
|
438
|
-
plan.serviceName,
|
|
439
|
-
"--region",
|
|
637
|
+
const upd = await rollService(serviceRollArgs({
|
|
638
|
+
serviceName: plan.serviceName,
|
|
440
639
|
region,
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
"
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
]);
|
|
640
|
+
projectId: meta.projectId,
|
|
641
|
+
imageRef: plan.imageRef,
|
|
642
|
+
serviceAccount: plan.slug === "admin"
|
|
643
|
+
? runServiceAccountEmail(meta.projectId, GCP_STACK_ENV)
|
|
644
|
+
: gatewayServiceAccountEmail(meta.projectId, GCP_STACK_ENV),
|
|
645
|
+
envFlags: plan.envFlags,
|
|
646
|
+
resourceFlags: plan.resourceFlags,
|
|
647
|
+
}));
|
|
450
648
|
if (!upd.ok) {
|
|
451
649
|
s.stop(red(`Failed: ${upd.stderr.trim()}`));
|
|
452
650
|
await rollbackPriorlyRolled(meta.projectId, region, rolled);
|
|
@@ -501,6 +699,74 @@ export async function upgradeCommand(opts = {}) {
|
|
|
501
699
|
rolled.push(plan);
|
|
502
700
|
}
|
|
503
701
|
log.success(`Upgrade to ${bold(targetTag)} complete (admin + gateway revisions Ready).`);
|
|
702
|
+
if (rolls.some((r) => liveEnvHasInlinePassword(r.liveEnv))) {
|
|
703
|
+
// The services just moved their database password to Secret Manager,
|
|
704
|
+
// but every earlier revision still shows it in its env.
|
|
705
|
+
log.warn(yellow(`The database password was stored in plain env vars before this upgrade and stays readable in the services' old revisions. Rotate it now: ${bold("bunx @caelo-cms/provisioning rotate-secret postgres-password")}`));
|
|
706
|
+
}
|
|
707
|
+
const digestOf = (slug) => {
|
|
708
|
+
const roll = rolls.find((r) => r.slug === slug);
|
|
709
|
+
if (!roll)
|
|
710
|
+
throw new Error(`upgrade rolled no ${slug} service`);
|
|
711
|
+
return roll.digest;
|
|
712
|
+
};
|
|
713
|
+
await recordRolledDigests(installId, meta.provider, {
|
|
714
|
+
admin: digestOf("admin"),
|
|
715
|
+
gateway: digestOf("gateway"),
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Record the digests just rolled so nothing re-deploys an older release:
|
|
720
|
+
* install.json (what a wizard re-run deploys) and the Pulumi stack config
|
|
721
|
+
* (what a plain `pulumi up` deploys). The roll already succeeded, so each
|
|
722
|
+
* write that fails warns with the exact manual fix instead of failing the
|
|
723
|
+
* upgrade, and one failing never skips the other.
|
|
724
|
+
*/
|
|
725
|
+
async function recordRolledDigests(installId, provider, digests) {
|
|
726
|
+
const reason = (e) => (e instanceof Error ? e.message.split("\n")[0] : String(e));
|
|
727
|
+
try {
|
|
728
|
+
recordImageDigests(installId, digests);
|
|
729
|
+
}
|
|
730
|
+
catch (e) {
|
|
731
|
+
log.warn(yellow(`Could not record the rolled digests in ${installRoot(installId)}/install.json (${reason(e)}). Add this before re-running the installer, or it deploys the newest release instead:\n "imageDigests": ${JSON.stringify(digests)}`));
|
|
732
|
+
}
|
|
733
|
+
const manual = stackConfigRecovery(installId, provider, digests);
|
|
734
|
+
const passphrase = readSecret(installId, "pulumi-passphrase");
|
|
735
|
+
if (!passphrase) {
|
|
736
|
+
log.warn(yellow(`No Pulumi passphrase in ~/.caelo-${installId}/secrets — stack config not updated. Before any manual \`pulumi up\`, run:\n${manual}`));
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
try {
|
|
740
|
+
const { writeImageDigestsToStack } = await import("./wizards/gcp-pulumi.js");
|
|
741
|
+
const { removedOverrides } = await writeImageDigestsToStack({
|
|
742
|
+
installRoot: installRoot(installId),
|
|
743
|
+
pulumiPassphrase: passphrase,
|
|
744
|
+
provider,
|
|
745
|
+
digests,
|
|
746
|
+
});
|
|
747
|
+
log.info(dim("Pinned the rolled image digests in the Pulumi stack config."));
|
|
748
|
+
if (removedOverrides.length > 0) {
|
|
749
|
+
log.warn(yellow(`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.`));
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
catch (e) {
|
|
753
|
+
log.warn(yellow(`Could not pin the rolled digests in the Pulumi stack config (${reason(e)}). Before any manual \`pulumi up\`, run:\n${manual}`));
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Shell commands that pin `digests` in the install's own Pulumi stack, with
|
|
758
|
+
* the same workspace, backend, stack and passphrase the CLI uses — run from
|
|
759
|
+
* any directory, they cannot hit an unrelated stack.
|
|
760
|
+
*/
|
|
761
|
+
function stackConfigRecovery(installId, provider, digests) {
|
|
762
|
+
const root = installRoot(installId);
|
|
763
|
+
const env = `PULUMI_BACKEND_URL=file://${join(root, "state")} PULUMI_CONFIG_PASSPHRASE="$(cat ${join(root, "secrets", "pulumi-passphrase")})"`;
|
|
764
|
+
const cwd = `--cwd ${resolvePath(import.meta.dir, "../stacks", provider)} --stack ${GCP_STACK_ENV}`;
|
|
765
|
+
const lines = Object.entries(digests).map(([svc, d]) => ` ${env} pulumi config set ${cwd} caelo-${provider}:image-digest-${svc} ${d}`);
|
|
766
|
+
if (provider === "gcp") {
|
|
767
|
+
lines.push(` ${env} pulumi config rm ${cwd} caelo-gcp:image-admin # only if set`, ` ${env} pulumi config rm ${cwd} caelo-gcp:image-gateway # only if set`);
|
|
768
|
+
}
|
|
769
|
+
return lines.join("\n");
|
|
504
770
|
}
|
|
505
771
|
/**
|
|
506
772
|
* Roll back any services that already passed their probe in this
|
|
@@ -662,30 +928,54 @@ export async function backupCommand() {
|
|
|
662
928
|
export async function rotateSecretCommand(name) {
|
|
663
929
|
if (!name) {
|
|
664
930
|
log.error(red("Usage: caelo-cms rotate-secret <name>"));
|
|
665
|
-
log.warn(`Names: ${
|
|
666
|
-
"postgres-password",
|
|
667
|
-
"csrf-secret",
|
|
668
|
-
"cookie-secret",
|
|
669
|
-
"secret-kek",
|
|
670
|
-
"anthropic-api-key",
|
|
671
|
-
"resend-api-key",
|
|
672
|
-
].join(", ")}`);
|
|
931
|
+
log.warn(`Names: ${ROTATABLE_SECRETS.join(", ")}`);
|
|
673
932
|
process.exit(2);
|
|
674
933
|
}
|
|
934
|
+
const refusal = rotationRefusal(name);
|
|
935
|
+
if (refusal) {
|
|
936
|
+
log.error(red(refusal));
|
|
937
|
+
process.exit(2);
|
|
938
|
+
}
|
|
939
|
+
const secret = name;
|
|
675
940
|
const { meta } = requireInstall();
|
|
676
|
-
if (meta.provider !== "gcp") {
|
|
941
|
+
if (meta.provider !== "gcp" && meta.provider !== "gcp-firebase") {
|
|
677
942
|
log.warn(`rotate-secret for provider ${meta.provider} not yet implemented.`);
|
|
678
943
|
return;
|
|
679
944
|
}
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
945
|
+
if (!meta.projectId)
|
|
946
|
+
return;
|
|
947
|
+
const region = installRegion(meta);
|
|
948
|
+
const s = spinner();
|
|
949
|
+
s.start(`Rotating ${secret}...`);
|
|
950
|
+
const services = {};
|
|
951
|
+
for (const slug of ["admin", "gateway"]) {
|
|
952
|
+
const serviceName = await resolveGcpResourceName("run-service", `caelo-production-${slug}`, meta.projectId, region);
|
|
953
|
+
if (!serviceName) {
|
|
954
|
+
s.stop(red(`Could not find caelo-production-${slug}* Cloud Run service — nothing rotated`));
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
services[slug] = serviceName;
|
|
958
|
+
}
|
|
959
|
+
const sqlInstance = secret === "postgres-password"
|
|
960
|
+
? await resolveGcpResourceName("sql-instance", "caelo-production-pg", meta.projectId)
|
|
961
|
+
: null;
|
|
962
|
+
const report = await rotateRuntimeSecret({
|
|
963
|
+
projectId: meta.projectId,
|
|
964
|
+
region,
|
|
965
|
+
env: GCP_STACK_ENV,
|
|
966
|
+
services: services,
|
|
967
|
+
...(sqlInstance ? { sqlInstance } : {}),
|
|
968
|
+
}, secret);
|
|
969
|
+
if (report.ok) {
|
|
970
|
+
s.stop(green(`${secret} rotated`));
|
|
971
|
+
}
|
|
972
|
+
else {
|
|
973
|
+
s.stop(red(`Rotating ${secret} failed`));
|
|
974
|
+
}
|
|
975
|
+
for (const step of report.steps)
|
|
976
|
+
log.info(` ${step}`);
|
|
977
|
+
if (report.error)
|
|
978
|
+
log.error(red(report.error));
|
|
689
979
|
}
|
|
690
980
|
// =========================================================================
|
|
691
981
|
// destroy — pulumi destroy + gcloud projects delete
|
|
@@ -729,7 +1019,7 @@ export async function truncateCommand() {
|
|
|
729
1019
|
}
|
|
730
1020
|
if ((meta.provider === "gcp" || meta.provider === "gcp-firebase") && meta.projectId) {
|
|
731
1021
|
const { truncateViaCloudRunJob } = await import("./migration-runner.js");
|
|
732
|
-
const region = meta
|
|
1022
|
+
const region = installRegion(meta);
|
|
733
1023
|
const r = await truncateViaCloudRunJob({ projectId: meta.projectId, region });
|
|
734
1024
|
if (!r.ok) {
|
|
735
1025
|
cancel(`Truncate failed: ${r.error}.`);
|