@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.
Files changed (65) 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/check-step.d.ts +61 -0
  6. package/dist/check-step.d.ts.map +1 -0
  7. package/dist/check-step.js +270 -0
  8. package/dist/composites/pulumi-adopt-op.d.ts.map +1 -1
  9. package/dist/composites/pulumi-adopt-op.js +4 -2
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +2 -0
  13. package/dist/integrity.json +2 -2
  14. package/dist/manifest.json +1 -1
  15. package/dist/member-outputs.d.ts +10 -3
  16. package/dist/member-outputs.d.ts.map +1 -1
  17. package/dist/member-outputs.js +11 -3
  18. package/dist/op/activities/pulumi-adopt.d.ts +5 -0
  19. package/dist/op/activities/pulumi-adopt.d.ts.map +1 -1
  20. package/dist/op/activities/pulumi-adopt.js +7 -2
  21. package/dist/op/activity-contracts.d.ts +2 -0
  22. package/dist/op/activity-contracts.d.ts.map +1 -1
  23. package/dist/op/activity-contracts.js +2 -2
  24. package/dist/planned-output-changes.d.ts +60 -0
  25. package/dist/planned-output-changes.d.ts.map +1 -0
  26. package/dist/planned-output-changes.js +94 -0
  27. package/dist/plugin.d.ts.map +1 -1
  28. package/dist/plugin.js +24 -0
  29. package/dist/preview-tips.d.ts +71 -0
  30. package/dist/preview-tips.d.ts.map +1 -0
  31. package/dist/preview-tips.js +76 -0
  32. package/dist/pulumi-up.d.ts +24 -1
  33. package/dist/pulumi-up.d.ts.map +1 -1
  34. package/dist/pulumi-up.js +64 -16
  35. package/dist/release-copy.d.ts +42 -2
  36. package/dist/release-copy.d.ts.map +1 -1
  37. package/dist/release-copy.js +96 -22
  38. package/dist/run.d.ts +27 -8
  39. package/dist/run.d.ts.map +1 -1
  40. package/dist/run.js +55 -14
  41. package/package.json +2 -2
  42. package/src/changed-units.test.ts +87 -0
  43. package/src/changed-units.ts +96 -0
  44. package/src/check-step.test.ts +191 -0
  45. package/src/check-step.ts +316 -0
  46. package/src/composites/pulumi-adopt-op.test.ts +35 -0
  47. package/src/composites/pulumi-adopt-op.ts +4 -2
  48. package/src/index.ts +2 -0
  49. package/src/member-outputs.test.ts +28 -6
  50. package/src/member-outputs.ts +22 -4
  51. package/src/op/activities/pulumi-adopt.ts +13 -2
  52. package/src/op/activity-contracts.ts +2 -2
  53. package/src/planned-output-changes.test.ts +85 -0
  54. package/src/planned-output-changes.ts +141 -0
  55. package/src/plugin.ts +26 -0
  56. package/src/preview-tips.test.ts +135 -0
  57. package/src/preview-tips.ts +128 -0
  58. package/src/pulumi-up.test.ts +100 -0
  59. package/src/pulumi-up.ts +84 -18
  60. package/src/release-copy.test.ts +90 -2
  61. package/src/release-copy.ts +103 -22
  62. package/src/run-plan-flags.test.ts +53 -0
  63. package/src/run.test.ts +1 -1
  64. package/src/run.ts +57 -14
  65. 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
- * `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
@@ -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 { prepareReleaseCopy } from "./release-copy";
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, opts.env ? { env: opts.env } : {});
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
- return { pulumi: pinned, plunge: pinned };
622
+ out = { pulumi: pinned, plunge: pinned };
623
+ } else {
624
+ out = { pulumi: opts.run ?? defaultPulumiRunner, plunge: opts.plunge ?? defaultPlungeRunner };
582
625
  }
583
- 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;
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
- /** A release's copy of the program, made runnable before its first engine run (#4542). */
688
- 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> {
689
736
  const runners = runnersFor(r, opts);
690
- 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 });
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 readyCopy(r, opts);
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, { part: read.part, preview: read.preview, ...(read.prior !== undefined ? { exported: read.prior } : {}) });
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 readyCopy(r, opts);
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
- ({ stdout } = await runApplyPulumi(runnersFor(r, opts).pulumi, pulumiUpArgs({ stack: input.stack, ...tail }), r.dir));
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, opts.env ? { env: opts.env } : {}).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, opts.env ? { env: opts.env } : {}).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 [];
@@ -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
  }
@@ -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"], ["--refresh"], ["--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"]]) {
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 [