@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.
Files changed (38) hide show
  1. package/README.md +1 -1
  2. package/dist/changed-units.d.ts +5 -0
  3. package/dist/changed-units.d.ts.map +1 -0
  4. package/dist/changed-units.js +101 -0
  5. package/dist/composites/pulumi-adopt-op.d.ts.map +1 -1
  6. package/dist/composites/pulumi-adopt-op.js +4 -2
  7. package/dist/integrity.json +2 -2
  8. package/dist/manifest.json +1 -1
  9. package/dist/op/activities/pulumi-adopt.d.ts +5 -0
  10. package/dist/op/activities/pulumi-adopt.d.ts.map +1 -1
  11. package/dist/op/activities/pulumi-adopt.js +7 -2
  12. package/dist/op/activity-contracts.d.ts +2 -0
  13. package/dist/op/activity-contracts.d.ts.map +1 -1
  14. package/dist/op/activity-contracts.js +2 -2
  15. package/dist/plugin.d.ts.map +1 -1
  16. package/dist/plugin.js +11 -0
  17. package/dist/pulumi-up.d.ts +16 -1
  18. package/dist/pulumi-up.d.ts.map +1 -1
  19. package/dist/pulumi-up.js +49 -14
  20. package/dist/release-copy.d.ts +42 -2
  21. package/dist/release-copy.d.ts.map +1 -1
  22. package/dist/release-copy.js +96 -22
  23. package/dist/run.d.ts +4 -4
  24. package/dist/run.js +4 -4
  25. package/package.json +2 -2
  26. package/src/changed-units.test.ts +87 -0
  27. package/src/changed-units.ts +96 -0
  28. package/src/composites/pulumi-adopt-op.test.ts +35 -0
  29. package/src/composites/pulumi-adopt-op.ts +4 -2
  30. package/src/op/activities/pulumi-adopt.ts +13 -2
  31. package/src/op/activity-contracts.ts +2 -2
  32. package/src/plugin.ts +12 -0
  33. package/src/pulumi-up.test.ts +100 -0
  34. package/src/pulumi-up.ts +61 -16
  35. package/src/release-copy.test.ts +90 -2
  36. package/src/release-copy.ts +103 -22
  37. package/src/run.ts +4 -4
  38. package/src/stack-export-snapshot.test.ts +3 -1
@@ -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
- * `prepareReleaseCopy` (`./release-copy.ts`), which runs `pulumi install`
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 { prepareReleaseCopy } from "./release-copy";
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, opts.env ? { env: opts.env } : {});
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
- return { pulumi: pinned, plunge: pinned };
622
+ out = { pulumi: pinned, plunge: pinned };
623
+ } else {
624
+ out = { pulumi: opts.run ?? defaultPulumiRunner, plunge: opts.plunge ?? defaultPlungeRunner };
590
625
  }
591
- return { pulumi: opts.run ?? defaultPulumiRunner, plunge: opts.plunge ?? defaultPlungeRunner };
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
- /** A release's copy of the program, made runnable before its first engine run (#4542). */
699
- async function readyCopy(r: Resolved, opts: PulumiUpOptions): Promise<void> {
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 prepareReleaseCopy({ dir: r.dir, member: r.member, engine: r.engine, run: r.engine === "plunge" ? runners.plunge : runners.pulumi });
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 readyCopy(r, opts);
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 readyCopy(r, opts);
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, opts.env ? { env: opts.env } : {}).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, opts.env ? { env: opts.env } : {}).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 [];
@@ -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
- * never touched. The runner is a fake, so nothing installs.
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 { isReleaseCopy, prepareReleaseCopy, PulumiReleaseCopyRefusedError, releaseCopyNeeds, resetPreparedReleaseCopies } from "./release-copy";
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
+ });
@@ -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
- * A directory that is not a release copy is never touched.
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 (existsSync(join(dir, "node_modules"))) return { install: false, reason: "the copy has node_modules" };
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 copy` };
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<ReleaseCopyPrepared>>();
173
+ const prepared = new Map<string, Promise<ProgramPrepared>>();
109
174
 
110
175
  /**
111
- * Make a release copy runnable, once per directory in this process: `install`
112
- * in the copy when {@link releaseCopyNeeds} says so. Throws for a copy that
113
- * cannot be prepared, or an install that fails.
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 prepareReleaseCopy(opts: { dir: string; member: string; engine: PulumiMemberEngine; run: PulumiRunner }): Promise<ReleaseCopyPrepared> {
116
- if (!isReleaseCopy(opts.dir)) return Promise.resolve({ action: "not-a-copy" });
117
- const key = `${opts.engine}\0${opts.dir}`;
118
- let done = prepared.get(key);
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<ReleaseCopyPrepared> => {
121
- const needs = releaseCopyNeeds(opts.dir, opts.member);
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"], opts.dir);
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(key, done);
134
- done.catch(() => prepared.delete(key));
202
+ prepared.set(dir, done);
203
+ done.catch(() => prepared.delete(dir));
135
204
  }
136
205
  return done;
137
206
  }
138
207
 
139
- /** Forget every prepared copy. For tests. */
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 release's recorded copy of a program (#4542),
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. The cut records tracked files only, so the copy has no
650
- * `node_modules` or virtualenv; the CLI's install follows `Pulumi.yaml`'s
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