@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/admin-domain.ts
CHANGED
|
@@ -31,6 +31,8 @@
|
|
|
31
31
|
|
|
32
32
|
import { gcloud as defaultGcloud, type GcloudResult } from "./gcloud.js";
|
|
33
33
|
import { ensureGcloudComponent } from "./gcloud-components.js";
|
|
34
|
+
import { STAGING_HOST_ENV } from "./stack-contract.js";
|
|
35
|
+
import { liveContainerEnv } from "./stack-converge.js";
|
|
34
36
|
|
|
35
37
|
type Run = (args: string[]) => Promise<GcloudResult>;
|
|
36
38
|
|
|
@@ -171,6 +173,75 @@ export async function enableStagingDomain(
|
|
|
171
173
|
return mapDomain(run, logOf(opts), scope, verifyDomain, opts.stagingHost, found.service);
|
|
172
174
|
}
|
|
173
175
|
|
|
176
|
+
/** What {@link setAdminStagingHostEnv} did. */
|
|
177
|
+
export type AdminStagingEnvResult =
|
|
178
|
+
| { readonly status: "set"; readonly service: string; readonly previous: string | null }
|
|
179
|
+
| { readonly status: "unchanged"; readonly service: string }
|
|
180
|
+
| { readonly status: "failed"; readonly error: string };
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* #1058 — point the admin's `CAELO_STAGING_HOST` at the host
|
|
184
|
+
* `staging-domain enable` just mapped. The database row
|
|
185
|
+
* (`staging_access.staging_host`) is the admin's truth and is set through
|
|
186
|
+
* `staging_access.provision` (staging-admin.ts), but the env var is what
|
|
187
|
+
* provisioning recorded: left on the old host, the seed would restore the
|
|
188
|
+
* old host on a fresh database, and the converge job refuses every
|
|
189
|
+
* cloud-setup update because install.json and the deployed admin disagree
|
|
190
|
+
* (converge-job-gcp.ts `deployedSiteMismatch`). Same variable the upgrade's
|
|
191
|
+
* env contract sets (stack-contract.ts STAGING_HOST_ENV); updating only it
|
|
192
|
+
* keeps every other var exactly as deployed. A new revision only when the
|
|
193
|
+
* value differs, so a re-run is a no-op.
|
|
194
|
+
*/
|
|
195
|
+
export async function setAdminStagingHostEnv(
|
|
196
|
+
opts: Pick<DomainOpts, "projectId" | "region" | "run"> & { stagingHost: string },
|
|
197
|
+
): Promise<AdminStagingEnvResult> {
|
|
198
|
+
const run = opts.run ?? defaultGcloud;
|
|
199
|
+
const scope = [`--region=${opts.region}`, `--project=${opts.projectId}`];
|
|
200
|
+
const found = await findService(run, scope, "admin");
|
|
201
|
+
if (!found.ok) return { status: "failed", error: found.error };
|
|
202
|
+
const described = await run([
|
|
203
|
+
"run",
|
|
204
|
+
"services",
|
|
205
|
+
"describe",
|
|
206
|
+
found.service,
|
|
207
|
+
...scope,
|
|
208
|
+
"--format=json",
|
|
209
|
+
]);
|
|
210
|
+
if (!described.ok) {
|
|
211
|
+
return { status: "failed", error: `read the admin's env: ${described.stderr.trim()}` };
|
|
212
|
+
}
|
|
213
|
+
let live: ReturnType<typeof liveContainerEnv>;
|
|
214
|
+
try {
|
|
215
|
+
live = liveContainerEnv(described.stdout);
|
|
216
|
+
} catch {
|
|
217
|
+
return { status: "failed", error: "read the admin's env: gcloud's answer is not JSON" };
|
|
218
|
+
}
|
|
219
|
+
const current = live.get(STAGING_HOST_ENV);
|
|
220
|
+
if (current?.kind === "secret") {
|
|
221
|
+
return {
|
|
222
|
+
status: "failed",
|
|
223
|
+
error: `the admin reads ${STAGING_HOST_ENV} from secret ${current.secret}, not a plain value; run \`cms-provision upgrade\` to bring its env back to the contract, then re-run`,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
if (current?.value === opts.stagingHost) return { status: "unchanged", service: found.service };
|
|
227
|
+
const update = await run([
|
|
228
|
+
"run",
|
|
229
|
+
"services",
|
|
230
|
+
"update",
|
|
231
|
+
found.service,
|
|
232
|
+
...scope,
|
|
233
|
+
`--update-env-vars=${STAGING_HOST_ENV}=${opts.stagingHost}`,
|
|
234
|
+
"--quiet",
|
|
235
|
+
]);
|
|
236
|
+
if (!update.ok) {
|
|
237
|
+
return {
|
|
238
|
+
status: "failed",
|
|
239
|
+
error: `set ${STAGING_HOST_ENV} on the admin: ${update.stderr.trim()}`,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
return { status: "set", service: found.service, previous: current?.value ?? null };
|
|
243
|
+
}
|
|
244
|
+
|
|
174
245
|
/** Steps 2–4 of the module doc for `hostname` → `service`. */
|
|
175
246
|
async function mapDomain(
|
|
176
247
|
run: Run,
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* adopt-existing.ts (#1079) against fake gcloud and Pulumi runners, on the
|
|
5
|
+
* plan viu.one's installer re-run produced: a stack converged by `upgrade`
|
|
6
|
+
* through gcloud plans to create service accounts that exist already. They
|
|
7
|
+
* are imported, the re-plan no longer creates them; anything that doesn't
|
|
8
|
+
* exist, any other kind and any failure is handled as the file comment says.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from "bun:test";
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import type { EngineEvent } from "@pulumi/pulumi/automation";
|
|
15
|
+
import {
|
|
16
|
+
type AdoptionCandidate,
|
|
17
|
+
AdoptionError,
|
|
18
|
+
adoptExistingResources,
|
|
19
|
+
adoptionCandidates,
|
|
20
|
+
formatAdopted,
|
|
21
|
+
} from "./adopt-existing.js";
|
|
22
|
+
import type { GcloudResult } from "./gcloud.js";
|
|
23
|
+
import type { GcloudRunner } from "./gcloud-retry.js";
|
|
24
|
+
import { gatewayServiceAccountId, MCP_IAP_SERVICE_ACCOUNT_ID } from "./gcp-names.js";
|
|
25
|
+
import { collectPreviewSteps, type PlannedOp, type PlannedStep } from "./preview-plan.js";
|
|
26
|
+
import { stackGcpProviderUrn } from "./wizards/gcp-pulumi.js";
|
|
27
|
+
|
|
28
|
+
const PROJECT = "viu-one-web";
|
|
29
|
+
const SA = "gcp:serviceaccount/account:Account";
|
|
30
|
+
const PROVIDER_URN =
|
|
31
|
+
"urn:pulumi:production::caelo-gcp-firebase::pulumi:providers:gcp::caelo-production-gcp";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The inputs the gcp-firebase stack declares for the creates in the fixture
|
|
35
|
+
* (the plan file lists names only): the two service accounts as
|
|
36
|
+
* stacks/gcp-firebase/index.ts declares them, IAM members with a role.
|
|
37
|
+
*/
|
|
38
|
+
function declaredInputs(type: string, name: string): Record<string, unknown> {
|
|
39
|
+
if (name === "caelo-production-gateway-sa") {
|
|
40
|
+
return {
|
|
41
|
+
accountId: gatewayServiceAccountId("production"),
|
|
42
|
+
displayName: "Caelo production gateway",
|
|
43
|
+
createIgnoreAlreadyExists: true,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
if (name === "caelo-production-mcp-sa") {
|
|
47
|
+
return {
|
|
48
|
+
project: PROJECT,
|
|
49
|
+
accountId: MCP_IAP_SERVICE_ACCOUNT_ID,
|
|
50
|
+
displayName: "Caelo MCP (IAP ingress)",
|
|
51
|
+
createIgnoreAlreadyExists: true,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return { role: "roles/secretmanager.secretAccessor", type };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The "Every step:" section of the real viu.one plan (preview-plan.ts
|
|
59
|
+
* writePlanFile, 2026-10-24; the Owner's email in one resource name replaced)
|
|
60
|
+
* as the planned steps the preview produced.
|
|
61
|
+
*/
|
|
62
|
+
function viuOnePlan(): PlannedStep[] {
|
|
63
|
+
const text = readFileSync(
|
|
64
|
+
join(import.meta.dir, "__tests__/fixtures/viu-one-plan-state-migrate.txt"),
|
|
65
|
+
"utf8",
|
|
66
|
+
);
|
|
67
|
+
const body = text.slice(text.indexOf("Every step:"));
|
|
68
|
+
const steps: PlannedStep[] = [];
|
|
69
|
+
for (const line of body.split("\n")) {
|
|
70
|
+
const step = /^- (\w+) (urn:\S+)$/.exec(line);
|
|
71
|
+
if (step) {
|
|
72
|
+
const urn = step[2] as string;
|
|
73
|
+
const [, , type, name] = urn.split("::") as [string, string, string, string];
|
|
74
|
+
const op = step[1] as PlannedOp;
|
|
75
|
+
steps.push({
|
|
76
|
+
op,
|
|
77
|
+
type,
|
|
78
|
+
name,
|
|
79
|
+
urn,
|
|
80
|
+
diffs: [],
|
|
81
|
+
replaceReasons: [],
|
|
82
|
+
...(op === "create" ? { inputs: declaredInputs(type, name) } : {}),
|
|
83
|
+
});
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const changes = /^ {4}changes: (.+)$/.exec(line);
|
|
87
|
+
const last = steps.at(-1);
|
|
88
|
+
if (changes && last) {
|
|
89
|
+
steps[steps.length - 1] = { ...last, diffs: (changes[1] as string).split(", ") };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return steps;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
|
|
96
|
+
const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
|
|
97
|
+
|
|
98
|
+
/** gcloud with these service-account emails in the project; every call recorded. */
|
|
99
|
+
function fakeGcloud(existing: readonly string[], other: Record<string, string> = {}) {
|
|
100
|
+
const calls: string[][] = [];
|
|
101
|
+
const run: GcloudRunner = async (args) => {
|
|
102
|
+
calls.push(args);
|
|
103
|
+
const email = args[3] ?? "";
|
|
104
|
+
if (args.slice(0, 3).join(" ") !== "iam service-accounts describe") {
|
|
105
|
+
return fail(`unexpected gcloud ${args.join(" ")}`);
|
|
106
|
+
}
|
|
107
|
+
if (other[email]) return fail(other[email] as string);
|
|
108
|
+
return existing.includes(email)
|
|
109
|
+
? ok(email)
|
|
110
|
+
: fail("ERROR: (gcloud.iam.service-accounts.describe) NOT_FOUND: Unknown service account.");
|
|
111
|
+
};
|
|
112
|
+
return { run, calls };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A stack whose preview is `plan`, minus the creates `pulumi import` took
|
|
117
|
+
* into its state (an imported account shows as an update of the
|
|
118
|
+
* Terraform-only `createIgnoreAlreadyExists`, which import cannot read).
|
|
119
|
+
*/
|
|
120
|
+
function fakePulumi(plan: readonly PlannedStep[], opts: { importFails?: boolean } = {}) {
|
|
121
|
+
const imported = new Set<string>();
|
|
122
|
+
const imports: { type: string; name: string; id: string }[][] = [];
|
|
123
|
+
let previews = 0;
|
|
124
|
+
let ups = 0;
|
|
125
|
+
return {
|
|
126
|
+
imports,
|
|
127
|
+
get previews() {
|
|
128
|
+
return previews;
|
|
129
|
+
},
|
|
130
|
+
get ups() {
|
|
131
|
+
return ups;
|
|
132
|
+
},
|
|
133
|
+
up: () => {
|
|
134
|
+
ups++;
|
|
135
|
+
},
|
|
136
|
+
preview: async (): Promise<readonly PlannedStep[]> => {
|
|
137
|
+
previews++;
|
|
138
|
+
return plan.map((s) =>
|
|
139
|
+
s.op === "create" && imported.has(s.urn)
|
|
140
|
+
? {
|
|
141
|
+
op: "update" as const,
|
|
142
|
+
type: s.type,
|
|
143
|
+
name: s.name,
|
|
144
|
+
urn: s.urn,
|
|
145
|
+
diffs: ["createIgnoreAlreadyExists"],
|
|
146
|
+
replaceReasons: [],
|
|
147
|
+
}
|
|
148
|
+
: s,
|
|
149
|
+
);
|
|
150
|
+
},
|
|
151
|
+
importResources: async (cs: readonly AdoptionCandidate[]) => {
|
|
152
|
+
if (opts.importFails) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
"code: 255\n stderr: error: Preview failed: resource 'projects/x' does not exist",
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
imports.push(cs.map((c) => ({ type: c.step.type, name: c.step.name, id: c.importId })));
|
|
158
|
+
for (const c of cs) imported.add(c.step.urn);
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const GATEWAY_EMAIL = `caelo-production-gateway-sa@${PROJECT}.iam.gserviceaccount.com`;
|
|
164
|
+
const MCP_EMAIL = `caelo-mcp@${PROJECT}.iam.gserviceaccount.com`;
|
|
165
|
+
|
|
166
|
+
/** The installer's order (wizards/gcp-pulumi.ts pulumiUpGcp): adopt, review, up. */
|
|
167
|
+
async function installerRun(
|
|
168
|
+
plan: readonly PlannedStep[],
|
|
169
|
+
gcloud: GcloudRunner,
|
|
170
|
+
pulumi: ReturnType<typeof fakePulumi>,
|
|
171
|
+
) {
|
|
172
|
+
const result = await adoptExistingResources(plan, {
|
|
173
|
+
projectId: PROJECT,
|
|
174
|
+
gcloud,
|
|
175
|
+
preview: pulumi.preview,
|
|
176
|
+
importResources: pulumi.importResources,
|
|
177
|
+
});
|
|
178
|
+
pulumi.up();
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
describe("adoptExistingResources — viu.one's installer re-run (#1079)", () => {
|
|
183
|
+
it("the fixture is the real plan: 8 deletes, 15 updates, 14 creates", () => {
|
|
184
|
+
const plan = viuOnePlan();
|
|
185
|
+
const count = (op: PlannedOp) => plan.filter((s) => s.op === op).length;
|
|
186
|
+
expect([count("delete"), count("update"), count("create")]).toEqual([8, 15, 14]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("imports the service accounts upgrade made; the re-plan creates neither", async () => {
|
|
190
|
+
const plan = viuOnePlan();
|
|
191
|
+
const gcloud = fakeGcloud([GATEWAY_EMAIL, MCP_EMAIL]);
|
|
192
|
+
const pulumi = fakePulumi(plan);
|
|
193
|
+
const { adopted, steps } = await installerRun(plan, gcloud.run, pulumi);
|
|
194
|
+
|
|
195
|
+
expect(pulumi.imports).toEqual([
|
|
196
|
+
[
|
|
197
|
+
{
|
|
198
|
+
type: SA,
|
|
199
|
+
name: "caelo-production-gateway-sa",
|
|
200
|
+
id: `projects/${PROJECT}/serviceAccounts/${GATEWAY_EMAIL}`,
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
type: SA,
|
|
204
|
+
name: "caelo-production-mcp-sa",
|
|
205
|
+
id: `projects/${PROJECT}/serviceAccounts/${MCP_EMAIL}`,
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
]);
|
|
209
|
+
expect(adopted.map((c) => c.display)).toEqual([GATEWAY_EMAIL, MCP_EMAIL]);
|
|
210
|
+
expect(steps.filter((s) => s.op === "create" && s.type === SA)).toEqual([]);
|
|
211
|
+
// The IAM members stay creates (idempotent), the retired-secret deletes stay.
|
|
212
|
+
expect(steps.filter((s) => s.op === "create")).toHaveLength(12);
|
|
213
|
+
expect(steps.filter((s) => s.op === "delete")).toHaveLength(8);
|
|
214
|
+
expect(pulumi.previews).toBe(1);
|
|
215
|
+
expect(pulumi.ups).toBe(1);
|
|
216
|
+
// Only the two service accounts were probed: no IAM member, no other kind.
|
|
217
|
+
expect(gcloud.calls.map((a) => a[3])).toEqual([GATEWAY_EMAIL, MCP_EMAIL]);
|
|
218
|
+
expect(gcloud.calls[0]).toEqual([
|
|
219
|
+
"iam",
|
|
220
|
+
"service-accounts",
|
|
221
|
+
"describe",
|
|
222
|
+
GATEWAY_EMAIL,
|
|
223
|
+
`--project=${PROJECT}`,
|
|
224
|
+
"--format=value(email)",
|
|
225
|
+
]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("shows what it adopted", async () => {
|
|
229
|
+
const plan = viuOnePlan();
|
|
230
|
+
const { adopted } = await installerRun(plan, fakeGcloud([GATEWAY_EMAIL]).run, fakePulumi(plan));
|
|
231
|
+
expect(formatAdopted(adopted)).toEqual([
|
|
232
|
+
"Adopted (1) — already in the project, now in the stack instead of created again:",
|
|
233
|
+
` service account ${GATEWAY_EMAIL} (caelo-production-gateway-sa)`,
|
|
234
|
+
]);
|
|
235
|
+
expect(formatAdopted([])).toEqual([]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("a service account that does not exist stays a plain create, and nothing is imported", async () => {
|
|
239
|
+
const plan = viuOnePlan();
|
|
240
|
+
const pulumi = fakePulumi(plan);
|
|
241
|
+
const { adopted, steps } = await installerRun(plan, fakeGcloud([]).run, pulumi);
|
|
242
|
+
expect(adopted).toEqual([]);
|
|
243
|
+
expect(steps).toBe(plan);
|
|
244
|
+
expect(pulumi.imports).toEqual([]);
|
|
245
|
+
expect(pulumi.previews).toBe(0);
|
|
246
|
+
expect(steps.filter((s) => s.op === "create" && s.type === SA)).toHaveLength(2);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("adopts only what exists: one of the two", async () => {
|
|
250
|
+
const plan = viuOnePlan();
|
|
251
|
+
const pulumi = fakePulumi(plan);
|
|
252
|
+
const { steps } = await installerRun(plan, fakeGcloud([MCP_EMAIL]).run, pulumi);
|
|
253
|
+
expect(pulumi.imports.flat().map((i) => i.name)).toEqual(["caelo-production-mcp-sa"]);
|
|
254
|
+
expect(steps.filter((s) => s.op === "create" && s.type === SA).map((s) => s.name)).toEqual([
|
|
255
|
+
"caelo-production-gateway-sa",
|
|
256
|
+
]);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("an import failure stops the run loudly with a next step: no re-plan, no up", async () => {
|
|
260
|
+
const plan = viuOnePlan();
|
|
261
|
+
const pulumi = fakePulumi(plan, { importFails: true });
|
|
262
|
+
const run = installerRun(plan, fakeGcloud([GATEWAY_EMAIL]).run, pulumi);
|
|
263
|
+
await expect(run).rejects.toBeInstanceOf(AdoptionError);
|
|
264
|
+
await expect(run).rejects.toThrow(
|
|
265
|
+
`could not adopt service account ${GATEWAY_EMAIL} (already in the project) into the stack: pulumi import says "error: Preview failed: resource 'projects/x' does not exist". Nothing was applied. Re-run the installer`,
|
|
266
|
+
);
|
|
267
|
+
expect(pulumi.previews).toBe(0);
|
|
268
|
+
expect(pulumi.ups).toBe(0);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("a probe that fails for another reason than NOT_FOUND stops the run before any import", async () => {
|
|
272
|
+
const plan = viuOnePlan();
|
|
273
|
+
const pulumi = fakePulumi(plan);
|
|
274
|
+
const gcloud = fakeGcloud([], {
|
|
275
|
+
[GATEWAY_EMAIL]: "ERROR: (gcloud.iam.service-accounts.describe) PERMISSION_DENIED",
|
|
276
|
+
});
|
|
277
|
+
const run = installerRun(plan, gcloud.run, pulumi);
|
|
278
|
+
await expect(run).rejects.toThrow(
|
|
279
|
+
`could not tell whether the service account ${GATEWAY_EMAIL} exists (gcloud: ERROR: (gcloud.iam.service-accounts.describe) PERMISSION_DENIED). Nothing was imported or applied.`,
|
|
280
|
+
);
|
|
281
|
+
expect(pulumi.imports).toEqual([]);
|
|
282
|
+
expect(pulumi.ups).toBe(0);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("a re-plan that still creates an adopted resource stops the run", async () => {
|
|
286
|
+
const plan = viuOnePlan();
|
|
287
|
+
const pulumi = fakePulumi(plan);
|
|
288
|
+
const run = adoptExistingResources(plan, {
|
|
289
|
+
projectId: PROJECT,
|
|
290
|
+
gcloud: fakeGcloud([GATEWAY_EMAIL]).run,
|
|
291
|
+
preview: async () => plan,
|
|
292
|
+
importResources: pulumi.importResources,
|
|
293
|
+
});
|
|
294
|
+
await expect(run).rejects.toThrow(
|
|
295
|
+
"but the next preview still creates caelo-production-gateway-sa. Nothing was applied.",
|
|
296
|
+
);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe("adoptionCandidates — only declared creates of an adoptable kind", () => {
|
|
301
|
+
const step = (over: Partial<PlannedStep> & Pick<PlannedStep, "type" | "name">): PlannedStep => ({
|
|
302
|
+
op: "create",
|
|
303
|
+
urn: `urn:pulumi:production::caelo-gcp::${over.type}::${over.name}`,
|
|
304
|
+
diffs: [],
|
|
305
|
+
replaceReasons: [],
|
|
306
|
+
inputs: {},
|
|
307
|
+
...over,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("leaves unknown and non-adoptable kinds, IAM members, updates and unnamed creates to Pulumi", () => {
|
|
311
|
+
const steps = [
|
|
312
|
+
step({ type: "gcp:storage/bucket:Bucket", name: "media", inputs: { name: "x-media" } }),
|
|
313
|
+
step({ type: "gcp:secretmanager/secret:Secret", name: "kek", inputs: { secretId: "k" } }),
|
|
314
|
+
step({ type: "gcp:cloudrunv2/job:Job", name: "job", inputs: { name: "j" } }),
|
|
315
|
+
step({ type: "gcp:projects/iAMMember:IAMMember", name: "m", inputs: { role: "r" } }),
|
|
316
|
+
step({ type: "acme:widget:Widget", name: "w", inputs: { accountId: "a" } }),
|
|
317
|
+
step({ type: SA, name: "updated", op: "update", inputs: { accountId: "caelo-run" } }),
|
|
318
|
+
step({ type: SA, name: "no-inputs", inputs: undefined }),
|
|
319
|
+
step({ type: SA, name: "computed-id", inputs: { accountId: undefined } }),
|
|
320
|
+
step({
|
|
321
|
+
type: SA,
|
|
322
|
+
name: "unknown-id",
|
|
323
|
+
inputs: { accountId: "04da6b54-80e4-46f7-96ec-b56ff0331ba9" },
|
|
324
|
+
}),
|
|
325
|
+
step({
|
|
326
|
+
type: SA,
|
|
327
|
+
name: "elsewhere",
|
|
328
|
+
inputs: { accountId: "caelo-run", project: "other-project" },
|
|
329
|
+
}),
|
|
330
|
+
];
|
|
331
|
+
expect(adoptionCandidates(steps, PROJECT)).toEqual([]);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("names a service account by its accountId in the install's project", () => {
|
|
335
|
+
const [c] = adoptionCandidates(
|
|
336
|
+
[step({ type: SA, name: "run", inputs: { accountId: "caelo-run" } })],
|
|
337
|
+
PROJECT,
|
|
338
|
+
);
|
|
339
|
+
expect(c?.importId).toBe(
|
|
340
|
+
`projects/${PROJECT}/serviceAccounts/caelo-run@${PROJECT}.iam.gserviceaccount.com`,
|
|
341
|
+
);
|
|
342
|
+
expect(c?.label).toBe("service account");
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
describe("preview steps carry a create's inputs for adoption", () => {
|
|
347
|
+
it("keeps the inputs of creates only", () => {
|
|
348
|
+
const c = collectPreviewSteps();
|
|
349
|
+
const event = (op: "create" | "update", name: string): EngineEvent => ({
|
|
350
|
+
sequence: 1,
|
|
351
|
+
timestamp: 1_760_000_000,
|
|
352
|
+
resourcePreEvent: {
|
|
353
|
+
metadata: {
|
|
354
|
+
op,
|
|
355
|
+
urn: `urn:pulumi:production::caelo-gcp::${SA}::${name}`,
|
|
356
|
+
type: SA,
|
|
357
|
+
provider: PROVIDER_URN,
|
|
358
|
+
new: {
|
|
359
|
+
type: SA,
|
|
360
|
+
urn: `urn:pulumi:production::caelo-gcp::${SA}::${name}`,
|
|
361
|
+
id: "",
|
|
362
|
+
parent: "",
|
|
363
|
+
provider: PROVIDER_URN,
|
|
364
|
+
inputs: { accountId: name },
|
|
365
|
+
outputs: {},
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
c.onEvent(event("create", "a"));
|
|
371
|
+
c.onEvent(event("update", "b"));
|
|
372
|
+
const [created, updated] = c.steps();
|
|
373
|
+
expect(created?.inputs).toEqual({ accountId: "a" });
|
|
374
|
+
expect(updated?.inputs).toBeUndefined();
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
describe("stackGcpProviderUrn — what adopted resources are imported under", () => {
|
|
379
|
+
it("is the state's one GCP provider", () => {
|
|
380
|
+
expect(
|
|
381
|
+
stackGcpProviderUrn([
|
|
382
|
+
{ type: "pulumi:pulumi:Stack", urn: "urn:stack" },
|
|
383
|
+
{ type: "pulumi:providers:gcp", urn: PROVIDER_URN },
|
|
384
|
+
]),
|
|
385
|
+
).toBe(PROVIDER_URN);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("refuses none or several", () => {
|
|
389
|
+
expect(() => stackGcpProviderUrn([])).toThrow(AdoptionError);
|
|
390
|
+
expect(() =>
|
|
391
|
+
stackGcpProviderUrn([
|
|
392
|
+
{ type: "pulumi:providers:gcp", urn: PROVIDER_URN },
|
|
393
|
+
{ type: "pulumi:providers:gcp", urn: `${PROVIDER_URN}-2` },
|
|
394
|
+
]),
|
|
395
|
+
).toThrow("holds 2 GCP providers");
|
|
396
|
+
});
|
|
397
|
+
});
|