@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.
- package/dist/cli/commands/check-lexicon.d.ts +7 -0
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/composite.d.ts +5 -1
- package/dist/composite.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +29 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/op/activities/apply.d.ts +40 -6
- package/dist/op/activities/apply.d.ts.map +1 -1
- package/dist/op/builders.d.ts +7 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/composites/apply-op.d.ts +21 -3
- package/dist/op/composites/apply-op.d.ts.map +1 -1
- package/dist/op/gate-approval.d.ts +10 -2
- package/dist/op/gate-approval.d.ts.map +1 -1
- package/dist/op/gate-plan-context.d.ts +101 -0
- package/dist/op/gate-plan-context.d.ts.map +1 -0
- package/dist/op/gate-point-run.d.ts +110 -0
- package/dist/op/gate-point-run.d.ts.map +1 -0
- package/dist/op/gate-point.d.ts +70 -0
- package/dist/op/gate-point.d.ts.map +1 -0
- package/dist/op/gate.d.ts +12 -1
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +6 -0
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +11 -3
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +2 -0
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/types.d.ts +11 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/workspace/status-gates.d.ts +15 -1
- package/dist/workspace/status-gates.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/check-lexicon.test.ts +21 -5
- package/src/cli/commands/check-lexicon.ts +14 -3
- package/src/cli/handlers/operator.ts +9 -0
- package/src/composite.ts +5 -1
- package/src/lifecycle/gate-ledger.ts +30 -0
- package/src/op/activities/apply.test.ts +43 -0
- package/src/op/activities/apply.ts +111 -6
- package/src/op/builders.ts +13 -1
- package/src/op/composites/apply-op.ts +24 -4
- package/src/op/gate-approval.ts +18 -2
- package/src/op/gate-plan-context.test.ts +186 -0
- package/src/op/gate-plan-context.ts +229 -0
- package/src/op/gate-point-run.ts +207 -0
- package/src/op/gate-point.e2e.test.ts +166 -0
- package/src/op/gate-point.test.ts +235 -0
- package/src/op/gate-point.ts +119 -0
- package/src/op/gate.ts +26 -1
- package/src/op/index.ts +8 -0
- package/src/op/local-executor.ts +96 -3
- package/src/op/op-ir.ts +4 -0
- package/src/op/types.ts +11 -0
- package/src/workspace/status-gates.ts +13 -2
- 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),
|
|
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.
|
|
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
|
}
|
package/src/op/builders.ts
CHANGED
|
@@ -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
|
|
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
|
]),
|
package/src/op/gate-approval.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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`,
|