@caelo-cms/provisioning 0.10.68 → 0.10.70
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/adopt-existing.d.ts +119 -0
- package/dist/adopt-existing.d.ts.map +1 -0
- package/dist/adopt-existing.js +118 -0
- package/dist/adopt-existing.js.map +1 -0
- 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/preview-plan.d.ts +7 -0
- package/dist/preview-plan.d.ts.map +1 -1
- package/dist/preview-plan.js +1 -0
- package/dist/preview-plan.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/state-command.d.ts.map +1 -1
- package/dist/state-command.js +3 -1
- package/dist/state-command.js.map +1 -1
- package/dist/state-migrate.d.ts +7 -0
- package/dist/state-migrate.d.ts.map +1 -1
- package/dist/state-migrate.js +18 -1
- package/dist/state-migrate.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/dist/wizards/gcp-pulumi.d.ts +19 -4
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +62 -7
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +14 -5
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/fixtures/viu-one-plan-state-migrate.txt +98 -0
- package/src/admin-domain.test.ts +87 -0
- package/src/admin-domain.ts +71 -0
- package/src/adopt-existing.test.ts +397 -0
- package/src/adopt-existing.ts +228 -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/preview-plan.ts +8 -0
- 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/state-command.ts +6 -1
- package/src/state-migrate.test.ts +38 -0
- package/src/state-migrate.ts +24 -1
- 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/wizards/gcp-pulumi.ts +84 -8
- package/src/wizards/gcp.ts +11 -4
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/state-command.ts
CHANGED
|
@@ -14,7 +14,8 @@ import { homedir } from "node:os";
|
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { confirm, isCancel, log, note } from "@clack/prompts";
|
|
16
16
|
import { bold, red } from "kleur/colors";
|
|
17
|
-
import {
|
|
17
|
+
import { adoptionCandidates, existingCandidates } from "./adopt-existing.js";
|
|
18
|
+
import { checkGcloudAuth, gcloud } from "./gcloud.js";
|
|
18
19
|
import {
|
|
19
20
|
adoptStateBackend,
|
|
20
21
|
currentMetadata,
|
|
@@ -260,6 +261,10 @@ export async function stateCommand(argv: readonly string[]): Promise<void> {
|
|
|
260
261
|
return moved;
|
|
261
262
|
},
|
|
262
263
|
writePlan: (label, steps) => writePlanFile(root, label, steps),
|
|
264
|
+
existingInProject: async (steps) =>
|
|
265
|
+
(await existingCandidates(adoptionCandidates(steps, projectId), gcloud)).map(
|
|
266
|
+
(c) => `${c.label} ${c.display}`,
|
|
267
|
+
),
|
|
263
268
|
},
|
|
264
269
|
);
|
|
265
270
|
if (!result.ok) {
|
|
@@ -154,6 +154,8 @@ function machine(initial: StateBackendRecord = { kind: "local" }) {
|
|
|
154
154
|
acceptCost: true,
|
|
155
155
|
bucketRecord: (initial.kind === "gcs" ? "gcs" : null) as "gcs" | "local" | null,
|
|
156
156
|
planFiles: [] as { label: string; steps: number }[],
|
|
157
|
+
/** What existingInProject answers (#1079); an Error is thrown. */
|
|
158
|
+
existing: [] as readonly string[] | Error,
|
|
157
159
|
};
|
|
158
160
|
const meta = (): MigrateInstall => ({
|
|
159
161
|
installId: "gcp-firebase-acme",
|
|
@@ -209,6 +211,10 @@ function machine(initial: StateBackendRecord = { kind: "local" }) {
|
|
|
209
211
|
m.planFiles.push({ label, steps: steps.length });
|
|
210
212
|
return `${ROOT}/plan-${label}.txt`;
|
|
211
213
|
},
|
|
214
|
+
existingInProject: async () => {
|
|
215
|
+
if (m.existing instanceof Error) throw m.existing;
|
|
216
|
+
return m.existing;
|
|
217
|
+
},
|
|
212
218
|
};
|
|
213
219
|
p.stacks.set(localUrl, {
|
|
214
220
|
outputs: {
|
|
@@ -380,6 +386,38 @@ describe("migrateState — failures leave the source authoritative", () => {
|
|
|
380
386
|
expect(t.m.backups).toBe(0);
|
|
381
387
|
});
|
|
382
388
|
|
|
389
|
+
it("the refusal names the planned creates the installer adopts (#1079)", async () => {
|
|
390
|
+
const t = machine();
|
|
391
|
+
const local = t.p.stacks.get(t.localUrl);
|
|
392
|
+
if (local) local.drift = 2;
|
|
393
|
+
t.m.existing = ["service account caelo-production-gateway-sa@acme.iam.gserviceaccount.com"];
|
|
394
|
+
const r = await migrateState(t.meta(), { to: "remote", dryRun: false }, t.deps);
|
|
395
|
+
expect(r.ok).toBe(false);
|
|
396
|
+
if (r.ok) return;
|
|
397
|
+
expect(r.error).toContain(
|
|
398
|
+
"1 of the planned creates exist in the project already (service account caelo-production-gateway-sa@acme.iam.gserviceaccount.com): upgrade made them without Pulumi. The installer adopts them into the stack first",
|
|
399
|
+
);
|
|
400
|
+
expect(r.error.indexOf("adopts them")).toBeLessThan(r.error.indexOf("Run upgrade first"));
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("the refusal says nothing about adopting when nothing exists, and says so when it can't check", async () => {
|
|
404
|
+
const t = machine();
|
|
405
|
+
const local = t.p.stacks.get(t.localUrl);
|
|
406
|
+
if (local) local.drift = 1;
|
|
407
|
+
const none = await migrateState(t.meta(), { to: "remote", dryRun: false }, t.deps);
|
|
408
|
+
expect(none.ok).toBe(false);
|
|
409
|
+
if (none.ok) return;
|
|
410
|
+
expect(none.error).not.toContain("adopt");
|
|
411
|
+
t.m.existing = new Error("gcloud: PERMISSION_DENIED");
|
|
412
|
+
const failed = await migrateState(t.meta(), { to: "remote", dryRun: false }, t.deps);
|
|
413
|
+
expect(failed.ok).toBe(false);
|
|
414
|
+
if (failed.ok) return;
|
|
415
|
+
expect(failed.error).toContain(
|
|
416
|
+
"(Could not check which planned creates exist in the project already: gcloud: PERMISSION_DENIED. The installer checks again.)",
|
|
417
|
+
);
|
|
418
|
+
expect(failed.error).toContain("Run upgrade first");
|
|
419
|
+
});
|
|
420
|
+
|
|
383
421
|
it("refuses a stack with pending operations", async () => {
|
|
384
422
|
const t = machine();
|
|
385
423
|
const local = t.p.stacks.get(t.localUrl);
|
package/src/state-migrate.ts
CHANGED
|
@@ -117,6 +117,13 @@ export interface MigrateDeps {
|
|
|
117
117
|
* writePlanFile); returns its path.
|
|
118
118
|
*/
|
|
119
119
|
writePlan(label: string, steps: readonly PlannedStep[]): string;
|
|
120
|
+
/**
|
|
121
|
+
* #1079 — the planned creates whose resource exists in the project already
|
|
122
|
+
* (adopt-existing.ts: made by `upgrade` through gcloud), one
|
|
123
|
+
* `<kind> <name>` each. The installer adopts these into the stack before it
|
|
124
|
+
* applies anything. Throws when it can't tell.
|
|
125
|
+
*/
|
|
126
|
+
existingInProject(steps: readonly PlannedStep[]): Promise<readonly string[]>;
|
|
120
127
|
}
|
|
121
128
|
|
|
122
129
|
export interface MigrateOptions {
|
|
@@ -167,6 +174,22 @@ function planReport(deps: MigrateDeps, label: string, steps: readonly PlannedSte
|
|
|
167
174
|
return [...formatPlan(steps).map((l) => ` ${l}`), where].join("\n");
|
|
168
175
|
}
|
|
169
176
|
|
|
177
|
+
/**
|
|
178
|
+
* The refusal's line about planned creates the installer adopts instead
|
|
179
|
+
* (#1079), or "" when there are none. A failed check is said, not fatal: the
|
|
180
|
+
* installer checks again before it applies anything.
|
|
181
|
+
*/
|
|
182
|
+
async function adoptionNote(deps: MigrateDeps, steps: readonly PlannedStep[]): Promise<string> {
|
|
183
|
+
let existing: readonly string[];
|
|
184
|
+
try {
|
|
185
|
+
existing = await deps.existingInProject(steps);
|
|
186
|
+
} catch (e) {
|
|
187
|
+
return `(Could not check which planned creates exist in the project already: ${errorText(e)}. The installer checks again.)\n`;
|
|
188
|
+
}
|
|
189
|
+
if (existing.length === 0) return "";
|
|
190
|
+
return `${existing.length} of the planned creates exist in the project already (${existing.join(", ")}): upgrade made them without Pulumi. The installer adopts them into the stack first instead of creating them again, then shows what is left.\n`;
|
|
191
|
+
}
|
|
192
|
+
|
|
170
193
|
/**
|
|
171
194
|
* Run the migration. Never throws for an expected failure: the result says
|
|
172
195
|
* what failed, and at every failure point the source backend is still the
|
|
@@ -264,7 +287,7 @@ export async function migrateState(
|
|
|
264
287
|
pre.failed(`A preview shows ${sourcePlan.length} change(s)`);
|
|
265
288
|
return {
|
|
266
289
|
ok: false,
|
|
267
|
-
error: `The stack is not converged (a preview shows ${sourcePlan.length} change(s)), so a copy can't be proven identical. Nothing was changed. The planned changes:\n${planReport(deps, "state-migrate", sourcePlan)}\
|
|
290
|
+
error: `The stack is not converged (a preview shows ${sourcePlan.length} change(s)), so a copy can't be proven identical. Nothing was changed. The planned changes:\n${planReport(deps, "state-migrate", sourcePlan)}\n${await adoptionNote(deps, sourcePlan)}Run upgrade first, then re-run the installer (${selfCommand()}, pick this install): it shows this list again and asks before it replaces or deletes anything. Migrate once it reports no changes.`,
|
|
268
291
|
};
|
|
269
292
|
}
|
|
270
293
|
sourceDigest = await pulumi.outputsDigest(from);
|
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}.`);
|
|
@@ -14,8 +14,14 @@
|
|
|
14
14
|
import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { join, relative, resolve as resolvePath } from "node:path";
|
|
16
16
|
import * as pulumi from "@pulumi/pulumi/automation";
|
|
17
|
+
import {
|
|
18
|
+
type AdoptionCandidate,
|
|
19
|
+
AdoptionError,
|
|
20
|
+
adoptExistingResources,
|
|
21
|
+
} from "../adopt-existing.js";
|
|
17
22
|
import { AUDIT_PARALLELISM_DEFAULT } from "../audit-job-setup.js";
|
|
18
23
|
import { checkpointableOutputs } from "../checkpoint-secrets.js";
|
|
24
|
+
import { gcloud } from "../gcloud.js";
|
|
19
25
|
import { GCP_STACK_ENV } from "../gcp-names.js";
|
|
20
26
|
import { type ImageDigests, stackWorkDir } from "../install-state.js";
|
|
21
27
|
import { collectPreviewSteps, type PlannedStep } from "../preview-plan.js";
|
|
@@ -395,6 +401,7 @@ export function resolveSecretReplication(
|
|
|
395
401
|
|
|
396
402
|
/** A resource as `pulumi stack export` lists it. */
|
|
397
403
|
export interface StateResource {
|
|
404
|
+
urn?: string;
|
|
398
405
|
type?: string;
|
|
399
406
|
inputs?: Record<string, unknown>;
|
|
400
407
|
outputs?: Record<string, unknown>;
|
|
@@ -534,6 +541,54 @@ export function pulumiGcpEnv(
|
|
|
534
541
|
};
|
|
535
542
|
}
|
|
536
543
|
|
|
544
|
+
/**
|
|
545
|
+
* The URN of the stack's explicit GCP provider (`new gcp.Provider` in both
|
|
546
|
+
* stacks), which every stack resource uses. An adopted resource is imported
|
|
547
|
+
* under it (#1079): under Pulumi's default provider instead, the next `up`
|
|
548
|
+
* would see a provider change on it.
|
|
549
|
+
*
|
|
550
|
+
* @throws AdoptionError unless the state holds exactly one
|
|
551
|
+
*/
|
|
552
|
+
export function stackGcpProviderUrn(resources: ReadonlyArray<StateResource>): string {
|
|
553
|
+
const urns = resources
|
|
554
|
+
.filter((r) => r.type === "pulumi:providers:gcp" && typeof r.urn === "string")
|
|
555
|
+
.map((r) => r.urn as string);
|
|
556
|
+
if (urns.length !== 1) {
|
|
557
|
+
throw new AdoptionError(
|
|
558
|
+
`the stack's state holds ${urns.length} GCP providers, so it is unclear which one resources that already exist would be adopted under. Nothing was imported or applied. Report this; the stack needs a look before the installer can run.`,
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
return urns[0] as string;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** The name {@link importIntoStack} gives the stack's provider in the import's name table. */
|
|
565
|
+
const IMPORT_PROVIDER_NAME = "gcpProvider";
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* `pulumi import` the candidates into the stack under its explicit provider.
|
|
569
|
+
* State only: nothing in the project changes. Not protected (the program
|
|
570
|
+
* does not protect them either) and no code generated.
|
|
571
|
+
*/
|
|
572
|
+
async function importIntoStack(
|
|
573
|
+
stack: pulumi.Stack,
|
|
574
|
+
providerUrn: string,
|
|
575
|
+
candidates: readonly AdoptionCandidate[],
|
|
576
|
+
onOutput: (msg: string) => void,
|
|
577
|
+
): Promise<void> {
|
|
578
|
+
await stack.import({
|
|
579
|
+
resources: candidates.map((c) => ({
|
|
580
|
+
type: c.step.type,
|
|
581
|
+
name: c.step.name,
|
|
582
|
+
id: c.importId,
|
|
583
|
+
provider: IMPORT_PROVIDER_NAME,
|
|
584
|
+
})),
|
|
585
|
+
nameTable: { [IMPORT_PROVIDER_NAME]: providerUrn },
|
|
586
|
+
protect: false,
|
|
587
|
+
generateCode: false,
|
|
588
|
+
onOutput,
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
537
592
|
/** Thrown when the review (preview-plan.ts planGate) stops `pulumi up` after the preview. */
|
|
538
593
|
export class PulumiUpDeclinedError extends Error {
|
|
539
594
|
override readonly name = "PulumiUpDeclinedError";
|
|
@@ -544,16 +599,23 @@ export class PulumiUpDeclinedError extends Error {
|
|
|
544
599
|
* Streams resource-create events to the supplied onEvent callback so
|
|
545
600
|
* the wizard can render a live progress bar.
|
|
546
601
|
*
|
|
547
|
-
* On a stack that already holds resources, a preview runs first (#737)
|
|
548
|
-
*
|
|
549
|
-
*
|
|
602
|
+
* On a stack that already holds resources, a preview runs first (#737).
|
|
603
|
+
* Planned creates of resources that exist in the project already (made by
|
|
604
|
+
* `upgrade` through gcloud) are adopted into the stack and the stack is
|
|
605
|
+
* previewed again (adopt-existing.ts, #1079). `review` sees that plan and
|
|
606
|
+
* what was adopted: it lists them and decides (planGate) whether `up` may
|
|
607
|
+
* apply the plan. Nothing is applied when it says no.
|
|
550
608
|
*
|
|
551
609
|
* @throws PulumiUpDeclinedError when `review` declines the plan
|
|
610
|
+
* @throws AdoptionError when adopting fails; nothing was applied
|
|
552
611
|
*/
|
|
553
612
|
export async function pulumiUpGcp(
|
|
554
613
|
inputs: PulumiUpInputs,
|
|
555
614
|
onEvent: (kind: "resource" | "error" | "log", message: string) => void,
|
|
556
|
-
review: (
|
|
615
|
+
review: (
|
|
616
|
+
steps: readonly PlannedStep[],
|
|
617
|
+
adopted: readonly AdoptionCandidate[],
|
|
618
|
+
) => Promise<boolean>,
|
|
557
619
|
): Promise<PulumiUpResult> {
|
|
558
620
|
const env = pulumiGcpEnv(process.env, inputs);
|
|
559
621
|
|
|
@@ -608,11 +670,25 @@ export async function pulumiUpGcp(
|
|
|
608
670
|
|
|
609
671
|
// #737 — never `up` a deployed stack blind: list what it would change
|
|
610
672
|
// (replaces and deletes first) and let the review stop it. A first run
|
|
611
|
-
// on an empty stack only creates, so it skips the extra preview
|
|
673
|
+
// on an empty stack only creates, so it skips the extra preview (and has
|
|
674
|
+
// no provider in its state to adopt anything under).
|
|
612
675
|
if (stateResources.length > 0) {
|
|
613
|
-
const
|
|
614
|
-
|
|
615
|
-
|
|
676
|
+
const preview = async () => {
|
|
677
|
+
const collector = collectPreviewSteps();
|
|
678
|
+
await stack.preview({ onEvent: collector.onEvent });
|
|
679
|
+
return collector.steps();
|
|
680
|
+
};
|
|
681
|
+
// #1079 — what `upgrade` created through gcloud joins the state first.
|
|
682
|
+
const { steps, adopted } = await adoptExistingResources(await preview(), {
|
|
683
|
+
projectId: inputs.projectId,
|
|
684
|
+
gcloud,
|
|
685
|
+
preview,
|
|
686
|
+
importResources: (candidates) =>
|
|
687
|
+
importIntoStack(stack, stackGcpProviderUrn(stateResources), candidates, (msg) =>
|
|
688
|
+
onEvent("log", msg),
|
|
689
|
+
),
|
|
690
|
+
});
|
|
691
|
+
if (!(await review(steps, adopted))) {
|
|
616
692
|
throw new PulumiUpDeclinedError("pulumi up was not run: the planned changes were declined.");
|
|
617
693
|
}
|
|
618
694
|
}
|
package/src/wizards/gcp.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { join } from "node:path";
|
|
|
33
33
|
import { cancel, confirm, isCancel, log, note, select, spinner } from "@clack/prompts";
|
|
34
34
|
import { bold, cyan, dim, green, red, yellow } from "kleur/colors";
|
|
35
35
|
import { checkAdcImpersonation } from "../adc-impersonation.js";
|
|
36
|
+
import { type AdoptionCandidate, AdoptionError, formatAdopted } from "../adopt-existing.js";
|
|
36
37
|
import { ensureAuditJob, resolveAdminImage } from "../audit-job-setup.js";
|
|
37
38
|
import { CERT_WAIT_TIMEOUT_MS, lbCertProbe, waitForCert } from "../cert-wait.js";
|
|
38
39
|
import { redactCredentials } from "../checkpoint-secrets.js";
|
|
@@ -1486,16 +1487,21 @@ interface PulumiUpOpts {
|
|
|
1486
1487
|
* The installer's review of a deployed stack's plan before `pulumi up`
|
|
1487
1488
|
* (#737): the grouped list, the full diff in the install dir, and a y/N
|
|
1488
1489
|
* for any replace or delete. A data-bearing one is asked about by name and
|
|
1489
|
-
* defaults to No; without a terminal it is refused (planGate).
|
|
1490
|
+
* defaults to No; without a terminal it is refused (planGate). Resources
|
|
1491
|
+
* adopted into the stack before this preview (adopt-existing.ts, #1079) are
|
|
1492
|
+
* listed first; they are no longer in the plan.
|
|
1490
1493
|
*
|
|
1491
1494
|
* @returns whether `pulumi up` may apply the plan
|
|
1492
1495
|
*/
|
|
1493
1496
|
async function reviewPulumiPlan(
|
|
1494
1497
|
installId: string,
|
|
1495
1498
|
steps: readonly PlannedStep[],
|
|
1499
|
+
adopted: readonly AdoptionCandidate[],
|
|
1496
1500
|
nonInteractive: boolean,
|
|
1497
1501
|
): Promise<boolean> {
|
|
1498
1502
|
const gate = planGate(steps, { interactive: !nonInteractive && process.stdin.isTTY === true });
|
|
1503
|
+
const adoptedLines = formatAdopted(adopted);
|
|
1504
|
+
if (adoptedLines.length > 0) log.info(adoptedLines.join("\n"));
|
|
1499
1505
|
if (steps.length === 0) {
|
|
1500
1506
|
log.info(dim("Preview: no changes planned."));
|
|
1501
1507
|
return true;
|
|
@@ -1601,11 +1607,12 @@ async function stepPulumiUp(installId: string, opts: PulumiUpOpts): Promise<void
|
|
|
1601
1607
|
// logs: silent — too noisy for a live progress UI; pulumi
|
|
1602
1608
|
// diagnostics will print on failure via the SDK's onOutput.
|
|
1603
1609
|
},
|
|
1604
|
-
(steps) => reviewPulumiPlan(installId, steps, opts.nonInteractive),
|
|
1610
|
+
(steps, adopted) => reviewPulumiPlan(installId, steps, adopted, opts.nonInteractive),
|
|
1605
1611
|
);
|
|
1606
1612
|
} catch (e) {
|
|
1607
|
-
if (
|
|
1608
|
-
declined = e.message;
|
|
1613
|
+
if (e instanceof AdoptionError) declined = `pulumi up was not run: ${e.message}`;
|
|
1614
|
+
else if (e instanceof PulumiUpDeclinedError) declined = e.message;
|
|
1615
|
+
else throw e;
|
|
1609
1616
|
} finally {
|
|
1610
1617
|
releaseLock();
|
|
1611
1618
|
}
|