@caelo-cms/provisioning 0.10.67 → 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/agent-db.js +261 -151
- package/dist/cli.js +18 -0
- package/dist/cli.js.map +1 -1
- package/dist/compose.js +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/compose.ts +1 -1
- 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
|
@@ -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}.`);
|