@caelo-cms/provisioning 0.10.69 → 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.
@@ -0,0 +1,228 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Adopt cloud resources the stack declares but its Pulumi state lacks
5
+ * (#1079), before the installer's `pulumi up`.
6
+ *
7
+ * `cms-provision upgrade` converges an install through gcloud and never runs
8
+ * Pulumi (stack-converge.ts), so it creates some resources the stack also
9
+ * declares — the gateway's service account (runtime-secrets.ts), the MCP
10
+ * service account (mcp-iap.ts) — without them entering the state. The next
11
+ * installer run then plans to CREATE them, and `state migrate` refuses an
12
+ * install whose preview plans anything. So, after the preview and before
13
+ * the review:
14
+ *
15
+ * 1. {@link adoptionCandidates}: the planned creates of a kind in
16
+ * {@link ADOPTABLE_KINDS} whose inputs name the cloud resource. Only
17
+ * planned steps are looked at, so nothing the stack does not declare is
18
+ * ever adopted; every other kind is left to Pulumi.
19
+ * 2. {@link existingCandidates}: a gcloud describe per candidate. Exists →
20
+ * adopt; NOT_FOUND → a plain create; anything else stops the run (we
21
+ * can't tell, and guessing either way is wrong).
22
+ * 3. `pulumi import` of the existing ones into the stack, under the
23
+ * stack's explicit GCP provider (wizards/gcp-pulumi.ts). State only: the
24
+ * cloud resources are not touched.
25
+ * 4. A second preview: what the review shows and `up` applies. An adopted
26
+ * resource that is still planned as a create stops the run.
27
+ *
28
+ * Why only service accounts ({@link ADOPTABLE_KINDS}). A kind is adopted
29
+ * when the stack declares it AND the CLI also creates it through gcloud, and
30
+ * its name is unique in the project. Checked per kind against both stacks:
31
+ *
32
+ * - Secret Manager secrets: the stack declares only `postgres-password` and
33
+ * `secret-kek`, which the CLI never creates; the secrets the CLI makes
34
+ * (stack-contract.ts CLI_GENERATED_SECRETS) the stack only references by
35
+ * id. Adopting a secret without its value resource would also make `up`
36
+ * add a version with a freshly generated value — a new KEK.
37
+ * - Buckets, Cloud Run jobs, custom roles: the stack's buckets are made by
38
+ * Pulumi alone; the CLI's own (ops, audit results, the upgrader, converge,
39
+ * audit and operator-access jobs, their custom roles) are not declared.
40
+ * - The admin's Cloud Run domain mapping: owned by `admin-domain enable`
41
+ * (admin-domain.ts), the stack's copy stays off; Pulumi, running as the
42
+ * provisioner, could not manage it.
43
+ * - IAM member resources (`*IamMember`, `IAMMember`): non-authoritative.
44
+ * Their create is a read-modify-write that merges the member into the
45
+ * policy and succeeds when it is there already, so a planned create of
46
+ * one is an adoption already.
47
+ * - Cloud SQL users: the CLI's are IAM users named after service accounts,
48
+ * the stack's are the built-in `admin_role` / `public_role`.
49
+ */
50
+
51
+ import type { GcloudRunner } from "./gcloud-retry.js";
52
+ import type { PlannedStep } from "./preview-plan.js";
53
+
54
+ /** The cloud identity of a resource a planned create would make. */
55
+ export interface AdoptionTarget {
56
+ /** How the operator knows it, e.g. the service account's email. */
57
+ readonly display: string;
58
+ /** gcloud args that succeed exactly when it exists. */
59
+ readonly describeArgs: readonly string[];
60
+ /** The id `pulumi import` takes for the kind. */
61
+ readonly importId: string;
62
+ }
63
+
64
+ /** A resource kind the installer adopts when it exists already. */
65
+ export interface AdoptableKind {
66
+ /** What it is, for messages (`service account`). */
67
+ readonly label: string;
68
+ /**
69
+ * The resource a create with these inputs would make, or null when the
70
+ * inputs don't pin it down (then the create is left to Pulumi).
71
+ */
72
+ target(inputs: Readonly<Record<string, unknown>>, projectId: string): AdoptionTarget | null;
73
+ }
74
+
75
+ /** Kinds adopted before `up`, by Pulumi type token. See the file comment for the rest. */
76
+ export const ADOPTABLE_KINDS: Readonly<Record<string, AdoptableKind>> = {
77
+ "gcp:serviceaccount/account:Account": {
78
+ label: "service account",
79
+ target(inputs, projectId) {
80
+ // GCP's account-id format; also keeps out the engine's placeholder
81
+ // for a value only known after apply.
82
+ const accountId = inputs.accountId;
83
+ if (typeof accountId !== "string" || !/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/.test(accountId)) {
84
+ return null;
85
+ }
86
+ // Unset means the provider's project, which is the install's
87
+ // (assertStackProject). Another project is never adopted.
88
+ const project = inputs.project ?? projectId;
89
+ if (project !== projectId) return null;
90
+ const email = `${accountId}@${projectId}.iam.gserviceaccount.com`;
91
+ return {
92
+ display: email,
93
+ describeArgs: [
94
+ "iam",
95
+ "service-accounts",
96
+ "describe",
97
+ email,
98
+ `--project=${projectId}`,
99
+ "--format=value(email)",
100
+ ],
101
+ importId: `projects/${projectId}/serviceAccounts/${email}`,
102
+ };
103
+ },
104
+ },
105
+ };
106
+
107
+ /** A planned create the installer may adopt instead. */
108
+ export interface AdoptionCandidate extends AdoptionTarget {
109
+ readonly step: PlannedStep;
110
+ readonly label: string;
111
+ }
112
+
113
+ /** Thrown when adoption cannot finish; nothing was applied. */
114
+ export class AdoptionError extends Error {
115
+ override readonly name = "AdoptionError";
116
+ }
117
+
118
+ /** The planned creates of an adoptable kind, with what each would create. */
119
+ export function adoptionCandidates(
120
+ steps: readonly PlannedStep[],
121
+ projectId: string,
122
+ ): AdoptionCandidate[] {
123
+ const out: AdoptionCandidate[] = [];
124
+ for (const step of steps) {
125
+ if (step.op !== "create") continue;
126
+ const kind = ADOPTABLE_KINDS[step.type];
127
+ if (!kind || !step.inputs) continue;
128
+ const target = kind.target(step.inputs, projectId);
129
+ if (target) out.push({ ...target, step, label: kind.label });
130
+ }
131
+ return out;
132
+ }
133
+
134
+ const NOT_FOUND = /NOT_FOUND|not found|does not exist/i;
135
+
136
+ /**
137
+ * The candidates that exist in the project.
138
+ *
139
+ * @throws AdoptionError when a describe fails for another reason than NOT_FOUND
140
+ */
141
+ export async function existingCandidates(
142
+ candidates: readonly AdoptionCandidate[],
143
+ run: GcloudRunner,
144
+ ): Promise<AdoptionCandidate[]> {
145
+ const existing: AdoptionCandidate[] = [];
146
+ for (const c of candidates) {
147
+ const r = await run([...c.describeArgs]);
148
+ if (r.ok) existing.push(c);
149
+ else if (!NOT_FOUND.test(r.stderr)) {
150
+ throw new AdoptionError(
151
+ `could not tell whether the ${c.label} ${c.display} exists (gcloud: ${r.stderr.trim()}). Nothing was imported or applied. Check that gcloud is signed in as the Owner (gcloud auth login) and re-run the installer.`,
152
+ );
153
+ }
154
+ }
155
+ return existing;
156
+ }
157
+
158
+ /** What adoption needs from Pulumi and gcloud; fakes in tests. */
159
+ export interface AdoptionDeps {
160
+ readonly projectId: string;
161
+ readonly gcloud: GcloudRunner;
162
+ /** A fresh preview of the stack. */
163
+ preview(): Promise<readonly PlannedStep[]>;
164
+ /** `pulumi import` the candidates into the stack (state only). */
165
+ importResources(candidates: readonly AdoptionCandidate[]): Promise<void>;
166
+ }
167
+
168
+ /** The plan to review after adoption, and what was adopted into it. */
169
+ export interface AdoptionResult {
170
+ readonly adopted: readonly AdoptionCandidate[];
171
+ readonly steps: readonly PlannedStep[];
172
+ }
173
+
174
+ /**
175
+ * The line of a failed Pulumi call that says what went wrong. The
176
+ * Automation API's error starts with `code: 255` and carries the CLI's
177
+ * stderr below; its first `error:` line is the reason.
178
+ */
179
+ export function pulumiErrorReason(e: unknown): string {
180
+ const lines = (e instanceof Error ? e.message : String(e))
181
+ .split("\n")
182
+ .map((l) => l.trim().replace(/^stderr:\s*/, ""))
183
+ .filter((l) => l !== "");
184
+ return lines.find((l) => /^error:/i.test(l)) ?? lines[0] ?? "no output";
185
+ }
186
+
187
+ /**
188
+ * Adopt the planned creates of `steps` that exist in the project already,
189
+ * and return the plan after it. With nothing to adopt, `steps` comes back
190
+ * unchanged and nothing else runs.
191
+ *
192
+ * @throws AdoptionError when a probe or the import fails, or a re-plan still
193
+ * creates an adopted resource; `up` must not run then
194
+ */
195
+ export async function adoptExistingResources(
196
+ steps: readonly PlannedStep[],
197
+ deps: AdoptionDeps,
198
+ ): Promise<AdoptionResult> {
199
+ const existing = await existingCandidates(adoptionCandidates(steps, deps.projectId), deps.gcloud);
200
+ if (existing.length === 0) return { adopted: [], steps };
201
+ const names = existing.map((c) => `${c.label} ${c.display}`).join(", ");
202
+ try {
203
+ await deps.importResources(existing);
204
+ } catch (e) {
205
+ throw new AdoptionError(
206
+ `could not adopt ${names} (already in the project) into the stack: pulumi import says "${pulumiErrorReason(e)}". Nothing was applied. Re-run the installer: what was adopted stays adopted. If it fails the same way, report it with this message.`,
207
+ );
208
+ }
209
+ const after = await deps.preview();
210
+ const stillCreated = after.filter(
211
+ (s) => s.op === "create" && existing.some((c) => c.step.urn === s.urn),
212
+ );
213
+ if (stillCreated.length > 0) {
214
+ throw new AdoptionError(
215
+ `imported ${names}, but the next preview still creates ${stillCreated.map((s) => s.name).join(", ")}. Nothing was applied. Report this with the message; re-running the installer does not fix it.`,
216
+ );
217
+ }
218
+ return { adopted: existing, steps: after };
219
+ }
220
+
221
+ /** The adopted resources as lines for the review, or none. */
222
+ export function formatAdopted(adopted: readonly AdoptionCandidate[]): string[] {
223
+ if (adopted.length === 0) return [];
224
+ return [
225
+ `Adopted (${adopted.length}) — already in the project, now in the stack instead of created again:`,
226
+ ...adopted.map((c) => ` ${c.label} ${c.display} (${c.step.name})`),
227
+ ];
228
+ }
@@ -31,6 +31,13 @@ export interface PlannedStep {
31
31
  readonly diffs: readonly string[];
32
32
  /** Properties that force the replace (replace steps only). */
33
33
  readonly replaceReasons: readonly string[];
34
+ /**
35
+ * The inputs the program declares, for create steps only (#1079: they name
36
+ * the cloud resource a create would make, e.g. a service account's
37
+ * `accountId`, so adopt-existing.ts can tell whether it exists already).
38
+ * Secret values arrive masked by the engine. Never written to a plan file.
39
+ */
40
+ readonly inputs?: Readonly<Record<string, unknown>>;
34
41
  }
35
42
 
36
43
  /**
@@ -116,6 +123,7 @@ export function collectPreviewSteps(): PreviewCollector {
116
123
  urn: m.urn,
117
124
  diffs: [...(m.diffs ?? [])],
118
125
  replaceReasons: op === "replace" ? [...(m.keys ?? [])] : [],
126
+ ...(op === "create" && m.new?.inputs ? { inputs: { ...m.new.inputs } } : {}),
119
127
  });
120
128
  },
121
129
  steps: () => [...byUrn.values()],
@@ -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);
@@ -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
  }