@intentius/chant 0.58.0 → 0.59.0
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/core.d.ts +17 -1
- package/dist/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +15 -4
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts +13 -0
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts +0 -16
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +22 -0
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +14 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +4 -1
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/component.d.ts +19 -4
- package/dist/components/component.d.ts.map +1 -1
- package/dist/components/driver.d.ts +8 -2
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/verbs/run-agent.d.ts +1 -7
- package/dist/components/verbs/run-agent.d.ts.map +1 -1
- package/dist/detectLexicon.d.ts +13 -0
- package/dist/detectLexicon.d.ts.map +1 -1
- package/dist/lexicon.d.ts +170 -3
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +9 -1
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lint/rules/comp/comp004-gate-needs-durable-runtime.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +2 -2
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +62 -2
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/builders.d.ts +2 -2
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/change-signal.d.ts +91 -0
- package/dist/op/change-signal.d.ts.map +1 -0
- package/dist/op/composites/apply-op.d.ts +7 -2
- package/dist/op/composites/apply-op.d.ts.map +1 -1
- package/dist/op/composites/reconcile-op.d.ts.map +1 -1
- package/dist/op/gate-name.d.ts +40 -0
- package/dist/op/gate-name.d.ts.map +1 -0
- package/dist/op/gate-summary.d.ts +43 -0
- package/dist/op/gate-summary.d.ts.map +1 -0
- package/dist/op/index.d.ts +6 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +2 -1
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/operator.d.ts +63 -0
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/types.d.ts +18 -3
- package/dist/op/types.d.ts.map +1 -1
- package/dist/terraform/bridge.d.ts +26 -7
- package/dist/terraform/bridge.d.ts.map +1 -1
- package/dist/terraform/carve-provider.d.ts +28 -2
- package/dist/terraform/carve-provider.d.ts.map +1 -1
- package/dist/terraform/data-source-shape.d.ts +77 -0
- package/dist/terraform/data-source-shape.d.ts.map +1 -0
- package/dist/terraform/graph.d.ts.map +1 -1
- package/dist/terraform/providers/kubernetes.d.ts +5 -0
- package/dist/terraform/providers/kubernetes.d.ts.map +1 -1
- package/dist/terraform/tier-map.d.ts +16 -4
- package/dist/terraform/tier-map.d.ts.map +1 -1
- package/dist/terraform/types.d.ts +8 -0
- package/dist/terraform/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/audit/core.ts +25 -3
- package/src/audit/discover.ts +122 -14
- package/src/cli/commands/audit.test.ts +5 -3
- package/src/cli/commands/audit.ts +5 -1
- package/src/cli/commands/build.test.ts +39 -1
- package/src/cli/commands/build.ts +14 -0
- package/src/cli/commands/carve-bridge.test.ts +237 -14
- package/src/cli/commands/carve-bridge.ts +21 -17
- package/src/cli/commands/carve-emit-k8s.test.ts +17 -10
- package/src/cli/commands/lint.test.ts +146 -1
- package/src/cli/commands/lint.ts +82 -7
- package/src/cli/handlers/operator.ts +81 -0
- package/src/cli/handlers/run.test.ts +132 -3
- package/src/cli/handlers/run.ts +87 -3
- package/src/cli/main.test.ts +9 -11
- package/src/cli/main.ts +5 -27
- package/src/cli/plugins.test.ts +68 -2
- package/src/cli/plugins.ts +39 -0
- package/src/cli/registry.ts +14 -0
- package/src/components/README.md +2 -2
- package/src/components/__fixtures__/neo4j-fanout.json +1 -1
- package/src/components/cli-support.test.ts +18 -7
- package/src/components/cli-support.ts +8 -3
- package/src/components/component-schema.test.ts +18 -2
- package/src/components/component.schema.json +17 -4
- package/src/components/component.test.ts +2 -2
- package/src/components/component.ts +25 -5
- package/src/components/config-defaults.test.ts +2 -2
- package/src/components/driver.test.ts +20 -1
- package/src/components/driver.ts +12 -5
- package/src/components/pilots/neo4j-fanout.pilot.ts +3 -3
- package/src/components/verbs/run-agent.test.ts +19 -0
- package/src/components/verbs/run-agent.ts +1 -7
- package/src/detectLexicon.ts +18 -1
- package/src/discovery/fold-import.test.ts +1 -1
- package/src/fold/foldable-helpers.ts +1 -1
- package/src/graph-ops.test.ts +1 -1
- package/src/lexicon.ts +177 -3
- package/src/lifecycle/gate-ledger.ts +12 -1
- package/src/lint/pipeline-change-gate.test.ts +2 -2
- package/src/lint/rules/comp/comp.test.ts +26 -0
- package/src/lint/rules/comp/comp004-gate-needs-durable-runtime.ts +4 -2
- package/src/lint/rules/op/ops014-converge-rule-refusals.test.ts +1 -1
- package/src/op/activities/index.ts +2 -2
- package/src/op/activities/reconcile.test.ts +82 -2
- package/src/op/activities/reconcile.ts +169 -2
- package/src/op/builders.ts +3 -3
- package/src/op/change-signal.test.ts +117 -0
- package/src/op/change-signal.ts +169 -0
- package/src/op/composites/apply-op.ts +14 -4
- package/src/op/composites/composites.test.ts +17 -4
- package/src/op/composites/reconcile-op.ts +7 -4
- package/src/op/effect-step.test.ts +3 -3
- package/src/op/gate-name.test.ts +65 -0
- package/src/op/gate-name.ts +60 -0
- package/src/op/gate-summary.ts +84 -0
- package/src/op/index.ts +9 -2
- package/src/op/local-executor.test.ts +16 -1
- package/src/op/local-executor.ts +4 -3
- package/src/op/op-ir.test.ts +12 -1
- package/src/op/op-ir.ts +5 -3
- package/src/op/op-verb-class.test.ts +2 -2
- package/src/op/op.test.ts +2 -2
- package/src/op/operator.test.ts +368 -0
- package/src/op/operator.ts +141 -5
- package/src/op/runtimes/local.test.ts +1 -1
- package/src/op/types.ts +23 -3
- package/src/terraform/aws-resources.test.ts +13 -4
- package/src/terraform/bridge.test.ts +22 -9
- package/src/terraform/bridge.ts +89 -28
- package/src/terraform/carve-provider.ts +38 -2
- package/src/terraform/data-source-shape.ts +95 -0
- package/src/terraform/graph.ts +35 -7
- package/src/terraform/providers/kubernetes.ts +48 -2
- package/src/terraform/tier-map.ts +21 -6
- package/src/terraform/types.ts +8 -0
package/src/op/operator.test.ts
CHANGED
|
@@ -9,7 +9,11 @@ import {
|
|
|
9
9
|
runOperatorRound,
|
|
10
10
|
runOperatorForever,
|
|
11
11
|
formatRoundLine,
|
|
12
|
+
formatSignalLine,
|
|
12
13
|
DEFAULT_OPERATOR_INTERVAL_MS,
|
|
14
|
+
type ChangeSubscriber,
|
|
15
|
+
type OperatorSignalEvent,
|
|
16
|
+
type OperatorTickEvent,
|
|
13
17
|
} from "./operator";
|
|
14
18
|
import { readLease } from "../lifecycle/lease";
|
|
15
19
|
import { appendConvergeRecord, readConvergeLedger } from "../lifecycle/converge-ledger";
|
|
@@ -513,3 +517,367 @@ describe("formatRoundLine", () => {
|
|
|
513
517
|
.toContain("not-due:*/10 * * * *");
|
|
514
518
|
});
|
|
515
519
|
});
|
|
520
|
+
|
|
521
|
+
describe("runOperatorForever: a substrate change signal wakes a tick (#1981)", () => {
|
|
522
|
+
async function waitFor(predicate: () => boolean, maxWaitMs = 5_000): Promise<void> {
|
|
523
|
+
const deadline = Date.now() + maxWaitMs;
|
|
524
|
+
while (!predicate() && Date.now() < deadline) {
|
|
525
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* A stub `subscribeChanges` seam. Hands the loop's `onChange`/`onError` back
|
|
531
|
+
* to the test so it can fabricate signals and kill the subscription, with no
|
|
532
|
+
* substrate anywhere near it, which is the whole point of the seam being a
|
|
533
|
+
* plain callback pair.
|
|
534
|
+
*/
|
|
535
|
+
function stubSubscriber(lexicon = "stub") {
|
|
536
|
+
const handle = {
|
|
537
|
+
lexicon,
|
|
538
|
+
subscribes: 0,
|
|
539
|
+
closes: 0,
|
|
540
|
+
onChange: undefined as undefined | (() => void),
|
|
541
|
+
onError: undefined as undefined | ((message: string) => void),
|
|
542
|
+
signal: undefined as undefined | AbortSignal,
|
|
543
|
+
failNextSubscribe: undefined as undefined | string,
|
|
544
|
+
};
|
|
545
|
+
const subscriber: ChangeSubscriber = {
|
|
546
|
+
lexicon,
|
|
547
|
+
async subscribe(ctx) {
|
|
548
|
+
handle.subscribes++;
|
|
549
|
+
if (handle.failNextSubscribe) {
|
|
550
|
+
const message = handle.failNextSubscribe;
|
|
551
|
+
handle.failNextSubscribe = undefined;
|
|
552
|
+
throw new Error(message);
|
|
553
|
+
}
|
|
554
|
+
handle.onChange = ctx.onChange;
|
|
555
|
+
handle.onError = ctx.onError;
|
|
556
|
+
handle.signal = ctx.signal;
|
|
557
|
+
return {
|
|
558
|
+
async close() {
|
|
559
|
+
handle.closes++;
|
|
560
|
+
},
|
|
561
|
+
};
|
|
562
|
+
},
|
|
563
|
+
};
|
|
564
|
+
return { handle, subscriber };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
test("a signal wakes the next round early, well inside the interval", async () => {
|
|
568
|
+
await withTestDir(async (dir) => {
|
|
569
|
+
await initRepo(dir);
|
|
570
|
+
writeFixtureConvergeOp(dir, "staging-converge", "staging");
|
|
571
|
+
const activities = fakeTickActivities(dir, "staging", "staging-converge");
|
|
572
|
+
const { handle, subscriber } = stubSubscriber("k8s");
|
|
573
|
+
|
|
574
|
+
const controller = new AbortController();
|
|
575
|
+
const rounds: number[] = [];
|
|
576
|
+
const signalEvents: OperatorSignalEvent[] = [];
|
|
577
|
+
const loop = runOperatorForever({
|
|
578
|
+
cwd: dir,
|
|
579
|
+
holder: "op-a",
|
|
580
|
+
intervalMs: 60_000, // a full minute: nothing but a signal can produce a second round
|
|
581
|
+
signalFloorMs: 0,
|
|
582
|
+
activities,
|
|
583
|
+
profiles: PROFILES,
|
|
584
|
+
signal: controller.signal,
|
|
585
|
+
subscribers: [subscriber],
|
|
586
|
+
onRound: () => rounds.push(Date.now()),
|
|
587
|
+
onSignalEvent: (e) => signalEvents.push(e),
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
await waitFor(() => handle.onChange !== undefined);
|
|
591
|
+
expect(rounds.length).toBe(1);
|
|
592
|
+
handle.onChange!();
|
|
593
|
+
await waitFor(() => rounds.length >= 2);
|
|
594
|
+
expect(rounds.length).toBeGreaterThanOrEqual(2);
|
|
595
|
+
|
|
596
|
+
controller.abort();
|
|
597
|
+
await loop;
|
|
598
|
+
expect(signalEvents.some((e) => e.kind === "subscribed" && e.lexicon === "k8s")).toBe(true);
|
|
599
|
+
expect(signalEvents.some((e) => e.kind === "woken")).toBe(true);
|
|
600
|
+
});
|
|
601
|
+
}, 15_000);
|
|
602
|
+
|
|
603
|
+
test("a fabricated signal cannot change what the tick reports, only when it runs", async () => {
|
|
604
|
+
await withTestDir(async (dir) => {
|
|
605
|
+
await initRepo(dir);
|
|
606
|
+
writeFixtureConvergeOp(dir, "staging-converge", "staging");
|
|
607
|
+
|
|
608
|
+
/** One round's events, run to completion on a timer alone. */
|
|
609
|
+
async function roundsWithoutSignal(): Promise<string> {
|
|
610
|
+
const controller = new AbortController();
|
|
611
|
+
const seen: OperatorTickEvent[][] = [];
|
|
612
|
+
const loop = runOperatorForever({
|
|
613
|
+
cwd: dir,
|
|
614
|
+
holder: "op-a",
|
|
615
|
+
intervalMs: 60_000,
|
|
616
|
+
activities: fakeTickActivities(dir, "staging", "staging-converge"),
|
|
617
|
+
profiles: PROFILES,
|
|
618
|
+
signal: controller.signal,
|
|
619
|
+
onRound: (events) => {
|
|
620
|
+
seen.push(events);
|
|
621
|
+
controller.abort();
|
|
622
|
+
},
|
|
623
|
+
});
|
|
624
|
+
await loop;
|
|
625
|
+
return JSON.stringify(seen[0].map((e) => ({ kind: e.kind, op: e.op, env: e.env })));
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** The same, woken by a signal carrying a fabricated payload. */
|
|
629
|
+
async function roundsWithFabricatedSignal(): Promise<string> {
|
|
630
|
+
const { handle, subscriber } = stubSubscriber("liar");
|
|
631
|
+
const controller = new AbortController();
|
|
632
|
+
const seen: OperatorTickEvent[][] = [];
|
|
633
|
+
const loop = runOperatorForever({
|
|
634
|
+
cwd: dir,
|
|
635
|
+
holder: "op-a",
|
|
636
|
+
intervalMs: 60_000,
|
|
637
|
+
signalFloorMs: 0,
|
|
638
|
+
activities: fakeTickActivities(dir, "staging", "staging-converge"),
|
|
639
|
+
profiles: PROFILES,
|
|
640
|
+
signal: controller.signal,
|
|
641
|
+
subscribers: [subscriber],
|
|
642
|
+
onRound: (events) => {
|
|
643
|
+
seen.push(events);
|
|
644
|
+
if (seen.length >= 2) controller.abort();
|
|
645
|
+
},
|
|
646
|
+
});
|
|
647
|
+
await waitFor(() => handle.onChange !== undefined);
|
|
648
|
+
// `onChange` takes no arguments; a caller that fabricates a resource
|
|
649
|
+
// has nowhere to put it, and the extra arguments go nowhere.
|
|
650
|
+
(handle.onChange as unknown as (...args: unknown[]) => void)(
|
|
651
|
+
{ kind: "Deployment", name: "ghost", status: "Drifted" },
|
|
652
|
+
"fabricated",
|
|
653
|
+
);
|
|
654
|
+
await waitFor(() => seen.length >= 2);
|
|
655
|
+
controller.abort();
|
|
656
|
+
await loop;
|
|
657
|
+
return JSON.stringify(seen[1].map((e) => ({ kind: e.kind, op: e.op, env: e.env })));
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const timerOnly = await roundsWithoutSignal();
|
|
661
|
+
const signalled = await roundsWithFabricatedSignal();
|
|
662
|
+
// Byte for byte the same verdict: the signal moved the clock, nothing else.
|
|
663
|
+
expect(signalled).toBe(timerOnly);
|
|
664
|
+
// And the seam itself carries no payload channel to begin with.
|
|
665
|
+
const { handle } = stubSubscriber();
|
|
666
|
+
expect(handle.onChange?.length ?? 0).toBe(0);
|
|
667
|
+
});
|
|
668
|
+
}, 20_000);
|
|
669
|
+
|
|
670
|
+
test("a storm of signals wakes at most one round per floor", async () => {
|
|
671
|
+
await withTestDir(async (dir) => {
|
|
672
|
+
await initRepo(dir);
|
|
673
|
+
writeFixtureConvergeOp(dir, "staging-converge", "staging");
|
|
674
|
+
const { handle, subscriber } = stubSubscriber();
|
|
675
|
+
|
|
676
|
+
const controller = new AbortController();
|
|
677
|
+
const rounds: number[] = [];
|
|
678
|
+
const loop = runOperatorForever({
|
|
679
|
+
cwd: dir,
|
|
680
|
+
holder: "op-a",
|
|
681
|
+
intervalMs: 60_000,
|
|
682
|
+
signalFloorMs: 400,
|
|
683
|
+
activities: fakeTickActivities(dir, "staging", "staging-converge"),
|
|
684
|
+
profiles: PROFILES,
|
|
685
|
+
signal: controller.signal,
|
|
686
|
+
subscribers: [subscriber],
|
|
687
|
+
onRound: () => rounds.push(Date.now()),
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
await waitFor(() => handle.onChange !== undefined);
|
|
691
|
+
const before = rounds.length;
|
|
692
|
+
for (let i = 0; i < 300; i++) handle.onChange!();
|
|
693
|
+
// Well past a floor, but nowhere near the 60s interval: at most one
|
|
694
|
+
// extra round may have happened, and a per-signal wake would have
|
|
695
|
+
// produced hundreds.
|
|
696
|
+
await waitFor(() => rounds.length > before, 2_000);
|
|
697
|
+
expect(rounds.length - before).toBeLessThanOrEqual(1);
|
|
698
|
+
|
|
699
|
+
controller.abort();
|
|
700
|
+
await loop;
|
|
701
|
+
});
|
|
702
|
+
}, 15_000);
|
|
703
|
+
|
|
704
|
+
test("a killed subscription is reported once, the loop keeps ticking, and the next round re-subscribes", async () => {
|
|
705
|
+
await withTestDir(async (dir) => {
|
|
706
|
+
await initRepo(dir);
|
|
707
|
+
writeFixtureConvergeOp(dir, "staging-converge", "staging");
|
|
708
|
+
const { handle, subscriber } = stubSubscriber("k8s");
|
|
709
|
+
|
|
710
|
+
const controller = new AbortController();
|
|
711
|
+
const rounds: number[] = [];
|
|
712
|
+
const signalEvents: OperatorSignalEvent[] = [];
|
|
713
|
+
const loop = runOperatorForever({
|
|
714
|
+
cwd: dir,
|
|
715
|
+
holder: "op-a",
|
|
716
|
+
intervalMs: 60,
|
|
717
|
+
signalFloorMs: 0,
|
|
718
|
+
activities: fakeTickActivities(dir, "staging", "staging-converge"),
|
|
719
|
+
profiles: PROFILES,
|
|
720
|
+
signal: controller.signal,
|
|
721
|
+
subscribers: [subscriber],
|
|
722
|
+
onRound: () => rounds.push(Date.now()),
|
|
723
|
+
onSignalEvent: (e) => signalEvents.push(e),
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
await waitFor(() => handle.onError !== undefined);
|
|
727
|
+
const subscribesBefore = handle.subscribes;
|
|
728
|
+
// The watch dies. Twice, because a dead subscription reports once.
|
|
729
|
+
handle.onError!("connection reset by peer");
|
|
730
|
+
handle.onError!("connection reset by peer");
|
|
731
|
+
|
|
732
|
+
await waitFor(() => handle.subscribes > subscribesBefore);
|
|
733
|
+
const roundsAtDeath = rounds.length;
|
|
734
|
+
await waitFor(() => rounds.length > roundsAtDeath + 1);
|
|
735
|
+
|
|
736
|
+
controller.abort();
|
|
737
|
+
await loop;
|
|
738
|
+
|
|
739
|
+
const lost = signalEvents.filter((e) => e.kind === "subscription-lost");
|
|
740
|
+
expect(lost).toHaveLength(1);
|
|
741
|
+
expect(formatSignalLine(lost[0])).toContain("polling on the timer");
|
|
742
|
+
// The loop kept ticking on the timer, and re-subscribed.
|
|
743
|
+
expect(rounds.length).toBeGreaterThan(roundsAtDeath + 1);
|
|
744
|
+
expect(handle.subscribes).toBeGreaterThan(subscribesBefore);
|
|
745
|
+
});
|
|
746
|
+
}, 15_000);
|
|
747
|
+
|
|
748
|
+
test("a subscription that cannot be established degrades to polling with one line, never a crash", async () => {
|
|
749
|
+
await withTestDir(async (dir) => {
|
|
750
|
+
await initRepo(dir);
|
|
751
|
+
writeFixtureConvergeOp(dir, "staging-converge", "staging");
|
|
752
|
+
const failing: ChangeSubscriber = {
|
|
753
|
+
lexicon: "k8s",
|
|
754
|
+
async subscribe() {
|
|
755
|
+
throw new Error("no cluster binding for env staging");
|
|
756
|
+
},
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
const controller = new AbortController();
|
|
760
|
+
const rounds: number[] = [];
|
|
761
|
+
const signalEvents: OperatorSignalEvent[] = [];
|
|
762
|
+
const loop = runOperatorForever({
|
|
763
|
+
cwd: dir,
|
|
764
|
+
holder: "op-a",
|
|
765
|
+
intervalMs: 40,
|
|
766
|
+
activities: fakeTickActivities(dir, "staging", "staging-converge"),
|
|
767
|
+
profiles: PROFILES,
|
|
768
|
+
signal: controller.signal,
|
|
769
|
+
subscribers: [failing],
|
|
770
|
+
onRound: () => rounds.push(Date.now()),
|
|
771
|
+
onSignalEvent: (e) => signalEvents.push(e),
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
await waitFor(() => rounds.length >= 3);
|
|
775
|
+
controller.abort();
|
|
776
|
+
await loop; // resolves: a failing subscription never rejects the loop
|
|
777
|
+
|
|
778
|
+
const failed = signalEvents.filter((e) => e.kind === "subscribe-failed");
|
|
779
|
+
expect(failed.length).toBeGreaterThanOrEqual(1);
|
|
780
|
+
expect(formatSignalLine(failed[0])).toContain("no cluster binding for env staging");
|
|
781
|
+
expect(rounds.length).toBeGreaterThanOrEqual(3);
|
|
782
|
+
});
|
|
783
|
+
}, 15_000);
|
|
784
|
+
|
|
785
|
+
test("stopping the operator closes every subscription and aborts its signal", async () => {
|
|
786
|
+
await withTestDir(async (dir) => {
|
|
787
|
+
await initRepo(dir);
|
|
788
|
+
writeFixtureConvergeOp(dir, "staging-converge", "staging");
|
|
789
|
+
const a = stubSubscriber("k8s");
|
|
790
|
+
const b = stubSubscriber("docker");
|
|
791
|
+
|
|
792
|
+
const controller = new AbortController();
|
|
793
|
+
const loop = runOperatorForever({
|
|
794
|
+
cwd: dir,
|
|
795
|
+
holder: "op-a",
|
|
796
|
+
intervalMs: 60_000,
|
|
797
|
+
activities: fakeTickActivities(dir, "staging", "staging-converge"),
|
|
798
|
+
profiles: PROFILES,
|
|
799
|
+
signal: controller.signal,
|
|
800
|
+
subscribers: [a.subscriber, b.subscriber],
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
await waitFor(() => a.handle.onChange !== undefined && b.handle.onChange !== undefined);
|
|
804
|
+
expect(a.handle.signal!.aborted).toBe(false);
|
|
805
|
+
|
|
806
|
+
controller.abort();
|
|
807
|
+
await loop;
|
|
808
|
+
|
|
809
|
+
expect(a.handle.closes).toBe(1);
|
|
810
|
+
expect(b.handle.closes).toBe(1);
|
|
811
|
+
expect(a.handle.signal!.aborted).toBe(true);
|
|
812
|
+
expect(b.handle.signal!.aborted).toBe(true);
|
|
813
|
+
});
|
|
814
|
+
}, 15_000);
|
|
815
|
+
|
|
816
|
+
test("a close() that throws still unwinds the loop cleanly", async () => {
|
|
817
|
+
await withTestDir(async (dir) => {
|
|
818
|
+
await initRepo(dir);
|
|
819
|
+
writeFixtureConvergeOp(dir, "staging-converge", "staging");
|
|
820
|
+
let subscribed = false;
|
|
821
|
+
const hostile: ChangeSubscriber = {
|
|
822
|
+
lexicon: "k8s",
|
|
823
|
+
async subscribe() {
|
|
824
|
+
subscribed = true;
|
|
825
|
+
return { close: async () => { throw new Error("close blew up"); } };
|
|
826
|
+
},
|
|
827
|
+
};
|
|
828
|
+
|
|
829
|
+
const controller = new AbortController();
|
|
830
|
+
const loop = runOperatorForever({
|
|
831
|
+
cwd: dir,
|
|
832
|
+
holder: "op-a",
|
|
833
|
+
intervalMs: 60_000,
|
|
834
|
+
activities: fakeTickActivities(dir, "staging", "staging-converge"),
|
|
835
|
+
profiles: PROFILES,
|
|
836
|
+
signal: controller.signal,
|
|
837
|
+
subscribers: [hostile],
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
await waitFor(() => subscribed);
|
|
841
|
+
controller.abort();
|
|
842
|
+
await expect(loop).resolves.toBeUndefined();
|
|
843
|
+
});
|
|
844
|
+
}, 15_000);
|
|
845
|
+
|
|
846
|
+
test("no subscribers: the loop is exactly the timer it always was", async () => {
|
|
847
|
+
await withTestDir(async (dir) => {
|
|
848
|
+
await initRepo(dir);
|
|
849
|
+
writeFixtureConvergeOp(dir, "staging-converge", "staging");
|
|
850
|
+
const controller = new AbortController();
|
|
851
|
+
const rounds: number[] = [];
|
|
852
|
+
const signalEvents: OperatorSignalEvent[] = [];
|
|
853
|
+
const loop = runOperatorForever({
|
|
854
|
+
cwd: dir,
|
|
855
|
+
holder: "op-a",
|
|
856
|
+
intervalMs: 40,
|
|
857
|
+
activities: fakeTickActivities(dir, "staging", "staging-converge"),
|
|
858
|
+
profiles: PROFILES,
|
|
859
|
+
signal: controller.signal,
|
|
860
|
+
subscribers: [], // a project whose lexicons implement no signal seam
|
|
861
|
+
onRound: () => rounds.push(Date.now()),
|
|
862
|
+
onSignalEvent: (e) => signalEvents.push(e),
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
await waitFor(() => rounds.length >= 3);
|
|
866
|
+
controller.abort();
|
|
867
|
+
await loop;
|
|
868
|
+
expect(rounds.length).toBeGreaterThanOrEqual(3);
|
|
869
|
+
expect(signalEvents).toEqual([]);
|
|
870
|
+
});
|
|
871
|
+
}, 15_000);
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
describe("formatSignalLine", () => {
|
|
875
|
+
test("renders each signal event kind as one line, none of them an observation", () => {
|
|
876
|
+
expect(formatSignalLine({ kind: "subscribed", lexicon: "k8s" })).toContain("the timer still runs");
|
|
877
|
+
expect(formatSignalLine({ kind: "subscribe-failed", lexicon: "k8s", error: "no binding" }))
|
|
878
|
+
.toContain("polling on the timer");
|
|
879
|
+
expect(formatSignalLine({ kind: "subscription-lost", lexicon: "k8s", error: "410 Gone" }))
|
|
880
|
+
.toContain("re-subscribes");
|
|
881
|
+
expect(formatSignalLine({ kind: "woken", afterMs: 120 })).toContain("running an ordinary round");
|
|
882
|
+
});
|
|
883
|
+
});
|
package/src/op/operator.ts
CHANGED
|
@@ -41,6 +41,8 @@ import { runOpLocally, OpRunFailure, type OpRunResult } from "./local-executor";
|
|
|
41
41
|
import { acquireLease, stillHoldsLease, currentHolderId, DEFAULT_LEASE_TTL_MS, type LeaseRecord, type AcquireLeaseResult } from "../lifecycle/lease";
|
|
42
42
|
import { StaleLockError } from "../lifecycle/git";
|
|
43
43
|
import { cronMatches, cronDueBetween } from "./cron";
|
|
44
|
+
import { createChangeSignalGate, DEFAULT_SIGNAL_FLOOR_MS, type WakeReason } from "./change-signal";
|
|
45
|
+
import type { ChangeSubscription } from "../lexicon";
|
|
44
46
|
|
|
45
47
|
/**
|
|
46
48
|
* Poll interval between rounds — the operator's own cadence, and the fallback
|
|
@@ -211,11 +213,67 @@ function sleepAbortable(ms: number, signal?: AbortSignal): Promise<void> {
|
|
|
211
213
|
});
|
|
212
214
|
}
|
|
213
215
|
|
|
216
|
+
/**
|
|
217
|
+
* One lexicon's change-signal seam, already bound to an environment (#1981).
|
|
218
|
+
*
|
|
219
|
+
* The loop never sees a `LexiconPlugin`: core's operator does not import
|
|
220
|
+
* plugins, and binding happens where plugins are already loaded
|
|
221
|
+
* (`collectChangeSubscribers` in `../cli/plugins.ts`), the same extract-then-
|
|
222
|
+
* thread shape `collectBuildRootContributors` uses for build roots.
|
|
223
|
+
*/
|
|
224
|
+
export interface ChangeSubscriber {
|
|
225
|
+
/** Which lexicon supplies the signal. Log lines only; never a fact. */
|
|
226
|
+
lexicon: string;
|
|
227
|
+
subscribe(ctx: {
|
|
228
|
+
onChange: () => void;
|
|
229
|
+
onError: (message: string) => void;
|
|
230
|
+
signal: AbortSignal;
|
|
231
|
+
}): Promise<ChangeSubscription>;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* What the loop's change-signal machinery reports, alongside the per-op tick
|
|
236
|
+
* events. Separate from {@link OperatorTickEvent} on purpose: none of this is
|
|
237
|
+
* about an Op, and none of it is an observation.
|
|
238
|
+
*/
|
|
239
|
+
export type OperatorSignalEvent =
|
|
240
|
+
/** A lexicon's subscription is live and the loop may now wake early. */
|
|
241
|
+
| { kind: "subscribed"; lexicon: string }
|
|
242
|
+
/** Establishing it failed. The loop keeps its timer and retries next round. */
|
|
243
|
+
| { kind: "subscribe-failed"; lexicon: string; error: string }
|
|
244
|
+
/** A live subscription died. Reported once; the next round re-subscribes. */
|
|
245
|
+
| { kind: "subscription-lost"; lexicon: string; error: string }
|
|
246
|
+
/** A signal shortened the sleep. The round that follows is an ordinary round. */
|
|
247
|
+
| { kind: "woken"; afterMs: number };
|
|
248
|
+
|
|
249
|
+
/** Render one signal event as `chant operator`'s log line shape. */
|
|
250
|
+
export function formatSignalLine(event: OperatorSignalEvent): string {
|
|
251
|
+
switch (event.kind) {
|
|
252
|
+
case "subscribed":
|
|
253
|
+
return `operator: ${event.lexicon} change signal subscribed (the timer still runs)`;
|
|
254
|
+
case "subscribe-failed":
|
|
255
|
+
return `operator: ${event.lexicon} change signal unavailable, polling on the timer (${event.error})`;
|
|
256
|
+
case "subscription-lost":
|
|
257
|
+
return `operator: ${event.lexicon} change signal lost, polling on the timer until the next round re-subscribes (${event.error})`;
|
|
258
|
+
case "woken":
|
|
259
|
+
return `operator: woken by a change signal after ${event.afterMs}ms, running an ordinary round`;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
214
263
|
export interface OperatorLoopOptions extends OperatorRoundOptions {
|
|
215
264
|
/** @default DEFAULT_OPERATOR_INTERVAL_MS */
|
|
216
265
|
intervalMs?: number;
|
|
217
266
|
/** Called after every round completes — the CLI's one-line-per-tick log lives here, not inside the loop itself, so a test can drive rounds without capturing stdout. */
|
|
218
267
|
onRound?: (events: OperatorTickEvent[]) => void;
|
|
268
|
+
/**
|
|
269
|
+
* Change-signal seams to keep subscribed for the life of the loop (#1981).
|
|
270
|
+
* Omitted, or empty, and the loop is exactly what it was: a timer.
|
|
271
|
+
*/
|
|
272
|
+
subscribers?: readonly ChangeSubscriber[];
|
|
273
|
+
/** @default DEFAULT_SIGNAL_FLOOR_MS. The shortest gap a signal may force between two rounds. */
|
|
274
|
+
signalFloorMs?: number;
|
|
275
|
+
/** Called for every subscription and wake event. The CLI logs one line each. */
|
|
276
|
+
onSignalEvent?: (event: OperatorSignalEvent) => void;
|
|
219
277
|
}
|
|
220
278
|
|
|
221
279
|
/**
|
|
@@ -224,6 +282,12 @@ export interface OperatorLoopOptions extends OperatorRoundOptions {
|
|
|
224
282
|
* (issue: "if it dies, nothing breaks and nothing is lost") — this function
|
|
225
283
|
* is exactly `while (!aborted) { round(); sleep(); }`, nothing durable lives
|
|
226
284
|
* in its own memory that a restart would need to recover.
|
|
285
|
+
*
|
|
286
|
+
* With `subscribers` (#1981) the sleep gains a second wake source, and only a
|
|
287
|
+
* wake source. A signal aborts the current sleep; the round that follows is
|
|
288
|
+
* the same round the timer would have run, deriving everything from a fresh
|
|
289
|
+
* observation. Nothing a subscription reports reaches a tick, because the
|
|
290
|
+
* seam has nowhere to put it: `onChange` takes no arguments.
|
|
227
291
|
*/
|
|
228
292
|
export async function runOperatorForever(opts: OperatorLoopOptions): Promise<void> {
|
|
229
293
|
const holder = opts.holder ?? currentHolderId();
|
|
@@ -232,11 +296,83 @@ export async function runOperatorForever(opts: OperatorLoopOptions): Promise<voi
|
|
|
232
296
|
// lands on a firing minute — a fresh operator owes no catch-up for the ticks
|
|
233
297
|
// it was not running for.
|
|
234
298
|
const scheduleState = opts.scheduleState ?? new Map<string, Date>();
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
299
|
+
const intervalMs = opts.intervalMs ?? DEFAULT_OPERATOR_INTERVAL_MS;
|
|
300
|
+
const subscribers = opts.subscribers ?? [];
|
|
301
|
+
|
|
302
|
+
// Nothing to subscribe to: the loop below is byte for byte the loop that
|
|
303
|
+
// shipped before #1981, and takes none of the machinery's cost.
|
|
304
|
+
if (subscribers.length === 0) {
|
|
305
|
+
while (!opts.signal?.aborted) {
|
|
306
|
+
const events = await runOperatorRound({ ...opts, holder, scheduleState });
|
|
307
|
+
opts.onRound?.(events);
|
|
308
|
+
if (opts.signal?.aborted) break;
|
|
309
|
+
await sleepAbortable(intervalMs, opts.signal);
|
|
310
|
+
}
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const gate = createChangeSignalGate({ floorMs: opts.signalFloorMs ?? DEFAULT_SIGNAL_FLOOR_MS });
|
|
315
|
+
// The subscriptions the loop currently holds, by lexicon. A lexicon absent
|
|
316
|
+
// from this map is one to (re-)subscribe on the next round, which is how a
|
|
317
|
+
// killed watch comes back without a special retry path.
|
|
318
|
+
const live = new Map<string, ChangeSubscription>();
|
|
319
|
+
// Stops every subscription at once when the loop ends, whatever ends it.
|
|
320
|
+
const subscriptionsAbort = new AbortController();
|
|
321
|
+
|
|
322
|
+
const closeAll = async (): Promise<void> => {
|
|
323
|
+
subscriptionsAbort.abort();
|
|
324
|
+
const open = [...live.values()];
|
|
325
|
+
live.clear();
|
|
326
|
+
// A close that throws must not mask why the loop is unwinding.
|
|
327
|
+
await Promise.allSettled(open.map((s) => s.close()));
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const ensureSubscribed = async (): Promise<void> => {
|
|
331
|
+
for (const subscriber of subscribers) {
|
|
332
|
+
if (live.has(subscriber.lexicon) || subscriptionsAbort.signal.aborted) continue;
|
|
333
|
+
try {
|
|
334
|
+
const subscription = await subscriber.subscribe({
|
|
335
|
+
onChange: () => gate.signal(),
|
|
336
|
+
onError: (error) => {
|
|
337
|
+
// Reported once, then forgotten: dropping it from `live` is what
|
|
338
|
+
// makes the next round re-subscribe. The loop itself is untouched.
|
|
339
|
+
if (!live.delete(subscriber.lexicon)) return;
|
|
340
|
+
opts.onSignalEvent?.({ kind: "subscription-lost", lexicon: subscriber.lexicon, error });
|
|
341
|
+
},
|
|
342
|
+
signal: subscriptionsAbort.signal,
|
|
343
|
+
});
|
|
344
|
+
live.set(subscriber.lexicon, subscription);
|
|
345
|
+
opts.onSignalEvent?.({ kind: "subscribed", lexicon: subscriber.lexicon });
|
|
346
|
+
} catch (err) {
|
|
347
|
+
// A subscription that cannot be established is a slower loop, never a
|
|
348
|
+
// stopped one: report it and fall through to the timer.
|
|
349
|
+
opts.onSignalEvent?.({
|
|
350
|
+
kind: "subscribe-failed",
|
|
351
|
+
lexicon: subscriber.lexicon,
|
|
352
|
+
error: err instanceof Error ? err.message : String(err),
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
while (!opts.signal?.aborted) {
|
|
360
|
+
gate.roundStarted();
|
|
361
|
+
const startedAt = Date.now();
|
|
362
|
+
const events = await runOperatorRound({ ...opts, holder, scheduleState });
|
|
363
|
+
opts.onRound?.(events);
|
|
364
|
+
if (opts.signal?.aborted) break;
|
|
365
|
+
|
|
366
|
+
await ensureSubscribed();
|
|
367
|
+
if (opts.signal?.aborted) break;
|
|
368
|
+
|
|
369
|
+
const reason: WakeReason = await gate.wait(intervalMs, opts.signal);
|
|
370
|
+
if (reason === "signal") {
|
|
371
|
+
opts.onSignalEvent?.({ kind: "woken", afterMs: Date.now() - startedAt });
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
} finally {
|
|
375
|
+
await closeAll();
|
|
240
376
|
}
|
|
241
377
|
}
|
|
242
378
|
|
|
@@ -123,7 +123,7 @@ describe("the local op runtime", () => {
|
|
|
123
123
|
|
|
124
124
|
const handle = await runtime.start(
|
|
125
125
|
op("gated", [
|
|
126
|
-
{ kind: "gate",
|
|
126
|
+
{ kind: "gate", gate: "approve-prod" },
|
|
127
127
|
{ kind: "activity", fn: "after", args: {} },
|
|
128
128
|
]),
|
|
129
129
|
{},
|
package/src/op/types.ts
CHANGED
|
@@ -170,13 +170,33 @@ export interface EffectStep {
|
|
|
170
170
|
description?: string;
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
-
|
|
173
|
+
/** Everything on a gate step except the key that names it. */
|
|
174
|
+
export interface GateStepBase {
|
|
174
175
|
kind: "gate";
|
|
175
|
-
/** The gate's name — what `chant approve <op> <gate>` resolves. */
|
|
176
|
-
signalName: string;
|
|
177
176
|
/** How long a recorded pending gate stays valid, as a duration string. Default: "48h". */
|
|
178
177
|
timeout?: string;
|
|
179
178
|
/** Human-readable description of the action required to unblock this gate. */
|
|
180
179
|
description?: string;
|
|
181
180
|
}
|
|
182
181
|
|
|
182
|
+
/**
|
|
183
|
+
* A human approval decided against the gate ledger. The name lives on `gate`;
|
|
184
|
+
* `signalName` is the key it carried through 0.58.0 and is still accepted
|
|
185
|
+
* (#2202) — read both through `gateName()` in `./gate-name.ts` rather than
|
|
186
|
+
* reaching for either key directly.
|
|
187
|
+
*/
|
|
188
|
+
export type GateStep = GateStepBase &
|
|
189
|
+
(
|
|
190
|
+
| {
|
|
191
|
+
/** The gate's name — what `chant approve <op> <gate>` resolves. */
|
|
192
|
+
gate: string;
|
|
193
|
+
/** @deprecated Renamed to `gate` in #2202. Accepted through 0.59.0, removed in 0.60.0. */
|
|
194
|
+
signalName?: string;
|
|
195
|
+
}
|
|
196
|
+
| {
|
|
197
|
+
gate?: undefined;
|
|
198
|
+
/** @deprecated Renamed to `gate` in #2202. Accepted through 0.59.0, removed in 0.60.0. */
|
|
199
|
+
signalName: string;
|
|
200
|
+
}
|
|
201
|
+
);
|
|
202
|
+
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
2
|
import { AWS_CARVE_TYPES, AWS_FOLD_MAPPERS, awsCarveType, applyAwsMapper, applyAwsFold } from "./aws-resources";
|
|
3
|
-
import { tierMap, foldParentOf, identityAttrOf, canBridge, canCarveEmit, carveEmitTypes } from "./tier-map";
|
|
3
|
+
import { tierMap, foldParentOf, identityAttrOf, canBridge, dataSourceShapeOf, canCarveEmit, carveEmitTypes } from "./tier-map";
|
|
4
4
|
import { canAdoptFromState } from "./adopt-state";
|
|
5
5
|
|
|
6
6
|
describe("AWS carve-out table", () => {
|
|
@@ -27,13 +27,22 @@ describe("AWS carve-out table", () => {
|
|
|
27
27
|
for (const t of carveEmitTypes()) expect(canAdoptFromState(t)).toBe(true);
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
-
test("canBridge
|
|
31
|
-
// A data source body is flat `attr = value`; a dotted path is not HCL.
|
|
30
|
+
test("canBridge follows the data-source shape, not the identity attribute (#2015, #2034)", () => {
|
|
31
|
+
// A data source body is flat `attr = value`; a dotted path is not HCL. So a
|
|
32
|
+
// dotted identity attribute bridges only through a declared shape, which is
|
|
33
|
+
// what `kubernetes_manifest` has: `data "kubernetes_resource"`.
|
|
32
34
|
expect(identityAttrOf("kubernetes_manifest")).toContain(".");
|
|
33
|
-
expect(canBridge("kubernetes_manifest")).toBe(
|
|
35
|
+
expect(canBridge("kubernetes_manifest")).toBe(true);
|
|
36
|
+
expect(dataSourceShapeOf("kubernetes_manifest")!.type).toBe("kubernetes_resource");
|
|
37
|
+
// A plain identity attribute implies its own same-type shape.
|
|
34
38
|
expect(canBridge("aws_s3_bucket")).toBe(true);
|
|
39
|
+
expect(dataSourceShapeOf("aws_s3_bucket")).toEqual({
|
|
40
|
+
type: "aws_s3_bucket",
|
|
41
|
+
args: [{ name: "bucket", from: "bucket", required: true }],
|
|
42
|
+
});
|
|
35
43
|
// No identity entry at all is fine — the bridge writes a TODO comment.
|
|
36
44
|
expect(canBridge("random_pet")).toBe(true);
|
|
45
|
+
expect(dataSourceShapeOf("random_pet")).toEqual({ type: "random_pet" });
|
|
37
46
|
for (const t of AWS_CARVE_TYPES) expect(canBridge(t.tfType)).toBe(true);
|
|
38
47
|
});
|
|
39
48
|
|