@intentius/chant-lexicon-pulumi 0.124.56 → 0.124.57
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/README.md +1 -1
- package/dist/changed-units.d.ts +5 -0
- package/dist/changed-units.d.ts.map +1 -0
- package/dist/changed-units.js +101 -0
- package/dist/composites/pulumi-adopt-op.d.ts.map +1 -1
- package/dist/composites/pulumi-adopt-op.js +4 -2
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/op/activities/pulumi-adopt.d.ts +5 -0
- package/dist/op/activities/pulumi-adopt.d.ts.map +1 -1
- package/dist/op/activities/pulumi-adopt.js +7 -2
- package/dist/op/activity-contracts.d.ts +2 -0
- package/dist/op/activity-contracts.d.ts.map +1 -1
- package/dist/op/activity-contracts.js +2 -2
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +11 -0
- package/dist/pulumi-up.d.ts +16 -1
- package/dist/pulumi-up.d.ts.map +1 -1
- package/dist/pulumi-up.js +49 -14
- package/dist/release-copy.d.ts +42 -2
- package/dist/release-copy.d.ts.map +1 -1
- package/dist/release-copy.js +96 -22
- package/dist/run.d.ts +4 -4
- package/dist/run.js +4 -4
- package/package.json +2 -2
- package/src/changed-units.test.ts +87 -0
- package/src/changed-units.ts +96 -0
- package/src/composites/pulumi-adopt-op.test.ts +35 -0
- package/src/composites/pulumi-adopt-op.ts +4 -2
- package/src/op/activities/pulumi-adopt.ts +13 -2
- package/src/op/activity-contracts.ts +2 -2
- package/src/plugin.ts +12 -0
- package/src/pulumi-up.test.ts +100 -0
- package/src/pulumi-up.ts +61 -16
- package/src/release-copy.test.ts +90 -2
- package/src/release-copy.ts +103 -22
- package/src/run.ts +4 -4
- package/src/stack-export-snapshot.test.ts +3 -1
package/src/pulumi-up.test.ts
CHANGED
|
@@ -299,6 +299,63 @@ describe("releaseInputs (#4542)", () => {
|
|
|
299
299
|
});
|
|
300
300
|
});
|
|
301
301
|
|
|
302
|
+
describe("the step's backend (plywood#22)", () => {
|
|
303
|
+
const store = "plunge+s3://plywood-tutorial-plunge?region=us-east-1&endpoint=http://floci:4566&s3ForcePathStyle=true";
|
|
304
|
+
|
|
305
|
+
test("plunge's preview, up and output read each get it as PULUMI_BACKEND_URL, over the job's", async () => {
|
|
306
|
+
const plunge = engine();
|
|
307
|
+
const cap = createPulumiUpCapability({ plunge: plunge.run, env: { PULUMI_BACKEND_URL: "file:///tmp/plywood-tutorial-backend" }, tmp: tmp(), snapshots: null });
|
|
308
|
+
const planned = await cap.plan!(ctx, step({ engine: "plunge", backend: store }));
|
|
309
|
+
await cap.run({ ...ctx, plans: { [member]: planned.artifact as PulumiUpArtifact } }, step({ engine: "plunge", backend: store }));
|
|
310
|
+
expect(plunge.calls.map((c) => c.args[0])).toEqual(["preview", "up", "stack"]);
|
|
311
|
+
for (const c of plunge.calls) expect(c.env?.PULUMI_BACKEND_URL).toBe(store);
|
|
312
|
+
// The preview keeps its streaming flag beside the backend.
|
|
313
|
+
expect(plunge.calls[0].env).toEqual({ [PULUMI_STREAMING_PREVIEW_ENV]: "true", PULUMI_BACKEND_URL: store });
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("stock Pulumi's preview, up and output read get it too, and outputs() reads through it", async () => {
|
|
317
|
+
const pulumi = engine();
|
|
318
|
+
const s3 = "s3://plywood-tutorial-state?region=us-east-1&endpoint=http://floci:4566&use_path_style=true";
|
|
319
|
+
const cap = createPulumiUpCapability({ run: pulumi.run, env: {}, tmp: tmp(), snapshots: null });
|
|
320
|
+
const planned = await cap.plan!(ctx, step({ engine: "pulumi", backend: s3 }));
|
|
321
|
+
await cap.run({ ...ctx, plans: { [member]: planned.artifact as PulumiUpArtifact } }, step({ engine: "pulumi", backend: s3 }));
|
|
322
|
+
await cap.outputs!(ctx, step({ engine: "pulumi", backend: s3 }));
|
|
323
|
+
expect(pulumi.calls.length).toBeGreaterThanOrEqual(4);
|
|
324
|
+
for (const c of pulumi.calls) expect(c.env?.PULUMI_BACKEND_URL).toBe(s3);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("a plunge+ backend picks plunge where the job's PULUMI_BACKEND_URL would have picked stock Pulumi", async () => {
|
|
328
|
+
const plunge = engine();
|
|
329
|
+
const pulumi = engine();
|
|
330
|
+
const cap = createPulumiUpCapability({ run: pulumi.run, plunge: plunge.run, env: { PULUMI_BACKEND_URL: "file:///tmp/b" }, tmp: tmp(), snapshots: null });
|
|
331
|
+
const planned = await cap.plan!(ctx, step({ backend: store }));
|
|
332
|
+
expect((planned.artifact as PulumiUpArtifact).engine).toBe("plunge");
|
|
333
|
+
expect(pulumi.calls).toHaveLength(0);
|
|
334
|
+
expect(cap.defaultLockGrain!(step({ backend: store }))).toBe("resource");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("the ledger lock (ruling for ci-products 2026-10-28): a stack's target row is its stack row, from Pulumi.yaml's name", () => {
|
|
338
|
+
const cap = createPulumiUpCapability({ env: {}, tmp: tmp(), snapshots: null });
|
|
339
|
+
expect(cap.targetLockRow!(step())).toEqual({ key: "stack corpus-aws-ts/floci", target: member, address: "corpus-aws-ts/floci" });
|
|
340
|
+
// A plunge stack on the resource grain still leases URN rows; the stack row it may also take meets this one.
|
|
341
|
+
expect(cap.targetLockRow!(step({ stack: "prod-us" }))).toMatchObject({ key: "stack corpus-aws-ts/prod-us" });
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("without it nothing changes: no PULUMI_BACKEND_URL is added", async () => {
|
|
345
|
+
const plunge = engine();
|
|
346
|
+
const cap = createPulumiUpCapability({ plunge: plunge.run, env: {}, tmp: tmp(), snapshots: null });
|
|
347
|
+
await cap.plan!(ctx, step({ engine: "plunge" }));
|
|
348
|
+
expect(plunge.calls[0].env).toEqual({ [PULUMI_STREAMING_PREVIEW_ENV]: "true" });
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("an empty backend is refused before anything runs", async () => {
|
|
352
|
+
const plunge = engine();
|
|
353
|
+
const cap = createPulumiUpCapability({ plunge: plunge.run, env: {}, tmp: tmp(), snapshots: null });
|
|
354
|
+
await expect(cap.plan!(ctx, step({ engine: "plunge", backend: " " }))).rejects.toThrow(/"backend" is the stack's backend URL/);
|
|
355
|
+
expect(plunge.calls).toHaveLength(0);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
302
359
|
describe("a step pinned to its engine release (#4592)", () => {
|
|
303
360
|
test("engineVersion runs the versioned binary, through the same argv checks", async () => {
|
|
304
361
|
const pinned = engine();
|
|
@@ -423,6 +480,8 @@ describe("CrossGuard packs read at base (#4613)", () => {
|
|
|
423
480
|
const stack = join(top, "stack");
|
|
424
481
|
mkdirSync(join(stack, "guard"), { recursive: true });
|
|
425
482
|
for (const f of ["Pulumi.yaml", "stack-export.json"]) copyFileSync(join(dir, f), join(stack, f));
|
|
483
|
+
// An installed checkout, so the step runs no install before the preview (plywood#22).
|
|
484
|
+
mkdirSync(join(stack, "node_modules"));
|
|
426
485
|
writeFileSync(join(stack, "guard", "index.ts"), "// no-public-bucket: mandatory\n");
|
|
427
486
|
git(top, "add", ".");
|
|
428
487
|
git(top, "commit", "-q", "-m", "base");
|
|
@@ -468,3 +527,44 @@ describe("CrossGuard packs read at base (#4613)", () => {
|
|
|
468
527
|
expect(pulumi.calls).toHaveLength(0);
|
|
469
528
|
});
|
|
470
529
|
});
|
|
530
|
+
|
|
531
|
+
describe("an in-place checkout (plywood#22)", () => {
|
|
532
|
+
/** A checkout of the corpus program: Pulumi.yaml, package.json and, unless left out, a lockfile; no node_modules. */
|
|
533
|
+
function checkout(lockfile = true): string {
|
|
534
|
+
const stack = tmp();
|
|
535
|
+
for (const f of ["Pulumi.yaml", "stack-export.json"]) copyFileSync(join(dir, f), join(stack, f));
|
|
536
|
+
writeFileSync(join(stack, "package.json"), "{}\n");
|
|
537
|
+
if (lockfile) writeFileSync(join(stack, "package-lock.json"), "{}\n");
|
|
538
|
+
return stack;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
test("a Node checkout with a lockfile and no node_modules gets one install, with the step's backend, before the first preview", async () => {
|
|
542
|
+
const stack = checkout();
|
|
543
|
+
const pulumi = engine();
|
|
544
|
+
const cap = createPulumiUpCapability({ run: pulumi.run, env: {}, tmp: tmp(), snapshots: null });
|
|
545
|
+
const planned = await cap.plan!(ctx, step({ dir: stack, engine: "pulumi", backend: "s3://shop-state" }));
|
|
546
|
+
expect(pulumi.calls[0]).toMatchObject({ args: ["install"], cwd: stack, env: { PULUMI_BACKEND_URL: "s3://shop-state" } });
|
|
547
|
+
expect(pulumi.calls[1]!.args[0]).toBe("preview");
|
|
548
|
+
|
|
549
|
+
await cap.run({ ...ctx, plans: { [planned.member]: planned.artifact as PulumiUpArtifact } }, step({ dir: stack, engine: "pulumi", backend: "s3://shop-state" }));
|
|
550
|
+
expect(pulumi.calls.filter((c) => c.args[0] === "install")).toHaveLength(1);
|
|
551
|
+
expect(pulumi.calls.some((c) => c.args[0] === "up")).toBe(true);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
test("a Node checkout with no lockfile is refused by name before the engine runs", async () => {
|
|
555
|
+
const stack = checkout(false);
|
|
556
|
+
const pulumi = engine();
|
|
557
|
+
const cap = createPulumiUpCapability({ run: pulumi.run, env: {}, tmp: tmp(), snapshots: null });
|
|
558
|
+
await expect(cap.plan!(ctx, step({ dir: stack, engine: "pulumi" }))).rejects.toThrow(/no lockfile .* beside its package.json/);
|
|
559
|
+
expect(pulumi.calls).toHaveLength(0);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test("an installed checkout runs no install", async () => {
|
|
563
|
+
const stack = checkout();
|
|
564
|
+
mkdirSync(join(stack, "node_modules"));
|
|
565
|
+
const pulumi = engine();
|
|
566
|
+
const cap = createPulumiUpCapability({ run: pulumi.run, env: {}, tmp: tmp(), snapshots: null });
|
|
567
|
+
await cap.plan!(ctx, step({ dir: stack, engine: "pulumi" }));
|
|
568
|
+
expect(pulumi.calls.map((c) => c.args[0])).not.toContain("install");
|
|
569
|
+
});
|
|
570
|
+
});
|
package/src/pulumi-up.ts
CHANGED
|
@@ -107,11 +107,17 @@
|
|
|
107
107
|
*
|
|
108
108
|
* The cut records tracked files, so a Node program's copy has no
|
|
109
109
|
* `node_modules`. Before the copy's first preview, `plan` and `run` call
|
|
110
|
-
* `
|
|
110
|
+
* `prepareProgram` (`./release-copy.ts`), which runs `pulumi install`
|
|
111
111
|
* (`plunge install`) in the copy when its runtime needs one, and refuses a
|
|
112
112
|
* Node copy with no lockfile by name, since an install would take today's
|
|
113
113
|
* versions rather than the release's.
|
|
114
114
|
*
|
|
115
|
+
* A CI job's checkout has the same gap, so the same call prepares the
|
|
116
|
+
* step's own directory by the same rule (plywood#22): a Node program with
|
|
117
|
+
* no `node_modules` in its directory or above it, and a lockfile beside its
|
|
118
|
+
* package.json, gets one `<engine> install` before the job's first engine
|
|
119
|
+
* run; with no lockfile the step refuses by name.
|
|
120
|
+
*
|
|
115
121
|
* ## review stacks (#4609)
|
|
116
122
|
*
|
|
117
123
|
* When `DeployContext.env` is a pull request's environment `pr-<n>` (`chant
|
|
@@ -170,6 +176,7 @@ import { pulumiMemberPlannedOutputs } from "./member-outputs";
|
|
|
170
176
|
import type { PulumiOutputChange } from "./planned-output-changes";
|
|
171
177
|
import { pulumiPlanShape, pulumiShapeDifferences } from "./plan-shape";
|
|
172
178
|
import { pulumiLockRows } from "./lock-rows";
|
|
179
|
+
import { stackRowKey } from "@intentius/chant/lifecycle/lock-rows";
|
|
173
180
|
import { pulumiTraceFacts } from "./trace-facts";
|
|
174
181
|
import {
|
|
175
182
|
defaultPlungeRunner,
|
|
@@ -192,7 +199,7 @@ import type { PulumiStep } from "./change-set";
|
|
|
192
199
|
import { writePulumiExportSnapshot, type PulumiExportWrite } from "./stack-export-snapshot";
|
|
193
200
|
import { pointerSegment, pulumiStackReferences, stackReferenceOwner } from "./stack-references";
|
|
194
201
|
import { isObject } from "./values";
|
|
195
|
-
import {
|
|
202
|
+
import { prepareProgram } from "./release-copy";
|
|
196
203
|
import { initReviewStack, reviewConfigSource, reviewStackFor, reviewStackLocation } from "./review-stack";
|
|
197
204
|
|
|
198
205
|
/** The lock grain values of `ci.lockGrain` (#4552's ruling A). */
|
|
@@ -210,6 +217,15 @@ export interface PulumiUpStepInput {
|
|
|
210
217
|
config?: Record<string, unknown>;
|
|
211
218
|
/** `pulumi` or `plunge`; see `pulumiMemberEngine`. */
|
|
212
219
|
engine?: PulumiMemberEngine;
|
|
220
|
+
/**
|
|
221
|
+
* The stack's backend URL (INTENTIUS/plywood#22), such as
|
|
222
|
+
* `plunge+s3://...` or `s3://...`: every engine command the step runs
|
|
223
|
+
* (preview, up, the stack output and export reads, a review stack's init)
|
|
224
|
+
* gets it as `PULUMI_BACKEND_URL`, over the job's own. It also answers the
|
|
225
|
+
* engine where `pulumiMemberEngine` would read `PULUMI_BACKEND_URL`.
|
|
226
|
+
* Absent: the job's `PULUMI_BACKEND_URL` or `Pulumi.yaml`'s, as before.
|
|
227
|
+
*/
|
|
228
|
+
backend?: string;
|
|
213
229
|
/**
|
|
214
230
|
* The engine release this stack is pinned to (#4592): the step runs
|
|
215
231
|
* `<engineRoot>/<engine>-<engineVersion>/<engine>`, the path the pulumi
|
|
@@ -540,18 +556,34 @@ interface Resolved {
|
|
|
540
556
|
engineSource: PulumiMemberEngineSource;
|
|
541
557
|
engineBecause: string;
|
|
542
558
|
grain: PulumiLockGrain;
|
|
559
|
+
/** The step's `backend`, every engine command's `PULUMI_BACKEND_URL`. */
|
|
560
|
+
backend?: string;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** The environment the engine is picked from: the job's, with the step's backend as `PULUMI_BACKEND_URL`. */
|
|
564
|
+
function engineEnv(input: Pick<PulumiUpStepInput, "backend">, opts: PulumiUpOptions): { env?: Record<string, string | undefined> } {
|
|
565
|
+
if (input.backend === undefined) return opts.env ? { env: opts.env } : {};
|
|
566
|
+
return { env: { ...(opts.env ?? process.env), PULUMI_BACKEND_URL: input.backend } };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** `run` with `PULUMI_BACKEND_URL` set to the step's backend over whatever env the call passes. */
|
|
570
|
+
function withBackend(run: PulumiRunner, backend: string): PulumiRunner {
|
|
571
|
+
return (args, o) => run(args, { ...o, env: { ...(o.env ?? {}), PULUMI_BACKEND_URL: backend } });
|
|
543
572
|
}
|
|
544
573
|
|
|
545
574
|
function resolveStep(input: PulumiUpStepInput, opts: PulumiUpOptions, declared?: PulumiLockGrain): Resolved {
|
|
546
575
|
requireInput(input);
|
|
576
|
+
if (input.backend !== undefined && (typeof input.backend !== "string" || input.backend.trim() === "")) {
|
|
577
|
+
throw new Error('pulumi-up: "backend" is the stack\'s backend URL, such as plunge+s3://<bucket> or s3://<bucket>');
|
|
578
|
+
}
|
|
547
579
|
const dir = stackDir(input);
|
|
548
580
|
const member = pulumiUpMember(input);
|
|
549
|
-
const picked = pulumiMemberEngine(dir, input.engine,
|
|
581
|
+
const picked = pulumiMemberEngine(dir, input.engine, engineEnv(input, opts));
|
|
550
582
|
// #4552: the step's own grain, else ci.lockGrain as the caller read it at base, else the engine's default.
|
|
551
583
|
const grain = input.lockGrain ?? declared ?? pulumiDefaultLockGrain(picked.engine);
|
|
552
584
|
if (grain === "resource" && picked.engine === "pulumi") throw new PulumiResourceGrainRefusedError(member);
|
|
553
585
|
const engineBin = pulumiUpEngineBin(input, picked.engine);
|
|
554
|
-
return { dir, member, engine: picked.engine, ...(engineBin ? { engineBin } : {}), engineSource: picked.source, engineBecause: picked.because, grain };
|
|
586
|
+
return { dir, member, engine: picked.engine, ...(engineBin ? { engineBin } : {}), engineSource: picked.source, engineBecause: picked.because, grain, ...(input.backend ? { backend: input.backend } : {}) };
|
|
555
587
|
}
|
|
556
588
|
|
|
557
589
|
/**
|
|
@@ -574,8 +606,8 @@ export function pulumiUpEngineBin(input: Pick<PulumiUpStepInput, "engineVersion"
|
|
|
574
606
|
}
|
|
575
607
|
|
|
576
608
|
/** The `run` and `plunge` options for the stack output and export reads: the pinned binary's for both when the step names one (#4592), else the caller's. */
|
|
577
|
-
function runnerOptions(r: Pick<Resolved, "engineBin">, opts: PulumiUpOptions): { run?: PulumiRunner; plunge?: PulumiRunner } {
|
|
578
|
-
if (r.engineBin) {
|
|
609
|
+
function runnerOptions(r: Pick<Resolved, "engineBin" | "backend">, opts: PulumiUpOptions): { run?: PulumiRunner; plunge?: PulumiRunner } {
|
|
610
|
+
if (r.engineBin || r.backend) {
|
|
579
611
|
const { pulumi, plunge } = runnersFor(r, opts);
|
|
580
612
|
return { run: pulumi, plunge };
|
|
581
613
|
}
|
|
@@ -583,12 +615,16 @@ function runnerOptions(r: Pick<Resolved, "engineBin">, opts: PulumiUpOptions): {
|
|
|
583
615
|
}
|
|
584
616
|
|
|
585
617
|
/** The runners a resolved step uses: the pinned binary's when it names one, else the options' or the PATH default. */
|
|
586
|
-
function runnersFor(r: Pick<Resolved, "engineBin">, opts: PulumiUpOptions): { pulumi: PulumiRunner; plunge: PulumiRunner } {
|
|
618
|
+
function runnersFor(r: Pick<Resolved, "engineBin" | "backend">, opts: PulumiUpOptions): { pulumi: PulumiRunner; plunge: PulumiRunner } {
|
|
619
|
+
let out: { pulumi: PulumiRunner; plunge: PulumiRunner };
|
|
587
620
|
if (r.engineBin) {
|
|
588
621
|
const pinned = opts.runAt ? opts.runAt(r.engineBin) : engineRunnerAt(r.engineBin);
|
|
589
|
-
|
|
622
|
+
out = { pulumi: pinned, plunge: pinned };
|
|
623
|
+
} else {
|
|
624
|
+
out = { pulumi: opts.run ?? defaultPulumiRunner, plunge: opts.plunge ?? defaultPlungeRunner };
|
|
590
625
|
}
|
|
591
|
-
|
|
626
|
+
// plywood#22: the step's backend reaches every command, whichever call site runs it.
|
|
627
|
+
return r.backend ? { pulumi: withBackend(out.pulumi, r.backend), plunge: withBackend(out.plunge, r.backend) } : out;
|
|
592
628
|
}
|
|
593
629
|
|
|
594
630
|
/** The scratch directory, the config copy in it and plunge's plan file path. */
|
|
@@ -695,10 +731,10 @@ function isArtifact(value: unknown): value is PulumiUpArtifact {
|
|
|
695
731
|
return v?.kind === "pulumi-up" && typeof v.planDigest === "string" && typeof v.shape === "object" && v.shape !== null;
|
|
696
732
|
}
|
|
697
733
|
|
|
698
|
-
/**
|
|
699
|
-
async function
|
|
734
|
+
/** The program's directory, a release's copy (#4542) or the checkout in place (plywood#22), made runnable before its first engine run. */
|
|
735
|
+
async function readyProgram(r: Resolved, opts: PulumiUpOptions): Promise<void> {
|
|
700
736
|
const runners = runnersFor(r, opts);
|
|
701
|
-
await
|
|
737
|
+
await prepareProgram({ dir: r.dir, member: r.member, engine: r.engine, run: r.engine === "plunge" ? runners.plunge : runners.pulumi });
|
|
702
738
|
}
|
|
703
739
|
|
|
704
740
|
async function planStack(ctx: DeployContext, original: PulumiUpStepInput, opts: PulumiUpOptions): Promise<CapabilityPlan> {
|
|
@@ -708,7 +744,7 @@ async function planStack(ctx: DeployContext, original: PulumiUpStepInput, opts:
|
|
|
708
744
|
const location = review ? { location: locationOf(r, review.stack) } : {};
|
|
709
745
|
const unresolved = [...(ctx.unresolvedInputs ?? [])].sort();
|
|
710
746
|
if (unresolved.length > 0) return { ...waitingStack(ctx, r, input, unresolved), ...location };
|
|
711
|
-
await
|
|
747
|
+
await readyProgram(r, opts);
|
|
712
748
|
if (review) {
|
|
713
749
|
const runners = runnersFor(r, opts);
|
|
714
750
|
await initReviewStack({
|
|
@@ -873,7 +909,7 @@ async function runStack(ctx: DeployContext, original: PulumiUpStepInput, opts: P
|
|
|
873
909
|
const handed = ctx.plans?.[r.member];
|
|
874
910
|
const fromWave = isArtifact(handed);
|
|
875
911
|
const targets = ctx.targets && ctx.targets.length > 0 ? ctx.targets : undefined;
|
|
876
|
-
await
|
|
912
|
+
await readyProgram(r, opts);
|
|
877
913
|
|
|
878
914
|
// Ruling D: no approved plan handed in, so plan here and apply that.
|
|
879
915
|
let approved: PulumiUpArtifact;
|
|
@@ -1027,15 +1063,24 @@ export function createPulumiUpCapability(opts: PulumiUpOptions = {}): Capability
|
|
|
1027
1063
|
// #4552: resource on plunge, the stack (target) on stock Pulumi.
|
|
1028
1064
|
defaultLockGrain(input) {
|
|
1029
1065
|
requireInput(input);
|
|
1030
|
-
return pulumiDefaultLockGrain(pulumiMemberEngine(stackDir(input), input.engine,
|
|
1066
|
+
return pulumiDefaultLockGrain(pulumiMemberEngine(stackDir(input), input.engine, engineEnv(input, opts)).engine);
|
|
1031
1067
|
},
|
|
1032
1068
|
// Stock Pulumi keeps the stack grain: its backend's stack lock already orders updates, and nothing in it is per resource.
|
|
1033
1069
|
lockGrainRefusal(input, grain) {
|
|
1034
1070
|
if (grain !== "resource") return undefined;
|
|
1035
1071
|
requireInput(input);
|
|
1036
|
-
const engine = pulumiMemberEngine(stackDir(input), input.engine,
|
|
1072
|
+
const engine = pulumiMemberEngine(stackDir(input), input.engine, engineEnv(input, opts)).engine;
|
|
1037
1073
|
return engine === "pulumi" ? new PulumiResourceGrainRefusedError(pulumiUpMember(input)).message : undefined;
|
|
1038
1074
|
},
|
|
1075
|
+
// The ruling for ci-products (2026-10-28): a stock stack waits for its stack row on the ledger in place of the backend's lock.
|
|
1076
|
+
// plunge keeps the resource grain, and a plunge stack declared on the target grain waits on the same row.
|
|
1077
|
+
targetLockRow(input, plan) {
|
|
1078
|
+
requireInput(input);
|
|
1079
|
+
const project = readProject(stackDir(input))?.name;
|
|
1080
|
+
const stack = plan && isArtifact(plan.artifact) ? plan.artifact.stack : input.stack;
|
|
1081
|
+
if (typeof project !== "string" || !stack) return undefined;
|
|
1082
|
+
return { key: stackRowKey(project, stack), target: pulumiUpMember(input), address: `${project}/${stack}` };
|
|
1083
|
+
},
|
|
1039
1084
|
// URN rows, object rows, and the stack row when something may appear only at apply (./lock-rows.ts).
|
|
1040
1085
|
lockRows(input, plan) {
|
|
1041
1086
|
if (!plan.changeSet) return [];
|
package/src/release-copy.test.ts
CHANGED
|
@@ -2,13 +2,24 @@
|
|
|
2
2
|
* A release's copy of a Pulumi program (#4542 item 3): `pulumi install` (or
|
|
3
3
|
* `plunge install`) runs in the copy when its runtime needs one, a Node copy
|
|
4
4
|
* with no lockfile is refused by name, and a directory that is not a copy is
|
|
5
|
-
*
|
|
5
|
+
* left alone by `prepareReleaseCopy`. `prepareProgram` prepares an in-place
|
|
6
|
+
* checkout by the same rule (plywood#22). The runner is a fake, so nothing
|
|
7
|
+
* installs.
|
|
6
8
|
*/
|
|
7
9
|
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
8
10
|
import { tmpdir } from "node:os";
|
|
9
11
|
import { join } from "node:path";
|
|
10
12
|
import { beforeEach, describe, expect, test } from "vitest";
|
|
11
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
isReleaseCopy,
|
|
15
|
+
prepareProgram,
|
|
16
|
+
prepareReleaseCopy,
|
|
17
|
+
programNeeds,
|
|
18
|
+
PulumiProgramNotReadyError,
|
|
19
|
+
PulumiReleaseCopyRefusedError,
|
|
20
|
+
releaseCopyNeeds,
|
|
21
|
+
resetPreparedReleaseCopies,
|
|
22
|
+
} from "./release-copy";
|
|
12
23
|
import { runReleaseInstall, type PulumiRunner } from "./run";
|
|
13
24
|
|
|
14
25
|
function copy(pulumiYaml: string, files: Record<string, string> = {}): string {
|
|
@@ -72,3 +83,80 @@ describe("a release copy", () => {
|
|
|
72
83
|
expect(calls).toEqual([]);
|
|
73
84
|
});
|
|
74
85
|
});
|
|
86
|
+
|
|
87
|
+
/** An in-place checkout: a directory outside any release copy, with its files. */
|
|
88
|
+
function checkout(pulumiYaml: string, files: Record<string, string> = {}): string {
|
|
89
|
+
const dir = join(mkdtempSync(join(tmpdir(), "checkout-")), "shop");
|
|
90
|
+
mkdirSync(dir, { recursive: true });
|
|
91
|
+
writeFileSync(join(dir, "Pulumi.yaml"), pulumiYaml);
|
|
92
|
+
for (const [name, text] of Object.entries(files)) writeFileSync(join(dir, name), text);
|
|
93
|
+
return dir;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe("an in-place checkout (plywood#22)", () => {
|
|
97
|
+
const node = "name: shop\nruntime: nodejs\n";
|
|
98
|
+
const locked = { "package.json": "{}", "package-lock.json": "{}" };
|
|
99
|
+
|
|
100
|
+
test("a Node checkout with a lockfile and no node_modules gets one install, whichever engine asks", async () => {
|
|
101
|
+
const dir = checkout(node, locked);
|
|
102
|
+
const plunge = runner();
|
|
103
|
+
const pulumi = runner();
|
|
104
|
+
const [a, b] = await Promise.all([
|
|
105
|
+
prepareProgram({ dir, member: "shop/dev", engine: "plunge", run: plunge.run }),
|
|
106
|
+
prepareProgram({ dir, member: "shop/staging", engine: "pulumi", run: pulumi.run }),
|
|
107
|
+
]);
|
|
108
|
+
expect(a).toEqual({ place: "checkout", action: "installed", command: "plunge install" });
|
|
109
|
+
expect(b).toEqual(a);
|
|
110
|
+
expect(plunge.calls).toEqual([{ args: ["install"], cwd: dir }]);
|
|
111
|
+
expect(pulumi.calls).toEqual([]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("node_modules here or above, or Yarn's Plug'n'Play loader, counts as installed", () => {
|
|
115
|
+
const here = checkout(node, locked);
|
|
116
|
+
mkdirSync(join(here, "node_modules"));
|
|
117
|
+
expect(programNeeds(here, "shop/dev", "checkout").install).toBe(false);
|
|
118
|
+
|
|
119
|
+
const hoisted = checkout(node, { "package.json": "{}" });
|
|
120
|
+
mkdirSync(join(hoisted, "..", "node_modules"));
|
|
121
|
+
expect(programNeeds(hoisted, "shop/dev", "checkout")).toMatchObject({ install: false, reason: expect.stringContaining("node_modules") });
|
|
122
|
+
|
|
123
|
+
const pnp = checkout(node, { "package.json": "{}", "yarn.lock": "", ".pnp.cjs": "" });
|
|
124
|
+
expect(programNeeds(pnp, "shop/dev", "checkout").install).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("a Node checkout with no lockfile is refused by name, and nothing runs", async () => {
|
|
128
|
+
const dir = checkout(node, { "package.json": "{}" });
|
|
129
|
+
const { run, calls } = runner();
|
|
130
|
+
await expect(prepareProgram({ dir, member: "shop/dev", engine: "pulumi", run })).rejects.toThrow(PulumiProgramNotReadyError);
|
|
131
|
+
await expect(prepareProgram({ dir, member: "shop/dev", engine: "pulumi", run })).rejects.toThrow(/no lockfile .* beside its package.json/);
|
|
132
|
+
expect(calls).toEqual([]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("a failed install names the command, and the next call tries again", async () => {
|
|
136
|
+
const dir = checkout(node, locked);
|
|
137
|
+
let fail = true;
|
|
138
|
+
const calls: string[][] = [];
|
|
139
|
+
const run: PulumiRunner = async (args) => {
|
|
140
|
+
calls.push(args);
|
|
141
|
+
if (fail) throw Object.assign(new Error("exit 1"), { code: 1, stderr: "npm error ERESOLVE" });
|
|
142
|
+
return { stdout: "" };
|
|
143
|
+
};
|
|
144
|
+
await expect(prepareProgram({ dir, member: "shop/dev", engine: "pulumi", run })).rejects.toThrow(/pulumi install failed: npm error ERESOLVE/);
|
|
145
|
+
fail = false;
|
|
146
|
+
expect(await prepareProgram({ dir, member: "shop/dev", engine: "pulumi", run })).toMatchObject({ action: "installed" });
|
|
147
|
+
expect(calls).toEqual([["install"], ["install"]]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("Python, Go and YAML follow the copy's rules", () => {
|
|
151
|
+
expect(programNeeds(checkout("name: a\nruntime:\n name: python\n options:\n virtualenv: venv\n"), "a", "checkout").install).toBe(true);
|
|
152
|
+
expect(programNeeds(checkout("name: a\nruntime: python\n"), "a", "checkout").install).toBe(false);
|
|
153
|
+
expect(programNeeds(checkout("name: a\nruntime: go\n"), "a", "checkout").install).toBe(false);
|
|
154
|
+
expect(programNeeds(checkout("name: a\nruntime: yaml\n"), "a", "checkout").install).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("a release copy keeps the copy's rule: node_modules above the copy does not count", () => {
|
|
158
|
+
const dir = copy("name: shop\nruntime: nodejs\n", { "package.json": "{}", "package-lock.json": "{}" });
|
|
159
|
+
mkdirSync(join(dir, "..", "node_modules"));
|
|
160
|
+
expect(releaseCopyNeeds(dir, "shop/prod").install).toBe(true);
|
|
161
|
+
});
|
|
162
|
+
});
|
package/src/release-copy.ts
CHANGED
|
@@ -24,10 +24,28 @@
|
|
|
24
24
|
* - `go`, `dotnet`, `java`, `yaml`: never; their tools fetch what the
|
|
25
25
|
* program needs on the run.
|
|
26
26
|
*
|
|
27
|
-
*
|
|
27
|
+
* ## In-place checkouts (plywood#22)
|
|
28
|
+
*
|
|
29
|
+
* A CI job's checkout has the same gap: git cloned the tracked files, and
|
|
30
|
+
* nothing installed the program's dependencies before the step's first
|
|
31
|
+
* preview. {@link prepareProgram}, which `pulumi-up`'s plan and run call,
|
|
32
|
+
* prepares a release copy as above and an in-place directory by the same
|
|
33
|
+
* install and the same lockfile rule, with two differences for a directory
|
|
34
|
+
* that sits in a working tree:
|
|
35
|
+
*
|
|
36
|
+
* - `nodejs`: `node_modules` in the directory or any directory above it (a
|
|
37
|
+
* workspace's hoisted install), or a Yarn Plug'n'Play `.pnp.cjs` or
|
|
38
|
+
* `.pnp.js` there, counts as installed, since Node resolves the SDK
|
|
39
|
+
* from either.
|
|
40
|
+
* - The refusal for a Node program with no lockfile names the checkout, and
|
|
41
|
+
* says to commit a lockfile or install before the run.
|
|
42
|
+
*
|
|
43
|
+
* Preparation runs once per directory per process, whichever engine asks
|
|
44
|
+
* first, so two stacks of one project on different engines never install
|
|
45
|
+
* into the same directory at once.
|
|
28
46
|
*/
|
|
29
47
|
import { existsSync, readFileSync } from "node:fs";
|
|
30
|
-
import { join, sep } from "node:path";
|
|
48
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
31
49
|
import { parseYAML } from "@intentius/chant/yaml";
|
|
32
50
|
import type { PulumiMemberEngine } from "./member-engine";
|
|
33
51
|
import { pulumiRunFailure, runReleaseInstall, type PulumiRunner } from "./run";
|
|
@@ -73,31 +91,78 @@ export class PulumiReleaseCopyRefusedError extends Error {
|
|
|
73
91
|
}
|
|
74
92
|
}
|
|
75
93
|
|
|
94
|
+
/** An in-place program that cannot be made runnable, with the reason (plywood#22). */
|
|
95
|
+
export class PulumiProgramNotReadyError extends Error {
|
|
96
|
+
constructor(
|
|
97
|
+
public readonly member: string,
|
|
98
|
+
public readonly dir: string,
|
|
99
|
+
reason: string,
|
|
100
|
+
) {
|
|
101
|
+
super(`pulumi-up: "${member}": the program in ${dir} cannot run: ${reason}`);
|
|
102
|
+
this.name = "PulumiProgramNotReadyError";
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
76
106
|
/** What preparing a copy did. */
|
|
77
107
|
export type ReleaseCopyPrepared =
|
|
78
108
|
| { action: "not-a-copy" }
|
|
79
109
|
| { action: "none"; reason: string }
|
|
80
110
|
| { action: "installed"; command: string };
|
|
81
111
|
|
|
112
|
+
/** What preparing a program's directory did: a release copy or an in-place checkout. */
|
|
113
|
+
export type ProgramPrepared = { place: "copy" | "checkout" } & ({ action: "none"; reason: string } | { action: "installed"; command: string });
|
|
114
|
+
|
|
115
|
+
/** Whether `dir` or a directory above it holds what Node resolves a package from: `node_modules`, or Yarn Plug'n'Play's loader. */
|
|
116
|
+
function nodeResolvesAbove(dir: string): string | undefined {
|
|
117
|
+
let at = resolve(dir);
|
|
118
|
+
for (;;) {
|
|
119
|
+
for (const f of ["node_modules", ".pnp.cjs", ".pnp.js"]) if (existsSync(join(at, f))) return join(at, f);
|
|
120
|
+
const up = dirname(at);
|
|
121
|
+
if (up === at) return undefined;
|
|
122
|
+
at = up;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
82
126
|
/** Why the copy needs an install, or why not; throws when it cannot be prepared. */
|
|
83
127
|
export function releaseCopyNeeds(dir: string, member: string): { install: boolean; reason: string } {
|
|
128
|
+
return programNeeds(dir, member, "copy");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Why a program's directory needs an install, or why not: a release copy
|
|
133
|
+
* (`copy`) or an in-place checkout (`checkout`). Throws when it cannot be
|
|
134
|
+
* prepared: a Node program with no lockfile beside its package.json.
|
|
135
|
+
*/
|
|
136
|
+
export function programNeeds(dir: string, member: string, place: "copy" | "checkout"): { install: boolean; reason: string } {
|
|
84
137
|
const { name, options } = pulumiRuntime(dir);
|
|
85
138
|
switch (name) {
|
|
86
139
|
case "nodejs": {
|
|
87
|
-
if (
|
|
140
|
+
if (place === "copy") {
|
|
141
|
+
if (existsSync(join(dir, "node_modules"))) return { install: false, reason: "the copy has node_modules" };
|
|
142
|
+
} else {
|
|
143
|
+
const found = nodeResolvesAbove(dir);
|
|
144
|
+
if (found) return { install: false, reason: `Node resolves the program's packages from ${found}` };
|
|
145
|
+
}
|
|
88
146
|
if (!NODE_LOCKFILES.some((f) => existsSync(join(dir, f)))) {
|
|
147
|
+
if (place === "checkout") {
|
|
148
|
+
throw new PulumiProgramNotReadyError(
|
|
149
|
+
member,
|
|
150
|
+
dir,
|
|
151
|
+
`the program is Node, nothing installed its dependencies (no node_modules here or above), and there is no lockfile (${NODE_LOCKFILES.join(", ")}) beside its package.json, so an install would take whatever versions resolve today; commit a lockfile, or install the dependencies before the run`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
89
154
|
throw new PulumiReleaseCopyRefusedError(
|
|
90
155
|
member,
|
|
91
156
|
`the program is Node, and the release recorded no lockfile (${NODE_LOCKFILES.join(", ")}) beside its package.json, so an install would take today's versions, not the release's; commit a lockfile and cut the release again`,
|
|
92
157
|
);
|
|
93
158
|
}
|
|
94
|
-
return { install: true, reason: "a Node program's copy has no node_modules" };
|
|
159
|
+
return { install: true, reason: place === "copy" ? "a Node program's copy has no node_modules" : "a Node program's checkout has no node_modules" };
|
|
95
160
|
}
|
|
96
161
|
case "python": {
|
|
97
162
|
const venv = typeof options.virtualenv === "string" ? options.virtualenv : undefined;
|
|
98
163
|
const toolchain = typeof options.toolchain === "string" ? options.toolchain : undefined;
|
|
99
164
|
if (toolchain === "poetry" || toolchain === "uv") return { install: true, reason: `a Python program on ${toolchain} installs its environment` };
|
|
100
|
-
if (venv && !existsSync(join(dir, venv))) return { install: true, reason: `the virtualenv ${venv} is not in the
|
|
165
|
+
if (venv && !existsSync(join(dir, venv))) return { install: true, reason: `the virtualenv ${venv} is not in the ${place}` };
|
|
101
166
|
return { install: false, reason: "the Python program names no virtualenv to build" };
|
|
102
167
|
}
|
|
103
168
|
default:
|
|
@@ -105,38 +170,54 @@ export function releaseCopyNeeds(dir: string, member: string): { install: boolea
|
|
|
105
170
|
}
|
|
106
171
|
}
|
|
107
172
|
|
|
108
|
-
const prepared = new Map<string, Promise<
|
|
173
|
+
const prepared = new Map<string, Promise<ProgramPrepared>>();
|
|
109
174
|
|
|
110
175
|
/**
|
|
111
|
-
* Make a
|
|
112
|
-
* in
|
|
113
|
-
*
|
|
176
|
+
* Make a program's directory runnable before its first engine run, once per
|
|
177
|
+
* directory in this process: `install` there when {@link programNeeds} says
|
|
178
|
+
* so. A release copy follows the copy's rules, any other directory the
|
|
179
|
+
* checkout's. Throws for a directory that cannot be prepared, or an install
|
|
180
|
+
* that fails. The first caller's engine runs the install; a second engine
|
|
181
|
+
* asking for the same directory waits on it.
|
|
114
182
|
*/
|
|
115
|
-
export function
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
let done = prepared.get(
|
|
183
|
+
export function prepareProgram(opts: { dir: string; member: string; engine: PulumiMemberEngine; run: PulumiRunner }): Promise<ProgramPrepared> {
|
|
184
|
+
const dir = resolve(opts.dir);
|
|
185
|
+
const place = isReleaseCopy(dir) ? "copy" : "checkout";
|
|
186
|
+
let done = prepared.get(dir);
|
|
119
187
|
if (!done) {
|
|
120
|
-
done = (async (): Promise<
|
|
121
|
-
const needs =
|
|
122
|
-
if (!needs.install) return { action: "none", reason: needs.reason };
|
|
188
|
+
done = (async (): Promise<ProgramPrepared> => {
|
|
189
|
+
const needs = programNeeds(dir, opts.member, place);
|
|
190
|
+
if (!needs.install) return { place, action: "none", reason: needs.reason };
|
|
123
191
|
const command = `${opts.engine} install`;
|
|
124
192
|
try {
|
|
125
|
-
await runReleaseInstall(opts.run, ["install"],
|
|
193
|
+
await runReleaseInstall(opts.run, ["install"], dir);
|
|
126
194
|
} catch (err) {
|
|
127
195
|
const f = pulumiRunFailure(err);
|
|
128
196
|
const why = f.missingBinary ? `the ${opts.engine} binary was not found on PATH` : (f.stderr?.trim() || f.message).split("\n").slice(-3).join("\n");
|
|
129
|
-
throw new PulumiReleaseCopyRefusedError(opts.member, `${command} in the copy failed: ${why}`);
|
|
197
|
+
if (place === "copy") throw new PulumiReleaseCopyRefusedError(opts.member, `${command} in the copy failed: ${why}`);
|
|
198
|
+
throw new PulumiProgramNotReadyError(opts.member, dir, `${command} failed: ${why}`);
|
|
130
199
|
}
|
|
131
|
-
return { action: "installed", command };
|
|
200
|
+
return { place, action: "installed", command };
|
|
132
201
|
})();
|
|
133
|
-
prepared.set(
|
|
134
|
-
done.catch(() => prepared.delete(
|
|
202
|
+
prepared.set(dir, done);
|
|
203
|
+
done.catch(() => prepared.delete(dir));
|
|
135
204
|
}
|
|
136
205
|
return done;
|
|
137
206
|
}
|
|
138
207
|
|
|
139
|
-
/**
|
|
208
|
+
/**
|
|
209
|
+
* Make a release copy runnable, once per directory in this process: `install`
|
|
210
|
+
* in the copy when {@link releaseCopyNeeds} says so. Throws for a copy that
|
|
211
|
+
* cannot be prepared, or an install that fails. A directory that is not a
|
|
212
|
+
* copy is left alone; {@link prepareProgram} prepares both.
|
|
213
|
+
*/
|
|
214
|
+
export async function prepareReleaseCopy(opts: { dir: string; member: string; engine: PulumiMemberEngine; run: PulumiRunner }): Promise<ReleaseCopyPrepared> {
|
|
215
|
+
if (!isReleaseCopy(opts.dir)) return { action: "not-a-copy" };
|
|
216
|
+
const done = await prepareProgram(opts);
|
|
217
|
+
return done.action === "none" ? { action: "none", reason: done.reason } : { action: "installed", command: done.command };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Forget every prepared directory. For tests. */
|
|
140
221
|
export function resetPreparedReleaseCopies(): void {
|
|
141
222
|
prepared.clear();
|
|
142
223
|
}
|
package/src/run.ts
CHANGED
|
@@ -644,11 +644,11 @@ export async function runReviewStackPlunge(run: PulumiRunner, args: string[], cw
|
|
|
644
644
|
}
|
|
645
645
|
|
|
646
646
|
/**
|
|
647
|
-
* The install chant runs in a
|
|
647
|
+
* The install chant runs in a program's directory before its first engine
|
|
648
|
+
* run, a release's recorded copy (#4542) or a job's checkout (plywood#22),
|
|
648
649
|
* for `./release-copy.ts` alone: exactly `pulumi install` or `plunge
|
|
649
|
-
* install`, no flag.
|
|
650
|
-
*
|
|
651
|
-
* runtime options and the recorded lockfile.
|
|
650
|
+
* install`, no flag. Neither has `node_modules` or a virtualenv; the CLI's
|
|
651
|
+
* install follows `Pulumi.yaml`'s runtime options and the lockfile.
|
|
652
652
|
*/
|
|
653
653
|
export const PULUMI_INSTALL_COMMANDS: readonly string[] = ["pulumi install", "plunge install"];
|
|
654
654
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* `local` store in a temp directory, so no git ref is written, no engine
|
|
10
10
|
* runs and nothing reaches a backend.
|
|
11
11
|
*/
|
|
12
|
-
import { cpSync, mkdtempSync, readFileSync } from "node:fs";
|
|
12
|
+
import { cpSync, mkdirSync, mkdtempSync, readFileSync } from "node:fs";
|
|
13
13
|
import { tmpdir } from "node:os";
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { describe, expect, test } from "vitest";
|
|
@@ -42,6 +42,8 @@ const localStore = (): SnapshotStore => createSnapshotStore({ store: "local", di
|
|
|
42
42
|
function unexportedStack(): string {
|
|
43
43
|
const dir = tmp();
|
|
44
44
|
cpSync(join(testdata, "pulumi-preview", "Pulumi.yaml"), join(dir, "Pulumi.yaml"));
|
|
45
|
+
// Installed, so the step runs no install before the preview (plywood#22).
|
|
46
|
+
mkdirSync(join(dir, "node_modules"));
|
|
45
47
|
return dir;
|
|
46
48
|
}
|
|
47
49
|
|