@intentius/chant 0.124.22 → 0.124.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audit/rules-doc.js +1 -1
- package/dist/ci-jobs.d.ts +41 -1
- package/dist/ci-jobs.d.ts.map +1 -1
- package/dist/ci-jobs.js +48 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/build.js +14 -0
- package/dist/cli/handlers/approve.d.ts.map +1 -1
- package/dist/cli/handlers/approve.js +9 -0
- package/dist/cli/handlers/emulator.d.ts +13 -2
- package/dist/cli/handlers/emulator.d.ts.map +1 -1
- package/dist/cli/handlers/emulator.js +63 -3
- package/dist/cli/handlers/graph.js +24 -1
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +7 -1
- package/dist/cli/handlers/run-wave.d.ts.map +1 -1
- package/dist/cli/handlers/run-wave.js +5 -4
- package/dist/cli/handlers/run.d.ts +4 -3
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/run.js +73 -9
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +17 -4
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +31 -0
- package/dist/cli/registry.d.ts +12 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +45 -6
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/cli-support.js +97 -22
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/driver.js +25 -10
- package/dist/graph-detail.d.ts.map +1 -1
- package/dist/graph-detail.js +24 -2
- package/dist/graph-ir.d.ts +43 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-ir.js +27 -0
- package/dist/lexicon.d.ts +25 -1
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/git.d.ts +11 -0
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/git.js +62 -6
- package/dist/lifecycle/no-record.d.ts +59 -0
- package/dist/lifecycle/no-record.d.ts.map +1 -0
- package/dist/lifecycle/no-record.js +88 -0
- package/dist/op/activities/shell.d.ts.map +1 -1
- package/dist/op/activities/shell.js +30 -9
- package/dist/op/emulator-lifecycle.d.ts +23 -0
- package/dist/op/emulator-lifecycle.d.ts.map +1 -1
- package/dist/op/emulator-lifecycle.js +39 -2
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/gate.js +3 -0
- package/dist/op/index.d.ts +1 -1
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/index.js +1 -1
- package/dist/op/local-executor.d.ts +10 -0
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +7 -2
- package/dist/op/op-waves-notify.d.ts +2 -0
- package/dist/op/op-waves-notify.d.ts.map +1 -1
- package/dist/op/op-waves-notify.js +9 -3
- package/dist/op/op-waves-run.d.ts +8 -2
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves-run.js +36 -11
- package/dist/op/op-waves.d.ts +85 -1
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/op/op-waves.js +153 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/op/runtimes/local.js +6 -0
- package/dist/op/step-console.d.ts +65 -0
- package/dist/op/step-console.d.ts.map +1 -0
- package/dist/op/step-console.js +140 -0
- package/dist/point-decision.d.ts.map +1 -1
- package/dist/point-decision.js +3 -0
- package/dist/wave-runtime.mjs +240 -224
- package/dist/workspace/checks/links.d.ts +5 -0
- package/dist/workspace/checks/links.d.ts.map +1 -1
- package/dist/workspace/checks/links.js +54 -8
- package/dist/workspace/checks.d.ts +4 -1
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/checks.js +6 -2
- package/dist/workspace/compose-graph.d.ts +26 -4
- package/dist/workspace/compose-graph.d.ts.map +1 -1
- package/dist/workspace/compose-graph.js +21 -7
- package/dist/workspace/dbt-project-reader.d.ts +36 -0
- package/dist/workspace/dbt-project-reader.d.ts.map +1 -0
- package/dist/workspace/dbt-project-reader.js +290 -0
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/graph-cli.js +3 -1
- package/dist/workspace/graph.schema.json +109 -4
- package/dist/workspace/intent.js +7 -2
- package/dist/workspace/kind-link-readers.d.ts +84 -0
- package/dist/workspace/kind-link-readers.d.ts.map +1 -0
- package/dist/workspace/kind-link-readers.js +53 -0
- package/dist/workspace/kinds.d.ts +15 -0
- package/dist/workspace/kinds.d.ts.map +1 -1
- package/dist/workspace/kinds.js +11 -0
- package/dist/workspace/lexicon-outputs.d.ts +63 -0
- package/dist/workspace/lexicon-outputs.d.ts.map +1 -0
- package/dist/workspace/lexicon-outputs.js +241 -0
- package/dist/workspace/links.d.ts +29 -7
- package/dist/workspace/links.d.ts.map +1 -1
- package/dist/workspace/links.js +109 -34
- package/dist/workspace/member-handles.d.ts +18 -1
- package/dist/workspace/member-handles.d.ts.map +1 -1
- package/dist/workspace/member-handles.js +31 -14
- package/dist/workspace/nested-graph.d.ts +1 -1
- package/dist/workspace/nested-graph.d.ts.map +1 -1
- package/dist/workspace/nested-graph.js +5 -1
- package/dist/workspace/records-checkout.d.ts +11 -1
- package/dist/workspace/records-checkout.d.ts.map +1 -1
- package/dist/workspace/records-checkout.js +38 -2
- package/dist/workspace/runs.d.ts.map +1 -1
- package/dist/workspace/runs.js +4 -2
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/status.js +3 -2
- package/dist/workspace/workspace-kinds.schema.json +5 -0
- package/package.json +1 -1
- package/src/audit/rules-doc.ts +1 -1
- package/src/ci-jobs.ts +58 -1
- package/src/cli/commands/build.ts +18 -0
- package/src/cli/handlers/approve.ts +9 -0
- package/src/cli/handlers/emulator.test.ts +61 -1
- package/src/cli/handlers/emulator.ts +65 -4
- package/src/cli/handlers/graph.ts +22 -1
- package/src/cli/handlers/promote.test.ts +7 -0
- package/src/cli/handlers/promote.ts +7 -1
- package/src/cli/handlers/run-no-record.e2e.test.ts +87 -0
- package/src/cli/handlers/run-wave.ts +5 -4
- package/src/cli/handlers/run.test.ts +126 -0
- package/src/cli/handlers/run.ts +69 -9
- package/src/cli/main.test.ts +24 -0
- package/src/cli/main.ts +17 -4
- package/src/cli/parse-args.ts +21 -0
- package/src/cli/registry.ts +12 -0
- package/src/components/cli-support.test.ts +75 -0
- package/src/components/cli-support.ts +130 -25
- package/src/components/driver.test.ts +28 -0
- package/src/components/driver.ts +23 -10
- package/src/components/generate-disabled.test.ts +56 -0
- package/src/graph-ci-edges.test.ts +50 -0
- package/src/graph-detail.ts +21 -2
- package/src/graph-ir.test.ts +23 -1
- package/src/graph-ir.ts +65 -0
- package/src/lexicon.ts +26 -1
- package/src/lifecycle/git.ts +61 -6
- package/src/lifecycle/no-record.test.ts +175 -0
- package/src/lifecycle/no-record.ts +108 -0
- package/src/op/activities/shell.ts +28 -6
- package/src/op/emulator-lifecycle.test.ts +24 -1
- package/src/op/emulator-lifecycle.ts +58 -2
- package/src/op/gate.ts +3 -0
- package/src/op/index.ts +1 -1
- package/src/op/local-executor.ts +20 -3
- package/src/op/op-waves-jobs.test.ts +213 -0
- package/src/op/op-waves-notify.ts +11 -5
- package/src/op/op-waves-run.ts +42 -11
- package/src/op/op-waves.ts +202 -2
- package/src/op/runtimes/local.ts +6 -0
- package/src/op/step-console.test.ts +150 -0
- package/src/op/step-console.ts +155 -0
- package/src/point-decision-bundle.test.ts +1 -0
- package/src/point-decision.ts +3 -0
- package/src/wave-loop-golden.test.ts +3 -0
- package/src/workspace/checks/links.ts +57 -8
- package/src/workspace/checks.e2e.test.ts +1 -1
- package/src/workspace/checks.ts +9 -2
- package/src/workspace/compose-graph.test.ts +55 -0
- package/src/workspace/compose-graph.ts +33 -9
- package/src/workspace/dbt-kind.e2e.test.ts +95 -0
- package/src/workspace/dbt-kind.test.ts +283 -0
- package/src/workspace/dbt-project-reader.ts +291 -0
- package/src/workspace/graph-cli.ts +3 -1
- package/src/workspace/graph-contract.test.ts +26 -1
- package/src/workspace/graph.schema.json +109 -4
- package/src/workspace/intent.ts +7 -2
- package/src/workspace/kind-link-readers.ts +109 -0
- package/src/workspace/kinds.ts +21 -1
- package/src/workspace/lexicon-outputs.ts +272 -0
- package/src/workspace/links.test.ts +22 -0
- package/src/workspace/links.ts +129 -34
- package/src/workspace/member-handles.ts +28 -8
- package/src/workspace/nested-graph.test.ts +14 -0
- package/src/workspace/nested-graph.ts +7 -2
- package/src/workspace/records-checkout.ts +36 -2
- package/src/workspace/records-uncommitted.test.ts +5 -1
- package/src/workspace/runs.ts +4 -2
- package/src/workspace/status.ts +3 -2
- package/src/workspace/workspace-kinds.schema.json +5 -0
package/src/lifecycle/git.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { getRuntime } from "../runtime-adapter";
|
|
8
8
|
import { memberLedgerPrefix, resolveMemberLedger } from "./member-ledger";
|
|
9
9
|
import { LIFECYCLE_LOCAL_NOTE } from "./local-note";
|
|
10
|
+
import { ledgerRecordingOff, NO_RECORD_PUSH_NOTE, overlayTip, setOverlayTip } from "./no-record";
|
|
10
11
|
import type { LedgerReadPort } from "./ledger-port";
|
|
11
12
|
|
|
12
13
|
const STATE_BRANCH = "chant/lifecycle";
|
|
@@ -346,6 +347,23 @@ export async function writeLedgerFiles(
|
|
|
346
347
|
// local race two concurrent writers used to hit silently (whoever called
|
|
347
348
|
// update-ref last simply overwrote the other's tree, no error). A
|
|
348
349
|
// conflict here throws RefCASConflictError instead of clobbering.
|
|
350
|
+
// #3955: a run that records nothing moves its in-memory tip instead of
|
|
351
|
+
// the branch, under the same compare-and-swap: nothing awaits between the
|
|
352
|
+
// read of the tip and the move, so two writes in this process cannot both
|
|
353
|
+
// parent on the same commit.
|
|
354
|
+
if (ledgerRecordingOff()) {
|
|
355
|
+
const key = await overlayKey(cwd);
|
|
356
|
+
const branch = overlayTip(key) === undefined ? await readRefSha(`refs/heads/${STATE_BRANCH}`, { cwd }) : null;
|
|
357
|
+
// Read again after the await: another write may have moved the tip meanwhile.
|
|
358
|
+
const moved = overlayTip(key);
|
|
359
|
+
const current = moved !== undefined ? moved : branch;
|
|
360
|
+
if (current === parentRef) {
|
|
361
|
+
setOverlayTip(key, commitSha, files.map((f) => f.path), commitMessage);
|
|
362
|
+
return commitSha;
|
|
363
|
+
}
|
|
364
|
+
lastErr = new RefCASConflictError(`refs/heads/${STATE_BRANCH}`, parentRef, `the run's unrecorded tip is ${current ?? "(none)"}`);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
349
367
|
try {
|
|
350
368
|
await updateRefCAS(`refs/heads/${STATE_BRANCH}`, commitSha, parentRef, { cwd });
|
|
351
369
|
return commitSha;
|
|
@@ -374,7 +392,7 @@ export async function readBlobFromPath(
|
|
|
374
392
|
const rt = getRuntime();
|
|
375
393
|
const dir = await ledgerDir(environment, opts);
|
|
376
394
|
const result = await rt.spawn(
|
|
377
|
-
["git", "show", `${
|
|
395
|
+
["git", "show", `${await ledgerRevision(opts?.cwd)}:${dir}/${filename}`],
|
|
378
396
|
{ cwd: opts?.cwd },
|
|
379
397
|
);
|
|
380
398
|
if (result.exitCode !== 0) return null;
|
|
@@ -396,7 +414,7 @@ export async function readPathSha(
|
|
|
396
414
|
const rt = getRuntime();
|
|
397
415
|
const dir = await ledgerDir(environment, opts);
|
|
398
416
|
const result = await rt.spawn(
|
|
399
|
-
["git", "rev-parse", "--verify", `${
|
|
417
|
+
["git", "rev-parse", "--verify", `${await ledgerRevision(opts?.cwd)}:${dir}/${filename}`],
|
|
400
418
|
{ cwd: opts?.cwd },
|
|
401
419
|
);
|
|
402
420
|
if (result.exitCode !== 0) return null;
|
|
@@ -566,7 +584,7 @@ export async function listLedgerTrees(opts?: LedgerLocation): Promise<string[]>
|
|
|
566
584
|
// `--full-tree`: from a subdirectory, a bare `ls-tree` lists only that
|
|
567
585
|
// subdirectory's part of the tree (#2550).
|
|
568
586
|
const rootResult = await rt.spawn(
|
|
569
|
-
["git", "ls-tree", "--full-tree", base === "" ?
|
|
587
|
+
["git", "ls-tree", "--full-tree", base === "" ? tip : `${tip}:${base}`],
|
|
570
588
|
{ cwd: opts?.cwd },
|
|
571
589
|
);
|
|
572
590
|
if (rootResult.exitCode !== 0) return [];
|
|
@@ -592,7 +610,7 @@ export async function listFilesInDir(dir: string, opts?: LedgerLocation): Promis
|
|
|
592
610
|
const rt = getRuntime();
|
|
593
611
|
const path = await ledgerDir(dir, opts);
|
|
594
612
|
const lsResult = await rt.spawn(
|
|
595
|
-
["git", "ls-tree", "--full-tree", "--name-only", `${
|
|
613
|
+
["git", "ls-tree", "--full-tree", "--name-only", `${tip}:${path}/`],
|
|
596
614
|
{ cwd: opts?.cwd },
|
|
597
615
|
);
|
|
598
616
|
if (lsResult.exitCode !== 0) return [];
|
|
@@ -612,7 +630,7 @@ export async function readEnvironmentSnapshots(
|
|
|
612
630
|
// List files in the environment directory
|
|
613
631
|
const dir = await ledgerDir(environment, opts);
|
|
614
632
|
const lsResult = await rt.spawn(
|
|
615
|
-
["git", "ls-tree", "--full-tree", "--name-only", `${
|
|
633
|
+
["git", "ls-tree", "--full-tree", "--name-only", `${await ledgerRevision(opts?.cwd)}:${dir}/`],
|
|
616
634
|
{ cwd: opts?.cwd },
|
|
617
635
|
);
|
|
618
636
|
if (lsResult.exitCode !== 0) return snapshots;
|
|
@@ -641,7 +659,7 @@ export async function listSnapshots(
|
|
|
641
659
|
const base = await ledgerDir("", opts);
|
|
642
660
|
const result = await rt.spawn(
|
|
643
661
|
// `:(top)`: a pathspec is otherwise relative to the directory git runs in.
|
|
644
|
-
["git", "log", "--format=%H %aI %s",
|
|
662
|
+
["git", "log", "--format=%H %aI %s", await ledgerRevision(opts?.cwd), ...(base === "" ? [] : ["--", `:(top)${base}`])],
|
|
645
663
|
{ cwd: opts?.cwd },
|
|
646
664
|
);
|
|
647
665
|
if (result.exitCode !== 0) return [];
|
|
@@ -702,6 +720,8 @@ export async function getRemoteLifecycleBranchSha(
|
|
|
702
720
|
export type LifecyclePushStatus =
|
|
703
721
|
| { status: "pushed"; remote: string; commit: string }
|
|
704
722
|
| { status: "no-remote" }
|
|
723
|
+
/** Recording is off for this process (#3955, ./no-record.ts): nothing was written to the branch, so nothing was pushed. */
|
|
724
|
+
| { status: "not-recorded" }
|
|
705
725
|
| { status: "stale"; remote: string; expected: string | null; stderr: string }
|
|
706
726
|
| { status: "failed"; remote: string; stderr: string };
|
|
707
727
|
|
|
@@ -733,6 +753,7 @@ async function trackRemoteLifecycle(remote: string, sha: string, cwd?: string):
|
|
|
733
753
|
* is the throwing form most callers use.
|
|
734
754
|
*/
|
|
735
755
|
export async function pushLifecycleStatus(opts?: { cwd?: string }): Promise<LifecyclePushStatus> {
|
|
756
|
+
if (ledgerRecordingOff()) return { status: "not-recorded" };
|
|
736
757
|
const rt = getRuntime();
|
|
737
758
|
const cwd = opts?.cwd;
|
|
738
759
|
const found = await ledgerRemote(cwd);
|
|
@@ -799,6 +820,8 @@ export function describeLifecyclePush(result: LifecyclePushStatus): string | nul
|
|
|
799
820
|
return null;
|
|
800
821
|
case "no-remote":
|
|
801
822
|
return `this repository has no remote, so ${STATE_BRANCH} stays local`;
|
|
823
|
+
case "not-recorded":
|
|
824
|
+
return NO_RECORD_PUSH_NOTE;
|
|
802
825
|
case "stale":
|
|
803
826
|
return `"${result.remote}" holds ${STATE_BRANCH} commits this checkout has not fetched, so the push was refused; fetch ${STATE_BRANCH} and write again${said(result.stderr)}`;
|
|
804
827
|
case "failed":
|
|
@@ -820,6 +843,8 @@ export async function pushLifecycleReport(opts?: { cwd?: string }): Promise<{ pu
|
|
|
820
843
|
return { pushed: true };
|
|
821
844
|
case "no-remote":
|
|
822
845
|
return { pushed: false, pushWarning: LIFECYCLE_LOCAL_NOTE };
|
|
846
|
+
case "not-recorded":
|
|
847
|
+
return { pushed: false, pushWarning: NO_RECORD_PUSH_NOTE };
|
|
823
848
|
case "stale":
|
|
824
849
|
return { pushed: false, pushWarning: new StaleLifecycleBranchError(result.expected, result.stderr).message };
|
|
825
850
|
case "failed":
|
|
@@ -1276,6 +1301,9 @@ export type PushRefStatus = "pushed" | "no-remote" | "rejected";
|
|
|
1276
1301
|
* `pushed`.
|
|
1277
1302
|
*/
|
|
1278
1303
|
export async function pushRefStatus(ref: string, opts?: { cwd?: string; expect?: string | null }): Promise<PushRefStatus> {
|
|
1304
|
+
// #3955: a run that records nothing pushes nothing, a lease ref included.
|
|
1305
|
+
// The lease still holds in this checkout, the way it does in one with no remote.
|
|
1306
|
+
if (ledgerRecordingOff()) return "no-remote";
|
|
1279
1307
|
const rt = getRuntime();
|
|
1280
1308
|
const remoteResult = await rt.spawn(["git", "remote"], { cwd: opts?.cwd });
|
|
1281
1309
|
if (remoteResult.exitCode !== 0 || !remoteResult.stdout.trim()) return "no-remote";
|
|
@@ -1382,6 +1410,10 @@ interface TreeLine {
|
|
|
1382
1410
|
}
|
|
1383
1411
|
|
|
1384
1412
|
async function getStateBranchTip(cwd?: string): Promise<string | null> {
|
|
1413
|
+
if (ledgerRecordingOff()) {
|
|
1414
|
+
const tip = overlayTip(await overlayKey(cwd));
|
|
1415
|
+
if (tip !== undefined) return tip;
|
|
1416
|
+
}
|
|
1385
1417
|
const rt = getRuntime();
|
|
1386
1418
|
const result = await rt.spawn(
|
|
1387
1419
|
["git", "rev-parse", "--verify", `refs/heads/${STATE_BRANCH}`],
|
|
@@ -1391,6 +1423,29 @@ async function getStateBranchTip(cwd?: string): Promise<string | null> {
|
|
|
1391
1423
|
return result.stdout.trim();
|
|
1392
1424
|
}
|
|
1393
1425
|
|
|
1426
|
+
/**
|
|
1427
|
+
* What a read names to see the ledger: `chant/lifecycle`, or, in a run that
|
|
1428
|
+
* records nothing (#3955), the in-memory tip its own writes built, once it
|
|
1429
|
+
* has written. Reads outside this module that name the branch themselves go
|
|
1430
|
+
* through here so they see the run's writes too.
|
|
1431
|
+
*/
|
|
1432
|
+
export async function ledgerRevision(cwd?: string): Promise<string> {
|
|
1433
|
+
if (!ledgerRecordingOff()) return STATE_BRANCH;
|
|
1434
|
+
return overlayTip(await overlayKey(cwd)) ?? STATE_BRANCH;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
/** The repository a run's in-memory ledger tip belongs to (#3955): its common git directory, so worktrees and members share one. */
|
|
1438
|
+
const overlayKeys = new Map<string, string>();
|
|
1439
|
+
async function overlayKey(cwd?: string): Promise<string> {
|
|
1440
|
+
const from = cwd ?? process.cwd();
|
|
1441
|
+
const cached = overlayKeys.get(from);
|
|
1442
|
+
if (cached !== undefined) return cached;
|
|
1443
|
+
const result = await getRuntime().spawn(["git", "rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd: from, env: C_LOCALE_ENV });
|
|
1444
|
+
const key = result.exitCode === 0 && result.stdout.trim() !== "" ? result.stdout.trim() : from;
|
|
1445
|
+
overlayKeys.set(from, key);
|
|
1446
|
+
return key;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1394
1449
|
/**
|
|
1395
1450
|
* The entries of directory `dir` (branch-relative, `""` for the root) in
|
|
1396
1451
|
* commit `tip`, or an empty map when it does not exist. Always pinned to the
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3955: a run that records nothing leaves `chant/lifecycle` exactly as it
|
|
3
|
+
* found it, locally and on the remote, while reading its own writes back.
|
|
4
|
+
*/
|
|
5
|
+
import { afterEach, describe, expect, test } from "vitest";
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { listFilesInDir, pushLifecycleStatus, pushRefStatus, readBlobFromPath, writeBlobToPath } from "./git";
|
|
11
|
+
import { appendReleaseRecord } from "./release-ledger";
|
|
12
|
+
import { GateNotRecordableError, ledgerRecordingOff, noRecordEndNote, resetNoRecordLedger, unrecordedWrites } from "./no-record";
|
|
13
|
+
import { runOpLocally } from "../op/local-executor";
|
|
14
|
+
import { evaluateGate, gitGateLedgerPort, memoryGateLedgerPort } from "../op/gate";
|
|
15
|
+
import { lifecycleReceiptStore } from "../op/lifecycle-receipt-store";
|
|
16
|
+
import type { OpConfig } from "../op/types";
|
|
17
|
+
|
|
18
|
+
const dirs: string[] = [];
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
delete process.env.CHANT_NO_RECORD;
|
|
22
|
+
resetNoRecordLedger();
|
|
23
|
+
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function git(args: string[], cwd: string): string {
|
|
27
|
+
return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function tmp(name: string): string {
|
|
31
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), `no-record-${name}-`)));
|
|
32
|
+
dirs.push(dir);
|
|
33
|
+
return dir;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A clone of a bare remote, with a `chant/lifecycle` holding one recorded release and pushed. */
|
|
37
|
+
async function recordedCheckout(): Promise<{ dir: string; remote: string }> {
|
|
38
|
+
const remote = tmp("remote");
|
|
39
|
+
git(["init", "-q", "--bare", "-b", "main"], remote);
|
|
40
|
+
const dir = tmp("checkout");
|
|
41
|
+
git(["clone", "-q", remote, dir], tmpdir());
|
|
42
|
+
git(["config", "user.email", "test@chant.dev"], dir);
|
|
43
|
+
git(["config", "user.name", "Test"], dir);
|
|
44
|
+
writeFileSync(join(dir, "README.md"), "fixture\n");
|
|
45
|
+
git(["add", "README.md"], dir);
|
|
46
|
+
git(["commit", "-q", "-m", "init"], dir);
|
|
47
|
+
git(["push", "-q", "origin", "main"], dir);
|
|
48
|
+
await writeBlobToPath("prod", "releases.jsonl", '{"seed":true}\n', "seed", { cwd: dir });
|
|
49
|
+
expect((await pushLifecycleStatus({ cwd: dir })).status).toBe("pushed");
|
|
50
|
+
return { dir, remote };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function refs(dir: string, remote: string): { local: string; remote: string } {
|
|
54
|
+
return {
|
|
55
|
+
local: git(["rev-parse", "refs/heads/chant/lifecycle"], dir),
|
|
56
|
+
remote: git(["rev-parse", "refs/heads/chant/lifecycle"], remote),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("CHANT_NO_RECORD", () => {
|
|
61
|
+
test("is off unless set to something other than empty, 0 or false", () => {
|
|
62
|
+
expect(ledgerRecordingOff({})).toBe(false);
|
|
63
|
+
expect(ledgerRecordingOff({ CHANT_NO_RECORD: "" })).toBe(false);
|
|
64
|
+
expect(ledgerRecordingOff({ CHANT_NO_RECORD: "0" })).toBe(false);
|
|
65
|
+
expect(ledgerRecordingOff({ CHANT_NO_RECORD: "false" })).toBe(false);
|
|
66
|
+
expect(ledgerRecordingOff({ CHANT_NO_RECORD: "1" })).toBe(true);
|
|
67
|
+
expect(ledgerRecordingOff({ CHANT_NO_RECORD: "yes" })).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("writes stay in memory and read back, and the ledger ref and the remote are unchanged", async () => {
|
|
71
|
+
const { dir, remote } = await recordedCheckout();
|
|
72
|
+
const before = refs(dir, remote);
|
|
73
|
+
|
|
74
|
+
process.env.CHANT_NO_RECORD = "1";
|
|
75
|
+
await writeBlobToPath("prod", "receipts/x.receipt", "sha256:1\n", "Receipt x", { cwd: dir });
|
|
76
|
+
await appendReleaseRecord(
|
|
77
|
+
{ component: "api", env: "prod", digest: "sha256:abc", actor: "ci", gitSha: "0".repeat(40), runId: "r1", timestamp: "2026-10-12T00:00:00.000Z" } as never,
|
|
78
|
+
{ cwd: dir },
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// The run sees its own writes on top of what was recorded before it.
|
|
82
|
+
expect(await readBlobFromPath("prod", "receipts/x.receipt", { cwd: dir })).toBe("sha256:1\n");
|
|
83
|
+
const releases = (await readBlobFromPath("prod", "releases.jsonl", { cwd: dir }))!;
|
|
84
|
+
expect(releases).toContain('{"seed":true}');
|
|
85
|
+
expect(releases).toContain("sha256:abc");
|
|
86
|
+
expect(await listFilesInDir("prod", { cwd: dir })).toEqual(expect.arrayContaining(["receipts", "releases.jsonl"]));
|
|
87
|
+
|
|
88
|
+
// Nothing reaches the branch or the remote, a lease ref included.
|
|
89
|
+
expect(await pushLifecycleStatus({ cwd: dir })).toEqual({ status: "not-recorded" });
|
|
90
|
+
git(["update-ref", "refs/chant/lease/x", git(["rev-parse", "HEAD"], dir)], dir);
|
|
91
|
+
expect(await pushRefStatus("refs/chant/lease/x", { cwd: dir })).toBe("no-remote");
|
|
92
|
+
expect(git(["ls-remote", remote, "refs/chant/lease/x"], dir)).toBe("");
|
|
93
|
+
expect(refs(dir, remote)).toEqual(before);
|
|
94
|
+
expect(unrecordedWrites().map((w) => w.path)).toEqual(["prod/receipts/x.receipt", "prod/releases.jsonl"]);
|
|
95
|
+
expect(noRecordEndNote("--no-record")).toContain("2 ledger writes were dropped (2 files)");
|
|
96
|
+
|
|
97
|
+
// Once the run is over, the ledger reads as it was.
|
|
98
|
+
delete process.env.CHANT_NO_RECORD;
|
|
99
|
+
resetNoRecordLedger();
|
|
100
|
+
expect(await readBlobFromPath("prod", "receipts/x.receipt", { cwd: dir })).toBeNull();
|
|
101
|
+
expect(await readBlobFromPath("prod", "releases.jsonl", { cwd: dir })).toBe('{"seed":true}\n');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("an Op run records no run, no receipt and no push, and its steps still read their own receipts", async () => {
|
|
105
|
+
const { dir, remote } = await recordedCheckout();
|
|
106
|
+
const before = refs(dir, remote);
|
|
107
|
+
const receipts = lifecycleReceiptStore({ environment: "prod", cwd: dir });
|
|
108
|
+
const ref = { name: "migrate:001", effect: "migrate", flavor: "hash", inputs: {} } as const;
|
|
109
|
+
let readBack: string | undefined;
|
|
110
|
+
const op = {
|
|
111
|
+
name: "migrate",
|
|
112
|
+
overview: "migrate",
|
|
113
|
+
phases: [{ name: "Apply", steps: [{ kind: "activity", fn: "apply", args: {} }, { kind: "activity", fn: "check", args: {} }] }],
|
|
114
|
+
} as unknown as OpConfig;
|
|
115
|
+
const activities = new Map([
|
|
116
|
+
["apply", async () => { await receipts.write(ref, "sha256:1"); return {}; }],
|
|
117
|
+
["check", async () => { readBack = await receipts.read(ref); return {}; }],
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
process.env.CHANT_NO_RECORD = "1";
|
|
121
|
+
const result = await runOpLocally(op, activities as never, {}, undefined, { runId: "r1", ledger: { cwd: dir }, cwd: dir });
|
|
122
|
+
|
|
123
|
+
expect(result.status).toBe("ok");
|
|
124
|
+
expect(readBack).toBe("sha256:1");
|
|
125
|
+
expect(refs(dir, remote)).toEqual(before);
|
|
126
|
+
// The run record was written, and dropped with the receipt.
|
|
127
|
+
expect(unrecordedWrites().some((w) => w.path.startsWith("local/runs__migrate"))).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("a gate step fails, writes no pending fact, and nothing after it runs but onFailure", async () => {
|
|
131
|
+
const { dir, remote } = await recordedCheckout();
|
|
132
|
+
const before = refs(dir, remote);
|
|
133
|
+
const ran: string[] = [];
|
|
134
|
+
const op = {
|
|
135
|
+
name: "release",
|
|
136
|
+
overview: "release",
|
|
137
|
+
phases: [
|
|
138
|
+
{ name: "Plan", steps: [{ kind: "activity", fn: "plan", args: {} }] },
|
|
139
|
+
{ name: "Apply", steps: [{ kind: "gate", gate: "approve-prod" }, { kind: "activity", fn: "apply", args: {} }] },
|
|
140
|
+
],
|
|
141
|
+
onFailure: [{ name: "Undo", steps: [{ kind: "activity", fn: "undo", args: {} }] }],
|
|
142
|
+
} as unknown as OpConfig;
|
|
143
|
+
const activities = new Map([
|
|
144
|
+
["plan", async () => { ran.push("plan"); return {}; }],
|
|
145
|
+
["apply", async () => { ran.push("apply"); return {}; }],
|
|
146
|
+
["undo", async () => { ran.push("undo"); return {}; }],
|
|
147
|
+
]);
|
|
148
|
+
|
|
149
|
+
process.env.CHANT_NO_RECORD = "1";
|
|
150
|
+
let result;
|
|
151
|
+
try {
|
|
152
|
+
result = await runOpLocally(op, activities as never, {}, undefined, { runId: "r1", ledger: { cwd: dir }, cwd: dir, gates: gitGateLedgerPort({ cwd: dir }) });
|
|
153
|
+
} catch (err) {
|
|
154
|
+
result = (err as { result: Awaited<ReturnType<typeof runOpLocally>> }).result;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
expect(result.status).toBe("fail");
|
|
158
|
+
// A failing step like any other: onFailure runs, and nothing after the gate does.
|
|
159
|
+
expect(ran).toEqual(["plan", "undo"]);
|
|
160
|
+
const gate = result.records.find((r) => r.fn.includes("approve-prod"));
|
|
161
|
+
expect(gate).toMatchObject({ status: "fail" });
|
|
162
|
+
expect(gate!.error).toContain('gate "approve-prod" of "release" cannot be passed under --no-record');
|
|
163
|
+
expect(unrecordedWrites().some((w) => w.path.startsWith("_gates/"))).toBe(false);
|
|
164
|
+
expect(refs(dir, remote)).toEqual(before);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("a standing approval does not pass the gate", async () => {
|
|
168
|
+
const port = memoryGateLedgerPort({
|
|
169
|
+
resolutions: [{ op: "release", gate: "approve-prod", resolvedBy: "alice", timestamp: "2026-10-12T00:00:00.000Z" } as never],
|
|
170
|
+
});
|
|
171
|
+
expect((await evaluateGate(port, { op: "release", gate: "approve-prod" })).satisfied).toBe(true);
|
|
172
|
+
process.env.CHANT_NO_RECORD = "1";
|
|
173
|
+
await expect(evaluateGate(port, { op: "release", gate: "approve-prod" })).rejects.toBeInstanceOf(GateNotRecordableError);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A run that records nothing on `chant/lifecycle` (#3955): `chant run
|
|
3
|
+
* --no-record`, or `CHANT_NO_RECORD=1` in the environment.
|
|
4
|
+
*
|
|
5
|
+
* Test and claim runs used to leave real records on the ledger and push them
|
|
6
|
+
* to origin, because every write path in `./git.ts` appends and pushes. In
|
|
7
|
+
* this mode the run still executes every step, and every ledger write still
|
|
8
|
+
* builds its commit, but the commit goes onto an in-memory tip instead of the
|
|
9
|
+
* branch ref:
|
|
10
|
+
*
|
|
11
|
+
* - `refs/heads/chant/lifecycle` is never updated, so the branch is exactly
|
|
12
|
+
* what it was before the run.
|
|
13
|
+
* - Nothing is pushed: neither the ledger branch nor a lease ref.
|
|
14
|
+
* - Reads in the same process see the run's own writes, so a step that reads
|
|
15
|
+
* back what an earlier step wrote (an effect receipt, a work lease) behaves
|
|
16
|
+
* as it would in a recorded run. The tip is dropped when the process exits.
|
|
17
|
+
*
|
|
18
|
+
* A gate cannot be passed in this mode ({@link GateNotRecordableError}). An
|
|
19
|
+
* approval lives on the ledger, and passing a gate spends it: a run that
|
|
20
|
+
* passed a gate here would apply under an approval while leaving it unspent
|
|
21
|
+
* for the next run, and a pending fact it wrote could never be approved.
|
|
22
|
+
*
|
|
23
|
+
* The state is per process and keyed by the repository's git directory, so a
|
|
24
|
+
* workspace member and the root share one tip. A child `chant` process gets
|
|
25
|
+
* the mode through the environment and keeps its own tip.
|
|
26
|
+
*
|
|
27
|
+
* Kept free of git and the runtime adapter so the gate deciders and the run
|
|
28
|
+
* renderers can import it without loading `./git.ts`.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** The environment variable that turns recording off for every chant process that inherits it. */
|
|
32
|
+
export const NO_RECORD_ENV = "CHANT_NO_RECORD";
|
|
33
|
+
|
|
34
|
+
/** Whether this process records nothing on `chant/lifecycle`: `CHANT_NO_RECORD` set to anything but empty, `0` or `false`. */
|
|
35
|
+
export function ledgerRecordingOff(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
36
|
+
const value = env[NO_RECORD_ENV]?.trim().toLowerCase();
|
|
37
|
+
return value !== undefined && value !== "" && value !== "0" && value !== "false";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The line a run prints before its first step when it records nothing. `source` is what turned recording off. */
|
|
41
|
+
export function noRecordStartNote(source: string): string {
|
|
42
|
+
return `not recording (${source}): nothing this run writes goes on chant/lifecycle, and nothing is pushed. A gate step fails.`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The push warning a ledger write reports in this mode, in place of a push. */
|
|
46
|
+
export const NO_RECORD_PUSH_NOTE = "not recorded (--no-record): chant/lifecycle was not written or pushed";
|
|
47
|
+
|
|
48
|
+
/** What the in-memory ledger holds for one repository: its tip, and the files written to it. */
|
|
49
|
+
interface Overlay {
|
|
50
|
+
tip: string | null;
|
|
51
|
+
writes: { path: string; message: string }[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const overlays = new Map<string, Overlay>();
|
|
55
|
+
|
|
56
|
+
/** The tip this process's writes have built for the repository at `key`, or `undefined` before its first write. */
|
|
57
|
+
export function overlayTip(key: string): string | null | undefined {
|
|
58
|
+
return overlays.get(key)?.tip;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Move the in-memory tip for `key` to `commit`, noting what the commit wrote. */
|
|
62
|
+
export function setOverlayTip(key: string, commit: string, paths: readonly string[], message: string): void {
|
|
63
|
+
const overlay = overlays.get(key) ?? { tip: null, writes: [] };
|
|
64
|
+
overlay.tip = commit;
|
|
65
|
+
for (const path of paths) overlay.writes.push({ path, message });
|
|
66
|
+
overlays.set(key, overlay);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Every ledger file this process wrote and did not record, in write order, across repositories. */
|
|
70
|
+
export function unrecordedWrites(): { path: string; message: string }[] {
|
|
71
|
+
return [...overlays.values()].flatMap((o) => o.writes);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The line a run prints after its last step: that nothing was recorded, and how many ledger writes it dropped. */
|
|
75
|
+
export function noRecordEndNote(source: string): string {
|
|
76
|
+
const writes = unrecordedWrites();
|
|
77
|
+
const files = new Set(writes.map((w) => w.path)).size;
|
|
78
|
+
const dropped =
|
|
79
|
+
writes.length === 0
|
|
80
|
+
? "the run made no ledger writes"
|
|
81
|
+
: `${writes.length} ledger write${writes.length === 1 ? " was" : "s were"} dropped (${files} file${files === 1 ? "" : "s"})`;
|
|
82
|
+
return `not recorded (${source}): nothing went on chant/lifecycle and nothing was pushed; ${dropped}.`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Forget every in-memory tip. For tests, and for an in-process caller that runs twice. */
|
|
86
|
+
export function resetNoRecordLedger(): void {
|
|
87
|
+
overlays.clear();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Thrown when a run that records nothing reaches a gate. The executor turns it into a failing gate step. */
|
|
91
|
+
export class GateNotRecordableError extends Error {
|
|
92
|
+
constructor(
|
|
93
|
+
public readonly op: string,
|
|
94
|
+
public readonly gate: string,
|
|
95
|
+
) {
|
|
96
|
+
super(
|
|
97
|
+
`gate "${gate}" of "${op}" cannot be passed under --no-record: a gate is decided and approved on ` +
|
|
98
|
+
"chant/lifecycle, which this run does not write. " +
|
|
99
|
+
"Run without --no-record (and without CHANT_NO_RECORD) to stop at the gate for approval.",
|
|
100
|
+
);
|
|
101
|
+
this.name = "GateNotRecordableError";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Throw {@link GateNotRecordableError} when recording is off. Called by every gate decider before it reads the ledger. */
|
|
106
|
+
export function refuseGateWithoutRecording(op: string, gate: string): void {
|
|
107
|
+
if (ledgerRecordingOff()) throw new GateNotRecordableError(op, gate);
|
|
108
|
+
}
|
|
@@ -3,6 +3,7 @@ import { promisify } from "node:util";
|
|
|
3
3
|
import { GateWait, asPendingGate } from "../gate-wait";
|
|
4
4
|
import type { PendingGateRecord } from "../../lifecycle/gate-ledger";
|
|
5
5
|
import { liveRunEnv } from "../run-live";
|
|
6
|
+
import { hasStepConsole, stepLineSplitter } from "../step-console";
|
|
6
7
|
|
|
7
8
|
const execAsync = promisify(exec);
|
|
8
9
|
|
|
@@ -114,8 +115,17 @@ interface ExecFailure extends Error {
|
|
|
114
115
|
*/
|
|
115
116
|
export async function shellCmd(args: ShellCmdArgs, signal?: AbortSignal): Promise<ShellCmdResult> {
|
|
116
117
|
const okExit = args.okExit ?? [0];
|
|
118
|
+
// #3956: under a step console (../step-console.ts) every line the command
|
|
119
|
+
// writes, stdout and stderr, is printed as it arrives; the result still
|
|
120
|
+
// carries both whole. Without one, stderr is printed when the command ends.
|
|
121
|
+
const streamed = hasStepConsole();
|
|
122
|
+
const printStderr = (text: string) => {
|
|
123
|
+
if (text && !streamed) console.error(text);
|
|
124
|
+
};
|
|
125
|
+
const out = stepLineSplitter();
|
|
126
|
+
const err = stepLineSplitter();
|
|
117
127
|
try {
|
|
118
|
-
const
|
|
128
|
+
const running = execAsync(args.cmd, {
|
|
119
129
|
cwd: args.cwd,
|
|
120
130
|
// In a run that keeps an in-flight record, CHANT_RUN_ACTIVITY names the
|
|
121
131
|
// file the command appends activity lines to (../run-live.ts).
|
|
@@ -123,10 +133,22 @@ export async function shellCmd(args: ShellCmdArgs, signal?: AbortSignal): Promis
|
|
|
123
133
|
maxBuffer: MAX_STDOUT_BYTES,
|
|
124
134
|
signal,
|
|
125
135
|
});
|
|
126
|
-
if (
|
|
136
|
+
if (streamed) {
|
|
137
|
+
running.child.stdout?.on("data", (chunk: string | Buffer) => out.push(chunk));
|
|
138
|
+
running.child.stderr?.on("data", (chunk: string | Buffer) => err.push(chunk));
|
|
139
|
+
}
|
|
140
|
+
let settled: { stdout: string; stderr: string };
|
|
141
|
+
try {
|
|
142
|
+
settled = await running;
|
|
143
|
+
} finally {
|
|
144
|
+
out.end();
|
|
145
|
+
err.end();
|
|
146
|
+
}
|
|
147
|
+
const { stdout, stderr } = settled;
|
|
148
|
+
printStderr(stderr);
|
|
127
149
|
return withJson({ stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0 }, args);
|
|
128
|
-
} catch (
|
|
129
|
-
const failure =
|
|
150
|
+
} catch (caught) {
|
|
151
|
+
const failure = caught as ExecFailure;
|
|
130
152
|
const exitCode = typeof failure.code === "number" ? failure.code : undefined;
|
|
131
153
|
// A signal kill (Ctrl-C, the profile's timeout, a maxBuffer overflow) is
|
|
132
154
|
// not an exit status the author declared anything about, so it rethrows
|
|
@@ -134,12 +156,12 @@ export async function shellCmd(args: ShellCmdArgs, signal?: AbortSignal): Promis
|
|
|
134
156
|
const died = failure.killed === true || typeof failure.signal === "string" || signal?.aborted === true;
|
|
135
157
|
if (exitCode !== undefined && !died && args.gatedExit !== undefined && exitCode === args.gatedExit) {
|
|
136
158
|
const stderrText = (failure.stderr ?? "").trim();
|
|
137
|
-
|
|
159
|
+
printStderr(stderrText);
|
|
138
160
|
throw new GateWait(await commandGate(args, (failure.stdout ?? "").trim(), exitCode));
|
|
139
161
|
}
|
|
140
162
|
if (exitCode !== undefined && !died && okExit.includes(exitCode)) {
|
|
141
163
|
const stderrText = (failure.stderr ?? "").trim();
|
|
142
|
-
|
|
164
|
+
printStderr(stderrText);
|
|
143
165
|
return withJson({ stdout: (failure.stdout ?? "").trim(), stderr: stderrText, exitCode }, args);
|
|
144
166
|
}
|
|
145
167
|
if (exitCode !== undefined && !died) {
|
|
@@ -26,7 +26,7 @@ vi.mock("node:child_process", async () => {
|
|
|
26
26
|
return { ...actual, exec: execStub, execFile: execFileStub };
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
-
const { clientHosts, emulatorLifecycle, endpointOnHost, hostPortInUse, parsePublishedAddresses, pickReachableEndpoint } =
|
|
29
|
+
const { checkSettings, clientHosts, emulatorLifecycle, endpointOnHost, hostPortInUse, parsePublishedAddresses, pickReachableEndpoint } =
|
|
30
30
|
await import("./emulator-lifecycle");
|
|
31
31
|
|
|
32
32
|
describe("emulatorLifecycle command builders", () => {
|
|
@@ -60,6 +60,29 @@ describe("emulatorLifecycle command builders", () => {
|
|
|
60
60
|
);
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
test("network, settings and loopback go before the image (#3952)", () => {
|
|
64
|
+
const e = emulatorLifecycle({ name: "n", image: "img:1", containerPort: 80, healthPath: "/h", settingsPrefix: "X_" });
|
|
65
|
+
expect(e.runCommand({ network: "loom", settings: { X_MODE: "memory", X_TTL: "30" }, loopback: true })).toBe(
|
|
66
|
+
"docker run -d --rm --name n -p 127.0.0.1:80:80 --network loom -e X_MODE=memory -e X_TTL=30 img:1",
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("a setting value with spaces or shell syntax is quoted as one word (#3952)", () => {
|
|
71
|
+
const e = emulatorLifecycle({ name: "n", image: "img:1", containerPort: 80, healthPath: "/h", settingsPrefix: "X_" });
|
|
72
|
+
expect(e.runCommand({ settings: { X_A: "a b; rm -rf /", X_B: "it's" } })).toBe(
|
|
73
|
+
"docker run -d --rm --name n -p 80:80 -e 'X_A=a b; rm -rf /' -e 'X_B=it'\\''s' img:1",
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("settings outside the prefix, or on a spec without one, are refused (#3952)", () => {
|
|
78
|
+
const e = emulatorLifecycle({ name: "n", image: "img:1", containerPort: 80, healthPath: "/h", settingsPrefix: "X_" });
|
|
79
|
+
expect(() => e.runCommand({ settings: { PATH: "/tmp" } })).toThrow(/setting PATH is not a X_\* variable/);
|
|
80
|
+
expect(() => e.runCommand({ settings: { X_: "1" } })).toThrow(/not a X_\* variable/);
|
|
81
|
+
expect(() => e.runCommand({ settings: { "X_A;B": "1" } })).toThrow(/not a X_\* variable/);
|
|
82
|
+
expect(() => emu.runCommand({ settings: { X_A: "1" } })).toThrow(/takes no settings/);
|
|
83
|
+
expect(() => checkSettings({ name: "n", image: "i", containerPort: 1, settingsPrefix: "X_" }, {})).not.toThrow();
|
|
84
|
+
});
|
|
85
|
+
|
|
63
86
|
test("exists / rm / health / endpoint", () => {
|
|
64
87
|
expect(emu.existsCommand("n")).toBe("docker ps -q -f name=n");
|
|
65
88
|
expect(emu.rmCommand("n")).toBe("docker rm -f n");
|
|
@@ -41,6 +41,13 @@ export interface EmulatorSpec {
|
|
|
41
41
|
ready?: (healthBody: string) => boolean;
|
|
42
42
|
/** Extra `docker run` args inserted before the image (e.g. a socket mount). */
|
|
43
43
|
runArgs?: readonly string[];
|
|
44
|
+
/**
|
|
45
|
+
* The prefix every key of {@link EmulatorUpArgs.settings} must start with,
|
|
46
|
+
* e.g. `FLOCI_` (#3952). Settings reach the container as `-e KEY=VALUE`, and
|
|
47
|
+
* the prefix keeps them to the emulator's own configuration instead of
|
|
48
|
+
* arbitrary env. An emulator without one takes no settings.
|
|
49
|
+
*/
|
|
50
|
+
settingsPrefix?: string;
|
|
44
51
|
/**
|
|
45
52
|
* Where {@link image} is published, so how far behind the pin is can be
|
|
46
53
|
* answered (#1345).
|
|
@@ -245,6 +252,43 @@ export interface EmulatorUpArgs {
|
|
|
245
252
|
intervalMs?: number;
|
|
246
253
|
/** Additional `docker run` args for this call (after `spec.runArgs`, before the image). */
|
|
247
254
|
extraArgs?: readonly string[];
|
|
255
|
+
/** Docker network the container joins (`--network <name>`) (#3952). */
|
|
256
|
+
network?: string;
|
|
257
|
+
/**
|
|
258
|
+
* Emulator settings passed as `-e KEY=VALUE` (#3952). Every key must start
|
|
259
|
+
* with the spec's {@link EmulatorSpec.settingsPrefix}; see {@link checkSettings}.
|
|
260
|
+
*/
|
|
261
|
+
settings?: Record<string, string>;
|
|
262
|
+
/** Publish the port on 127.0.0.1 only, instead of every host interface (#3952). */
|
|
263
|
+
loopback?: boolean;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Refuse settings an emulator does not take (#3952): a key outside the spec's
|
|
268
|
+
* {@link EmulatorSpec.settingsPrefix}, a key that is not an env-var name, or
|
|
269
|
+
* any setting at all for an emulator that declares no prefix. Throws with the
|
|
270
|
+
* first problem found.
|
|
271
|
+
*/
|
|
272
|
+
export function checkSettings(spec: EmulatorSpec, settings: Record<string, string> | undefined): void {
|
|
273
|
+
const keys = Object.keys(settings ?? {});
|
|
274
|
+
if (keys.length === 0) return;
|
|
275
|
+
const prefix = spec.settingsPrefix;
|
|
276
|
+
if (!prefix) {
|
|
277
|
+
throw new Error(`emulator "${spec.name}" takes no settings; got ${keys.join(", ")}`);
|
|
278
|
+
}
|
|
279
|
+
for (const key of keys) {
|
|
280
|
+
if (!key.startsWith(prefix) || key.length === prefix.length || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
281
|
+
throw new Error(`emulator "${spec.name}": setting ${key} is not a ${prefix}* variable; only ${prefix}<NAME> keys reach the container`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Quote one `docker run` word for the shell `up` runs it through: a setting's
|
|
288
|
+
* value or a network name can hold spaces or shell syntax.
|
|
289
|
+
*/
|
|
290
|
+
function shellWord(word: string): string {
|
|
291
|
+
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`;
|
|
248
292
|
}
|
|
249
293
|
|
|
250
294
|
/** The shared lifecycle a per-cloud wrapper adapts. */
|
|
@@ -277,10 +321,14 @@ export function emulatorLifecycle(spec: EmulatorSpec): EmulatorLifecycle {
|
|
|
277
321
|
const name = args.name ?? spec.name;
|
|
278
322
|
const port = args.port ?? spec.containerPort;
|
|
279
323
|
const image = args.image ?? spec.image;
|
|
324
|
+
checkSettings(spec, args.settings);
|
|
325
|
+
const bind = args.loopback ? `127.0.0.1:${port}:${spec.containerPort}` : `${port}:${spec.containerPort}`;
|
|
280
326
|
return [
|
|
281
|
-
"docker", "run", "-d", "--rm", "--name", name, "-p",
|
|
327
|
+
"docker", "run", "-d", "--rm", "--name", name, "-p", bind,
|
|
328
|
+
...(args.network ? ["--network", args.network] : []),
|
|
329
|
+
...Object.entries(args.settings ?? {}).flatMap(([k, v]) => ["-e", `${k}=${v}`]),
|
|
282
330
|
...(spec.runArgs ?? []), ...(args.extraArgs ?? []), image,
|
|
283
|
-
].join(" ");
|
|
331
|
+
].map(shellWord).join(" ");
|
|
284
332
|
};
|
|
285
333
|
const existsCommand = (name: string): string => `docker ps -q -f name=${name}`;
|
|
286
334
|
const rmCommand = (name: string): string => `docker rm -f ${name}`;
|
|
@@ -314,6 +362,8 @@ export function emulatorLifecycle(spec: EmulatorSpec): EmulatorLifecycle {
|
|
|
314
362
|
const port = args.port ?? spec.containerPort;
|
|
315
363
|
const timeoutMs = args.timeoutMs ?? 60_000;
|
|
316
364
|
const intervalMs = args.intervalMs ?? 2_000;
|
|
365
|
+
// Before the reuse check, so a bad key is refused whether or not a container runs.
|
|
366
|
+
checkSettings(spec, args.settings);
|
|
317
367
|
|
|
318
368
|
let running = false;
|
|
319
369
|
try {
|
|
@@ -330,6 +380,12 @@ export function emulatorLifecycle(spec: EmulatorSpec): EmulatorLifecycle {
|
|
|
330
380
|
// Progress → stderr, so a `--json` consumer (chant emulator, behold) reads
|
|
331
381
|
// clean JSON on stdout. runOp/Op activities capture both streams regardless.
|
|
332
382
|
console.error(`emulator container "${name}" already running — reusing`);
|
|
383
|
+
if (args.image || args.network || args.loopback || Object.keys(args.settings ?? {}).length) {
|
|
384
|
+
console.error(
|
|
385
|
+
`emulator "${name}": the image, network, settings and loopback options apply when a container starts, not to this running one. ` +
|
|
386
|
+
`Run \`chant emulator down\` first to start it with them.`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
333
389
|
let published: PublishedAddress[] = [];
|
|
334
390
|
try {
|
|
335
391
|
const { stdout } = await execFileAsync("docker", ["port", name, `${spec.containerPort}/tcp`], { signal });
|