@caelo-cms/provisioning 0.10.68 → 0.10.69
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 +28 -0
- package/dist/admin-domain.d.ts.map +1 -1
- package/dist/admin-domain.js +65 -0
- package/dist/admin-domain.js.map +1 -1
- package/dist/cli.js +18 -0
- package/dist/cli.js.map +1 -1
- package/dist/default-firewall.d.ts +1 -1
- package/dist/default-firewall.d.ts.map +1 -1
- package/dist/default-firewall.js +2 -1
- package/dist/default-firewall.js.map +1 -1
- package/dist/gcloud-retry.d.ts +21 -0
- package/dist/gcloud-retry.d.ts.map +1 -1
- package/dist/gcloud-retry.js +34 -0
- package/dist/gcloud-retry.js.map +1 -1
- package/dist/ops-backend.d.ts.map +1 -1
- package/dist/ops-backend.js +13 -3
- package/dist/ops-backend.js.map +1 -1
- package/dist/stack-contract.d.ts +9 -2
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +4 -2
- package/dist/stack-contract.js.map +1 -1
- package/dist/stack-converge.d.ts +14 -0
- package/dist/stack-converge.d.ts.map +1 -1
- package/dist/stack-converge.js +83 -8
- package/dist/stack-converge.js.map +1 -1
- package/dist/upgrade/app-roll.d.ts +9 -1
- package/dist/upgrade/app-roll.d.ts.map +1 -1
- package/dist/upgrade/app-roll.js +14 -2
- package/dist/upgrade/app-roll.js.map +1 -1
- package/dist/upgrade/upgrade.d.ts.map +1 -1
- package/dist/upgrade/upgrade.js +1 -1
- package/dist/upgrade/upgrade.js.map +1 -1
- package/dist/upgrader-job.d.ts.map +1 -1
- package/dist/upgrader-job.js +4 -1
- package/dist/upgrader-job.js.map +1 -1
- package/package.json +2 -2
- package/src/admin-domain.test.ts +87 -0
- package/src/admin-domain.ts +71 -0
- package/src/cli.ts +22 -0
- package/src/default-firewall.ts +5 -3
- package/src/gcloud-retry.ts +37 -0
- package/src/ops-backend.test.ts +56 -0
- package/src/ops-backend.ts +21 -3
- package/src/remote-mcp.test.ts +2 -0
- package/src/stack-contract.ts +10 -2
- package/src/stack-converge-secrets.test.ts +137 -1
- package/src/stack-converge.ts +88 -7
- package/src/upgrade/app-roll.ts +29 -0
- package/src/upgrade/upgrade.test.ts +1 -1
- package/src/upgrade/upgrade.ts +1 -0
- package/src/upgrader-job.ts +3 -0
package/src/ops-backend.test.ts
CHANGED
|
@@ -301,6 +301,62 @@ describe("ensureOpsBackend — new install", () => {
|
|
|
301
301
|
});
|
|
302
302
|
});
|
|
303
303
|
|
|
304
|
+
describe("ensureOpsBackend — a just-enabled KMS API (#1060)", () => {
|
|
305
|
+
const DISABLED =
|
|
306
|
+
"ERROR: (gcloud.kms.keyrings.describe) PERMISSION_DENIED: Cloud Key Management Service (KMS) API has not been used in project 42 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/cloudkms.googleapis.com/overview?project=42 then retry. (reason: SERVICE_DISABLED)";
|
|
307
|
+
|
|
308
|
+
/** KMS calls answer SERVICE_DISABLED for the first `failures` calls after enabling. */
|
|
309
|
+
function propagating(failures: number) {
|
|
310
|
+
const g = fakeGcp();
|
|
311
|
+
let left = failures;
|
|
312
|
+
const sleeps: number[] = [];
|
|
313
|
+
const run = async (args: string[]) => {
|
|
314
|
+
if (args[0] === "kms" && left > 0) {
|
|
315
|
+
left--;
|
|
316
|
+
g.calls.push(args.join(" "));
|
|
317
|
+
return fail(DISABLED);
|
|
318
|
+
}
|
|
319
|
+
return g.run(args);
|
|
320
|
+
};
|
|
321
|
+
const sleep = async (ms: number) => {
|
|
322
|
+
sleeps.push(ms);
|
|
323
|
+
};
|
|
324
|
+
return { g, run, sleep, sleeps };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
it("waits for the enablement to propagate, then creates the key ring and key", async () => {
|
|
328
|
+
const p = propagating(2);
|
|
329
|
+
const r = await ensureOpsBackend(TARGET, { run: p.run, sleep: p.sleep });
|
|
330
|
+
expect(r.ok).toBe(true);
|
|
331
|
+
expect(p.g.state.ring && p.g.state.key).toBe(true);
|
|
332
|
+
expect(p.sleeps.length).toBe(2);
|
|
333
|
+
expect(p.g.calls.filter((c) => c.startsWith("kms keyrings describe")).length).toBe(3);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("fails loudly with the next step when the API never comes up", async () => {
|
|
337
|
+
const p = propagating(Number.POSITIVE_INFINITY);
|
|
338
|
+
const r = await ensureOpsBackend(TARGET, { run: p.run, sleep: p.sleep });
|
|
339
|
+
expect(r.ok).toBe(false);
|
|
340
|
+
if (r.ok) return;
|
|
341
|
+
expect(r.error).toContain("Reading key ring caelo failed");
|
|
342
|
+
expect(r.error).toContain("still reported as disabled");
|
|
343
|
+
expect(r.error).toContain("gcloud services list --enabled --project=acme");
|
|
344
|
+
expect(r.error).toContain("re-run the same command");
|
|
345
|
+
// Waited a few minutes in total, then stopped — no endless loop.
|
|
346
|
+
const waited = p.sleeps.reduce((a, b) => a + b, 0);
|
|
347
|
+
expect(waited).toBeGreaterThanOrEqual(3 * 60_000);
|
|
348
|
+
expect(waited).toBeLessThanOrEqual(5 * 60_000);
|
|
349
|
+
expect(p.g.state.ring).toBe(false);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("does not wait in a dry run", async () => {
|
|
353
|
+
const p = propagating(Number.POSITIVE_INFINITY);
|
|
354
|
+
const r = await ensureOpsBackend(TARGET, { run: p.run, sleep: p.sleep, dryRun: true });
|
|
355
|
+
expect(r.ok).toBe(true);
|
|
356
|
+
expect(p.sleeps).toEqual([]);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
304
360
|
describe("cost table (CLAUDE.md §11.C)", () => {
|
|
305
361
|
it("lists the state bucket + KMS key at under $0.10/mo without float noise in the total", () => {
|
|
306
362
|
const est = estimateGcpCost({
|
package/src/ops-backend.ts
CHANGED
|
@@ -31,7 +31,15 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
|
31
31
|
import { tmpdir } from "node:os";
|
|
32
32
|
import { join } from "node:path";
|
|
33
33
|
import { gcloud as defaultGcloud } from "./gcloud.js";
|
|
34
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
type GcloudRunner,
|
|
36
|
+
realSleep,
|
|
37
|
+
retryWhileServiceEnabling,
|
|
38
|
+
runWithRetry,
|
|
39
|
+
SERVICE_ENABLE_DELAYS_MS,
|
|
40
|
+
SERVICE_NOT_ENABLED,
|
|
41
|
+
type Sleep,
|
|
42
|
+
} from "./gcloud-retry.js";
|
|
35
43
|
import { operatorMember } from "./provisioner-identity.js";
|
|
36
44
|
import {
|
|
37
45
|
gcsBackendRecord,
|
|
@@ -144,9 +152,16 @@ export async function ensureOpsBackend(
|
|
|
144
152
|
target: OpsBackendTarget,
|
|
145
153
|
deps: { run?: GcloudRunner; sleep?: Sleep; dryRun?: boolean } = {},
|
|
146
154
|
): Promise<OpsBackendResult> {
|
|
147
|
-
const run = deps.run ?? defaultGcloud;
|
|
148
155
|
const sleep = deps.sleep ?? realSleep;
|
|
149
156
|
const dryRun = deps.dryRun ?? false;
|
|
157
|
+
// `services enable` returns before the enablement has propagated: for a
|
|
158
|
+
// few minutes the first KMS (or storage) call still answers
|
|
159
|
+
// SERVICE_DISABLED (#1060). Every call waits that out — on a re-run whose
|
|
160
|
+
// APIs are long on it costs nothing. A dry run only reads and treats a
|
|
161
|
+
// disabled API as "not there yet", so it does not wait.
|
|
162
|
+
const run = dryRun
|
|
163
|
+
? (deps.run ?? defaultGcloud)
|
|
164
|
+
: retryWhileServiceEnabling(deps.run ?? defaultGcloud, sleep);
|
|
150
165
|
const { projectId, region } = target;
|
|
151
166
|
const project = `--project=${projectId}`;
|
|
152
167
|
const bucket = opsBucketName(projectId);
|
|
@@ -157,9 +172,12 @@ export async function ensureOpsBackend(
|
|
|
157
172
|
];
|
|
158
173
|
const actions: string[] = [];
|
|
159
174
|
const act = (what: string) => actions.push(dryRun ? `would ${what}` : what);
|
|
175
|
+
const waitedMinutes = Math.round(SERVICE_ENABLE_DELAYS_MS.reduce((a, b) => a + b, 0) / 60_000);
|
|
160
176
|
const fail = (what: string, r: { stderr: string; exitCode: number }): OpsBackendResult => ({
|
|
161
177
|
ok: false,
|
|
162
|
-
error:
|
|
178
|
+
error: SERVICE_NOT_ENABLED.test(r.stderr)
|
|
179
|
+
? `${what} failed: the API is still reported as disabled ${waitedMinutes} minutes after it was enabled (${reason(r.stderr, r.exitCode)}). Check that ${OPS_APIS.join(" and ")} show as enabled in project ${projectId} (gcloud services list --enabled --project=${projectId}), then re-run the same command — it picks up where it stopped.`
|
|
180
|
+
: `${what} failed: ${reason(r.stderr, r.exitCode)}`,
|
|
163
181
|
});
|
|
164
182
|
|
|
165
183
|
// --- APIs -------------------------------------------------------------
|
package/src/remote-mcp.test.ts
CHANGED
|
@@ -284,6 +284,8 @@ describe("planSecretFiles / planRequestTimeout (upgrade)", () => {
|
|
|
284
284
|
);
|
|
285
285
|
expect(plan.ok && plan.flags).toEqual([
|
|
286
286
|
"--remove-secrets=/secrets/mcp-oauth-client/client.json",
|
|
287
|
+
"--remove-volume-mount=/secrets/mcp-oauth-client",
|
|
288
|
+
"--remove-volume=mcp-oauth-client",
|
|
287
289
|
"--update-secrets=/secrets/operator-oauth-client/client.json=caelo-production-operator-oauth-client:latest",
|
|
288
290
|
]);
|
|
289
291
|
// Converged: nothing left to remove or mount.
|
package/src/stack-contract.ts
CHANGED
|
@@ -443,6 +443,12 @@ export const SERVICE_SECRET_FILES: Readonly<Record<CloudRunSlug, readonly Secret
|
|
|
443
443
|
|
|
444
444
|
/** A secret file an older contract mounted; upgrade unmounts it in the roll. */
|
|
445
445
|
export interface RetiredSecretFile {
|
|
446
|
+
/**
|
|
447
|
+
* The contract name of the secret the file was mounted from (gcp id
|
|
448
|
+
* `caelo-<env>-<secret>`). Names the volume behind the mount — and a
|
|
449
|
+
* volume left behind without one (#954) — so the roll removes it too.
|
|
450
|
+
*/
|
|
451
|
+
readonly secret: string;
|
|
446
452
|
readonly mountDir: string;
|
|
447
453
|
readonly fileName: string;
|
|
448
454
|
readonly why: string;
|
|
@@ -450,13 +456,15 @@ export interface RetiredSecretFile {
|
|
|
450
456
|
|
|
451
457
|
/**
|
|
452
458
|
* Secret files older contracts mounted that nothing reads any more, per
|
|
453
|
-
* service. The roll removes each mount (`--remove-secrets=<path>`)
|
|
454
|
-
*
|
|
459
|
+
* service. The roll removes each mount (`--remove-secrets=<path>`) and the
|
|
460
|
+
* volume of that secret (`--remove-volume=<name>`) in the same revision that
|
|
461
|
+
* mounts its replacement (stack-converge.ts planSecretFiles).
|
|
455
462
|
*/
|
|
456
463
|
export const RETIRED_SECRET_FILES: Readonly<Record<CloudRunSlug, readonly RetiredSecretFile[]>> = {
|
|
457
464
|
admin: [],
|
|
458
465
|
gateway: [
|
|
459
466
|
{
|
|
467
|
+
secret: "mcp-oauth-client",
|
|
460
468
|
mountDir: "/secrets/mcp-oauth-client",
|
|
461
469
|
fileName: "client.json",
|
|
462
470
|
why: "the Google sign-in client is mounted from operator-oauth-client now (#914)",
|
|
@@ -14,13 +14,20 @@
|
|
|
14
14
|
|
|
15
15
|
import { describe, expect, it } from "bun:test";
|
|
16
16
|
import { databaseUrlFromEnv } from "@caelo-cms/shared";
|
|
17
|
-
import type {
|
|
17
|
+
import type { GcloudResult } from "./gcloud.js";
|
|
18
18
|
import {
|
|
19
|
+
type CloudRunSlug,
|
|
20
|
+
OPERATOR_OAUTH_CLIENT_FILE,
|
|
21
|
+
RETIRED_SECRET_FILES,
|
|
22
|
+
} from "./stack-contract.js";
|
|
23
|
+
import {
|
|
24
|
+
findVolumesWithMissingSecrets,
|
|
19
25
|
type LiveEnvValue,
|
|
20
26
|
liveContainerEnv,
|
|
21
27
|
liveEnvHasInlinePassword,
|
|
22
28
|
planContractEnv,
|
|
23
29
|
planEnvUpdate,
|
|
30
|
+
planSecretFiles,
|
|
24
31
|
serviceRollArgs,
|
|
25
32
|
} from "./stack-converge.js";
|
|
26
33
|
|
|
@@ -460,3 +467,132 @@ describe("liveEnvHasInlinePassword", () => {
|
|
|
460
467
|
expect(liveEnvHasInlinePassword(services.gateway.env)).toBe(false);
|
|
461
468
|
});
|
|
462
469
|
});
|
|
470
|
+
|
|
471
|
+
describe("retiring a secret file removes its volume too (#954)", () => {
|
|
472
|
+
// The shape searchviu's gateway was left in: gcloud names a volume it
|
|
473
|
+
// mints for `--update-secrets` after the secret plus a random suffix.
|
|
474
|
+
const RETIRED_VOLUME = "caelo-production-mcp-oauth-client-mac-won";
|
|
475
|
+
const OPERATOR_VOLUME = "caelo-production-operator-oauth-client-puw-tuq";
|
|
476
|
+
const operatorVolume = {
|
|
477
|
+
name: OPERATOR_VOLUME,
|
|
478
|
+
secret: {
|
|
479
|
+
secretName: "caelo-production-operator-oauth-client",
|
|
480
|
+
items: [{ key: "latest", path: "client.json" }],
|
|
481
|
+
},
|
|
482
|
+
};
|
|
483
|
+
const retiredVolume = {
|
|
484
|
+
name: RETIRED_VOLUME,
|
|
485
|
+
secret: {
|
|
486
|
+
secretName: "projects/42/secrets/caelo-production-mcp-oauth-client",
|
|
487
|
+
items: [{ key: "latest", path: "client.json" }],
|
|
488
|
+
},
|
|
489
|
+
};
|
|
490
|
+
const plan = (live: Parameters<typeof planSecretFiles>[0]) =>
|
|
491
|
+
planSecretFiles(live, [OPERATOR_OAUTH_CLIENT_FILE], "production", RETIRED_SECRET_FILES.gateway);
|
|
492
|
+
|
|
493
|
+
it("a retired mount: removes the file, the mount and the volume behind it in the roll", () => {
|
|
494
|
+
const r = plan({
|
|
495
|
+
executionEnvironment: null,
|
|
496
|
+
volumes: [retiredVolume],
|
|
497
|
+
mounts: [{ name: RETIRED_VOLUME, mountPath: "/secrets/mcp-oauth-client" }],
|
|
498
|
+
});
|
|
499
|
+
expect(r.ok).toBe(true);
|
|
500
|
+
if (!r.ok) return;
|
|
501
|
+
expect(r.flags).toContain("--remove-secrets=/secrets/mcp-oauth-client/client.json");
|
|
502
|
+
expect(r.flags).toContain(`--remove-volume=${RETIRED_VOLUME}`);
|
|
503
|
+
expect(r.flags).toContain("--remove-volume-mount=/secrets/mcp-oauth-client");
|
|
504
|
+
expect(r.changes.some((c) => c.startsWith(`volume ${RETIRED_VOLUME}: removed`))).toBe(true);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("an orphaned retired volume (no mount, the searchviu state): removes it and says so", () => {
|
|
508
|
+
const r = plan({
|
|
509
|
+
executionEnvironment: null,
|
|
510
|
+
volumes: [retiredVolume, operatorVolume],
|
|
511
|
+
mounts: [{ name: OPERATOR_VOLUME, mountPath: "/secrets/operator-oauth-client" }],
|
|
512
|
+
});
|
|
513
|
+
expect(r).toEqual({
|
|
514
|
+
ok: true,
|
|
515
|
+
flags: [`--remove-volume=${RETIRED_VOLUME}`],
|
|
516
|
+
changes: [
|
|
517
|
+
`volume ${RETIRED_VOLUME}: removed (the Google sign-in client is mounted from operator-oauth-client now (#914); nothing mounts it)`,
|
|
518
|
+
],
|
|
519
|
+
});
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("a converged service plans nothing", () => {
|
|
523
|
+
const r = plan({
|
|
524
|
+
executionEnvironment: null,
|
|
525
|
+
volumes: [operatorVolume],
|
|
526
|
+
mounts: [{ name: OPERATOR_VOLUME, mountPath: "/secrets/operator-oauth-client" }],
|
|
527
|
+
});
|
|
528
|
+
expect(r).toEqual({ ok: true, flags: [], changes: [] });
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it("refuses a volume of the retired secret mounted somewhere else instead of pulling it", () => {
|
|
532
|
+
const r = plan({
|
|
533
|
+
executionEnvironment: null,
|
|
534
|
+
volumes: [retiredVolume, operatorVolume],
|
|
535
|
+
mounts: [
|
|
536
|
+
{ name: OPERATOR_VOLUME, mountPath: "/secrets/operator-oauth-client" },
|
|
537
|
+
{ name: RETIRED_VOLUME, mountPath: "/elsewhere" },
|
|
538
|
+
],
|
|
539
|
+
});
|
|
540
|
+
expect(r.ok).toBe(false);
|
|
541
|
+
if (r.ok) return;
|
|
542
|
+
expect(r.error).toContain(`--remove-volume=${RETIRED_VOLUME}`);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
describe("preflight: a volume pointing at a deleted secret", () => {
|
|
546
|
+
const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
|
|
547
|
+
const missing: GcloudResult = {
|
|
548
|
+
ok: false,
|
|
549
|
+
stdout: "",
|
|
550
|
+
stderr:
|
|
551
|
+
"ERROR: (gcloud.secrets.describe) NOT_FOUND: Secret [projects/42/secrets/x] not found",
|
|
552
|
+
exitCode: 1,
|
|
553
|
+
};
|
|
554
|
+
const where = {
|
|
555
|
+
serviceName: "caelo-production-gateway-532b35d",
|
|
556
|
+
projectId: "acme",
|
|
557
|
+
region: "europe-west1",
|
|
558
|
+
};
|
|
559
|
+
const live = {
|
|
560
|
+
executionEnvironment: null,
|
|
561
|
+
volumes: [retiredVolume, operatorVolume],
|
|
562
|
+
mounts: [{ name: OPERATOR_VOLUME, mountPath: "/secrets/operator-oauth-client" }],
|
|
563
|
+
};
|
|
564
|
+
const gcloud = (gone: string) => {
|
|
565
|
+
const calls: string[] = [];
|
|
566
|
+
const run = async (args: string[]) => {
|
|
567
|
+
calls.push(args.join(" "));
|
|
568
|
+
return args[2] === gone ? missing : ok("name");
|
|
569
|
+
};
|
|
570
|
+
return { run, calls };
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
it("names the volume and the exact fix", async () => {
|
|
574
|
+
const g = gcloud("caelo-production-mcp-oauth-client");
|
|
575
|
+
const problems = await findVolumesWithMissingSecrets(live, where, [], g.run);
|
|
576
|
+
expect(problems).toHaveLength(1);
|
|
577
|
+
expect(problems[0]).toContain(`volume "${RETIRED_VOLUME}" (not mounted)`);
|
|
578
|
+
expect(problems[0]).toContain("caelo-production-mcp-oauth-client, which no longer exists");
|
|
579
|
+
expect(problems[0]).toContain(
|
|
580
|
+
`gcloud run services update caelo-production-gateway-532b35d --region=europe-west1 --project=acme --remove-volume=${RETIRED_VOLUME}`,
|
|
581
|
+
);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it("skips a volume the roll removes anyway", async () => {
|
|
585
|
+
const g = gcloud("caelo-production-mcp-oauth-client");
|
|
586
|
+
expect(await findVolumesWithMissingSecrets(live, where, [RETIRED_VOLUME], g.run)).toEqual([]);
|
|
587
|
+
expect(g.calls.some((c) => c.includes("mcp-oauth-client"))).toBe(false);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it("a mounted volume's fix removes the mount too", async () => {
|
|
591
|
+
const g = gcloud("caelo-production-operator-oauth-client");
|
|
592
|
+
const [problem] = await findVolumesWithMissingSecrets(live, where, [RETIRED_VOLUME], g.run);
|
|
593
|
+
expect(problem).toContain(
|
|
594
|
+
`--remove-volume-mount=/secrets/operator-oauth-client --remove-volume=${OPERATOR_VOLUME}`,
|
|
595
|
+
);
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
});
|
package/src/stack-converge.ts
CHANGED
|
@@ -1023,14 +1023,51 @@ export function planSecretFiles(
|
|
|
1023
1023
|
const changes: string[] = [];
|
|
1024
1024
|
// #914 — mounts an older contract made and nothing reads any more
|
|
1025
1025
|
// (RETIRED_SECRET_FILES) go in the same revision that mounts their
|
|
1026
|
-
// replacement. `--remove-secrets=<dir>/<file>` drops the file
|
|
1027
|
-
// mount
|
|
1028
|
-
//
|
|
1029
|
-
//
|
|
1026
|
+
// replacement. `--remove-secrets=<dir>/<file>` drops the file and its
|
|
1027
|
+
// mount, but NOT the volume: gcloud's `_PruneManagedVolumeMapping` only
|
|
1028
|
+
// deletes the mount, so the volume stayed behind naming a secret the
|
|
1029
|
+
// upgrade deletes after the roll, and the next revision could not deploy
|
|
1030
|
+
// (#954, searchviu). The plan therefore removes that volume itself, plus
|
|
1031
|
+
// any volume of a retired secret that an earlier roll already orphaned.
|
|
1032
|
+
// Repeated map/list flags merge (gcloud UpdateAction); removing a volume
|
|
1033
|
+
// or mount that is already gone is a no-op in gcloud.
|
|
1034
|
+
const removedVolumes = new Set<string>();
|
|
1035
|
+
const removeVolume = (name: string, why: string) => {
|
|
1036
|
+
if (removedVolumes.has(name)) return;
|
|
1037
|
+
removedVolumes.add(name);
|
|
1038
|
+
flags.push(`--remove-volume=${name}`);
|
|
1039
|
+
changes.push(`volume ${name}: removed (${why})`);
|
|
1040
|
+
};
|
|
1030
1041
|
for (const old of retired) {
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1042
|
+
const retiredId = gcpSecretId(env, old.secret);
|
|
1043
|
+
const mount = live.mounts.find((m) => m.mountPath === old.mountDir);
|
|
1044
|
+
if (mount) {
|
|
1045
|
+
const volume = live.volumes.find((v) => v.name === mount.name);
|
|
1046
|
+
const volumeSecret = bareSecretId(volume?.secret?.secretName);
|
|
1047
|
+
if (volume && volumeSecret !== retiredId) {
|
|
1048
|
+
return {
|
|
1049
|
+
ok: false,
|
|
1050
|
+
error: `the service mounts volume "${mount.name}" (secret ${volumeSecret || "(none)"}) at ${old.mountDir}, where only the retired ${retiredId} was ever mounted. Check what it is; remove it (gcloud run services update <service> --remove-volume-mount=${old.mountDir} --remove-volume=${mount.name}) only if nothing needs it, then re-run upgrade.`,
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
flags.push(`--remove-secrets=${old.mountDir}/${old.fileName}`);
|
|
1054
|
+
flags.push(`--remove-volume-mount=${old.mountDir}`);
|
|
1055
|
+
changes.push(`secret file ${old.mountDir}/${old.fileName}: removed (${old.why})`);
|
|
1056
|
+
if (volume) removeVolume(volume.name, old.why);
|
|
1057
|
+
}
|
|
1058
|
+
const mounted = new Set(
|
|
1059
|
+
live.mounts.filter((m) => m.mountPath !== old.mountDir).map((m) => m.name),
|
|
1060
|
+
);
|
|
1061
|
+
for (const v of live.volumes) {
|
|
1062
|
+
if (bareSecretId(v.secret?.secretName) !== retiredId) continue;
|
|
1063
|
+
if (mounted.has(v.name)) {
|
|
1064
|
+
return {
|
|
1065
|
+
ok: false,
|
|
1066
|
+
error: `the service mounts volume "${v.name}" of the retired secret ${retiredId} somewhere other than ${old.mountDir}. Nothing reads that secret any more and upgrade deletes it after the roll; remove the mount and the volume (gcloud run services update <service> --remove-volume-mount=<path> --remove-volume=${v.name}), then re-run upgrade.`,
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
removeVolume(v.name, `${old.why}; nothing mounts it`);
|
|
1070
|
+
}
|
|
1034
1071
|
}
|
|
1035
1072
|
for (const file of files) {
|
|
1036
1073
|
const secretId = gcpSecretId(env, file.secret);
|
|
@@ -1052,6 +1089,50 @@ export function planSecretFiles(
|
|
|
1052
1089
|
return { ok: true, flags, changes };
|
|
1053
1090
|
}
|
|
1054
1091
|
|
|
1092
|
+
/**
|
|
1093
|
+
* #954 — the secret volumes of a service whose secret no longer exists in
|
|
1094
|
+
* Secret Manager. Cloud Run refuses every new revision of such a service,
|
|
1095
|
+
* so the upgrade names each one with the exact fix before it starts a roll
|
|
1096
|
+
* instead of failing mid-roll. Volumes in `removing` (the roll's own
|
|
1097
|
+
* `--remove-volume` flags) are skipped: the roll takes them off anyway.
|
|
1098
|
+
* A secret gcloud cannot read for another reason is reported too — the
|
|
1099
|
+
* roll's outcome would be unknowable.
|
|
1100
|
+
*/
|
|
1101
|
+
export async function findVolumesWithMissingSecrets(
|
|
1102
|
+
live: LiveVolumes,
|
|
1103
|
+
where: { readonly serviceName: string; readonly projectId: string; readonly region: string },
|
|
1104
|
+
removing: readonly string[],
|
|
1105
|
+
run: GcloudRunner,
|
|
1106
|
+
): Promise<string[]> {
|
|
1107
|
+
const problems: string[] = [];
|
|
1108
|
+
for (const v of live.volumes) {
|
|
1109
|
+
const secretId = bareSecretId(v.secret?.secretName);
|
|
1110
|
+
if (!secretId || removing.includes(v.name)) continue;
|
|
1111
|
+
const r = await run([
|
|
1112
|
+
"secrets",
|
|
1113
|
+
"describe",
|
|
1114
|
+
secretId,
|
|
1115
|
+
`--project=${where.projectId}`,
|
|
1116
|
+
"--format=value(name)",
|
|
1117
|
+
]);
|
|
1118
|
+
if (r.ok) continue;
|
|
1119
|
+
const mounts = live.mounts.filter((m) => m.name === v.name).map((m) => m.mountPath);
|
|
1120
|
+
const fix = [
|
|
1121
|
+
`gcloud run services update ${where.serviceName}`,
|
|
1122
|
+
`--region=${where.region}`,
|
|
1123
|
+
`--project=${where.projectId}`,
|
|
1124
|
+
...mounts.map((p) => `--remove-volume-mount=${p}`),
|
|
1125
|
+
`--remove-volume=${v.name}`,
|
|
1126
|
+
].join(" ");
|
|
1127
|
+
problems.push(
|
|
1128
|
+
RESOURCE_MISSING.test(r.stderr)
|
|
1129
|
+
? `${where.serviceName} has volume "${v.name}"${mounts.length > 0 ? ` (mounted at ${mounts.join(", ")})` : " (not mounted)"} pointing at secret ${secretId}, which no longer exists, so Cloud Run would refuse the next revision. If nothing reads it, remove it: ${fix} — then re-run upgrade.`
|
|
1130
|
+
: `could not check secret ${secretId} behind volume "${v.name}" of ${where.serviceName}: ${r.stderr.trim() || `gcloud exited ${r.exitCode}`}`,
|
|
1131
|
+
);
|
|
1132
|
+
}
|
|
1133
|
+
return problems;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1055
1136
|
/** The request timeout of a described service, in seconds (null: Cloud Run's default, 300). */
|
|
1056
1137
|
export function liveTimeoutSeconds(serviceJson: string): number | null {
|
|
1057
1138
|
const svc = JSON.parse(serviceJson) as {
|
package/src/upgrade/app-roll.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
import {
|
|
38
38
|
type DeployedService,
|
|
39
39
|
type EnvChange,
|
|
40
|
+
findVolumesWithMissingSecrets,
|
|
40
41
|
type LiveEnvValue,
|
|
41
42
|
type LiveVolumes,
|
|
42
43
|
liveContainerEnv,
|
|
@@ -147,6 +148,15 @@ export async function planAppRoll(
|
|
|
147
148
|
install: AppRollInstall,
|
|
148
149
|
images: Readonly<Record<RollSlug, ReleaseImage>>,
|
|
149
150
|
run: GcloudRunner,
|
|
151
|
+
checks: {
|
|
152
|
+
/**
|
|
153
|
+
* #954 — check that every secret volume's secret still exists. The
|
|
154
|
+
* operator / converge identity can read secret metadata; the upgrader
|
|
155
|
+
* job's narrow identity holds no Secret Manager permission at all
|
|
156
|
+
* (upgrader-setup.ts), so it passes false.
|
|
157
|
+
*/
|
|
158
|
+
readonly secretVolumes: boolean;
|
|
159
|
+
},
|
|
150
160
|
): Promise<PlanResult> {
|
|
151
161
|
const services: ServicePlan[] = [];
|
|
152
162
|
for (const slug of ROLL_ORDER) {
|
|
@@ -223,6 +233,25 @@ export async function planAppRoll(
|
|
|
223
233
|
}
|
|
224
234
|
secretFiles[s.slug] = files;
|
|
225
235
|
}
|
|
236
|
+
// #954 — a volume pointing at a deleted secret makes Cloud Run refuse
|
|
237
|
+
// every new revision: name it with its fix now, before any roll starts.
|
|
238
|
+
const orphaned: string[] = [];
|
|
239
|
+
for (const s of checks.secretVolumes ? services : []) {
|
|
240
|
+
const removing = secretFiles[s.slug].flags
|
|
241
|
+
.filter((f) => f.startsWith("--remove-volume="))
|
|
242
|
+
.map((f) => f.slice("--remove-volume=".length));
|
|
243
|
+
orphaned.push(
|
|
244
|
+
...(await findVolumesWithMissingSecrets(
|
|
245
|
+
s.liveVolumes,
|
|
246
|
+
{ serviceName: s.serviceName, projectId: install.projectId, region: install.region },
|
|
247
|
+
removing,
|
|
248
|
+
run,
|
|
249
|
+
)),
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
if (orphaned.length > 0) {
|
|
253
|
+
return { ok: false, error: `a service can't take a new revision: ${orphaned.join(" ")}` };
|
|
254
|
+
}
|
|
226
255
|
const warnings: string[] = [];
|
|
227
256
|
const rolls = services.map((s): RollPlan => {
|
|
228
257
|
// #553 — the admin runs the Lighthouse quality audit; raise it to the
|
|
@@ -906,7 +906,7 @@ const INSTALL: AppRollInstall = {
|
|
|
906
906
|
};
|
|
907
907
|
|
|
908
908
|
async function plannedRoll(run: GcloudRunner): Promise<AppRollPlan> {
|
|
909
|
-
const planned = await planAppRoll(INSTALL, IMAGES, run);
|
|
909
|
+
const planned = await planAppRoll(INSTALL, IMAGES, run, { secretVolumes: true });
|
|
910
910
|
if (!planned.ok) throw new Error(planned.error);
|
|
911
911
|
return planned.plan;
|
|
912
912
|
}
|
package/src/upgrade/upgrade.ts
CHANGED
package/src/upgrader-job.ts
CHANGED
|
@@ -519,6 +519,9 @@ export async function runUpgraderJob(deps: UpgraderJobDeps): Promise<UpgraderRun
|
|
|
519
519
|
},
|
|
520
520
|
images,
|
|
521
521
|
deps.run,
|
|
522
|
+
// Holds no Secret Manager permission (upgrader-setup.ts); the
|
|
523
|
+
// converge — operator or converge job — runs the #954 volume check.
|
|
524
|
+
{ secretVolumes: false },
|
|
522
525
|
);
|
|
523
526
|
if (!planned.ok) {
|
|
524
527
|
return stopWith("failed", ["plan"], `Nothing was changed: ${planned.error}.`);
|