@intentius/chant 0.106.3 → 0.107.1

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 (57) hide show
  1. package/dist/cli/commands/check-lexicon.d.ts +7 -0
  2. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  3. package/dist/cli/handlers/operator.d.ts.map +1 -1
  4. package/dist/composite.d.ts +5 -1
  5. package/dist/composite.d.ts.map +1 -1
  6. package/dist/lifecycle/gate-ledger.d.ts +29 -0
  7. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  8. package/dist/op/activities/apply.d.ts +40 -6
  9. package/dist/op/activities/apply.d.ts.map +1 -1
  10. package/dist/op/builders.d.ts +7 -0
  11. package/dist/op/builders.d.ts.map +1 -1
  12. package/dist/op/composites/apply-op.d.ts +21 -3
  13. package/dist/op/composites/apply-op.d.ts.map +1 -1
  14. package/dist/op/gate-approval.d.ts +10 -2
  15. package/dist/op/gate-approval.d.ts.map +1 -1
  16. package/dist/op/gate-plan-context.d.ts +101 -0
  17. package/dist/op/gate-plan-context.d.ts.map +1 -0
  18. package/dist/op/gate-point-run.d.ts +110 -0
  19. package/dist/op/gate-point-run.d.ts.map +1 -0
  20. package/dist/op/gate-point.d.ts +70 -0
  21. package/dist/op/gate-point.d.ts.map +1 -0
  22. package/dist/op/gate.d.ts +12 -1
  23. package/dist/op/gate.d.ts.map +1 -1
  24. package/dist/op/index.d.ts +6 -0
  25. package/dist/op/index.d.ts.map +1 -1
  26. package/dist/op/local-executor.d.ts +11 -3
  27. package/dist/op/local-executor.d.ts.map +1 -1
  28. package/dist/op/op-ir.d.ts +2 -0
  29. package/dist/op/op-ir.d.ts.map +1 -1
  30. package/dist/op/types.d.ts +11 -0
  31. package/dist/op/types.d.ts.map +1 -1
  32. package/dist/workspace/status-gates.d.ts +15 -1
  33. package/dist/workspace/status-gates.d.ts.map +1 -1
  34. package/package.json +1 -1
  35. package/src/cli/commands/check-lexicon.test.ts +21 -5
  36. package/src/cli/commands/check-lexicon.ts +14 -3
  37. package/src/cli/handlers/operator.ts +9 -0
  38. package/src/composite.ts +5 -1
  39. package/src/lifecycle/gate-ledger.ts +30 -0
  40. package/src/op/activities/apply.test.ts +43 -0
  41. package/src/op/activities/apply.ts +111 -6
  42. package/src/op/builders.ts +13 -1
  43. package/src/op/composites/apply-op.ts +24 -4
  44. package/src/op/gate-approval.ts +18 -2
  45. package/src/op/gate-plan-context.test.ts +186 -0
  46. package/src/op/gate-plan-context.ts +229 -0
  47. package/src/op/gate-point-run.ts +207 -0
  48. package/src/op/gate-point.e2e.test.ts +166 -0
  49. package/src/op/gate-point.test.ts +235 -0
  50. package/src/op/gate-point.ts +119 -0
  51. package/src/op/gate.ts +26 -1
  52. package/src/op/index.ts +8 -0
  53. package/src/op/local-executor.ts +96 -3
  54. package/src/op/op-ir.ts +4 -0
  55. package/src/op/types.ts +11 -0
  56. package/src/workspace/status-gates.ts +13 -2
  57. package/src/workspace/status.schema.json +11 -1
@@ -106,6 +106,23 @@ export function isApprovalUrl(raw: string): boolean {
106
106
  }
107
107
  }
108
108
 
109
+ /**
110
+ * The answer record a gate that asks a decision point cites (#3170): the
111
+ * point, the record's id (what `chant workspace points answer` takes) and its
112
+ * path from the repository root. On a pending fact it is the open question
113
+ * the run waits on; on a resolution, the answer that passed the gate, with
114
+ * the answer, the decider that gave it and the people who did.
115
+ */
116
+ export interface GateAnswerRef {
117
+ point: string;
118
+ id: string;
119
+ path: string;
120
+ answer?: string | boolean;
121
+ /** `table`, `model` or `quorum`. */
122
+ decider?: string;
123
+ answeredBy?: string[];
124
+ }
125
+
109
126
  /** One immutable gate-resolution record. */
110
127
  export interface GateResolutionRecord {
111
128
  /** Schema version, so an incompatible future shape is detected before being misread. */
@@ -204,6 +221,13 @@ export interface GateResolutionRecord {
204
221
  * verifies. Absent on an unsigned approval.
205
222
  */
206
223
  seal?: GateApprovalSeal;
224
+ /**
225
+ * The answer record that passed the gate, on a gate that asks a decision
226
+ * point (#3170). The run writes this resolution itself when the point's
227
+ * question is answered with an answer the gate passes on; the record's
228
+ * `constrains` names the gate back (`gate:<op>/<gate>`).
229
+ */
230
+ answer?: GateAnswerRef;
207
231
  }
208
232
 
209
233
  /** A gate approval's seal (#3163): who signed, the key's fingerprint (reported, never trusted), and the armored signature. */
@@ -283,6 +307,12 @@ export interface PendingGateRecord {
283
307
  * the run produced, and `chant operator status` to show quorum progress.
284
308
  */
285
309
  approval?: ResolvedGateApproval;
310
+ /**
311
+ * The open question this gate waits on, on a gate that asks a decision
312
+ * point (#3170). It is answered with `chant workspace points answer <id>`,
313
+ * not `chant approve`.
314
+ */
315
+ answer?: GateAnswerRef;
286
316
  }
287
317
 
288
318
  export type PendingGateInput = Omit<PendingGateRecord, "version" | "kind">;
@@ -749,3 +749,46 @@ describe('defaultOutput (target-aware apply output)', () => {
749
749
  expect(defaultOutput('grafana')).toBe('dist/grafana.json');
750
750
  });
751
751
  });
752
+
753
+ describe("nativeApply: ruler dispatches to the prometheus lexicon (chant #3372)", () => {
754
+ const spy = () => {
755
+ const calls: Array<Parameters<ClickHouseApplier>[0]> = [];
756
+ const applier: ClickHouseApplier = async (args) => {
757
+ calls.push(args);
758
+ return applyResult([{ kind: "RuleGroup", name: "shop/api", action: "created" }]);
759
+ };
760
+ return { calls, applier };
761
+ };
762
+ const applyRuler = (args: Parameters<typeof nativeApply>[0], applier: ClickHouseApplier) =>
763
+ nativeApply(args, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, applier);
764
+
765
+ test("output maps to the build path, env to the environment, and owned-only prunes", async () => {
766
+ const { calls, applier } = spy();
767
+ const result = await applyRuler({ target: "ruler", env: "prod", deleteMode: "owned-only" }, applier);
768
+ expect(calls).toEqual([{ buildPath: "dist/rules.yml", environment: "prod", prune: true }]);
769
+ expect(result).toEqual({ applied: 1, pruned: 0, notAttempted: 0 });
770
+ expect(defaultOutput("ruler")).toBe("dist/rules.yml");
771
+ });
772
+
773
+ test("the default does not prune", async () => {
774
+ const { calls, applier } = spy();
775
+ await applyRuler({ target: "ruler", env: "prod" }, applier);
776
+ expect(calls[0].prune).toBe(false);
777
+ });
778
+
779
+ test("ruler has a native rollback, which re-applies the previous groups", async () => {
780
+ expect(hasNativeRollback("ruler")).toBe(true);
781
+ const calls: unknown[] = [];
782
+ const result = await compensateApply(
783
+ { target: "ruler", env: "prod", output: "dist/rules.yml" },
784
+ undefined,
785
+ undefined,
786
+ async (args) => {
787
+ calls.push(args);
788
+ return { restored: 2, removed: 1, alertmanagerRestored: false, hadSnapshot: true };
789
+ },
790
+ );
791
+ expect(calls).toEqual([{ buildPath: "dist/rules.yml", environment: "prod" }]);
792
+ expect(result).toEqual({ restored: 2, removed: 1 });
793
+ });
794
+ });
@@ -28,7 +28,10 @@
28
28
  * classified changes need, over its HTTP interface. `postgres` joined after
29
29
  * that (#3280): the sql lexicon's `postgresApply`, which sends a Postgres
30
30
  * server the statements a build's classified changes need, grouped into
31
- * transactions, with `CONCURRENTLY` statements outside them.
31
+ * transactions, with `CONCURRENTLY` statements outside them. `ruler` joined
32
+ * last (#3372): the prometheus lexicon's `rulerApply`, which posts rule groups
33
+ * to a Mimir, Cortex or Loki ruler and uploads a Mimir or Cortex Alertmanager
34
+ * config, inside the namespaces the project declares.
32
35
  * **The dispatcher stayed here**, because "which
33
36
  * mechanism applies this target" is not any one product's knowledge — and
34
37
  * because the activity keeps its name, its arguments and its place in
@@ -48,7 +51,7 @@ import { importLexiconPackage } from "../../lexicon-module";
48
51
  const execAsync = promisify(exec);
49
52
 
50
53
  /** The native apply mechanism for a target. */
51
- export type ApplyTarget = "cloudformation" | "kubectl" | "arm" | "kustomize" | "gcp" | "fly" | "grafana" | "clickhouse" | "postgres";
54
+ export type ApplyTarget = "cloudformation" | "kubectl" | "arm" | "kustomize" | "gcp" | "fly" | "grafana" | "clickhouse" | "postgres" | "ruler";
52
55
 
53
56
  /**
54
57
  * How apply treats resources no longer declared.
@@ -69,7 +72,11 @@ export type ApplyTarget = "cloudformation" | "kubectl" | "arm" | "kustomize" | "
69
72
  * stack is reported not-prunable), `postgres` drops the objects in the
70
73
  * declared schemas whose comment carries the project's marker (via the sql
71
74
  * lexicon's `postgresApply`; never an object another tool keeps, and a
72
- * project with no ownership stack is reported not-prunable), and
75
+ * project with no ownership stack is reported not-prunable), `ruler`
76
+ * deletes groups the ruler has and the project does not declare, only inside
77
+ * the ruler namespaces the profile declares (via the prometheus lexicon's
78
+ * `rulerApply`; a namespace the project does not declare is never listed,
79
+ * so never touched, unlike `mimirtool rules sync`), and
73
80
  * `cloudformation` is bounded by the stack — which holds, because a
74
81
  * resource CFN did not create is not in the stack.
75
82
  * - `gated` — same delete scope as `owned-only`, but an approval gate precedes
@@ -256,6 +263,23 @@ export type ClickHouseApplier = (
256
263
  */
257
264
  export type PostgresApplier = ClickHouseApplier;
258
265
 
266
+ /**
267
+ * The prometheus lexicon's ruler applier, as this module needs to call it
268
+ * (#3372): `rulerApply` composed with the lexicon's own `toApplyResult`.
269
+ *
270
+ * `environment` selects `prometheus.profiles.<environment>` (else the
271
+ * `PROMETHEUS_RULER_*` variables), which carries the ruler kind and URL, the
272
+ * `X-Scope-OrgID` tenant and the declared namespaces. The namespaces are the
273
+ * ownership boundary: nothing outside them is read, written or deleted.
274
+ */
275
+ export type RulerApplier = ClickHouseApplier;
276
+
277
+ /** The prometheus lexicon's ruler rollback, as {@link compensateApply} needs to call it. */
278
+ export type RulerRollback = (
279
+ args: { buildPath: string; environment?: string },
280
+ signal?: AbortSignal,
281
+ ) => Promise<{ restored: number; removed: number; alertmanagerRestored: boolean; hadSnapshot: boolean }>;
282
+
259
283
  /**
260
284
  * The aws lexicon's CloudFormation applier, as this module needs to call it
261
285
  * (#1449). Structural, like {@link K8sApplier} and {@link AzureApplier}.
@@ -533,6 +557,51 @@ async function loadPostgresApplier(): Promise<PostgresApplier> {
533
557
  return async (args, signal) => toApplyResult(await postgresApply(args, signal));
534
558
  }
535
559
 
560
+ /**
561
+ * Load the prometheus lexicon's `rulerApply` (#3372). Same variable-specifier
562
+ * trick as {@link loadK8sApplier}, for the same reason.
563
+ */
564
+ async function loadRulerApplier(): Promise<RulerApplier> {
565
+ const spec = "@intentius/chant-lexicon-prometheus/api/apply";
566
+ type PromModule = {
567
+ rulerApply?: (args: { buildPath: string; environment?: string; prune?: boolean }, signal?: AbortSignal) => Promise<unknown>;
568
+ toApplyResult?: (result: unknown) => ApplyResult;
569
+ };
570
+ let mod: PromModule;
571
+ try {
572
+ mod = (await importLexiconPackage(spec)) as PromModule;
573
+ } catch (err) {
574
+ throw new Error(
575
+ `apply target "ruler" needs @intentius/chant-lexicon-prometheus, which could not be loaded ` +
576
+ `(${err instanceof Error ? err.message : String(err)}). Ruler applies go through the prometheus ` +
577
+ `lexicon's applier (chant #3372); install the prometheus lexicon and list it in chant.config.ts.`,
578
+ );
579
+ }
580
+ const { rulerApply, toApplyResult } = mod;
581
+ if (typeof rulerApply !== "function" || typeof toApplyResult !== "function") {
582
+ throw new Error("the installed @intentius/chant-lexicon-prometheus exports no rulerApply — it predates chant #3372");
583
+ }
584
+ return async (args, signal) => toApplyResult(await rulerApply(args, signal));
585
+ }
586
+
587
+ /** Load the prometheus lexicon's `rulerRollback` (#3372), the compensation twin of {@link loadRulerApplier}. */
588
+ async function loadRulerRollback(): Promise<RulerRollback> {
589
+ const spec = "@intentius/chant-lexicon-prometheus/api/apply";
590
+ let mod: { rulerRollback?: RulerRollback };
591
+ try {
592
+ mod = (await importLexiconPackage(spec)) as { rulerRollback?: RulerRollback };
593
+ } catch (err) {
594
+ throw new Error(
595
+ `rollback for target "ruler" needs @intentius/chant-lexicon-prometheus, which could not be loaded ` +
596
+ `(${err instanceof Error ? err.message : String(err)}). Install the prometheus lexicon and list it in chant.config.ts.`,
597
+ );
598
+ }
599
+ if (typeof mod.rulerRollback !== "function") {
600
+ throw new Error("the installed @intentius/chant-lexicon-prometheus exports no rulerRollback — it predates chant #3372");
601
+ }
602
+ return mod.rulerRollback;
603
+ }
604
+
536
605
  /**
537
606
  * Load the aws lexicon's `awsApply` (#1449). Same variable-specifier trick as
538
607
  * {@link loadK8sApplier}, for the same reason.
@@ -591,7 +660,8 @@ async function loadAwsRollback(): Promise<AwsRollback> {
591
660
  * (the CNRM manifest) for gcp, `dist/fly.json` (the serialized plan) for fly,
592
661
  * `dist/grafana.json` (the index the grafana serializer writes, with the
593
662
  * dashboard files beside it) for grafana, `dist/schema.json` (the sql
594
- * lexicon's build output) for clickhouse and postgres. Pure — exported for
663
+ * lexicon's build output) for clickhouse and postgres, `dist/rules.yml` (the
664
+ * rule file, with `alertmanager.yml` beside it) for ruler. Pure — exported for
595
665
  * testing.
596
666
  */
597
667
  export function defaultOutput(target: ApplyTarget): string {
@@ -603,6 +673,8 @@ export function defaultOutput(target: ApplyTarget): string {
603
673
  return "dist/gcp.yaml";
604
674
  case "fly":
605
675
  return "dist/fly.json";
676
+ case "ruler":
677
+ return "dist/rules.yml";
606
678
  case "grafana":
607
679
  return "dist/grafana.json";
608
680
  case "clickhouse":
@@ -671,6 +743,7 @@ export async function nativeApply(
671
743
  grafanaApplier?: GrafanaApplier,
672
744
  clickhouseApplier?: ClickHouseApplier,
673
745
  postgresApplier?: PostgresApplier,
746
+ rulerApplier?: RulerApplier,
674
747
  ): Promise<NativeApplyResult> {
675
748
  const output = args.output ?? defaultOutput(args.target);
676
749
  const deleteMode = args.deleteMode ?? "never";
@@ -790,6 +863,18 @@ export async function nativeApply(
790
863
  return collapseEnvelope(envelope, args.env);
791
864
  }
792
865
 
866
+ if (args.target === "ruler") {
867
+ // ruler (#3372): the prometheus lexicon's applier. env selects
868
+ // `prometheus.profiles.<env>`, whose ruler names the tenant and the
869
+ // namespaces the project owns. A delete stays inside those namespaces.
870
+ const apply = rulerApplier ?? (await loadRulerApplier());
871
+ const envelope = await apply({ buildPath: output, environment: args.env, prune: deleteMode !== "never" }, signal);
872
+ // A group with no declared namespace, a plain Prometheus or Alertmanager
873
+ // with no upload API, and a refused write ride the envelope as
874
+ // NOT-ATTEMPTED, so an Op can gate on them.
875
+ return collapseEnvelope(envelope, args.env);
876
+ }
877
+
793
878
  // cloudformation (#1449): the aws lexicon's native applier. env is the stack
794
879
  // name and output the template path; endpoint resolution (#1694) and
795
880
  // capability derivation (#980) are awsApply's own, so nothing else is passed.
@@ -811,7 +896,7 @@ export async function nativeApply(
811
896
  * with compensation on rather than warn at rollback time.
812
897
  */
813
898
  export function hasNativeRollback(target: ApplyTarget): boolean {
814
- return target === "cloudformation";
899
+ return target === "cloudformation" || target === "ruler";
815
900
  }
816
901
 
817
902
  export interface CompensateApplyArgs {
@@ -821,6 +906,8 @@ export interface CompensateApplyArgs {
821
906
  env: string;
822
907
  /** Explicit rollback command, used in preference to the native default. */
823
908
  command?: string;
909
+ /** The build output the apply read — `ruler` finds the previous-group snapshot beside it. */
910
+ output?: string;
824
911
  }
825
912
 
826
913
  /** What a compensation did. */
@@ -834,6 +921,10 @@ export interface CompensateApplyResult {
834
921
  rolledBack?: boolean;
835
922
  /** The settled post-rollback stack status — cloudformation. */
836
923
  status?: string;
924
+ /** Groups put back to their previous form — ruler. */
925
+ restored?: number;
926
+ /** Groups the apply created, deleted again — ruler. */
927
+ removed?: number;
837
928
  }
838
929
 
839
930
  /**
@@ -856,6 +947,7 @@ export async function compensateApply(
856
947
  args: CompensateApplyArgs,
857
948
  signal?: AbortSignal,
858
949
  rollback?: AwsRollback,
950
+ rulerRollback?: RulerRollback,
859
951
  ): Promise<CompensateApplyResult> {
860
952
  if (args.command) {
861
953
  const { stdout, stderr } = await execAsync(args.command, { signal });
@@ -874,12 +966,25 @@ export async function compensateApply(
874
966
  };
875
967
  }
876
968
 
969
+ if (args.target === "ruler") {
970
+ // ruler (#3372): re-apply the previous group set the last apply saved.
971
+ const roll = rulerRollback ?? (await loadRulerRollback());
972
+ const result = await roll({ buildPath: args.output ?? defaultOutput("ruler"), environment: args.env }, signal);
973
+ console.log(
974
+ result.hadSnapshot
975
+ ? `[${args.env}] ruler rollback: restored ${result.restored} group(s), removed ${result.removed}` +
976
+ (result.alertmanagerRestored ? ", restored the Alertmanager config" : "")
977
+ : `[${args.env}] ruler rollback: no apply left a snapshot, nothing to restore`,
978
+ );
979
+ return { restored: result.restored, removed: result.removed };
980
+ }
981
+
877
982
  // Defensive: unreachable from an ApplyOp, which refuses this combination at
878
983
  // build time (#1449). Reaching it means the op was assembled without that
879
984
  // check, and the honest outcome is a failure that says the state stands.
880
985
  throw new Error(
881
986
  `no automatic rollback for target "${args.target}" — the partial apply to ${args.env} was NOT ` +
882
987
  `reverted and cannot be. ApplyOp refuses compensate for this target at build time; either ` +
883
- `supply compensate.command or use a target with a mapped rollback (cloudformation).`,
988
+ `supply compensate.command or use a target with a mapped rollback (cloudformation, ruler).`,
884
989
  );
885
990
  }
@@ -4,6 +4,7 @@ import { WORK_LEASE_STEP_ID, type OpConfig, type PhaseDefinition, type StepDefin
4
4
  import { isEffectReceipt, type EffectReceiptDeclaration } from "../effect-receipt";
5
5
  import { receiptCheckInput } from "./receipt-store";
6
6
  import { gateApprovalProblems } from "./gate-approval";
7
+ import { gatePointProblems } from "./gate-point";
7
8
  import { makeOutProxy, stepOutput, type StepOutputRef, type WithStepRefs } from "./step-output-ref";
8
9
  import type { ChantBuildArgs } from "./activities/build";
9
10
  import type { ShellCmdArgs } from "./activities/shell";
@@ -128,15 +129,25 @@ export function activity(
128
129
  *
129
130
  * Pass `approval` to require a quorum of human approvers, or to evaluate a
130
131
  * policy for each approval (#2508). See {@link GateStep.approval}.
132
+ *
133
+ * Pass `point` to have the gate ask a declared decision point instead
134
+ * (#3170): `gate("apply", { plan: plan.out.digest, point: "prod-apply" })`.
135
+ * The point's deciders and quorum decide, `chant workspace points answer`
136
+ * answers an open question, and the run passes on the answer. See
137
+ * {@link GateStep.point}.
131
138
  */
132
139
  export function gate(
133
140
  name: string,
134
- opts?: { timeout?: string; description?: string; plan?: GateStep["plan"]; approval?: GateStep["approval"] },
141
+ opts?: { timeout?: string; description?: string; plan?: GateStep["plan"]; approval?: GateStep["approval"]; point?: GateStep["point"] },
135
142
  ): GateStep {
136
143
  if (opts?.approval !== undefined) {
137
144
  const problems = gateApprovalProblems(opts.approval);
138
145
  if (problems.length > 0) throw new Error(`gate(${JSON.stringify(name)}): ${problems[0]}`);
139
146
  }
147
+ if (opts?.point !== undefined) {
148
+ const problems = gatePointProblems(opts.point, opts.approval !== undefined);
149
+ if (problems.length > 0) throw new Error(`gate(${JSON.stringify(name)}): ${problems[0]}`);
150
+ }
140
151
  return {
141
152
  kind: "gate",
142
153
  gate: name,
@@ -144,6 +155,7 @@ export function gate(
144
155
  ...(opts?.description ? { description: opts.description } : {}),
145
156
  ...(opts?.plan !== undefined ? { plan: opts.plan } : {}),
146
157
  ...(opts?.approval !== undefined ? { approval: opts.approval } : {}),
158
+ ...(opts?.point !== undefined ? { point: opts.point } : {}),
147
159
  };
148
160
  }
149
161
 
@@ -12,7 +12,8 @@
12
12
  * (chant #1449), the metadata marker (machines) and managed app
13
13
  * (volumes/ips/certs/secrets) on `fly` (chant #1449), the ownership labels on
14
14
  * `grafana` (chant #3011), the comment marker on `clickhouse` (chant #3208)
15
- * and `postgres` (chant #3280),
15
+ * and `postgres` (chant #3280), the declared ruler namespaces on `ruler`
16
+ * (chant #3372),
16
17
  * and the stack itself on `cloudformation`, which a
17
18
  * resource CFN did not create is not in. All of
18
19
  * them are owned-only. See {@link DeleteMode}.
@@ -41,6 +42,15 @@
41
42
  * delete: "gated",
42
43
  * gate: { gate: "approve-apply", description: "Approve prod apply with deletes" },
43
44
  * });
45
+ *
46
+ * // the gate asks the prod-apply decision point (#3170): answered with
47
+ * // `chant workspace points answer <id>`, then re-run
48
+ * export const { op } = ApplyOp({
49
+ * name: "prod-apply",
50
+ * env: "prod",
51
+ * target: "kubectl",
52
+ * gate: { gate: "approve-apply", point: "prod-apply" },
53
+ * });
44
54
  * ```
45
55
  *
46
56
  * @see #112 — stateless-authoritative state model + live import
@@ -50,6 +60,7 @@ import { Op, phase, activity, gate } from "../builders";
50
60
  import { stepOutput } from "../step-output-ref";
51
61
  import { gateName } from "../gate-name";
52
62
  import type { OpResource } from "../resource";
63
+ import type { GateStep } from "../types";
53
64
  import { defaultOutput, hasNativeRollback, type ApplyTarget, type DeleteMode } from "../activities/apply";
54
65
 
55
66
  export interface ApplyOpConfig {
@@ -57,7 +68,7 @@ export interface ApplyOpConfig {
57
68
  name: string;
58
69
  /** Environment — CFN stack name / ARM resource group / kube context env /
59
70
  * the `grafana.profiles.<env>` entry on `grafana` / the `sql.profiles.<env>`
60
- * entry on `clickhouse` and `postgres`; a log label on `gcp` and
71
+ * entry on `clickhouse` and `postgres`, the `prometheus.profiles.<env>` entry on `ruler`; a log label on `gcp` and
61
72
  * `fly`. */
62
73
  env: string;
63
74
  /** Native apply mechanism. Default: "kubectl". */
@@ -65,7 +76,7 @@ export interface ApplyOpConfig {
65
76
  /** Built manifest/template path. Default per target: `dist` (dir) for kubectl,
66
77
  * `template.json` (file) for CloudFormation/ARM, `dist/gcp.yaml` for gcp,
67
78
  * `dist/fly.json` for fly, `dist/grafana.json` (the build index) for
68
- * grafana, `dist/schema.json` for clickhouse and postgres. Must match your
79
+ * grafana, `dist/schema.json` for clickhouse and postgres, `dist/rules.yml` for ruler. Must match your
69
80
  * build output. */
70
81
  output?: string;
71
82
  /** Project directory to build. Default: ".". */
@@ -97,6 +108,13 @@ export interface ApplyOpConfig {
97
108
  signalName?: string;
98
109
  timeout?: string;
99
110
  description?: string;
111
+ /**
112
+ * A declared decision point the gate asks (#3170), in place of `chant
113
+ * approve`: its deciders and quorum decide, and `chant workspace points
114
+ * answer` answers an open question. The point declares the input
115
+ * `gate.planDigest`, since the gate binds the plan. See `GateStep.point`.
116
+ */
117
+ point?: GateStep["point"];
100
118
  };
101
119
  /**
102
120
  * Saga-style rollback on partial apply failure, run as an `onFailure` phase.
@@ -164,6 +182,7 @@ export function ApplyOp(config: ApplyOpConfig): ApplyOpResources {
164
182
  // the gate refuses, naming the approved digest and the planned one,
165
183
  // instead of applying what nobody read.
166
184
  plan: stepOutput("plan", "planDigest"),
185
+ ...(config.gate?.point !== undefined ? { point: config.gate.point } : {}),
167
186
  description:
168
187
  config.gate?.description ??
169
188
  `Approve apply to ${config.env} (delete mode: ${deleteMode}` +
@@ -202,7 +221,7 @@ export function ApplyOp(config: ApplyOpConfig): ApplyOpResources {
202
221
  if (config.compensate !== undefined && config.compensate !== false && !rollbackAvailable) {
203
222
  throw new Error(
204
223
  `ApplyOp "${config.name}": compensate is enabled, but target "${target}" has no automatic ` +
205
- `rollback — the only mapped one today is cloudformation's rollbackStack. Either supply ` +
224
+ `rollback — the mapped ones are cloudformation's rollbackStack and the ruler's previous-group re-apply. Either supply ` +
206
225
  `compensate: { command: "..." } with a rollback of your own, or set compensate: false.`,
207
226
  );
208
227
  }
@@ -229,6 +248,7 @@ export function ApplyOp(config: ApplyOpConfig): ApplyOpResources {
229
248
  activity("compensateApply", {
230
249
  target,
231
250
  env: config.env,
251
+ ...(target === "ruler" ? { output } : {}),
232
252
  ...(compensateCommand ? { command: compensateCommand } : {}),
233
253
  }),
234
254
  ]),
@@ -12,7 +12,9 @@
12
12
  * and lets only the quorum bind. `"enforce"` lets a permit pass the gate on
13
13
  * its own, which is how an agent principal passes a low-risk plan.
14
14
  * - `context`: plan attributes handed to the policy (risk labels, changed
15
- * paths), resolved at run time like a gate's `plan`.
15
+ * paths), resolved at run time like a gate's `plan`. A gate whose `plan`
16
+ * names a step that returned a change set also gets `context.plan`, that
17
+ * change set's summary (#3182, `./gate-plan-context.ts`).
16
18
  *
17
19
  * Core does not know Cedar. A {@link GatePolicyRef} names the lexicon that
18
20
  * made it, and {@link loadGatePolicyEvaluator} imports that lexicon's
@@ -34,6 +36,7 @@
34
36
  import { createHash } from "node:crypto";
35
37
  import { lexiconModulePath, importLexiconPackage } from "../lexicon-module";
36
38
  import { isStepOutputRef, type StepOutputRef } from "./step-output-ref";
39
+ import { GATE_PLAN_CONTEXT_KEY } from "./gate-plan-context";
37
40
 
38
41
  /** What a policy's decision does to the gate. */
39
42
  export type GateApprovalMode = "log-only" | "enforce";
@@ -74,7 +77,13 @@ export interface GateApproval {
74
77
  policy?: GatePolicyRef;
75
78
  /** Default `"log-only"`, so a new policy is observed against real gate traffic before it binds. */
76
79
  mode?: GateApprovalMode;
77
- /** Plan attributes handed to the policy as Cedar `context`. A {@link StepOutputRef} is resolved at run time. */
80
+ /**
81
+ * Plan attributes handed to the policy as Cedar `context`. A {@link StepOutputRef} is resolved at run time.
82
+ *
83
+ * `plan` is reserved (#3182): when the gate's `plan` names a step that
84
+ * returned a change set (`terraformPlan`, `composeChangeSet`, ...), the run
85
+ * puts that change set's `GatePlanSummary` there on its own.
86
+ */
78
87
  context?: Record<string, GateContextValue>;
79
88
  }
80
89
 
@@ -205,6 +214,13 @@ export function gateApprovalProblems(approval: unknown): string[] {
205
214
  problems.push("`approval.context` is only read by a policy, and this gate declares none");
206
215
  } else {
207
216
  for (const [key, value] of Object.entries(a.context)) {
217
+ if (key === GATE_PLAN_CONTEXT_KEY) {
218
+ problems.push(
219
+ `\`approval.context.${key}\` is reserved: the run fills it with the plan's change-set summary (#3182) ` +
220
+ "when the gate's `plan` names the step that planned it",
221
+ );
222
+ continue;
223
+ }
208
224
  if (!isContextValue(value)) {
209
225
  problems.push(
210
226
  `\`approval.context.${key}\` must be a string, number, boolean, list of strings, or a step output reference`,