@caelo-cms/provisioning 0.10.44 → 0.10.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adc-impersonation.d.ts +28 -0
- package/dist/adc-impersonation.d.ts.map +1 -0
- package/dist/adc-impersonation.js +126 -0
- package/dist/adc-impersonation.js.map +1 -0
- package/dist/checkpoint-secrets.d.ts.map +1 -1
- package/dist/checkpoint-secrets.js +2 -1
- package/dist/checkpoint-secrets.js.map +1 -1
- package/dist/cli.js +199 -33
- package/dist/cli.js.map +1 -1
- package/dist/compose.d.ts +45 -0
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +112 -29
- package/dist/compose.js.map +1 -1
- package/dist/gcloud-retry.d.ts +5 -1
- package/dist/gcloud-retry.d.ts.map +1 -1
- package/dist/gcloud-retry.js.map +1 -1
- package/dist/gcloud.d.ts +2 -1
- package/dist/gcloud.d.ts.map +1 -1
- package/dist/gcloud.js +11 -13
- package/dist/gcloud.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/infra-contract.d.ts +24 -0
- package/dist/infra-contract.d.ts.map +1 -0
- package/dist/infra-contract.js +25 -0
- package/dist/infra-contract.js.map +1 -0
- package/dist/install-state.d.ts +57 -2
- package/dist/install-state.d.ts.map +1 -1
- package/dist/install-state.js +142 -22
- 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 +185 -819
- package/dist/lifecycle.js.map +1 -1
- package/dist/migration-runner.d.ts +3 -0
- package/dist/migration-runner.d.ts.map +1 -1
- package/dist/migration-runner.js +7 -5
- package/dist/migration-runner.js.map +1 -1
- package/dist/ops-backend.d.ts +46 -0
- package/dist/ops-backend.d.ts.map +1 -0
- package/dist/ops-backend.js +370 -0
- package/dist/ops-backend.js.map +1 -0
- package/dist/provisioner-identity.d.ts +103 -0
- package/dist/provisioner-identity.d.ts.map +1 -0
- package/dist/provisioner-identity.js +323 -0
- package/dist/provisioner-identity.js.map +1 -0
- package/dist/regions.d.ts.map +1 -1
- package/dist/regions.js +2 -1
- package/dist/regions.js.map +1 -1
- package/dist/release-contract.d.ts +37 -0
- package/dist/release-contract.d.ts.map +1 -0
- package/dist/release-contract.js +55 -0
- package/dist/release-contract.js.map +1 -0
- package/dist/release-manifest.d.ts +140 -0
- package/dist/release-manifest.d.ts.map +1 -0
- package/dist/release-manifest.js +319 -0
- package/dist/release-manifest.js.map +1 -0
- package/dist/self-command.d.ts +16 -0
- package/dist/self-command.d.ts.map +1 -0
- package/dist/self-command.js +20 -0
- package/dist/self-command.js.map +1 -0
- package/dist/self-hosted-release.d.ts +81 -0
- package/dist/self-hosted-release.d.ts.map +1 -0
- package/dist/self-hosted-release.js +148 -0
- package/dist/self-hosted-release.js.map +1 -0
- package/dist/self-hosted-upgrade.d.ts +91 -0
- package/dist/self-hosted-upgrade.d.ts.map +1 -0
- package/dist/self-hosted-upgrade.js +355 -0
- package/dist/self-hosted-upgrade.js.map +1 -0
- package/dist/stack-converge.d.ts.map +1 -1
- package/dist/stack-converge.js +2 -1
- package/dist/stack-converge.js.map +1 -1
- package/dist/state-backend.d.ts +103 -0
- package/dist/state-backend.d.ts.map +1 -0
- package/dist/state-backend.js +180 -0
- package/dist/state-backend.js.map +1 -0
- package/dist/state-store.d.ts +55 -0
- package/dist/state-store.d.ts.map +1 -0
- package/dist/state-store.js +140 -0
- package/dist/state-store.js.map +1 -0
- package/dist/upgrade/app-roll.d.ts +164 -0
- package/dist/upgrade/app-roll.d.ts.map +1 -0
- package/dist/upgrade/app-roll.js +270 -0
- package/dist/upgrade/app-roll.js.map +1 -0
- package/dist/upgrade/backup.d.ts +48 -0
- package/dist/upgrade/backup.d.ts.map +1 -0
- package/dist/upgrade/backup.js +71 -0
- package/dist/upgrade/backup.js.map +1 -0
- package/dist/upgrade/converge.d.ts +45 -0
- package/dist/upgrade/converge.d.ts.map +1 -0
- package/dist/upgrade/converge.js +287 -0
- package/dist/upgrade/converge.js.map +1 -0
- package/dist/upgrade/gcp-lookup.d.ts +23 -0
- package/dist/upgrade/gcp-lookup.d.ts.map +1 -0
- package/dist/upgrade/gcp-lookup.js +95 -0
- package/dist/upgrade/gcp-lookup.js.map +1 -0
- package/dist/upgrade/health.d.ts +99 -0
- package/dist/upgrade/health.d.ts.map +1 -0
- package/dist/upgrade/health.js +214 -0
- package/dist/upgrade/health.js.map +1 -0
- package/dist/upgrade/release-images.d.ts +125 -0
- package/dist/upgrade/release-images.d.ts.map +1 -0
- package/dist/upgrade/release-images.js +315 -0
- package/dist/upgrade/release-images.js.map +1 -0
- package/dist/upgrade/reporter.d.ts +17 -0
- package/dist/upgrade/reporter.d.ts.map +1 -0
- package/dist/upgrade/reporter.js +28 -0
- package/dist/upgrade/reporter.js.map +1 -0
- package/dist/upgrade/upgrade.d.ts +79 -0
- package/dist/upgrade/upgrade.d.ts.map +1 -0
- package/dist/upgrade/upgrade.js +105 -0
- package/dist/upgrade/upgrade.js.map +1 -0
- package/dist/wizard.d.ts.map +1 -1
- package/dist/wizard.js +7 -3
- package/dist/wizard.js.map +1 -1
- package/dist/wizards/gcp-cost.d.ts.map +1 -1
- package/dist/wizards/gcp-cost.js +7 -1
- package/dist/wizards/gcp-cost.js.map +1 -1
- package/dist/wizards/gcp-pulumi.d.ts +99 -8
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +157 -50
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +182 -58
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +4 -3
- package/stacks/self-hosted/README.md +4 -0
- package/stacks/self-hosted/index.ts +10 -2
package/dist/lifecycle.js
CHANGED
|
@@ -15,18 +15,27 @@
|
|
|
15
15
|
* - emits human-readable progress + a final summary
|
|
16
16
|
*/
|
|
17
17
|
import { join, resolve as resolvePath } from "node:path";
|
|
18
|
+
import { provisioningUpgradeCommand } from "@caelo-cms/shared";
|
|
18
19
|
import { cancel, confirm, isCancel, log, note, select, spinner } from "@clack/prompts";
|
|
19
20
|
import { bold, cyan, dim, green, red, yellow } from "kleur/colors";
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import { GCP_STACK_ENV
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
21
|
+
import { activeAccount, gcloud } from "./gcloud.js";
|
|
22
|
+
import { realSleep } from "./gcloud-retry.js";
|
|
23
|
+
import { GCP_STACK_ENV } from "./gcp-names.js";
|
|
24
|
+
import { INFRA_CONTRACT_VERSION } from "./infra-contract.js";
|
|
25
|
+
import { currentMetadata, installFlag, installRoot, installStagingHost, listInstalls, readMetadata, readSecret, recordImageDigests, recordInfraContractVersion, scrubInstallProgress, selectInstall, writeMetadata, } from "./install-state.js";
|
|
26
|
+
import { runMigrationsViaCloudRunJob } from "./migration-runner.js";
|
|
27
|
+
import { convergeKeylessProvisioner, provisionerServiceAccountEmail, } from "./provisioner-identity.js";
|
|
26
28
|
import { assertRegionUnchanged } from "./regions.js";
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
29
|
+
import { ROTATABLE_SECRETS, rolesOfSecret, rotateRuntimeSecret, rotationRefusal, } from "./runtime-secrets.js";
|
|
30
|
+
import { selfCommand } from "./self-command.js";
|
|
31
|
+
import { backendShellEnv, resolveBackend } from "./state-backend.js";
|
|
32
|
+
import { ROLL_ORDER, } from "./upgrade/app-roll.js";
|
|
33
|
+
import { restoreCommand, takeSqlBackup } from "./upgrade/backup.js";
|
|
34
|
+
import { findServiceUrl, resolveGcpResourceName } from "./upgrade/gcp-lookup.js";
|
|
35
|
+
import { createIapHealthProbe } from "./upgrade/health.js";
|
|
36
|
+
import { fetchLatestReleaseTag, RELEASE_REGISTRY, releaseImageRef, releaseTag, resolveTagDigest, verifyCosignAll, } from "./upgrade/release-images.js";
|
|
37
|
+
import { clackReporter } from "./upgrade/reporter.js";
|
|
38
|
+
import { upgradeGcpInstall } from "./upgrade/upgrade.js";
|
|
30
39
|
/**
|
|
31
40
|
* The installs a command acts on. `--install <id or domain>` names one
|
|
32
41
|
* (`--install all` every one, where the command allows it). Without the flag:
|
|
@@ -52,7 +61,7 @@ async function chooseInstalls(opts) {
|
|
|
52
61
|
}
|
|
53
62
|
if (installs.length === 0)
|
|
54
63
|
return exitNoInstall("No Caelo install found on this machine.");
|
|
55
|
-
return installs;
|
|
64
|
+
return installs.map(currentMetadata);
|
|
56
65
|
}
|
|
57
66
|
if (wanted === undefined && installs.length > 1 && process.stdin.isTTY) {
|
|
58
67
|
const choice = await select({
|
|
@@ -73,18 +82,18 @@ async function chooseInstalls(opts) {
|
|
|
73
82
|
process.exit(1);
|
|
74
83
|
}
|
|
75
84
|
if (choice === "all")
|
|
76
|
-
return installs;
|
|
85
|
+
return installs.map(currentMetadata);
|
|
77
86
|
wanted = choice;
|
|
78
87
|
}
|
|
79
88
|
const selected = selectInstall(installs, wanted);
|
|
80
89
|
if (!selected.ok)
|
|
81
90
|
return exitNoInstall(selected.message);
|
|
82
|
-
return [selected.meta];
|
|
91
|
+
return [currentMetadata(selected.meta)];
|
|
83
92
|
}
|
|
84
93
|
function exitNoInstall(message) {
|
|
85
94
|
log.error(red(message));
|
|
86
95
|
if (listInstalls().length === 0) {
|
|
87
|
-
log.warn(`Run ${bold(
|
|
96
|
+
log.warn(`Run ${bold(selfCommand())} first to provision an install, OR copy ${dim("~/.caelo-<install-id>/")} from the provisioning machine.`);
|
|
88
97
|
}
|
|
89
98
|
process.exit(1);
|
|
90
99
|
}
|
|
@@ -138,39 +147,6 @@ function installRegion(meta, requested) {
|
|
|
138
147
|
process.exit(1);
|
|
139
148
|
}
|
|
140
149
|
}
|
|
141
|
-
/** A Cloud Run lookup is regional; there is no default region to guess. */
|
|
142
|
-
function requiredRegion(region) {
|
|
143
|
-
if (!region)
|
|
144
|
-
throw new Error("a Cloud Run lookup needs the install region (install.json)");
|
|
145
|
-
return region;
|
|
146
|
-
}
|
|
147
|
-
/**
|
|
148
|
-
* Pulumi auto-naming appends a random 7-char suffix to every resource
|
|
149
|
-
* (e.g. `caelo-production-admin-3efcfea`). Lifecycle commands need the
|
|
150
|
-
* actual deployed names; this helper queries gcloud with a prefix
|
|
151
|
-
* filter and returns the single match (or null if missing/ambiguous).
|
|
152
|
-
*
|
|
153
|
-
* Used for Cloud Run services + Cloud SQL instances. We accept a
|
|
154
|
-
* `kind` for routing the gcloud subcommand (services vs sql instances).
|
|
155
|
-
*/
|
|
156
|
-
async function resolveGcpResourceName(kind, prefix, projectId, region) {
|
|
157
|
-
const args = [];
|
|
158
|
-
if (kind === "run-service") {
|
|
159
|
-
args.push("run", "services", "list", "--region", requiredRegion(region), "--project", projectId, "--filter", `metadata.name~^${prefix}`, "--format=value(metadata.name)");
|
|
160
|
-
}
|
|
161
|
-
else {
|
|
162
|
-
args.push("sql", "instances", "list", "--project", projectId, "--filter", `name~^${prefix}`, "--format=value(name)");
|
|
163
|
-
}
|
|
164
|
-
const r = await gcloud(args);
|
|
165
|
-
if (!r.ok)
|
|
166
|
-
return null;
|
|
167
|
-
const matches = r.stdout
|
|
168
|
-
.trim()
|
|
169
|
-
.split("\n")
|
|
170
|
-
.map((s) => s.trim())
|
|
171
|
-
.filter((s) => s.length > 0);
|
|
172
|
-
return matches[0] ?? null;
|
|
173
|
-
}
|
|
174
150
|
// =========================================================================
|
|
175
151
|
// status — health check + monthly cost
|
|
176
152
|
// =========================================================================
|
|
@@ -202,23 +178,10 @@ async function getLatestReleaseTag() {
|
|
|
202
178
|
if (releaseCheckCache && now - releaseCheckCache.fetchedAt < 10 * 60 * 1000) {
|
|
203
179
|
return releaseCheckCache.latest;
|
|
204
180
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if (!res.ok) {
|
|
210
|
-
releaseCheckCache = { fetchedAt: now, latest: null };
|
|
211
|
-
return null;
|
|
212
|
-
}
|
|
213
|
-
const json = (await res.json());
|
|
214
|
-
const latest = json.tag_name ?? null;
|
|
215
|
-
releaseCheckCache = { fetchedAt: now, latest };
|
|
216
|
-
return latest;
|
|
217
|
-
}
|
|
218
|
-
catch {
|
|
219
|
-
releaseCheckCache = { fetchedAt: now, latest: null };
|
|
220
|
-
return null;
|
|
221
|
-
}
|
|
181
|
+
const r = await fetchLatestReleaseTag();
|
|
182
|
+
const latest = r.ok ? r.tag : null;
|
|
183
|
+
releaseCheckCache = { fetchedAt: now, latest };
|
|
184
|
+
return latest;
|
|
222
185
|
}
|
|
223
186
|
async function gcpStatus(meta) {
|
|
224
187
|
if (!meta.projectId)
|
|
@@ -226,8 +189,8 @@ async function gcpStatus(meta) {
|
|
|
226
189
|
const s = spinner();
|
|
227
190
|
s.start("Resolving deployed resources + checking health...");
|
|
228
191
|
const region = installRegion(meta);
|
|
229
|
-
const adminName = await resolveGcpResourceName("run-service", "caelo-production-admin", meta.projectId, region);
|
|
230
|
-
const sqlName = await resolveGcpResourceName("sql-instance", "caelo-production-pg", meta.projectId);
|
|
192
|
+
const adminName = await resolveGcpResourceName(gcloud, "run-service", "caelo-production-admin", meta.projectId, region);
|
|
193
|
+
const sqlName = await resolveGcpResourceName(gcloud, "sql-instance", "caelo-production-pg", meta.projectId);
|
|
231
194
|
if (!adminName || !sqlName) {
|
|
232
195
|
s.stop(red("Could not resolve deployed resource names — is the install live?"));
|
|
233
196
|
return;
|
|
@@ -258,8 +221,10 @@ async function gcpStatus(meta) {
|
|
|
258
221
|
const { CAELO_VERSION } = await import("@caelo-cms/shared");
|
|
259
222
|
const latestTag = await getLatestReleaseTag();
|
|
260
223
|
const latestStable = latestTag?.replace(/^v/, "") ?? null;
|
|
224
|
+
// #696 — the upgrade runs as the new release's CLI and rolls exactly that
|
|
225
|
+
// release, on exactly this install (same command Settings → Updates shows).
|
|
261
226
|
const upgradeHint = latestStable && latestStable !== CAELO_VERSION
|
|
262
|
-
? `${yellow(`v${latestStable} available`)} — run
|
|
227
|
+
? `${yellow(`v${latestStable} available`)} — run \`${provisioningUpgradeCommand({ version: latestStable, install: meta.domain })}\``
|
|
263
228
|
: latestStable === CAELO_VERSION
|
|
264
229
|
? green("up to date")
|
|
265
230
|
: dim("(latest unknown)");
|
|
@@ -271,132 +236,13 @@ async function gcpStatus(meta) {
|
|
|
271
236
|
`${dim("CLI version")} v${CAELO_VERSION} ${upgradeHint}`,
|
|
272
237
|
].join("\n"), "Status");
|
|
273
238
|
}
|
|
274
|
-
/**
|
|
275
|
-
* Resolve a tag (e.g. `0.2.6` or `latest`) to the underlying sha256
|
|
276
|
-
* digest via the Docker Registry V2 API directly, without needing the
|
|
277
|
-
* caller's gcloud session to have IAM access to the registry's
|
|
278
|
-
* project. Used for the upgrade pre-flight resolve — the registry is
|
|
279
|
-
* public so an anonymous HEAD on the manifest endpoint suffices.
|
|
280
|
-
*/
|
|
281
|
-
async function resolveTagDigest(region, project, repo, image, tag) {
|
|
282
|
-
const url = `https://${region}-docker.pkg.dev/v2/${project}/${repo}/${image}/manifests/${tag}`;
|
|
283
|
-
// Accept both Docker v2 + OCI manifest types so the registry returns
|
|
284
|
-
// the resource we asked about (single-arch + multi-arch index both
|
|
285
|
-
// surface a Docker-Content-Digest header that names the manifest
|
|
286
|
-
// we'd pull on a `docker pull <image>:<tag>`).
|
|
287
|
-
const accept = [
|
|
288
|
-
"application/vnd.docker.distribution.manifest.v2+json",
|
|
289
|
-
"application/vnd.oci.image.manifest.v1+json",
|
|
290
|
-
"application/vnd.docker.distribution.manifest.list.v2+json",
|
|
291
|
-
"application/vnd.oci.image.index.v1+json",
|
|
292
|
-
].join(",");
|
|
293
|
-
try {
|
|
294
|
-
const res = await fetch(url, { method: "HEAD", headers: { Accept: accept } });
|
|
295
|
-
if (!res.ok) {
|
|
296
|
-
return { ok: false, reason: `HTTP ${res.status} ${res.statusText}` };
|
|
297
|
-
}
|
|
298
|
-
const digest = res.headers.get("docker-content-digest");
|
|
299
|
-
if (!digest || !/^sha256:[0-9a-f]{64}$/.test(digest)) {
|
|
300
|
-
return { ok: false, reason: `unexpected digest header: ${digest ?? "(missing)"}` };
|
|
301
|
-
}
|
|
302
|
-
return { ok: true, digest };
|
|
303
|
-
}
|
|
304
|
-
catch (e) {
|
|
305
|
-
return { ok: false, reason: e instanceof Error ? e.message : String(e) };
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
/** The deployed service as `gcloud run services describe --format=json` returns it. */
|
|
309
|
-
async function describeServiceJson(projectId, region, serviceName) {
|
|
310
|
-
const r = await gcloud([
|
|
311
|
-
"run",
|
|
312
|
-
"services",
|
|
313
|
-
"describe",
|
|
314
|
-
serviceName,
|
|
315
|
-
"--region",
|
|
316
|
-
region,
|
|
317
|
-
"--project",
|
|
318
|
-
projectId,
|
|
319
|
-
"--format=json",
|
|
320
|
-
]);
|
|
321
|
-
return r.ok ? r.stdout : null;
|
|
322
|
-
}
|
|
323
|
-
/** Look up the currently-serving Cloud Run revision so we can roll back to it. */
|
|
324
|
-
async function findCurrentRevision(projectId, region, serviceName) {
|
|
325
|
-
const r = await gcloud([
|
|
326
|
-
"run",
|
|
327
|
-
"services",
|
|
328
|
-
"describe",
|
|
329
|
-
serviceName,
|
|
330
|
-
"--region",
|
|
331
|
-
region,
|
|
332
|
-
"--project",
|
|
333
|
-
projectId,
|
|
334
|
-
"--format=value(status.traffic[0].revisionName)",
|
|
335
|
-
]);
|
|
336
|
-
if (!r.ok)
|
|
337
|
-
return null;
|
|
338
|
-
const rev = r.stdout.trim().split("\n")[0]?.trim();
|
|
339
|
-
return rev || null;
|
|
340
|
-
}
|
|
341
|
-
/** Cloud Run service URL, used as the health-probe target after a roll. */
|
|
342
|
-
async function findServiceUrl(projectId, region, serviceName) {
|
|
343
|
-
const r = await gcloud([
|
|
344
|
-
"run",
|
|
345
|
-
"services",
|
|
346
|
-
"describe",
|
|
347
|
-
serviceName,
|
|
348
|
-
"--region",
|
|
349
|
-
region,
|
|
350
|
-
"--project",
|
|
351
|
-
projectId,
|
|
352
|
-
"--format=value(status.url)",
|
|
353
|
-
]);
|
|
354
|
-
if (!r.ok)
|
|
355
|
-
return null;
|
|
356
|
-
return r.stdout.trim() || null;
|
|
357
|
-
}
|
|
358
|
-
async function rollbackTraffic(projectId, region, serviceName, priorRevision) {
|
|
359
|
-
const r = await gcloud([
|
|
360
|
-
"run",
|
|
361
|
-
"services",
|
|
362
|
-
"update-traffic",
|
|
363
|
-
serviceName,
|
|
364
|
-
"--region",
|
|
365
|
-
region,
|
|
366
|
-
"--project",
|
|
367
|
-
projectId,
|
|
368
|
-
"--to-revisions",
|
|
369
|
-
`${priorRevision}=100`,
|
|
370
|
-
"--quiet",
|
|
371
|
-
]);
|
|
372
|
-
return r.ok;
|
|
373
|
-
}
|
|
374
|
-
/**
|
|
375
|
-
* The admin's IAP resource: the Cloud Run service itself on gcp-firebase
|
|
376
|
-
* (native IAP), the LB backend service on gcp (Pulumi-suffixed name).
|
|
377
|
-
*/
|
|
378
|
-
async function resolveAdminIapResource(meta, region, adminServiceName) {
|
|
379
|
-
if (meta.provider === "gcp-firebase") {
|
|
380
|
-
return { kind: "cloud-run", service: adminServiceName, region };
|
|
381
|
-
}
|
|
382
|
-
const r = await gcloud([
|
|
383
|
-
"compute",
|
|
384
|
-
"backend-services",
|
|
385
|
-
"list",
|
|
386
|
-
"--global",
|
|
387
|
-
`--project=${meta.projectId}`,
|
|
388
|
-
"--filter=name~^caelo-production-admin-backend",
|
|
389
|
-
"--format=value(name)",
|
|
390
|
-
]);
|
|
391
|
-
const name = r.ok ? r.stdout.trim().split("\n")[0]?.trim() : "";
|
|
392
|
-
return name ? { kind: "backend-services", service: name } : null;
|
|
393
|
-
}
|
|
394
239
|
export async function upgradeCommand(opts = {}) {
|
|
395
240
|
if (opts.installId) {
|
|
396
241
|
const selected = selectInstall(listInstalls(), opts.installId);
|
|
397
242
|
if (!selected.ok)
|
|
398
243
|
return exitNoInstall(selected.message);
|
|
399
|
-
|
|
244
|
+
const meta = currentMetadata(selected.meta);
|
|
245
|
+
if (!(await upgradeInstall(meta.installId, meta, opts)))
|
|
400
246
|
process.exitCode = 1;
|
|
401
247
|
return;
|
|
402
248
|
}
|
|
@@ -407,518 +253,163 @@ export async function upgradeCommand(opts = {}) {
|
|
|
407
253
|
? "--region names one install's region; drop it when upgrading several installs."
|
|
408
254
|
: null);
|
|
409
255
|
}
|
|
256
|
+
/**
|
|
257
|
+
* Resolve both release images' digests from the public registry. Via the
|
|
258
|
+
* registry's anonymous V2 API, not `gcloud artifacts docker tags list`: that
|
|
259
|
+
* needs IAM read on Caelo's registry project, which operators don't have.
|
|
260
|
+
*/
|
|
261
|
+
async function resolveReleaseImages(tag) {
|
|
262
|
+
const images = {};
|
|
263
|
+
for (const slug of ROLL_ORDER) {
|
|
264
|
+
const res = await resolveTagDigest(slug, tag);
|
|
265
|
+
if (!res.ok) {
|
|
266
|
+
const r = RELEASE_REGISTRY;
|
|
267
|
+
return {
|
|
268
|
+
ok: false,
|
|
269
|
+
error: `Couldn't resolve image digest for ${slug}:${tag}: the public registry returned ${res.reason}.\n` +
|
|
270
|
+
`Verify the tag exists at https://${r.region}-docker.pkg.dev/${r.project}/${r.repo}/${slug}:${tag}\n` +
|
|
271
|
+
"(latest releases live at https://github.com/caelo-cms/caelo-cms/releases — pass --version vX.Y.Z to pin.)",
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
images[slug] = { digest: res.digest, imageRef: releaseImageRef(slug, res.digest) };
|
|
275
|
+
}
|
|
276
|
+
return { ok: true, images: images };
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* `upgrade`'s step of the keyless provisioner migration (#696,
|
|
280
|
+
* provisioner-identity.ts) — a converge step (upgrade/converge.ts runs it
|
|
281
|
+
* first). Idempotent: on an install that already runs keyless it only
|
|
282
|
+
* confirms the grant.
|
|
283
|
+
*
|
|
284
|
+
* @returns false when the upgrade must stop (the message is printed)
|
|
285
|
+
*/
|
|
286
|
+
async function upgradeKeylessProvisioner(installId, projectId) {
|
|
287
|
+
const account = await activeAccount();
|
|
288
|
+
if (!account) {
|
|
289
|
+
log.error(red("No active gcloud account. Run `gcloud auth login`, then re-run upgrade."));
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
const s = spinner();
|
|
293
|
+
s.start("Ensuring the provisioner service account is used without a key...");
|
|
294
|
+
const r = await convergeKeylessProvisioner({
|
|
295
|
+
projectId,
|
|
296
|
+
secretsDir: join(installRoot(installId), "secrets"),
|
|
297
|
+
operatorAccount: account,
|
|
298
|
+
});
|
|
299
|
+
if (!r.ok) {
|
|
300
|
+
s.stop(red("Could not move the provisioner to impersonation. Aborting upgrade."));
|
|
301
|
+
log.error(r.error);
|
|
302
|
+
log.warn("Nothing was rolled and no migrations ran. Fix the above and re-run.");
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
if (r.grant === "no-provisioner") {
|
|
306
|
+
s.stop(green(`No ${provisionerServiceAccountEmail(projectId)} in this project — nothing to do`));
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
s.stop(green(r.legacyKey === "removed"
|
|
310
|
+
? "Provisioner moved to impersonation; its long-lived key is deleted"
|
|
311
|
+
: `Provisioner keyless ok ${dim(`(${account} may impersonate it)`)}`));
|
|
312
|
+
if (r.legacyKey === "removed") {
|
|
313
|
+
note([
|
|
314
|
+
`This install no longer keeps a service-account key on your machine (#696).`,
|
|
315
|
+
...r.lines.map((l) => ` ${l}`),
|
|
316
|
+
"",
|
|
317
|
+
`Infrastructure runs (re-running the installer) now use your own Google login:`,
|
|
318
|
+
`${account} impersonates ${provisionerServiceAccountEmail(projectId)} with short-lived tokens.`,
|
|
319
|
+
`They need Application Default Credentials once on this machine: ${bold("gcloud auth application-default login")}`,
|
|
320
|
+
].join("\n"), "Service-account key removed");
|
|
321
|
+
}
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Upgrade one install: converge, then app-roll (upgrade/upgrade.ts holds the
|
|
326
|
+
* sequence; this wires it to gcloud, the registry, cosign and install.json).
|
|
327
|
+
*/
|
|
410
328
|
async function upgradeInstall(installId, meta, opts) {
|
|
411
329
|
// #656 — earlier installers checkpointed database URLs with role
|
|
412
330
|
// passwords (and the setup link) into progress.json. Scrub them first,
|
|
413
331
|
// on every provider and whether or not the roll below succeeds.
|
|
414
332
|
for (const line of scrubInstallProgress(installId))
|
|
415
333
|
log.warn(yellow(line));
|
|
416
|
-
//
|
|
417
|
-
// the
|
|
418
|
-
// Registry image, `caelo-production-<slug>` service naming, the same
|
|
419
|
-
// gcloud commands). The only delta is the static-site layer (Cloud
|
|
420
|
-
// CDN vs Firebase Hosting) which upgradeCommand doesn't touch. AWS
|
|
421
|
-
// + Azure still bail with "not yet implemented" until those
|
|
422
|
-
// provider adapters land.
|
|
334
|
+
// gcp and gcp-firebase share the admin + gateway shape on Cloud Run; only
|
|
335
|
+
// the static-site layer differs, which upgrade doesn't touch.
|
|
423
336
|
if (meta.provider !== "gcp" && meta.provider !== "gcp-firebase") {
|
|
424
337
|
log.warn(`upgrade for provider ${meta.provider} not yet implemented.`);
|
|
425
338
|
return false;
|
|
426
339
|
}
|
|
427
340
|
if (!meta.projectId)
|
|
428
341
|
return false;
|
|
342
|
+
const provider = meta.provider;
|
|
343
|
+
const projectId = meta.projectId;
|
|
429
344
|
// #607 — upgrade never moves an install: a `--region` other than the
|
|
430
345
|
// recorded one is refused before anything rolls.
|
|
431
346
|
const region = installRegion(meta, opts.region);
|
|
432
|
-
const
|
|
433
|
-
const registryRegion = "europe-west1";
|
|
434
|
-
const registryRepo = "caelo-cms-images";
|
|
435
|
-
const targetTag = (() => {
|
|
436
|
-
if (opts.version)
|
|
437
|
-
return opts.version.startsWith("v") ? opts.version.slice(1) : opts.version;
|
|
438
|
-
if (opts.channel === "rc")
|
|
439
|
-
return "rc";
|
|
440
|
-
if (opts.channel === "beta")
|
|
441
|
-
return "beta";
|
|
442
|
-
return "latest";
|
|
443
|
-
})();
|
|
347
|
+
const targetTag = releaseTag(opts);
|
|
444
348
|
log.info(`Upgrading admin + gateway to ${bold(targetTag)}`);
|
|
445
|
-
// ────────────────────────────────────────────────────────────────
|
|
446
|
-
// Phase 1: pre-flight. Resolve both digests + capture both prior
|
|
447
|
-
// revisions BEFORE rolling anything. If any service is missing or
|
|
448
|
-
// any digest can't be resolved, abort cleanly with no partial state.
|
|
449
|
-
// ────────────────────────────────────────────────────────────────
|
|
450
|
-
const plans = [];
|
|
451
|
-
const sPre = spinner();
|
|
452
|
-
sPre.start("Pre-flight: resolving services + image digests + current revisions...");
|
|
453
|
-
for (const slug of ["admin", "gateway"]) {
|
|
454
|
-
const serviceName = await resolveGcpResourceName("run-service", `caelo-production-${slug}`, meta.projectId, region);
|
|
455
|
-
if (!serviceName) {
|
|
456
|
-
sPre.stop(red(`Could not find caelo-production-${slug}* Cloud Run service`));
|
|
457
|
-
return false;
|
|
458
|
-
}
|
|
459
|
-
// Resolve the tag via the public Docker Registry V2 API directly,
|
|
460
|
-
// not `gcloud artifacts docker tags list`. The gcloud path requires
|
|
461
|
-
// the operator's local credentials to have IAM read on the Caelo
|
|
462
|
-
// team's caelo-website project — end users don't have that, so
|
|
463
|
-
// the call fails for them with a misleading "tag doesn't exist"
|
|
464
|
-
// error. The AR repo is configured public; the V2 HEAD on the
|
|
465
|
-
// manifest endpoint returns the Docker-Content-Digest header
|
|
466
|
-
// anonymously. (Same source of truth `gcloud artifacts docker
|
|
467
|
-
// tags list` is wrapping; we just skip the IAM-gated CLI.)
|
|
468
|
-
const digestRes = await resolveTagDigest(registryRegion, registryProject, registryRepo, slug, targetTag);
|
|
469
|
-
if (!digestRes.ok) {
|
|
470
|
-
sPre.stop(red(`Couldn't resolve image digest for ${slug}:${targetTag}`));
|
|
471
|
-
log.error(`Public registry returned ${digestRes.reason}.\n` +
|
|
472
|
-
`Verify the tag exists at https://${registryRegion}-docker.pkg.dev/${registryProject}/${registryRepo}/${slug}:${targetTag}\n` +
|
|
473
|
-
`(latest releases live at https://github.com/caelo-cms/caelo-cms/releases — pass --version vX.Y.Z to pin.)`);
|
|
474
|
-
return false;
|
|
475
|
-
}
|
|
476
|
-
const digest = digestRes.digest;
|
|
477
|
-
const priorRevision = await findCurrentRevision(meta.projectId, region, serviceName);
|
|
478
|
-
if (!priorRevision) {
|
|
479
|
-
sPre.stop(red(`Could not capture current revision for ${slug} — refusing to roll`));
|
|
480
|
-
return false;
|
|
481
|
-
}
|
|
482
|
-
const serviceJson = await describeServiceJson(meta.projectId, region, serviceName);
|
|
483
|
-
if (!serviceJson) {
|
|
484
|
-
sPre.stop(red(`Could not read the ${slug} service's configuration — refusing to roll`));
|
|
485
|
-
return false;
|
|
486
|
-
}
|
|
487
|
-
plans.push({
|
|
488
|
-
slug,
|
|
489
|
-
serviceName,
|
|
490
|
-
digest,
|
|
491
|
-
imageRef: `${registryRegion}-docker.pkg.dev/${registryProject}/${registryRepo}/${slug}@${digest}`,
|
|
492
|
-
priorRevision,
|
|
493
|
-
liveEnv: liveContainerEnv(serviceJson),
|
|
494
|
-
liveMemory: liveContainerMemory(serviceJson),
|
|
495
|
-
liveVolumes: liveVolumes(serviceJson),
|
|
496
|
-
});
|
|
497
|
-
}
|
|
498
349
|
// #650 — an install from before the staging host setting converges to
|
|
499
350
|
// the default (staging.<domain>) without operator action: recorded here,
|
|
500
351
|
// handed to the admin as CAELO_STAGING_HOST, which seeds its setting.
|
|
501
352
|
const stagingHost = installStagingHost(meta);
|
|
502
|
-
if (!meta.stagingDomain)
|
|
353
|
+
if (!meta.stagingDomain)
|
|
503
354
|
writeMetadata(installId, { ...meta, stagingDomain: stagingHost });
|
|
504
|
-
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
|
|
355
|
+
const run = gcloud;
|
|
356
|
+
const http = fetch;
|
|
357
|
+
const ok = await upgradeGcpInstall({
|
|
358
|
+
provider,
|
|
359
|
+
projectId,
|
|
360
|
+
region,
|
|
508
361
|
env: GCP_STACK_ENV,
|
|
509
362
|
domain: meta.domain,
|
|
510
|
-
region,
|
|
511
363
|
stagingHost,
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
:
|
|
527
|
-
|
|
528
|
-
sPre.stop(red("Pre-flight failed: the admin's media volume can't be set up"));
|
|
529
|
-
log.error(mediaVolume.error);
|
|
530
|
-
return false;
|
|
531
|
-
}
|
|
532
|
-
let rolls = plans.map((p) => {
|
|
533
|
-
// #553 — the admin runs the Lighthouse quality audit; raise it to the
|
|
534
|
-
// stack's memory default (never lowered; an unparsable value is kept).
|
|
535
|
-
const memory = p.slug === "admin" ? planAdminMemory(p.liveMemory) : null;
|
|
536
|
-
if (memory && !memory.ok)
|
|
537
|
-
log.warn(yellow(`admin memory left as is: ${memory.error}`));
|
|
538
|
-
return {
|
|
539
|
-
...p,
|
|
540
|
-
envFlags: envPlan.services[p.slug].flags,
|
|
541
|
-
envChanges: envPlan.services[p.slug].changes,
|
|
542
|
-
resourceFlags: memory?.ok ? memory.flags : [],
|
|
543
|
-
volumeFlags: p.slug === "admin" ? mediaVolume.flags : [],
|
|
544
|
-
volumeChanges: p.slug === "admin" ? mediaVolume.changes : [],
|
|
545
|
-
};
|
|
546
|
-
});
|
|
547
|
-
sPre.stop(green(`Pre-flight ok — ${rolls.length} services planned`));
|
|
548
|
-
for (const roll of rolls) {
|
|
549
|
-
for (const c of roll.volumeChanges)
|
|
550
|
-
log.info(`${roll.slug} ${bold("media")}: ${c}`);
|
|
551
|
-
for (const f of roll.resourceFlags) {
|
|
552
|
-
log.info(`${roll.slug} ${bold("resources")}: ${roll.liveMemory ?? dim("(default)")} → ${f}`);
|
|
553
|
-
}
|
|
554
|
-
for (const c of roll.envChanges) {
|
|
555
|
-
log.info(`${roll.slug} env ${bold(c.name)}: ${c.from === undefined ? dim("(unset)") : c.from} → ${c.to === undefined ? dim("(removed)") : c.to}`);
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
// ────────────────────────────────────────────────────────────────
|
|
559
|
-
// P21 ship 4 — cosign verify each resolved digest against the
|
|
560
|
-
// Caelo release workflow's keyless OIDC identity. Refuses to roll
|
|
561
|
-
// on signature mismatch (compromised registry, typosquatted repo,
|
|
562
|
-
// or operator pointed at a fork's image).
|
|
563
|
-
//
|
|
564
|
-
// Verification is opt-out via --skip-verify for forks/staging that
|
|
565
|
-
// intentionally use unsigned images. Cosign-not-installed produces
|
|
566
|
-
// a clear "install cosign or pass --skip-verify" message rather
|
|
567
|
-
// than a confusing stack.
|
|
568
|
-
// ────────────────────────────────────────────────────────────────
|
|
569
|
-
if (!opts.skipVerify) {
|
|
570
|
-
const verified = await verifyCosignAll(plans, registryRegion, registryProject, registryRepo);
|
|
571
|
-
if (!verified)
|
|
572
|
-
return false;
|
|
573
|
-
}
|
|
574
|
-
else {
|
|
575
|
-
log.warn(yellow("--skip-verify set — image signatures NOT verified."));
|
|
576
|
-
}
|
|
577
|
-
// ────────────────────────────────────────────────────────────────
|
|
578
|
-
// Runtime identities + secrets the env contract references: the
|
|
579
|
-
// gateway's own run SA and the CLI-generated secrets
|
|
580
|
-
// (runtime-secrets.ts). Created once, never overwritten. Must exist
|
|
581
|
-
// before the IAM invariants bind them and before the rolls reference
|
|
582
|
-
// them, so any failure aborts here.
|
|
583
|
-
// ────────────────────────────────────────────────────────────────
|
|
584
|
-
const sRt = spinner();
|
|
585
|
-
sRt.start("Ensuring the gateway service account + generated runtime secrets...");
|
|
586
|
-
const secretsTarget = { projectId: meta.projectId, env: GCP_STACK_ENV };
|
|
587
|
-
const replication = await readSecretReplication(secretsTarget);
|
|
588
|
-
const runtime = [
|
|
589
|
-
await ensureGatewayServiceAccount(secretsTarget),
|
|
590
|
-
...(replication.ok
|
|
591
|
-
? await ensureGeneratedSecrets({
|
|
592
|
-
...secretsTarget,
|
|
593
|
-
region,
|
|
594
|
-
replication: replication.replication,
|
|
595
|
-
seed: plainGeneratedSecretSeed(deployed.admin.liveEnv),
|
|
596
|
-
})
|
|
597
|
-
: [{ id: "secret replication", status: "failed", error: replication.error }]),
|
|
598
|
-
];
|
|
599
|
-
const runtimeFailed = runtime.filter((o) => o.status === "failed");
|
|
600
|
-
if (runtimeFailed.length > 0) {
|
|
601
|
-
sRt.stop(red("Runtime identities/secrets could not be ensured. Aborting upgrade."));
|
|
602
|
-
for (const o of runtimeFailed)
|
|
603
|
-
log.error(red(` FAILED: ${o.id}\n ${o.error ?? ""}`));
|
|
604
|
-
log.warn("No traffic was shifted and no migrations ran. Fix the above and re-run.");
|
|
605
|
-
return false;
|
|
606
|
-
}
|
|
607
|
-
sRt.stop(green("Gateway service account + runtime secrets ok"));
|
|
608
|
-
for (const o of runtime.filter((o) => o.status === "applied")) {
|
|
609
|
-
log.info(` created: ${o.id}${o.detail ? ` (${o.detail})` : ""}`);
|
|
610
|
-
}
|
|
611
|
-
// ────────────────────────────────────────────────────────────────
|
|
612
|
-
// Converge the infrastructure the stack declares but upgrade can't get
|
|
613
|
-
// from an image roll: IAM bindings + CDN policy added to the stacks after
|
|
614
|
-
// this install was provisioned (stack-contract.ts). Additive + idempotent.
|
|
615
|
-
// A failure the install can't work without aborts here, before
|
|
616
|
-
// migrations or any traffic shift; the rest warn. Also deletes GCP's
|
|
617
|
-
// auto-created SSH/RDP-from-anywhere firewall rules (default-firewall.ts).
|
|
618
|
-
// ────────────────────────────────────────────────────────────────
|
|
619
|
-
const sInv = spinner();
|
|
620
|
-
sInv.start("Ensuring the IAM bindings + CDN settings the stack declares, removing the default network's pre-populated firewall rules...");
|
|
621
|
-
const invariants = await ensureStackInvariants({
|
|
622
|
-
provider: meta.provider,
|
|
623
|
-
projectId: meta.projectId,
|
|
624
|
-
region,
|
|
625
|
-
env: GCP_STACK_ENV,
|
|
626
|
-
services: Object.fromEntries(rolls.map((r) => [r.slug, r.serviceName])),
|
|
627
|
-
});
|
|
628
|
-
const applied = invariants.outcomes.filter((o) => o.status === "applied");
|
|
629
|
-
const failed = invariants.outcomes.filter((o) => o.status === "failed");
|
|
630
|
-
if (invariants.mustAbort) {
|
|
631
|
-
sInv.stop(red("Stack invariants could not be ensured. Aborting upgrade."));
|
|
632
|
-
}
|
|
633
|
-
else {
|
|
634
|
-
sInv.stop(green(`Stack invariants ok (${invariants.outcomes.length - failed.length}/${invariants.outcomes.length}, ${applied.length} applied)`));
|
|
635
|
-
}
|
|
636
|
-
for (const o of applied)
|
|
637
|
-
log.info(` applied: ${o.id} ${dim(`(${o.why})`)}`);
|
|
638
|
-
for (const o of failed) {
|
|
639
|
-
const line = ` ${o.onFailure === "abort" ? "FAILED" : "not applied"}: ${o.id} — ${o.why}\n ${o.error ?? ""}`;
|
|
640
|
-
if (o.onFailure === "abort")
|
|
641
|
-
log.error(red(line));
|
|
642
|
-
else
|
|
643
|
-
log.warn(yellow(line));
|
|
644
|
-
}
|
|
645
|
-
if (invariants.mustAbort) {
|
|
646
|
-
log.warn("No traffic was shifted and no migrations ran. Fix the bindings above and re-run.");
|
|
647
|
-
return false;
|
|
648
|
-
}
|
|
649
|
-
// ────────────────────────────────────────────────────────────────
|
|
650
|
-
// P21 ship 3 — DB migrations BEFORE traffic shifts. Idempotent
|
|
651
|
-
// (drizzle bookkeeping table); a failure here aborts the upgrade
|
|
652
|
-
// before the new image touches traffic, so the admin keeps
|
|
653
|
-
// serving the old version against the existing schema.
|
|
654
|
-
// ────────────────────────────────────────────────────────────────
|
|
655
|
-
const sMig = spinner();
|
|
656
|
-
sMig.start("Applying DB migrations (idempotent)...");
|
|
657
|
-
const { runMigrationsViaCloudRunJob } = await import("./migration-runner.js");
|
|
658
|
-
// v0.9.2 — pass the NEW admin image as imageOverride. Pre-v0.9.2 the
|
|
659
|
-
// migration runner read the CURRENT admin image (the one being
|
|
660
|
-
// replaced) which didn't carry the new release's migration files,
|
|
661
|
-
// so new migrations were silently skipped. Production hit this on
|
|
662
|
-
// every upgrade that introduced schema changes — the symptom was
|
|
663
|
-
// post-rollout queries failing with "column does not exist".
|
|
664
|
-
const adminPlan = rolls.find((p) => p.slug === "admin");
|
|
665
|
-
const mig = await runMigrationsViaCloudRunJob({
|
|
666
|
-
projectId: meta.projectId,
|
|
667
|
-
region,
|
|
668
|
-
...(adminPlan ? { imageOverride: adminPlan.imageRef } : {}),
|
|
364
|
+
ownerEmail: meta.ownerEmail,
|
|
365
|
+
targetTag,
|
|
366
|
+
infraContractVersion: INFRA_CONTRACT_VERSION,
|
|
367
|
+
skipVerify: opts.skipVerify === true,
|
|
368
|
+
skipHealthCheck: opts.skipHealthCheck === true,
|
|
369
|
+
}, {
|
|
370
|
+
run,
|
|
371
|
+
http,
|
|
372
|
+
reporter: clackReporter(),
|
|
373
|
+
resolveImages: resolveReleaseImages,
|
|
374
|
+
verifyImages: verifyCosignAll,
|
|
375
|
+
migrate: (imageRef) => runMigrationsViaCloudRunJob({ projectId, region, imageOverride: imageRef }, { run }),
|
|
376
|
+
healthProbe: (plan) => gcpHealthProbe(run, http, plan),
|
|
377
|
+
recordDigests: (digests) => recordRolledDigests(installId, provider, digests),
|
|
378
|
+
recordInfraContractVersion: (version) => recordInfraContractVersion(installId, version),
|
|
379
|
+
provisionerIdentity: () => upgradeKeylessProvisioner(installId, projectId),
|
|
669
380
|
});
|
|
670
|
-
if (
|
|
671
|
-
sMig.stop(red(`Migrations failed (${mig.error ?? "unknown"}). Aborting upgrade.`));
|
|
672
|
-
log.warn("No traffic was shifted. Inspect the Cloud Run Job logs:\n" +
|
|
673
|
-
" gcloud logging read 'resource.type=cloud_run_job AND " +
|
|
674
|
-
'resource.labels.job_name=~"caelo-migrate-.*"\' ' +
|
|
675
|
-
`--project=${meta.projectId} --limit=50`);
|
|
676
|
-
return false;
|
|
677
|
-
}
|
|
678
|
-
sMig.stop(green("Migrations applied"));
|
|
679
|
-
// ────────────────────────────────────────────────────────────────
|
|
680
|
-
// #613 — the gateway's own database login. Migration 0248 created
|
|
681
|
-
// gateway_role without a password; give it the gateway-role-password
|
|
682
|
-
// secret's value now, before the gateway rolls onto it. Without it the
|
|
683
|
-
// new gateway could not reach cms_admin, so a failure aborts here.
|
|
684
|
-
// public_role moves onto its own password right before the gateway's
|
|
685
|
-
// roll (gateway-credentials.ts), so the old revision keeps working
|
|
686
|
-
// until then.
|
|
687
|
-
// ────────────────────────────────────────────────────────────────
|
|
688
|
-
const sRole = spinner();
|
|
689
|
-
sRole.start("Ensuring the gateway's database login (gateway_role)...");
|
|
690
|
-
const sqlInstance = await resolveGcpResourceName("sql-instance", "caelo-production-pg", meta.projectId);
|
|
691
|
-
if (!sqlInstance) {
|
|
692
|
-
sRole.stop(red("Could not find the Cloud SQL instance. Aborting upgrade."));
|
|
693
|
-
log.warn("Migrations ran; no traffic was shifted. Re-run upgrade once the instance is found.");
|
|
694
|
-
return false;
|
|
695
|
-
}
|
|
696
|
-
const roleTarget = { projectId: meta.projectId, env: GCP_STACK_ENV, sqlInstance };
|
|
697
|
-
const gatewayRole = await ensureGatewayRolePassword(roleTarget);
|
|
698
|
-
if (gatewayRole.status === "failed") {
|
|
699
|
-
sRole.stop(red("gateway_role could not be given its password. Aborting upgrade."));
|
|
700
|
-
log.error(red(` ${gatewayRole.error ?? ""}`));
|
|
701
|
-
log.warn("Migrations ran (0248 creates gateway_role); no traffic was shifted. Fix the above and re-run upgrade.");
|
|
702
|
-
return false;
|
|
703
|
-
}
|
|
704
|
-
sRole.stop(green("gateway_role ready"));
|
|
705
|
-
const gatewayLive = plans.find((p) => p.slug === "gateway");
|
|
706
|
-
const publicRole = planPublicRoleSwitch(roleTarget, gatewayLive?.liveEnv ?? new Map());
|
|
707
|
-
// ────────────────────────────────────────────────────────────────
|
|
708
|
-
// Issue #37 — MCP through IAP. Idempotently ensure the MCP service
|
|
709
|
-
// account + its IAP/token-creator bindings; the admin learns the SA's
|
|
710
|
-
// email from CAELO_MCP_IAP_SERVICE_ACCOUNT in the env contract. Installs
|
|
711
|
-
// provisioned before this existed get it here, with no operator config.
|
|
712
|
-
// A failure only costs MCP access, so it warns instead of aborting.
|
|
713
|
-
// ────────────────────────────────────────────────────────────────
|
|
714
|
-
// Env vars the rolls must leave as the service has them, because the
|
|
715
|
-
// thing they point at could not be set up.
|
|
716
|
-
const leaveUntouched = [];
|
|
717
|
-
if (adminPlan) {
|
|
718
|
-
const sMcp = spinner();
|
|
719
|
-
sMcp.start("Ensuring MCP access through IAP...");
|
|
720
|
-
const resource = await resolveAdminIapResource(meta, region, adminPlan.serviceName);
|
|
721
|
-
const mcp = resource
|
|
722
|
-
? await ensureMcpIapAccess({ projectId: meta.projectId, resource })
|
|
723
|
-
: { ok: false, error: "admin IAP backend service not found" };
|
|
724
|
-
if (mcp.ok) {
|
|
725
|
-
sMcp.stop(green(`MCP access ready (${mcp.serviceAccount}; ${mcp.operators.length} operator(s))`));
|
|
726
|
-
}
|
|
727
|
-
else {
|
|
728
|
-
sMcp.stop(yellow(`MCP access not configured: ${mcp.error}`));
|
|
729
|
-
log.warn("The upgrade continues; external MCP clients stay blocked by IAP until this succeeds.");
|
|
730
|
-
// Don't hand the admin an MCP service account that may not exist or
|
|
731
|
-
// lacks its bindings: /settings/mcp would print a `claude mcp add`
|
|
732
|
-
// command that can't work. Leave the var as the service has it.
|
|
733
|
-
leaveUntouched.push(MCP_ENV_VAR);
|
|
734
|
-
}
|
|
735
|
-
// ──────────────────────────────────────────────────────────────
|
|
736
|
-
// Operator access: the sync job that keeps Google IAP in step with the
|
|
737
|
-
// user list (operator-access.ts) — its own SA, the only principal that
|
|
738
|
-
// may change the IAP binding; the admin may only start it. Also removes
|
|
739
|
-
// the operator-access rights an earlier revision gave the admin's SA.
|
|
740
|
-
// After migrations: the job's database user gets the role migration
|
|
741
|
-
// 0239 creates. A failure only costs IAP following the user list, so it
|
|
742
|
-
// warns; the admin then reports the sync as not set up, loudly.
|
|
743
|
-
// ──────────────────────────────────────────────────────────────
|
|
744
|
-
const sOa = spinner();
|
|
745
|
-
sOa.start("Ensuring the operator-access sync job...");
|
|
746
|
-
const resolved = await resolveOperatorAccessTarget({
|
|
747
|
-
provider: meta.provider,
|
|
748
|
-
projectId: meta.projectId,
|
|
749
|
-
region,
|
|
750
|
-
env: GCP_STACK_ENV,
|
|
751
|
-
ownerEmail: meta.ownerEmail,
|
|
752
|
-
imageRef: adminPlan.imageRef,
|
|
753
|
-
});
|
|
754
|
-
const oa = resolved.ok
|
|
755
|
-
? await ensureOperatorAccessSync(resolved.target)
|
|
756
|
-
: { ok: false, done: [], error: resolved.error };
|
|
757
|
-
for (const step of oa.done)
|
|
758
|
-
log.info(` ${dim(step)}`);
|
|
759
|
-
if (oa.ok) {
|
|
760
|
-
sOa.stop(green("Operator-access sync job ready (IAP follows the user list)"));
|
|
761
|
-
}
|
|
762
|
-
else {
|
|
763
|
-
sOa.stop(yellow(`Operator-access sync job not set up: ${oa.error}`));
|
|
764
|
-
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.");
|
|
765
|
-
leaveUntouched.push(OPERATOR_ACCESS_JOB_ENV_VAR);
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
if (leaveUntouched.length > 0) {
|
|
769
|
-
const replan = planContractEnv(install, deployed, { leaveUntouched });
|
|
770
|
-
if (replan.ok) {
|
|
771
|
-
rolls = rolls.map((r) => ({
|
|
772
|
-
...r,
|
|
773
|
-
envFlags: replan.services[r.slug].flags,
|
|
774
|
-
envChanges: replan.services[r.slug].changes,
|
|
775
|
-
}));
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
// ────────────────────────────────────────────────────────────────
|
|
779
|
-
// Phase 2: roll each service, probe health, auto-rollback on fail.
|
|
780
|
-
// If admin succeeds but gateway fails, also roll admin back so the
|
|
781
|
-
// operator never ends up on a mismatched-version pair. The env
|
|
782
|
-
// contract's changes ride the same `services update` as the image, so
|
|
783
|
-
// they land in the new revision (and roll back with it). So does the
|
|
784
|
-
// service's run SA: the gateway moves to its own SA here.
|
|
785
|
-
// ────────────────────────────────────────────────────────────────
|
|
786
|
-
const rolled = [];
|
|
787
|
-
/** A failed gateway roll goes back to a revision that reads public_role's old password. */
|
|
788
|
-
const restorePublicRole = async (slug) => {
|
|
789
|
-
if (slug !== "gateway")
|
|
790
|
-
return;
|
|
791
|
-
const outcome = await publicRole.afterGatewayRollFailed();
|
|
792
|
-
if (outcome.startsWith("could NOT"))
|
|
793
|
-
log.error(red(` ${outcome}`));
|
|
794
|
-
else
|
|
795
|
-
log.info(` ${outcome}`);
|
|
796
|
-
};
|
|
797
|
-
for (const plan of rolls) {
|
|
798
|
-
const s = spinner();
|
|
799
|
-
s.start(`Rolling ${plan.slug} → ${plan.digest.slice(0, 19)}...`);
|
|
800
|
-
if (plan.slug === "gateway") {
|
|
801
|
-
const moved = await publicRole.beforeGatewayRoll();
|
|
802
|
-
if (moved.status === "failed") {
|
|
803
|
-
s.stop(red(`public_role could not be moved onto its own password: ${moved.error ?? ""}`));
|
|
804
|
-
await rollbackPriorlyRolled(meta.projectId, region, rolled);
|
|
805
|
-
return false;
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
const upd = await rollService(serviceRollArgs({
|
|
809
|
-
serviceName: plan.serviceName,
|
|
810
|
-
region,
|
|
811
|
-
projectId: meta.projectId,
|
|
812
|
-
imageRef: plan.imageRef,
|
|
813
|
-
serviceAccount: plan.slug === "admin"
|
|
814
|
-
? runServiceAccountEmail(meta.projectId, GCP_STACK_ENV)
|
|
815
|
-
: gatewayServiceAccountEmail(meta.projectId, GCP_STACK_ENV),
|
|
816
|
-
envFlags: plan.envFlags,
|
|
817
|
-
resourceFlags: plan.resourceFlags,
|
|
818
|
-
volumeFlags: plan.volumeFlags,
|
|
819
|
-
}));
|
|
820
|
-
if (!upd.ok) {
|
|
821
|
-
s.stop(red(`Failed: ${upd.stderr.trim()}`));
|
|
822
|
-
await restorePublicRole(plan.slug);
|
|
823
|
-
await rollbackPriorlyRolled(meta.projectId, region, rolled);
|
|
824
|
-
return false;
|
|
825
|
-
}
|
|
826
|
-
// Force traffic onto the new revision. `update --image` only auto-
|
|
827
|
-
// flips when the service has no explicit traffic config — but our
|
|
828
|
-
// own auto-rollback path (rollbackTraffic) pins traffic to the
|
|
829
|
-
// prior revision, and that pin survives subsequent `update --image`
|
|
830
|
-
// calls. Without an explicit `update-traffic --to-latest` here, a
|
|
831
|
-
// prior failed upgrade silently locks every future upgrade off the
|
|
832
|
-
// serving path: new revisions are created, marked Ready, and never
|
|
833
|
-
// see traffic. Re-issuing it on every successful image update is
|
|
834
|
-
// idempotent + fixes already-pinned services.
|
|
835
|
-
const flip = await gcloud([
|
|
836
|
-
"run",
|
|
837
|
-
"services",
|
|
838
|
-
"update-traffic",
|
|
839
|
-
plan.serviceName,
|
|
840
|
-
"--region",
|
|
841
|
-
region,
|
|
842
|
-
"--project",
|
|
843
|
-
meta.projectId,
|
|
844
|
-
"--to-latest",
|
|
845
|
-
"--quiet",
|
|
846
|
-
]);
|
|
847
|
-
if (!flip.ok) {
|
|
848
|
-
s.stop(red(`Traffic flip to latest failed: ${flip.stderr.trim()}`));
|
|
849
|
-
await restorePublicRole(plan.slug);
|
|
850
|
-
await rollbackPriorlyRolled(meta.projectId, region, rolled);
|
|
851
|
-
return false;
|
|
852
|
-
}
|
|
853
|
-
if (!(await findServiceUrl(meta.projectId, region, plan.serviceName))) {
|
|
854
|
-
s.stop(red(`Could not resolve service URL for ${plan.slug} — rolling back`));
|
|
855
|
-
await rollbackTraffic(meta.projectId, region, plan.serviceName, plan.priorRevision);
|
|
856
|
-
await restorePublicRole(plan.slug);
|
|
857
|
-
await rollbackPriorlyRolled(meta.projectId, region, rolled);
|
|
858
|
-
return false;
|
|
859
|
-
}
|
|
860
|
-
// Cloud Run's `update --image` (with default --quiet) returns only
|
|
861
|
-
// after the new revision passes its container-readiness probe AND
|
|
862
|
-
// becomes the serving revision. That's the gate we rely on now —
|
|
863
|
-
// the prior `/_caelo/health` HTTP probe via the run.app URL always
|
|
864
|
-
// 403'd because the admin Cloud Run service is invoker-restricted
|
|
865
|
-
// to the IAP service account (the LB+IAP gates user traffic; direct
|
|
866
|
-
// run.app calls are denied by design). Probing through the LB would
|
|
867
|
-
// require an IAP-issued OAuth token, which the operator's gcloud
|
|
868
|
-
// session doesn't carry. Trust Cloud Run's own readiness signal —
|
|
869
|
-
// a real startup failure surfaces as `gcloud update` returning an
|
|
870
|
-
// error, which the upd.ok check above already catches and rolls
|
|
871
|
-
// back. Deeper checks (DB connectivity, schema match) surface on
|
|
872
|
-
// the first request after upgrade.
|
|
873
|
-
s.stop(green(`${plan.slug} rolled to ${plan.digest.slice(0, 19)}... ✓`));
|
|
874
|
-
rolled.push(plan);
|
|
875
|
-
}
|
|
876
|
-
log.success(`Upgrade to ${bold(targetTag)} complete (admin + gateway revisions Ready).`);
|
|
877
|
-
if (meta.provider === "gcp-firebase") {
|
|
381
|
+
if (ok && provider === "gcp-firebase") {
|
|
878
382
|
// #650 — staging builds now render the staging host; the gateway
|
|
879
383
|
// serves it once it is mapped. The command is idempotent, so this
|
|
880
384
|
// reminder is safe to repeat on every upgrade.
|
|
881
|
-
log.info(`Protected staging site: run ${bold("cms-provision staging-domain enable")} once to serve the staging host (it maps the host the admin uses — ${bold(
|
|
385
|
+
log.info(`Protected staging site: run ${bold("cms-provision staging-domain enable")} once to serve the staging host (it maps the host the admin uses — ${bold(stagingHost)} unless changed there —, turns on password protection, prints the login and the DNS record). Host and access: admin → Settings → Domains / Staging access.`);
|
|
882
386
|
}
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
const
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
region,
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
log.info(` removed: ${o.id} ${dim(`(${o.why})`)}`);
|
|
897
|
-
if (o.status === "failed") {
|
|
898
|
-
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.`));
|
|
387
|
+
return ok;
|
|
388
|
+
}
|
|
389
|
+
/** The deep health probe for `plan`'s install, through IAP. */
|
|
390
|
+
async function gcpHealthProbe(run, http, plan) {
|
|
391
|
+
const { install } = plan;
|
|
392
|
+
const urls = {};
|
|
393
|
+
for (const s of plan.services) {
|
|
394
|
+
const url = await findServiceUrl(run, install.projectId, install.region, s.serviceName);
|
|
395
|
+
if (!url) {
|
|
396
|
+
return {
|
|
397
|
+
ok: false,
|
|
398
|
+
error: `Could not read the ${s.slug} service's URL for the health check.`,
|
|
399
|
+
};
|
|
899
400
|
}
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
const digestOf = (slug) => {
|
|
912
|
-
const roll = rolls.find((r) => r.slug === slug);
|
|
913
|
-
if (!roll)
|
|
914
|
-
throw new Error(`upgrade rolled no ${slug} service`);
|
|
915
|
-
return roll.digest;
|
|
401
|
+
urls[s.slug] = url;
|
|
402
|
+
}
|
|
403
|
+
return {
|
|
404
|
+
ok: true,
|
|
405
|
+
probe: createIapHealthProbe({
|
|
406
|
+
provider: install.provider,
|
|
407
|
+
projectId: install.projectId,
|
|
408
|
+
domain: install.domain,
|
|
409
|
+
adminServiceUrl: urls.admin,
|
|
410
|
+
gatewayServiceUrl: urls.gateway,
|
|
411
|
+
}, { run, http, sleep: realSleep }),
|
|
916
412
|
};
|
|
917
|
-
await recordRolledDigests(installId, meta.provider, {
|
|
918
|
-
admin: digestOf("admin"),
|
|
919
|
-
gateway: digestOf("gateway"),
|
|
920
|
-
});
|
|
921
|
-
return true;
|
|
922
413
|
}
|
|
923
414
|
/**
|
|
924
415
|
* Record the digests just rolled so nothing re-deploys an older release:
|
|
@@ -933,19 +424,27 @@ async function recordRolledDigests(installId, provider, digests) {
|
|
|
933
424
|
recordImageDigests(installId, digests);
|
|
934
425
|
}
|
|
935
426
|
catch (e) {
|
|
936
|
-
log.warn(yellow(`Could not record the rolled digests in ${
|
|
427
|
+
log.warn(yellow(`Could not record the rolled digests in the install.json of ${installId} (${reason(e)}). Add this before re-running the installer, or it deploys the newest release instead:\n "imageDigests": ${JSON.stringify(digests)}`));
|
|
428
|
+
}
|
|
429
|
+
const meta = readMetadata(installId);
|
|
430
|
+
const manual = stackConfigRecovery(installId, meta ?? {}, provider, digests);
|
|
431
|
+
let backend;
|
|
432
|
+
try {
|
|
433
|
+
if (!meta)
|
|
434
|
+
throw new Error(`no install.json for '${installId}'`);
|
|
435
|
+
backend = resolveBackend(meta, {
|
|
436
|
+
installRoot: installRoot(installId),
|
|
437
|
+
readPassphrase: () => readSecret(installId, "pulumi-passphrase"),
|
|
438
|
+
});
|
|
937
439
|
}
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
if (!passphrase) {
|
|
941
|
-
log.warn(yellow(`No Pulumi passphrase in ~/.caelo-${installId}/secrets — stack config not updated. Before any manual \`pulumi up\`, run:\n${manual}`));
|
|
440
|
+
catch (e) {
|
|
441
|
+
log.warn(yellow(`Stack config not updated (${reason(e)}). Before any manual \`pulumi up\`, run:\n${manual}`));
|
|
942
442
|
return;
|
|
943
443
|
}
|
|
944
444
|
try {
|
|
945
445
|
const { writeImageDigestsToStack } = await import("./wizards/gcp-pulumi.js");
|
|
946
446
|
const { removedOverrides } = await writeImageDigestsToStack({
|
|
947
|
-
|
|
948
|
-
pulumiPassphrase: passphrase,
|
|
447
|
+
backend,
|
|
949
448
|
provider,
|
|
950
449
|
digests,
|
|
951
450
|
});
|
|
@@ -963,9 +462,8 @@ async function recordRolledDigests(installId, provider, digests) {
|
|
|
963
462
|
* the same workspace, backend, stack and passphrase the CLI uses — run from
|
|
964
463
|
* any directory, they cannot hit an unrelated stack.
|
|
965
464
|
*/
|
|
966
|
-
function stackConfigRecovery(installId, provider, digests) {
|
|
967
|
-
const
|
|
968
|
-
const env = `PULUMI_BACKEND_URL=file://${join(root, "state")} PULUMI_CONFIG_PASSPHRASE="$(cat ${join(root, "secrets", "pulumi-passphrase")})"`;
|
|
465
|
+
function stackConfigRecovery(installId, meta, provider, digests) {
|
|
466
|
+
const env = backendShellEnv(meta, installRoot(installId));
|
|
969
467
|
const cwd = `--cwd ${resolvePath(import.meta.dir, "../stacks", provider)} --stack ${GCP_STACK_ENV}`;
|
|
970
468
|
const lines = Object.entries(digests).map(([svc, d]) => ` ${env} pulumi config set ${cwd} caelo-${provider}:image-digest-${svc} ${d}`);
|
|
971
469
|
if (provider === "gcp") {
|
|
@@ -973,133 +471,15 @@ function stackConfigRecovery(installId, provider, digests) {
|
|
|
973
471
|
}
|
|
974
472
|
return lines.join("\n");
|
|
975
473
|
}
|
|
976
|
-
/**
|
|
977
|
-
* Roll back any services that already passed their probe in this
|
|
978
|
-
* upgrade run. Called when a later service fails its probe so the
|
|
979
|
-
* operator never ends up on an admin-new + gateway-old (or vice versa)
|
|
980
|
-
* mismatched pair.
|
|
981
|
-
*/
|
|
982
|
-
/** Returns true if `argv` runs to a zero exit; false on non-zero or ENOENT. */
|
|
983
|
-
function probeBinary(argv) {
|
|
984
|
-
try {
|
|
985
|
-
const r = Bun.spawnSync(argv, { stdout: "pipe", stderr: "pipe" });
|
|
986
|
-
return r.exitCode === 0;
|
|
987
|
-
}
|
|
988
|
-
catch {
|
|
989
|
-
return false;
|
|
990
|
-
}
|
|
991
|
-
}
|
|
992
|
-
/**
|
|
993
|
-
* Ensure cosign is on PATH. If not — and the operator is on macOS with
|
|
994
|
-
* brew available — offer to `brew install cosign` interactively. Falls
|
|
995
|
-
* back to printing the platform install URL + the --skip-verify
|
|
996
|
-
* escape hatch when auto-install isn't possible.
|
|
997
|
-
*
|
|
998
|
-
* Returns true if cosign is callable after this function returns.
|
|
999
|
-
*/
|
|
1000
|
-
async function ensureCosignAvailable() {
|
|
1001
|
-
if (probeBinary(["cosign", "version"]))
|
|
1002
|
-
return true;
|
|
1003
|
-
log.error(red("cosign not found on PATH — required for image-signature verification."));
|
|
1004
|
-
// macOS auto-install offer. Only when `brew` is itself on PATH —
|
|
1005
|
-
// otherwise the operator is on a stripped-down install where
|
|
1006
|
-
// homebrew was never set up, and the manual instructions are more
|
|
1007
|
-
// useful than a failing brew shell-out.
|
|
1008
|
-
if (process.platform === "darwin" && probeBinary(["brew", "--version"])) {
|
|
1009
|
-
const proceed = await confirm({
|
|
1010
|
-
message: "Install cosign via brew now? (one-shot: brew install cosign)",
|
|
1011
|
-
initialValue: true,
|
|
1012
|
-
});
|
|
1013
|
-
if (!isCancel(proceed) && proceed === true) {
|
|
1014
|
-
const s = spinner();
|
|
1015
|
-
s.start("brew install cosign");
|
|
1016
|
-
const installResult = Bun.spawnSync(["brew", "install", "cosign"], {
|
|
1017
|
-
stdout: "pipe",
|
|
1018
|
-
stderr: "pipe",
|
|
1019
|
-
});
|
|
1020
|
-
if (installResult.exitCode === 0 && probeBinary(["cosign", "version"])) {
|
|
1021
|
-
s.stop(green("cosign installed ✓"));
|
|
1022
|
-
return true;
|
|
1023
|
-
}
|
|
1024
|
-
const stderr = new TextDecoder().decode(installResult.stderr);
|
|
1025
|
-
s.stop(red("brew install cosign failed"));
|
|
1026
|
-
log.error(stderr.trim().slice(0, 500));
|
|
1027
|
-
}
|
|
1028
|
-
}
|
|
1029
|
-
log.warn("Install cosign:\n" +
|
|
1030
|
-
" • brew install cosign (macOS)\n" +
|
|
1031
|
-
" • https://docs.sigstore.dev/cosign/installation/ (other)\n" +
|
|
1032
|
-
"Or pass --skip-verify to roll without signature checks (NOT recommended for production).");
|
|
1033
|
-
return false;
|
|
1034
|
-
}
|
|
1035
|
-
/**
|
|
1036
|
-
* P21 ship 4 — verify cosign keyless signatures on every planned
|
|
1037
|
-
* image digest. Sigstore Fulcio + Rekor; the certificate identity
|
|
1038
|
-
* must match the Caelo release-images workflow. A mismatch means
|
|
1039
|
-
* either a registry compromise, a typosquat, or the operator pointed
|
|
1040
|
-
* at a fork's image. Either way: refuse to roll.
|
|
1041
|
-
*
|
|
1042
|
-
* Returns true on success (or skips with a clear error and returns
|
|
1043
|
-
* false if cosign isn't installed / verification fails).
|
|
1044
|
-
*/
|
|
1045
|
-
async function verifyCosignAll(plans, registryRegion, registryProject, registryRepo) {
|
|
1046
|
-
// Probe cosign. Bun.spawnSync THROWS ENOENT when the binary is
|
|
1047
|
-
// missing (instead of returning a non-zero exit), so the probe
|
|
1048
|
-
// needs try/catch — relying on `exitCode !== 0` would surface a
|
|
1049
|
-
// confusing stack trace to the operator instead of the actionable
|
|
1050
|
-
// "install cosign" hint.
|
|
1051
|
-
if (!(await ensureCosignAvailable()))
|
|
1052
|
-
return false;
|
|
1053
|
-
for (const plan of plans) {
|
|
1054
|
-
const s = spinner();
|
|
1055
|
-
s.start(`Verifying cosign signature for ${plan.slug}@${plan.digest.slice(0, 19)}...`);
|
|
1056
|
-
const fullRef = `${registryRegion}-docker.pkg.dev/${registryProject}/${registryRepo}/${plan.slug}@${plan.digest}`;
|
|
1057
|
-
const verify = Bun.spawnSync([
|
|
1058
|
-
"cosign",
|
|
1059
|
-
"verify",
|
|
1060
|
-
fullRef,
|
|
1061
|
-
"--certificate-identity-regexp",
|
|
1062
|
-
// Both release-images.yml and release.yml dispatch images;
|
|
1063
|
-
// accept either workflow as the signer identity. The repo
|
|
1064
|
-
// path is fixed; only the workflow filename varies.
|
|
1065
|
-
"https://github.com/caelo-cms/caelo-cms/.github/workflows/(release-images|release).yml@.*",
|
|
1066
|
-
"--certificate-oidc-issuer",
|
|
1067
|
-
"https://token.actions.githubusercontent.com",
|
|
1068
|
-
], { stdout: "pipe", stderr: "pipe" });
|
|
1069
|
-
if (verify.exitCode !== 0) {
|
|
1070
|
-
const stderr = new TextDecoder().decode(verify.stderr);
|
|
1071
|
-
s.stop(red(`cosign verify FAILED for ${plan.slug}`));
|
|
1072
|
-
log.error(red(`${plan.slug}@${plan.digest.slice(0, 19)} signature does NOT match the Caelo release workflow identity.\n` +
|
|
1073
|
-
"Either:\n" +
|
|
1074
|
-
" • the registry was compromised, OR\n" +
|
|
1075
|
-
" • you're targeting a fork's image (verify your install's registry path), OR\n" +
|
|
1076
|
-
" • cosign / sigstore had a transient outage (retry).\n" +
|
|
1077
|
-
`cosign stderr: ${stderr.trim().slice(0, 500)}`));
|
|
1078
|
-
return false;
|
|
1079
|
-
}
|
|
1080
|
-
s.stop(green(`${plan.slug} signature verified ✓`));
|
|
1081
|
-
}
|
|
1082
|
-
return true;
|
|
1083
|
-
}
|
|
1084
|
-
async function rollbackPriorlyRolled(projectId, region, rolled) {
|
|
1085
|
-
if (rolled.length === 0)
|
|
1086
|
-
return;
|
|
1087
|
-
log.warn(yellow(`Rolling back ${rolled.length} service(s) that succeeded earlier in this run...`));
|
|
1088
|
-
for (const plan of rolled) {
|
|
1089
|
-
const ok = await rollbackTraffic(projectId, region, plan.serviceName, plan.priorRevision);
|
|
1090
|
-
log.info(ok
|
|
1091
|
-
? green(` ${plan.slug} traffic restored to ${plan.priorRevision}`)
|
|
1092
|
-
: red(` ${plan.slug} rollback FAILED — manual fix required`));
|
|
1093
|
-
}
|
|
1094
|
-
}
|
|
1095
474
|
// =========================================================================
|
|
1096
|
-
// backup — Cloud SQL on-demand backup
|
|
475
|
+
// backup — Cloud SQL on-demand backup
|
|
1097
476
|
// =========================================================================
|
|
1098
477
|
export async function backupCommand() {
|
|
1099
478
|
await forEachInstall("backup", (_installId, meta) => backupOf(meta));
|
|
1100
479
|
}
|
|
480
|
+
/** Both GCP providers keep their data in the install's Cloud SQL instance. */
|
|
1101
481
|
async function backupOf(meta) {
|
|
1102
|
-
if (meta.provider !== "gcp") {
|
|
482
|
+
if (meta.provider !== "gcp" && meta.provider !== "gcp-firebase") {
|
|
1103
483
|
log.warn(`backup for provider ${meta.provider} not yet implemented.`);
|
|
1104
484
|
return false;
|
|
1105
485
|
}
|
|
@@ -1107,27 +487,13 @@ async function backupOf(meta) {
|
|
|
1107
487
|
return false;
|
|
1108
488
|
const s = spinner();
|
|
1109
489
|
s.start("Resolving Cloud SQL instance + triggering on-demand backup...");
|
|
1110
|
-
const
|
|
1111
|
-
if (!sqlName) {
|
|
1112
|
-
s.stop(red("Could not find caelo-production-pg* Cloud SQL instance"));
|
|
1113
|
-
return false;
|
|
1114
|
-
}
|
|
1115
|
-
const r = await gcloud([
|
|
1116
|
-
"sql",
|
|
1117
|
-
"backups",
|
|
1118
|
-
"create",
|
|
1119
|
-
"--instance",
|
|
1120
|
-
sqlName,
|
|
1121
|
-
"--project",
|
|
1122
|
-
meta.projectId,
|
|
1123
|
-
"--description",
|
|
1124
|
-
`caelo-cms backup ${new Date().toISOString()}`,
|
|
1125
|
-
]);
|
|
490
|
+
const r = await takeSqlBackup(gcloud, { projectId: meta.projectId, env: GCP_STACK_ENV }, "backup");
|
|
1126
491
|
if (!r.ok) {
|
|
1127
|
-
s.stop(red(`Failed: ${r.
|
|
492
|
+
s.stop(red(`Failed: ${r.error}`));
|
|
1128
493
|
return false;
|
|
1129
494
|
}
|
|
1130
|
-
s.stop(green(`Backup
|
|
495
|
+
s.stop(green(`Backup ${r.backup.backupId} of ${r.backup.instance} created.`));
|
|
496
|
+
log.info(`Restore it (overwrites all data written since): ${bold(restoreCommand(r.backup))}`);
|
|
1131
497
|
return true;
|
|
1132
498
|
}
|
|
1133
499
|
// =========================================================================
|
|
@@ -1157,7 +523,7 @@ export async function rotateSecretCommand(name) {
|
|
|
1157
523
|
s.start(`Rotating ${secret}...`);
|
|
1158
524
|
const services = {};
|
|
1159
525
|
for (const slug of ["admin", "gateway"]) {
|
|
1160
|
-
const serviceName = await resolveGcpResourceName("run-service", `caelo-production-${slug}`, meta.projectId, region);
|
|
526
|
+
const serviceName = await resolveGcpResourceName(gcloud, "run-service", `caelo-production-${slug}`, meta.projectId, region);
|
|
1161
527
|
if (!serviceName) {
|
|
1162
528
|
s.stop(red(`Could not find caelo-production-${slug}* Cloud Run service — nothing rotated`));
|
|
1163
529
|
return;
|
|
@@ -1165,7 +531,7 @@ export async function rotateSecretCommand(name) {
|
|
|
1165
531
|
services[slug] = serviceName;
|
|
1166
532
|
}
|
|
1167
533
|
const sqlInstance = rolesOfSecret(secret).length > 0
|
|
1168
|
-
? await resolveGcpResourceName("sql-instance", "caelo-production-pg", meta.projectId)
|
|
534
|
+
? await resolveGcpResourceName(gcloud, "sql-instance", "caelo-production-pg", meta.projectId)
|
|
1169
535
|
: null;
|
|
1170
536
|
const report = await rotateRuntimeSecret({
|
|
1171
537
|
projectId: meta.projectId,
|