@intentius/chant-lexicon-pulumi 0.124.55 → 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/check-step.d.ts +61 -0
- package/dist/check-step.d.ts.map +1 -0
- package/dist/check-step.js +270 -0
- package/dist/composites/pulumi-adopt-op.d.ts.map +1 -1
- package/dist/composites/pulumi-adopt-op.js +4 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/member-outputs.d.ts +10 -3
- package/dist/member-outputs.d.ts.map +1 -1
- package/dist/member-outputs.js +11 -3
- 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/planned-output-changes.d.ts +60 -0
- package/dist/planned-output-changes.d.ts.map +1 -0
- package/dist/planned-output-changes.js +94 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +24 -0
- package/dist/preview-tips.d.ts +71 -0
- package/dist/preview-tips.d.ts.map +1 -0
- package/dist/preview-tips.js +76 -0
- package/dist/pulumi-up.d.ts +24 -1
- package/dist/pulumi-up.d.ts.map +1 -1
- package/dist/pulumi-up.js +64 -16
- 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 +27 -8
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +55 -14
- package/package.json +2 -2
- package/src/changed-units.test.ts +87 -0
- package/src/changed-units.ts +96 -0
- package/src/check-step.test.ts +191 -0
- package/src/check-step.ts +316 -0
- package/src/composites/pulumi-adopt-op.test.ts +35 -0
- package/src/composites/pulumi-adopt-op.ts +4 -2
- package/src/index.ts +2 -0
- package/src/member-outputs.test.ts +28 -6
- package/src/member-outputs.ts +22 -4
- package/src/op/activities/pulumi-adopt.ts +13 -2
- package/src/op/activity-contracts.ts +2 -2
- package/src/planned-output-changes.test.ts +85 -0
- package/src/planned-output-changes.ts +141 -0
- package/src/plugin.ts +26 -0
- package/src/preview-tips.test.ts +135 -0
- package/src/preview-tips.ts +128 -0
- package/src/pulumi-up.test.ts +100 -0
- package/src/pulumi-up.ts +84 -18
- package/src/release-copy.test.ts +90 -2
- package/src/release-copy.ts +103 -22
- package/src/run-plan-flags.test.ts +53 -0
- package/src/run.test.ts +1 -1
- package/src/run.ts +57 -14
- package/src/stack-export-snapshot.test.ts +3 -1
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
|
|
@@ -167,8 +173,10 @@ import { pulumiExitOf } from "./exit-codes";
|
|
|
167
173
|
import { pulumiMemberEngine, type PulumiMemberEngine, type PulumiMemberEngineSource } from "./member-engine";
|
|
168
174
|
import { ciEngineDir, ciRelease, CI_ENGINE_ROOT } from "./ci-setup";
|
|
169
175
|
import { pulumiMemberPlannedOutputs } from "./member-outputs";
|
|
176
|
+
import type { PulumiOutputChange } from "./planned-output-changes";
|
|
170
177
|
import { pulumiPlanShape, pulumiShapeDifferences } from "./plan-shape";
|
|
171
178
|
import { pulumiLockRows } from "./lock-rows";
|
|
179
|
+
import { stackRowKey } from "@intentius/chant/lifecycle/lock-rows";
|
|
172
180
|
import { pulumiTraceFacts } from "./trace-facts";
|
|
173
181
|
import {
|
|
174
182
|
defaultPlungeRunner,
|
|
@@ -191,7 +199,7 @@ import type { PulumiStep } from "./change-set";
|
|
|
191
199
|
import { writePulumiExportSnapshot, type PulumiExportWrite } from "./stack-export-snapshot";
|
|
192
200
|
import { pointerSegment, pulumiStackReferences, stackReferenceOwner } from "./stack-references";
|
|
193
201
|
import { isObject } from "./values";
|
|
194
|
-
import {
|
|
202
|
+
import { prepareProgram } from "./release-copy";
|
|
195
203
|
import { initReviewStack, reviewConfigSource, reviewStackFor, reviewStackLocation } from "./review-stack";
|
|
196
204
|
|
|
197
205
|
/** The lock grain values of `ci.lockGrain` (#4552's ruling A). */
|
|
@@ -209,6 +217,15 @@ export interface PulumiUpStepInput {
|
|
|
209
217
|
config?: Record<string, unknown>;
|
|
210
218
|
/** `pulumi` or `plunge`; see `pulumiMemberEngine`. */
|
|
211
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;
|
|
212
229
|
/**
|
|
213
230
|
* The engine release this stack is pinned to (#4592): the step runs
|
|
214
231
|
* `<engineRoot>/<engine>-<engineVersion>/<engine>`, the path the pulumi
|
|
@@ -300,6 +317,13 @@ export interface PulumiUpArtifact {
|
|
|
300
317
|
* outside reference stale, current or unknown.
|
|
301
318
|
*/
|
|
302
319
|
stackReferences?: { read: string[]; outside: { name: string; why: string }[]; lastUpdate?: string };
|
|
320
|
+
/**
|
|
321
|
+
* Each stack output's verdict from the preview (#4635,
|
|
322
|
+
* `./planned-output-changes.ts`): `changing` and `untraced` ones are
|
|
323
|
+
* unknown to the stacks that read them, `unchanged` ones keep the
|
|
324
|
+
* export's value. Absent when the preview planned nothing to apply.
|
|
325
|
+
*/
|
|
326
|
+
plannedOutputs?: PulumiOutputChange[];
|
|
303
327
|
}
|
|
304
328
|
|
|
305
329
|
/** The CrossGuard packs a plan read at base (#4613). */
|
|
@@ -532,18 +556,34 @@ interface Resolved {
|
|
|
532
556
|
engineSource: PulumiMemberEngineSource;
|
|
533
557
|
engineBecause: string;
|
|
534
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 } });
|
|
535
572
|
}
|
|
536
573
|
|
|
537
574
|
function resolveStep(input: PulumiUpStepInput, opts: PulumiUpOptions, declared?: PulumiLockGrain): Resolved {
|
|
538
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
|
+
}
|
|
539
579
|
const dir = stackDir(input);
|
|
540
580
|
const member = pulumiUpMember(input);
|
|
541
|
-
const picked = pulumiMemberEngine(dir, input.engine,
|
|
581
|
+
const picked = pulumiMemberEngine(dir, input.engine, engineEnv(input, opts));
|
|
542
582
|
// #4552: the step's own grain, else ci.lockGrain as the caller read it at base, else the engine's default.
|
|
543
583
|
const grain = input.lockGrain ?? declared ?? pulumiDefaultLockGrain(picked.engine);
|
|
544
584
|
if (grain === "resource" && picked.engine === "pulumi") throw new PulumiResourceGrainRefusedError(member);
|
|
545
585
|
const engineBin = pulumiUpEngineBin(input, picked.engine);
|
|
546
|
-
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 } : {}) };
|
|
547
587
|
}
|
|
548
588
|
|
|
549
589
|
/**
|
|
@@ -566,8 +606,8 @@ export function pulumiUpEngineBin(input: Pick<PulumiUpStepInput, "engineVersion"
|
|
|
566
606
|
}
|
|
567
607
|
|
|
568
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. */
|
|
569
|
-
function runnerOptions(r: Pick<Resolved, "engineBin">, opts: PulumiUpOptions): { run?: PulumiRunner; plunge?: PulumiRunner } {
|
|
570
|
-
if (r.engineBin) {
|
|
609
|
+
function runnerOptions(r: Pick<Resolved, "engineBin" | "backend">, opts: PulumiUpOptions): { run?: PulumiRunner; plunge?: PulumiRunner } {
|
|
610
|
+
if (r.engineBin || r.backend) {
|
|
571
611
|
const { pulumi, plunge } = runnersFor(r, opts);
|
|
572
612
|
return { run: pulumi, plunge };
|
|
573
613
|
}
|
|
@@ -575,12 +615,16 @@ function runnerOptions(r: Pick<Resolved, "engineBin">, opts: PulumiUpOptions): {
|
|
|
575
615
|
}
|
|
576
616
|
|
|
577
617
|
/** The runners a resolved step uses: the pinned binary's when it names one, else the options' or the PATH default. */
|
|
578
|
-
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 };
|
|
579
620
|
if (r.engineBin) {
|
|
580
621
|
const pinned = opts.runAt ? opts.runAt(r.engineBin) : engineRunnerAt(r.engineBin);
|
|
581
|
-
|
|
622
|
+
out = { pulumi: pinned, plunge: pinned };
|
|
623
|
+
} else {
|
|
624
|
+
out = { pulumi: opts.run ?? defaultPulumiRunner, plunge: opts.plunge ?? defaultPlungeRunner };
|
|
582
625
|
}
|
|
583
|
-
|
|
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;
|
|
584
628
|
}
|
|
585
629
|
|
|
586
630
|
/** The scratch directory, the config copy in it and plunge's plan file path. */
|
|
@@ -619,7 +663,10 @@ async function previewStack(
|
|
|
619
663
|
stack: input.stack,
|
|
620
664
|
...(files.planFile ? { savePlan: files.planFile } : {}),
|
|
621
665
|
...(files.configFile ? { configFile: files.configFile } : {}),
|
|
666
|
+
// #4627: --refresh and --target-dependents pass to the preview as they came.
|
|
667
|
+
...(ctx.refresh ? { refresh: true } : {}),
|
|
622
668
|
...(targets ? { targets } : {}),
|
|
669
|
+
...(targets && ctx.targetDependents ? { targetDependents: true } : {}),
|
|
623
670
|
...(packs.length ? { policyPacks: packs } : {}),
|
|
624
671
|
});
|
|
625
672
|
let stdout: string;
|
|
@@ -684,10 +731,10 @@ function isArtifact(value: unknown): value is PulumiUpArtifact {
|
|
|
684
731
|
return v?.kind === "pulumi-up" && typeof v.planDigest === "string" && typeof v.shape === "object" && v.shape !== null;
|
|
685
732
|
}
|
|
686
733
|
|
|
687
|
-
/**
|
|
688
|
-
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> {
|
|
689
736
|
const runners = runnersFor(r, opts);
|
|
690
|
-
await
|
|
737
|
+
await prepareProgram({ dir: r.dir, member: r.member, engine: r.engine, run: r.engine === "plunge" ? runners.plunge : runners.pulumi });
|
|
691
738
|
}
|
|
692
739
|
|
|
693
740
|
async function planStack(ctx: DeployContext, original: PulumiUpStepInput, opts: PulumiUpOptions): Promise<CapabilityPlan> {
|
|
@@ -697,7 +744,7 @@ async function planStack(ctx: DeployContext, original: PulumiUpStepInput, opts:
|
|
|
697
744
|
const location = review ? { location: locationOf(r, review.stack) } : {};
|
|
698
745
|
const unresolved = [...(ctx.unresolvedInputs ?? [])].sort();
|
|
699
746
|
if (unresolved.length > 0) return { ...waitingStack(ctx, r, input, unresolved), ...location };
|
|
700
|
-
await
|
|
747
|
+
await readyProgram(r, opts);
|
|
701
748
|
if (review) {
|
|
702
749
|
const runners = runnersFor(r, opts);
|
|
703
750
|
await initReviewStack({
|
|
@@ -720,7 +767,12 @@ async function planStack(ctx: DeployContext, original: PulumiUpStepInput, opts:
|
|
|
720
767
|
}
|
|
721
768
|
const planDigest = read.part.member.planDigest!;
|
|
722
769
|
const references = stackReferencesOf(read.prior, input);
|
|
723
|
-
const planned = pulumiMemberPlannedOutputs(r.member, {
|
|
770
|
+
const planned = pulumiMemberPlannedOutputs(r.member, {
|
|
771
|
+
part: read.part,
|
|
772
|
+
preview: read.preview,
|
|
773
|
+
...(read.prior !== undefined ? { exported: read.prior } : {}),
|
|
774
|
+
...(ctx.targets?.length ? { targeted: true } : {}),
|
|
775
|
+
});
|
|
724
776
|
const artifact: PulumiUpArtifact = {
|
|
725
777
|
kind: "pulumi-up",
|
|
726
778
|
member: r.member,
|
|
@@ -736,6 +788,7 @@ async function planStack(ctx: DeployContext, original: PulumiUpStepInput, opts:
|
|
|
736
788
|
policy: read.policy,
|
|
737
789
|
...(packs.read ? { policyPacks: packs.read } : {}),
|
|
738
790
|
...(references ? { stackReferences: references } : {}),
|
|
791
|
+
...(planned.outputChanges ? { plannedOutputs: planned.outputChanges } : {}),
|
|
739
792
|
};
|
|
740
793
|
return {
|
|
741
794
|
member: r.member,
|
|
@@ -856,7 +909,7 @@ async function runStack(ctx: DeployContext, original: PulumiUpStepInput, opts: P
|
|
|
856
909
|
const handed = ctx.plans?.[r.member];
|
|
857
910
|
const fromWave = isArtifact(handed);
|
|
858
911
|
const targets = ctx.targets && ctx.targets.length > 0 ? ctx.targets : undefined;
|
|
859
|
-
await
|
|
912
|
+
await readyProgram(r, opts);
|
|
860
913
|
|
|
861
914
|
// Ruling D: no approved plan handed in, so plan here and apply that.
|
|
862
915
|
let approved: PulumiUpArtifact;
|
|
@@ -892,6 +945,7 @@ async function runStack(ctx: DeployContext, original: PulumiUpStepInput, opts: P
|
|
|
892
945
|
const tail = {
|
|
893
946
|
...(configFile ? { configFile } : {}),
|
|
894
947
|
...(targets ? { targets } : {}),
|
|
948
|
+
...(targets && ctx.targetDependents ? { targetDependents: true } : {}),
|
|
895
949
|
...(policyPacks?.length ? { policyPacks } : {}),
|
|
896
950
|
};
|
|
897
951
|
|
|
@@ -914,7 +968,8 @@ async function runStack(ctx: DeployContext, original: PulumiUpStepInput, opts: P
|
|
|
914
968
|
if (differences.length > 0) throw new PlanShapeMovedError(r.member, differences);
|
|
915
969
|
}
|
|
916
970
|
try {
|
|
917
|
-
|
|
971
|
+
// #4627: stock up plans again, so it refreshes as its preview did; plunge applies the saved plan, whose preview refreshed.
|
|
972
|
+
({ stdout } = await runApplyPulumi(runnersFor(r, opts).pulumi, pulumiUpArgs({ stack: input.stack, ...tail, ...(ctx.refresh ? { refresh: true } : {}) }), r.dir));
|
|
918
973
|
} catch (err) {
|
|
919
974
|
throw upFailure(r, err, approved.policy);
|
|
920
975
|
}
|
|
@@ -989,6 +1044,8 @@ export function createPulumiUpCapability(opts: PulumiUpOptions = {}): Capability
|
|
|
989
1044
|
rollbackPolicy: "needs-opt-out",
|
|
990
1045
|
// Each target passes as `--target <urn>` to the preview and to `up`.
|
|
991
1046
|
targets: "accept",
|
|
1047
|
+
// #4627: `--refresh` and `--target-dependents` pass to the preview, and to `pulumi up`; the digest binds them.
|
|
1048
|
+
planFlags: { refresh: "accept", targetDependents: "accept" },
|
|
992
1049
|
plan: (ctx, input) => planStack(ctx, input, opts),
|
|
993
1050
|
run: (ctx, input) => runStack(ctx, input, opts),
|
|
994
1051
|
// The outputs standing now, for a dependent planned before this stack applies.
|
|
@@ -1006,15 +1063,24 @@ export function createPulumiUpCapability(opts: PulumiUpOptions = {}): Capability
|
|
|
1006
1063
|
// #4552: resource on plunge, the stack (target) on stock Pulumi.
|
|
1007
1064
|
defaultLockGrain(input) {
|
|
1008
1065
|
requireInput(input);
|
|
1009
|
-
return pulumiDefaultLockGrain(pulumiMemberEngine(stackDir(input), input.engine,
|
|
1066
|
+
return pulumiDefaultLockGrain(pulumiMemberEngine(stackDir(input), input.engine, engineEnv(input, opts)).engine);
|
|
1010
1067
|
},
|
|
1011
1068
|
// Stock Pulumi keeps the stack grain: its backend's stack lock already orders updates, and nothing in it is per resource.
|
|
1012
1069
|
lockGrainRefusal(input, grain) {
|
|
1013
1070
|
if (grain !== "resource") return undefined;
|
|
1014
1071
|
requireInput(input);
|
|
1015
|
-
const engine = pulumiMemberEngine(stackDir(input), input.engine,
|
|
1072
|
+
const engine = pulumiMemberEngine(stackDir(input), input.engine, engineEnv(input, opts)).engine;
|
|
1016
1073
|
return engine === "pulumi" ? new PulumiResourceGrainRefusedError(pulumiUpMember(input)).message : undefined;
|
|
1017
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
|
+
},
|
|
1018
1084
|
// URN rows, object rows, and the stack row when something may appear only at apply (./lock-rows.ts).
|
|
1019
1085
|
lockRows(input, plan) {
|
|
1020
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
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--refresh` and `--target-dependents` through the pulumi lexicon's
|
|
3
|
+
* allowlists (#4627): both in the preview and in stock `pulumi up`, in the
|
|
4
|
+
* tail's order, `--target-dependents` only after a `--target`, and no
|
|
5
|
+
* `--refresh` on plunge's `up --plan`, which applies a saved plan whose
|
|
6
|
+
* preview refreshed. Written for the ci-products post-merge pass.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from "vitest";
|
|
9
|
+
import { plungeUpArgs, pulumiUpArgs, pulumiUpPreviewArgs, runAllowedPlunge, runAllowedPulumi, runApplyPlunge, runApplyPulumi, type PulumiRunner } from "./run";
|
|
10
|
+
import { createPulumiUpCapability } from "./pulumi-up";
|
|
11
|
+
|
|
12
|
+
function fake(): { run: PulumiRunner; calls: string[][] } {
|
|
13
|
+
const calls: string[][] = [];
|
|
14
|
+
return { run: async (args) => (calls.push(args), { stdout: "" }), calls };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("--refresh and --target-dependents (#4627)", () => {
|
|
18
|
+
test("the preview carries them in the tail's order", async () => {
|
|
19
|
+
const args = pulumiUpPreviewArgs({ stack: "dev", refresh: true, targets: ["urn:a"], targetDependents: true });
|
|
20
|
+
expect(args).toEqual(["preview", "--json", "--show-sames", "--stack", "dev", "--refresh", "--target", "urn:a", "--target-dependents"]);
|
|
21
|
+
const { run, calls } = fake();
|
|
22
|
+
await runAllowedPulumi(run, args, "/tmp");
|
|
23
|
+
await runAllowedPlunge(run, args, "/tmp");
|
|
24
|
+
expect(calls).toHaveLength(2);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("--target-dependents is dropped without a target", () => {
|
|
28
|
+
expect(pulumiUpPreviewArgs({ stack: "dev", targetDependents: true })).not.toContain("--target-dependents");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("stock up takes both; plunge up --plan takes --target-dependents and no --refresh", async () => {
|
|
32
|
+
const { run, calls } = fake();
|
|
33
|
+
await runApplyPulumi(run, pulumiUpArgs({ stack: "dev", refresh: true, targets: ["urn:a"], targetDependents: true }), "/tmp");
|
|
34
|
+
await runApplyPlunge(run, plungeUpArgs({ plan: "/tmp/p.json", stack: "dev", targets: ["urn:a"], targetDependents: true }), "/tmp");
|
|
35
|
+
expect(calls).toEqual([
|
|
36
|
+
["up", "--yes", "--json", "--stack", "dev", "--refresh", "--target", "urn:a", "--target-dependents"],
|
|
37
|
+
["up", "--plan", "/tmp/p.json", "--yes", "--json", "--stack", "dev", "--target", "urn:a", "--target-dependents"],
|
|
38
|
+
]);
|
|
39
|
+
await expect(runApplyPlunge(run, ["up", "--plan", "/tmp/p.json", "--yes", "--json", "--stack", "dev", "--refresh"], "/tmp")).rejects.toThrow(/applies a plunge stack only as/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("out of order, or --target-dependents with no target, is refused", async () => {
|
|
43
|
+
const { run, calls } = fake();
|
|
44
|
+
for (const tail of [["--target-dependents"], ["--target", "urn:a", "--refresh"], ["--target-dependents", "--target", "urn:a"], ["--refresh", "--refresh"]]) {
|
|
45
|
+
await expect(runApplyPulumi(run, ["up", "--yes", "--json", "--stack", "dev", ...tail], "/tmp"), tail.join(" ")).rejects.toThrow(/applies a Pulumi stack only as/);
|
|
46
|
+
}
|
|
47
|
+
expect(calls).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("the capability says it takes both", () => {
|
|
51
|
+
expect(createPulumiUpCapability().planFlags).toEqual({ refresh: "accept", targetDependents: "accept" });
|
|
52
|
+
});
|
|
53
|
+
});
|
package/src/run.test.ts
CHANGED
|
@@ -464,7 +464,7 @@ describe("the apply entry points (#4541)", () => {
|
|
|
464
464
|
test("anything off the list is refused before the runner", async () => {
|
|
465
465
|
const { run, calls } = fake(() => "");
|
|
466
466
|
const base = pulumiUpArgs({ stack: "floci" });
|
|
467
|
-
for (const extra of [["--skip-preview"], ["--
|
|
467
|
+
for (const extra of [["--skip-preview"], ["--show-secrets"], ["--replace", "urn:a"], ["--target-replace", "urn:a"], ["--target-dependents"], ["--exclude", "urn:a"], ["--remote"], ["--continue-on-error"], ["--config", "k=v"], ["--plan", "/tmp/p.json"]]) {
|
|
468
468
|
await expect(runApplyPulumi(run, [...base, ...extra], "/tmp")).rejects.toThrow(/applies a Pulumi stack only as/);
|
|
469
469
|
}
|
|
470
470
|
for (const args of [
|