@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.
Files changed (82) hide show
  1. package/dist/admin-domain.d.ts +28 -0
  2. package/dist/admin-domain.d.ts.map +1 -1
  3. package/dist/admin-domain.js +65 -0
  4. package/dist/admin-domain.js.map +1 -1
  5. package/dist/adopt-existing.d.ts +119 -0
  6. package/dist/adopt-existing.d.ts.map +1 -0
  7. package/dist/adopt-existing.js +118 -0
  8. package/dist/adopt-existing.js.map +1 -0
  9. package/dist/cli.js +18 -0
  10. package/dist/cli.js.map +1 -1
  11. package/dist/default-firewall.d.ts +1 -1
  12. package/dist/default-firewall.d.ts.map +1 -1
  13. package/dist/default-firewall.js +2 -1
  14. package/dist/default-firewall.js.map +1 -1
  15. package/dist/gcloud-retry.d.ts +21 -0
  16. package/dist/gcloud-retry.d.ts.map +1 -1
  17. package/dist/gcloud-retry.js +34 -0
  18. package/dist/gcloud-retry.js.map +1 -1
  19. package/dist/ops-backend.d.ts.map +1 -1
  20. package/dist/ops-backend.js +13 -3
  21. package/dist/ops-backend.js.map +1 -1
  22. package/dist/preview-plan.d.ts +7 -0
  23. package/dist/preview-plan.d.ts.map +1 -1
  24. package/dist/preview-plan.js +1 -0
  25. package/dist/preview-plan.js.map +1 -1
  26. package/dist/stack-contract.d.ts +9 -2
  27. package/dist/stack-contract.d.ts.map +1 -1
  28. package/dist/stack-contract.js +4 -2
  29. package/dist/stack-contract.js.map +1 -1
  30. package/dist/stack-converge.d.ts +14 -0
  31. package/dist/stack-converge.d.ts.map +1 -1
  32. package/dist/stack-converge.js +83 -8
  33. package/dist/stack-converge.js.map +1 -1
  34. package/dist/state-command.d.ts.map +1 -1
  35. package/dist/state-command.js +3 -1
  36. package/dist/state-command.js.map +1 -1
  37. package/dist/state-migrate.d.ts +7 -0
  38. package/dist/state-migrate.d.ts.map +1 -1
  39. package/dist/state-migrate.js +18 -1
  40. package/dist/state-migrate.js.map +1 -1
  41. package/dist/upgrade/app-roll.d.ts +9 -1
  42. package/dist/upgrade/app-roll.d.ts.map +1 -1
  43. package/dist/upgrade/app-roll.js +14 -2
  44. package/dist/upgrade/app-roll.js.map +1 -1
  45. package/dist/upgrade/upgrade.d.ts.map +1 -1
  46. package/dist/upgrade/upgrade.js +1 -1
  47. package/dist/upgrade/upgrade.js.map +1 -1
  48. package/dist/upgrader-job.d.ts.map +1 -1
  49. package/dist/upgrader-job.js +4 -1
  50. package/dist/upgrader-job.js.map +1 -1
  51. package/dist/wizards/gcp-pulumi.d.ts +19 -4
  52. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  53. package/dist/wizards/gcp-pulumi.js +62 -7
  54. package/dist/wizards/gcp-pulumi.js.map +1 -1
  55. package/dist/wizards/gcp.d.ts.map +1 -1
  56. package/dist/wizards/gcp.js +14 -5
  57. package/dist/wizards/gcp.js.map +1 -1
  58. package/package.json +2 -2
  59. package/src/__tests__/fixtures/viu-one-plan-state-migrate.txt +98 -0
  60. package/src/admin-domain.test.ts +87 -0
  61. package/src/admin-domain.ts +71 -0
  62. package/src/adopt-existing.test.ts +397 -0
  63. package/src/adopt-existing.ts +228 -0
  64. package/src/cli.ts +22 -0
  65. package/src/default-firewall.ts +5 -3
  66. package/src/gcloud-retry.ts +37 -0
  67. package/src/ops-backend.test.ts +56 -0
  68. package/src/ops-backend.ts +21 -3
  69. package/src/preview-plan.ts +8 -0
  70. package/src/remote-mcp.test.ts +2 -0
  71. package/src/stack-contract.ts +10 -2
  72. package/src/stack-converge-secrets.test.ts +137 -1
  73. package/src/stack-converge.ts +88 -7
  74. package/src/state-command.ts +6 -1
  75. package/src/state-migrate.test.ts +38 -0
  76. package/src/state-migrate.ts +24 -1
  77. package/src/upgrade/app-roll.ts +29 -0
  78. package/src/upgrade/upgrade.test.ts +1 -1
  79. package/src/upgrade/upgrade.ts +1 -0
  80. package/src/upgrader-job.ts +3 -0
  81. package/src/wizards/gcp-pulumi.ts +84 -8
  82. package/src/wizards/gcp.ts +11 -4
@@ -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, and the
1027
- // mount with it when it was the volume's only file (gcloud
1028
- // `_PruneManagedVolumeMapping`). Repeated `--remove-secrets` /
1029
- // `--update-secrets` flags merge (gcloud map flags use UpdateAction).
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
- if (!live.mounts.some((m) => m.mountPath === old.mountDir)) continue;
1032
- flags.push(`--remove-secrets=${old.mountDir}/${old.fileName}`);
1033
- changes.push(`secret file ${old.mountDir}/${old.fileName}: removed (${old.why})`);
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 {
@@ -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 { checkGcloudAuth } from "./gcloud.js";
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);
@@ -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)}\nRun 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.`,
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);
@@ -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
  }
@@ -177,6 +177,7 @@ export async function upgradeGcpInstallOutcome(
177
177
  },
178
178
  images.images,
179
179
  run,
180
+ { secretVolumes: true },
180
181
  );
181
182
  if (!planned.ok) {
182
183
  sPre.failed("Pre-flight failed");
@@ -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) and
548
- * `review` sees its planned steps: it lists them and decides (planGate)
549
- * whether `up` may apply them. Nothing is applied when it says no.
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: (steps: readonly PlannedStep[]) => Promise<boolean>,
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 collector = collectPreviewSteps();
614
- await stack.preview({ onEvent: collector.onEvent });
615
- if (!(await review(collector.steps()))) {
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
  }
@@ -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 (!(e instanceof PulumiUpDeclinedError)) throw e;
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
  }