@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.
- 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/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/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/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/adopt-existing.test.ts +397 -0
- package/src/adopt-existing.ts +228 -0
- package/src/preview-plan.ts +8 -0
- package/src/state-command.ts +6 -1
- package/src/state-migrate.test.ts +38 -0
- package/src/state-migrate.ts +24 -1
- package/src/wizards/gcp-pulumi.ts +84 -8
- package/src/wizards/gcp.ts +11 -4
|
@@ -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
|
+
}
|
package/src/preview-plan.ts
CHANGED
|
@@ -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()],
|
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);
|
|
@@ -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
|
}
|