@intentius/chant 0.59.0 → 0.61.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/build-params.d.ts +2 -2
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/lint.d.ts.map +1 -1
- package/dist/codegen/json-schema.d.ts +5 -2
- package/dist/codegen/json-schema.d.ts.map +1 -1
- package/dist/components/pilots/alb-ecs.pilot.d.ts +2 -2
- package/dist/config.d.ts +4 -4
- package/dist/graph-ir.d.ts +70 -2
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/lexicon.d.ts +48 -5
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/assert-live.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts +4 -4
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/observation.d.ts +21 -1
- package/dist/observation.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +1 -1
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +79 -7
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/gate-summary.d.ts +16 -4
- package/dist/op/gate-summary.d.ts.map +1 -1
- package/dist/params.d.ts +1 -1
- package/dist/project-root.d.ts +2 -2
- package/package.json +1 -1
- package/src/build-params.ts +2 -2
- package/src/cli/commands/build.ts +8 -8
- package/src/cli/commands/lint.test.ts +151 -0
- package/src/cli/commands/lint.ts +37 -4
- package/src/cli/handlers/components.ts +1 -1
- package/src/cli/handlers/graph.test.ts +4 -4
- package/src/cli/handlers/graph.ts +11 -11
- package/src/cli/handlers/lifecycle.ts +1 -0
- package/src/cli/handlers/lint.test.ts +107 -0
- package/src/cli/handlers/lint.ts +30 -0
- package/src/codegen/json-schema.test.ts +159 -0
- package/src/codegen/json-schema.ts +106 -8
- package/src/components/SPRAWL-VALIDATION.md +5 -5
- package/src/components/pilots/README.md +1 -1
- package/src/components/pilots/alb-ecs.pilot.ts +2 -2
- package/src/config.ts +4 -4
- package/src/discovery/fold-import.test.ts +1 -1
- package/src/discovery/fold-import.ts +3 -3
- package/src/graph-ir.test.ts +63 -0
- package/src/graph-ir.ts +125 -8
- package/src/lexicon.ts +49 -5
- package/src/lifecycle/assert-live.ts +1 -0
- package/src/lifecycle/observe.test.ts +2 -2
- package/src/lifecycle/observe.ts +11 -8
- package/src/lifecycle/release-ledger.test.ts +2 -2
- package/src/observation.test.ts +21 -9
- package/src/observation.ts +31 -2
- package/src/op/activities/index.ts +8 -1
- package/src/op/activities/reconcile.test.ts +238 -0
- package/src/op/activities/reconcile.ts +267 -13
- package/src/op/gate-summary.test.ts +62 -0
- package/src/op/gate-summary.ts +17 -5
- package/src/params.ts +1 -1
- package/src/project-root.ts +2 -2
package/src/graph-ir.test.ts
CHANGED
|
@@ -224,3 +224,66 @@ describe("buildGraphIr", () => {
|
|
|
224
224
|
expect(JSON.stringify(buildGraphIr(reordered))).toEqual(JSON.stringify(ir));
|
|
225
225
|
});
|
|
226
226
|
});
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The two duck-typed channels an entity may carry beside its `props` (#2265,
|
|
230
|
+
* #2266), for a lexicon whose references are strings in someone else's syntax
|
|
231
|
+
* rather than `AttrRef` objects. Asserted here with a made-up lexicon rather
|
|
232
|
+
* than terraform's, because what is being checked is that this module reads
|
|
233
|
+
* the channels without knowing anything about who filled them.
|
|
234
|
+
*/
|
|
235
|
+
describe("entity-declared references and stacks", () => {
|
|
236
|
+
test("turns a declared reference into an edge, with both attribute names", () => {
|
|
237
|
+
const vpc = decl({ lexicon: "hcl", entityType: "Vpc" });
|
|
238
|
+
const subnet = decl({
|
|
239
|
+
lexicon: "hcl",
|
|
240
|
+
entityType: "Subnet",
|
|
241
|
+
props: { vpc_id: "${aws_vpc.main.id}" },
|
|
242
|
+
references: [{ to: "vpc", viaAttr: "vpc_id", toAttr: "id" }],
|
|
243
|
+
});
|
|
244
|
+
const entities = new Map<string, Declarable>([
|
|
245
|
+
["vpc", vpc],
|
|
246
|
+
["subnet", subnet],
|
|
247
|
+
]);
|
|
248
|
+
resolveAttrRefs(entities);
|
|
249
|
+
|
|
250
|
+
const ir = buildGraphIr(entities);
|
|
251
|
+
expect(ir.edges).toEqual([{ from: "subnet", to: "vpc", kind: "ref", viaAttr: "vpc_id", toAttr: "id" }]);
|
|
252
|
+
// The reference channel is metadata about the graph, not declared config,
|
|
253
|
+
// so it never lands in attrs; the string it was resolved from still does.
|
|
254
|
+
expect(ir.nodes.find((n) => n.id === "subnet")!.attrs).toEqual({ vpc_id: "${aws_vpc.main.id}" });
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("drops a declared reference to a self or to something that is not a node", () => {
|
|
258
|
+
const a = decl({
|
|
259
|
+
lexicon: "hcl",
|
|
260
|
+
entityType: "Vpc",
|
|
261
|
+
references: [
|
|
262
|
+
{ to: "a", viaAttr: "self" },
|
|
263
|
+
{ to: "nowhere", viaAttr: "gone" },
|
|
264
|
+
],
|
|
265
|
+
});
|
|
266
|
+
const entities = new Map<string, Declarable>([["a", a]]);
|
|
267
|
+
resolveAttrRefs(entities);
|
|
268
|
+
expect(buildGraphIr(entities).edges).toEqual([]);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("keys byStack by the unit an entity names, and leaves byLexicon alone", () => {
|
|
272
|
+
const app = decl({ lexicon: "hcl", entityType: "Vpc", stack: "app" });
|
|
273
|
+
const net = decl({ lexicon: "hcl", entityType: "Vpc", stack: "network" });
|
|
274
|
+
// An entity that names no unit still falls back to the lexicon partition,
|
|
275
|
+
// which is every other lexicon's behaviour and stays unchanged.
|
|
276
|
+
const plain = decl({ lexicon: "gcp", entityType: "Vpc" });
|
|
277
|
+
const entities = new Map<string, Declarable>([
|
|
278
|
+
["app/vpc", app],
|
|
279
|
+
["network/vpc", net],
|
|
280
|
+
["plain", plain],
|
|
281
|
+
]);
|
|
282
|
+
resolveAttrRefs(entities);
|
|
283
|
+
|
|
284
|
+
const ir = buildGraphIr(entities);
|
|
285
|
+
expect(ir.groups.byStack).toEqual({ app: ["app/vpc"], gcp: ["plain"], network: ["network/vpc"] });
|
|
286
|
+
expect(ir.groups.byLexicon).toEqual({ gcp: ["plain"], hcl: ["app/vpc", "network/vpc"] });
|
|
287
|
+
expect(ir.nodes.find((n) => n.id === "app/vpc")!.attrs).not.toHaveProperty("stack");
|
|
288
|
+
});
|
|
289
|
+
});
|
package/src/graph-ir.ts
CHANGED
|
@@ -13,7 +13,8 @@ import type { UnobservedEntity } from "./observation";
|
|
|
13
13
|
* resolved infrastructure graph. Painters (mermaid, graphviz, custom SVG) and
|
|
14
14
|
* the agentic diagrammer consume this; it is a pure function of lint-clean
|
|
15
15
|
* source. Every node traces to the file that declared it; every edge is a real
|
|
16
|
-
* cross-resource reference
|
|
16
|
+
* cross-resource reference, either an `AttrRef` this module resolves itself or
|
|
17
|
+
* an {@link EntityReference} the producing lexicon resolved (#2265).
|
|
17
18
|
*
|
|
18
19
|
* Emitted by `chant graph --format ir`. See issue #493 / epic #492.
|
|
19
20
|
*/
|
|
@@ -90,6 +91,88 @@ export interface IREdge {
|
|
|
90
91
|
toAttr?: string;
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
/**
|
|
95
|
+
* One reference an entity declares in a vocabulary this module cannot read
|
|
96
|
+
* (#2265), already resolved to the entity key it points at.
|
|
97
|
+
*
|
|
98
|
+
* `collectEdges` below finds references two ways: an {@link AttrRef} object a
|
|
99
|
+
* typed lexicon puts in its props, and a `Ref`-shaped intrinsic. Both are
|
|
100
|
+
* objects with a resolvable target, which is what makes them findable by a
|
|
101
|
+
* walk that knows no lexicon. A lexicon that PARSES someone else's source has
|
|
102
|
+
* neither: the terraform lexicon's entities keep their block body verbatim
|
|
103
|
+
* from hcl2json, where a reference to another block is the string
|
|
104
|
+
* `"${aws_vpc.main.id}"` and nothing else. The walk finds nothing, correctly,
|
|
105
|
+
* and a 247-node estate graphs with zero edges.
|
|
106
|
+
*
|
|
107
|
+
* The reference is not missing, it is just in the producer's vocabulary, and
|
|
108
|
+
* resolving it needs an HCL expression parser and the lexicon's own key shape.
|
|
109
|
+
* So the lexicon resolves it and says so here, on the entity, in the one
|
|
110
|
+
* vocabulary this module does understand: an entity key, plus the two
|
|
111
|
+
* attribute names {@link IREdge} already carries.
|
|
112
|
+
*
|
|
113
|
+
* The hook is a plain duck-typed optional field any entity MAY carry beside
|
|
114
|
+
* its `props`, read through {@link entityReferences}. That is the same shape
|
|
115
|
+
* (and the same reasoning) as `suppressions`, ../lint/suppressions.ts: a
|
|
116
|
+
* reference is a fact ABOUT one entity, `Declarable` already lets each lexicon
|
|
117
|
+
* carry its own fields beside `props`, and nothing that constructs or merges
|
|
118
|
+
* an entity map had to change for it. A lexicon that wants edges adopts the
|
|
119
|
+
* convention on its own entities; every other lexicon keeps the AttrRef walk
|
|
120
|
+
* it already had, and the two sources of edges merge in one place.
|
|
121
|
+
*
|
|
122
|
+
* An entry whose `to` is not a node in this graph is dropped rather than kept
|
|
123
|
+
* as a dangling endpoint, the same rule {@link buildLiveGraphIr} applies to an
|
|
124
|
+
* observed edge.
|
|
125
|
+
*/
|
|
126
|
+
export interface EntityReference {
|
|
127
|
+
/** The referenced entity's key, in the same space as the IR's node ids. */
|
|
128
|
+
to: string;
|
|
129
|
+
/** The consumer-side attribute the reference flows through (`IREdge.viaAttr`). */
|
|
130
|
+
viaAttr?: string;
|
|
131
|
+
/** The producer-side attribute referenced (`IREdge.toAttr`), when the reference named exactly one. */
|
|
132
|
+
toAttr?: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** True when `value` has the {@link EntityReference} shape. */
|
|
136
|
+
function isEntityReference(value: unknown): value is EntityReference {
|
|
137
|
+
return (
|
|
138
|
+
typeof value === "object" &&
|
|
139
|
+
value !== null &&
|
|
140
|
+
typeof (value as { to?: unknown }).to === "string"
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The references an entity declared for itself (#2265), or an empty list.
|
|
146
|
+
* Duck-typed on purpose, see {@link EntityReference}.
|
|
147
|
+
*/
|
|
148
|
+
export function entityReferences(entity: Declarable): readonly EntityReference[] {
|
|
149
|
+
const declared = (entity as unknown as { references?: unknown }).references;
|
|
150
|
+
if (!Array.isArray(declared)) return [];
|
|
151
|
+
return declared.filter(isEntityReference);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The deployable unit an entity says it belongs to (#2266), or undefined.
|
|
156
|
+
*
|
|
157
|
+
* Same duck-typed channel as {@link entityReferences}, for the other half of
|
|
158
|
+
* the same problem. `groups.byStack` keys by lexicon partition when nothing
|
|
159
|
+
* says otherwise, on the reasoning that one lexicon serialises to one
|
|
160
|
+
* deployable stack. That reasoning is exactly wrong for a lexicon reading an
|
|
161
|
+
* estate that already has several: a terraform project declaring five
|
|
162
|
+
* `terraform.roots` has five things `terraform apply` runs against, and each
|
|
163
|
+
* one is a stack in every sense `byStack` means, so five roots landed in one
|
|
164
|
+
* bucket named `terraform`.
|
|
165
|
+
*
|
|
166
|
+
* The root is not inferable here and should not be: it is the producer's own
|
|
167
|
+
* name for its own unit. A consumer could recover it from the node id prefix
|
|
168
|
+
* the terraform lexicon mints, but an id convention is not a promise, and
|
|
169
|
+
* `byStack` is the field whose whole job is saying which box a node goes in.
|
|
170
|
+
*/
|
|
171
|
+
export function entityStack(entity: Declarable): string | undefined {
|
|
172
|
+
const stack = (entity as unknown as { stack?: unknown }).stack;
|
|
173
|
+
return typeof stack === "string" && stack.length > 0 ? stack : undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
93
176
|
/** Grouping metadata for cluster/subgraph rendering. Maps group name -> node ids. */
|
|
94
177
|
export interface IRGroups {
|
|
95
178
|
byLexicon?: Record<string, string[]>;
|
|
@@ -99,11 +182,15 @@ export interface IRGroups {
|
|
|
99
182
|
* boundary boxes) read this rather than inferring stacks — which is the point,
|
|
100
183
|
* so it should never require one.
|
|
101
184
|
*
|
|
102
|
-
*
|
|
185
|
+
* Three sources, depending on how the project is shaped:
|
|
103
186
|
*
|
|
104
187
|
* - **side-by-side stacks**, declared in config and composed by
|
|
105
188
|
* `buildDeclaredPerStack` — keys are the declared stack names. Nothing is
|
|
106
189
|
* inferred; the project stated both the names and the membership.
|
|
190
|
+
* - **entities that name their own unit** ({@link entityStack}, #2266) —
|
|
191
|
+
* keys are those names, one entry per unit. A terraform root is the case
|
|
192
|
+
* this exists for: five `terraform.roots` are five `terraform apply`s and
|
|
193
|
+
* five boundary boxes, not one bucket called `terraform`.
|
|
107
194
|
* - **one source tree** — keys are lexicon partitions, since each lexicon
|
|
108
195
|
* serialises to one deployable stack.
|
|
109
196
|
*
|
|
@@ -315,7 +402,12 @@ function project(value: unknown, seen: Set<unknown>, reverse: Map<object, string
|
|
|
315
402
|
return out;
|
|
316
403
|
}
|
|
317
404
|
|
|
318
|
-
|
|
405
|
+
// `references` and `stack` are the two duck-typed channels above
|
|
406
|
+
// ({@link entityReferences}, {@link entityStack}). They are metadata about the
|
|
407
|
+
// entity's place in the graph, not declared configuration, and both are read
|
|
408
|
+
// directly, so projecting them into `attrs` would only restate an edge and a
|
|
409
|
+
// group key the IR already carries.
|
|
410
|
+
const SKIP_KEYS = new Set(["lexicon", "entityType", "kind", "attributes", "Ref", "references", "stack"]);
|
|
319
411
|
|
|
320
412
|
/** The config bag of a node, paired with each key. Lexicon entities keep their
|
|
321
413
|
* declared props in a (usually non-enumerable) `props` object; simpler entities
|
|
@@ -349,7 +441,17 @@ function projectConfig(
|
|
|
349
441
|
return out;
|
|
350
442
|
}
|
|
351
443
|
|
|
352
|
-
/**
|
|
444
|
+
/**
|
|
445
|
+
* Collect ref edges from one node, labelling each with its consumer property.
|
|
446
|
+
*
|
|
447
|
+
* Two sources, merged here. First the references the entity resolved for
|
|
448
|
+
* itself ({@link EntityReference}), which is how a lexicon that parses HCL,
|
|
449
|
+
* YAML or any other foreign source says what its strings point at. Then the
|
|
450
|
+
* walk over the config bag for {@link AttrRef} objects and `Ref` intrinsics,
|
|
451
|
+
* which is how a typed lexicon's props carry the same fact. A lexicon uses one
|
|
452
|
+
* or the other; nothing stops it using both, and the dedup in `buildGraphIr`
|
|
453
|
+
* collapses an edge two sources agree on.
|
|
454
|
+
*/
|
|
353
455
|
function collectEdges(
|
|
354
456
|
entity: Declarable,
|
|
355
457
|
from: string,
|
|
@@ -357,6 +459,16 @@ function collectEdges(
|
|
|
357
459
|
reverse: Map<object, string>,
|
|
358
460
|
): IREdge[] {
|
|
359
461
|
const edges: IREdge[] = [];
|
|
462
|
+
for (const ref of entityReferences(entity)) {
|
|
463
|
+
if (ref.to === from || !nodeIds.has(ref.to)) continue;
|
|
464
|
+
edges.push({
|
|
465
|
+
from,
|
|
466
|
+
to: ref.to,
|
|
467
|
+
kind: "ref",
|
|
468
|
+
...(ref.viaAttr ? { viaAttr: ref.viaAttr } : {}),
|
|
469
|
+
...(ref.toAttr ? { toAttr: ref.toAttr } : {}),
|
|
470
|
+
});
|
|
471
|
+
}
|
|
360
472
|
const seen = new Set<unknown>();
|
|
361
473
|
const visit = (value: unknown, viaAttr: string): void => {
|
|
362
474
|
if (value === null || typeof value !== "object") return;
|
|
@@ -440,9 +552,14 @@ export function buildGraphIr(
|
|
|
440
552
|
nodes.push(node);
|
|
441
553
|
|
|
442
554
|
(byLexicon[entity.lexicon] ??= []).push(name);
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
//
|
|
555
|
+
// An entity that names its own deployable unit is taken at its word
|
|
556
|
+
// (#2266): a terraform root is one `terraform apply`, and a project
|
|
557
|
+
// declaring five of them has five stacks whatever the lexicon count says.
|
|
558
|
+
// See {@link entityStack}.
|
|
559
|
+
//
|
|
560
|
+
// Otherwise, within ONE source tree a stack is a lexicon partition — each
|
|
561
|
+
// lexicon serialises to one deployable stack (a CloudFormation template, a
|
|
562
|
+
// CI config) — so that is what `byStack` reports. It stays a distinct axis
|
|
446
563
|
// from `byLexicon` (which is for provenance/colouring), emitted separately
|
|
447
564
|
// even where the two coincide.
|
|
448
565
|
//
|
|
@@ -455,7 +572,7 @@ export function buildGraphIr(
|
|
|
455
572
|
// child-project". #513 is closed, that phase was never filed, and directory
|
|
456
573
|
// partitioning is the exception rather than the rule — so the promise is
|
|
457
574
|
// withdrawn rather than left pointing at a closed issue.
|
|
458
|
-
(byStack[entity.lexicon] ??= []).push(name);
|
|
575
|
+
(byStack[entityStack(entity) ?? entity.lexicon] ??= []).push(name);
|
|
459
576
|
if (prov?.composite) (byComposite[prov.composite] ??= []).push(name);
|
|
460
577
|
}
|
|
461
578
|
|
package/src/lexicon.ts
CHANGED
|
@@ -494,10 +494,11 @@ export interface ComponentPipelineResult {
|
|
|
494
494
|
* the generated job needs to act on a finding — elevated write access for
|
|
495
495
|
* `issue`/`comment`/`pull-request`/`merge-request`, none for `report`.
|
|
496
496
|
*
|
|
497
|
-
* `comment` posts the finding on the pull request
|
|
498
|
-
* (#
|
|
499
|
-
*
|
|
500
|
-
*
|
|
497
|
+
* `comment` posts the finding on the pull request — or, on GitLab, the merge
|
|
498
|
+
* request (#2256) — that triggered the run, so unlike every other mode it
|
|
499
|
+
* constrains the trigger: the github and gitlab generators both refuse it by
|
|
500
|
+
* name on anything but `pull_request`. The forgejo generator refuses it
|
|
501
|
+
* outright, having no forge client to post the equivalent comment with.
|
|
501
502
|
*/
|
|
502
503
|
export type OpFindingMode = "report" | "issue" | "comment" | "pull-request" | "merge-request";
|
|
503
504
|
|
|
@@ -525,7 +526,9 @@ export type OpTrigger =
|
|
|
525
526
|
*
|
|
526
527
|
* A CI provider with no action concept degrades by name rather than
|
|
527
528
|
* silently: the gitlab generator refuses a `uses` entry at build time and
|
|
528
|
-
* emits a `run` entry as an ordinary script line.
|
|
529
|
+
* emits a `run` entry as an ordinary script line. The additive `permissions`
|
|
530
|
+
* map degrades the same way there, with one exception: `id-token: write`
|
|
531
|
+
* becomes GitLab's own `id_tokens:` declaration (#2256).
|
|
529
532
|
*/
|
|
530
533
|
export type OpSetupStep = OpSetupUsesStep | OpSetupRunStep;
|
|
531
534
|
|
|
@@ -551,6 +554,38 @@ export interface OpSetupRunStep {
|
|
|
551
554
|
env?: Record<string, string>;
|
|
552
555
|
}
|
|
553
556
|
|
|
557
|
+
/**
|
|
558
|
+
* The deployment environment a generated Op job runs in (#2257) — a forge
|
|
559
|
+
* object rather than a chant one. On GitHub Actions an environment carries
|
|
560
|
+
* its own protection rules (required reviewers, a wait timer, a branch
|
|
561
|
+
* restriction) and its own secrets and variables, so naming one on a job is
|
|
562
|
+
* how a generated apply is put behind a human before the job starts. GitLab
|
|
563
|
+
* has the same key with the same two fields and its own protected-environment
|
|
564
|
+
* approvals behind it. Forgejo Actions has no environments at all, so its
|
|
565
|
+
* dialect drops the key and says so in the generated file's header.
|
|
566
|
+
*
|
|
567
|
+
* This is not a chant gate and does not replace one. The environment reviewer
|
|
568
|
+
* stops the job before any step runs; chant's own gate (#2119) stops the apply
|
|
569
|
+
* inside a run that already started, on a fact recorded on the
|
|
570
|
+
* `chant/lifecycle` branch, and is cleared by `chant approve`. A project may
|
|
571
|
+
* have either, both, or neither per environment.
|
|
572
|
+
*/
|
|
573
|
+
export interface OpEnvironment {
|
|
574
|
+
/**
|
|
575
|
+
* The environment's name, exactly as the forge spells it. Nothing creates
|
|
576
|
+
* it: an environment is repository configuration, and a job naming one that
|
|
577
|
+
* does not exist yet gets an unprotected environment created on first run
|
|
578
|
+
* rather than an error, which is precisely why the name is not guessed here.
|
|
579
|
+
*/
|
|
580
|
+
name: string;
|
|
581
|
+
/**
|
|
582
|
+
* The URL shown against the resulting deployment. Absolute, or an
|
|
583
|
+
* expression the forge resolves (`${{ ... }}`) — a relative path renders as
|
|
584
|
+
* a dead link on the deployments page rather than failing anywhere.
|
|
585
|
+
*/
|
|
586
|
+
url?: string;
|
|
587
|
+
}
|
|
588
|
+
|
|
554
589
|
/** One scheduled Op to generate CI for — the cron-triggered counterpart to a component (generate mode). */
|
|
555
590
|
export interface ScheduledOpSpec {
|
|
556
591
|
/** Op name (`*.op.ts`'s `Op({ name })`) — what `chant run <name>` targets. */
|
|
@@ -594,6 +629,15 @@ export interface ScheduledOpSpec {
|
|
|
594
629
|
* grants and OIDC cannot work without.
|
|
595
630
|
*/
|
|
596
631
|
permissions?: Record<string, "read" | "write">;
|
|
632
|
+
/**
|
|
633
|
+
* The forge deployment environment this Op's generated job runs in
|
|
634
|
+
* (#2257). Per Op, beside `setup` and `permissions`, and for the same
|
|
635
|
+
* reason: which environment a job deploys to is a property of that job — a
|
|
636
|
+
* pull-request plan touches none, and only the push apply belongs behind
|
|
637
|
+
* the reviewer. See {@link OpEnvironment} for how it composes with chant's
|
|
638
|
+
* own gate.
|
|
639
|
+
*/
|
|
640
|
+
environment?: OpEnvironment;
|
|
597
641
|
}
|
|
598
642
|
|
|
599
643
|
/**
|
|
@@ -204,8 +204,8 @@ describe("observeResources", () => {
|
|
|
204
204
|
const stack = (opts as { stack?: string }).stack;
|
|
205
205
|
calls.push(stack);
|
|
206
206
|
// Different resources per stack — the multi-stack, per-component case
|
|
207
|
-
// (#57
|
|
208
|
-
//
|
|
207
|
+
// (#57). Bare-string stacks keep BARE ids (no `src` scope), so the
|
|
208
|
+
// union is `db-a`+`db-b`, not stack-qualified: per-component ids are
|
|
209
209
|
// already unique and behold reads them bare. Qualification is a scoped
|
|
210
210
|
// (`src`) feature — see the per-stack src test below.
|
|
211
211
|
const resources: Record<string, ResourceMetadata> =
|
package/src/lifecycle/observe.ts
CHANGED
|
@@ -55,6 +55,8 @@ function qualifyObservation(obs: NormalizedObservation, stackName: string): Norm
|
|
|
55
55
|
resources: q(obs.resources),
|
|
56
56
|
unobserved: q(obs.unobserved),
|
|
57
57
|
queried: q(obs.queried),
|
|
58
|
+
// Which read answered (#2267) is per entity, so it re-keys with them.
|
|
59
|
+
sources: q(obs.sources),
|
|
58
60
|
notes: obs.notes,
|
|
59
61
|
// Exports are already keyed by stack (#1279); nothing to qualify.
|
|
60
62
|
...(obs.stackExports ? { stackExports: obs.stackExports } : {}),
|
|
@@ -71,10 +73,10 @@ function qualifyObservation(obs: NormalizedObservation, stackName: string): Norm
|
|
|
71
73
|
* (`read-failed`, #1089) rather than dropped, so a failed read is visibly a
|
|
72
74
|
* hole instead of a silent absence.
|
|
73
75
|
*
|
|
74
|
-
* `stacks` (#57) is for a multi-stack, per-component project
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
76
|
+
* `stacks` (#57) is for a multi-stack, per-component project, where there is no
|
|
77
|
+
* single stack named after the environment — AWS's single-stack convention
|
|
78
|
+
* (`lexicons/aws/src/plugin.ts`'s `describeResources`, absent an explicit
|
|
79
|
+
* `stack`) queries a stack that simply doesn't exist there,
|
|
78
80
|
* so the single-call path always observes zero nodes. When `stacks` is
|
|
79
81
|
* present and non-empty, each observing plugin's `describeResources` is
|
|
80
82
|
* called once per stack and the returned observations are merged. A stack entry
|
|
@@ -197,10 +199,10 @@ export async function observeResources(
|
|
|
197
199
|
// Qualify ids by stack ONLY for a scoped (`src`) stack (#1162): that
|
|
198
200
|
// is the multi-region case where the SAME bare LogicalResourceId
|
|
199
201
|
// (e.g. `vpc`) exists in every stack, so a bare union would collide.
|
|
200
|
-
// A bare-string stack (#57
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
202
|
+
// A bare-string stack (#57) has unique per-component ids and is asked
|
|
203
|
+
// the whole-project entity set, so it keeps the bare-id tri-state
|
|
204
|
+
// merge (present > not-observed > absent) that behold and other
|
|
205
|
+
// consumers read.
|
|
204
206
|
parts.push(stack.src ? qualifyObservation(norm, stack.name) : norm);
|
|
205
207
|
}
|
|
206
208
|
observed = mergeObservations(parts);
|
|
@@ -283,6 +285,7 @@ export async function observeResources(
|
|
|
283
285
|
resources: {},
|
|
284
286
|
unobserved: unobservedAll(entityNames, "read-failed", message, entities),
|
|
285
287
|
queried: {},
|
|
288
|
+
sources: {},
|
|
286
289
|
notes: [],
|
|
287
290
|
},
|
|
288
291
|
environment,
|
|
@@ -256,10 +256,10 @@ describe("release-ledger", () => {
|
|
|
256
256
|
test("a Forgejo instance resolves through the same env contract to its own host", () => {
|
|
257
257
|
const env = {
|
|
258
258
|
GITHUB_RUN_ID: "42",
|
|
259
|
-
GITHUB_REPOSITORY: "
|
|
259
|
+
GITHUB_REPOSITORY: "acme/widgets",
|
|
260
260
|
GITHUB_SERVER_URL: "https://forge.example.dev",
|
|
261
261
|
};
|
|
262
|
-
expect(resolveRunId(undefined, env).runOrigin!.url).toBe("https://forge.example.dev/
|
|
262
|
+
expect(resolveRunId(undefined, env).runOrigin!.url).toBe("https://forge.example.dev/acme/widgets/actions/runs/42");
|
|
263
263
|
});
|
|
264
264
|
|
|
265
265
|
test("GitLab CI env records the project path and takes CI_PIPELINE_URL verbatim", () => {
|
package/src/observation.test.ts
CHANGED
|
@@ -29,7 +29,7 @@ const meta = (over: Partial<ResourceMetadata> = {}): ResourceMetadata => ({
|
|
|
29
29
|
|
|
30
30
|
describe("normalizeObservation", () => {
|
|
31
31
|
test("a bare map means 'I looked at everything'", () => {
|
|
32
|
-
expect(normalizeObservation({ a: meta() })).toEqual({ resources: { a: meta() }, unobserved: {}, queried: {}, notes: [] });
|
|
32
|
+
expect(normalizeObservation({ a: meta() })).toEqual({ resources: { a: meta() }, unobserved: {}, queried: {}, sources: {}, notes: [] });
|
|
33
33
|
});
|
|
34
34
|
|
|
35
35
|
test("the envelope carries both halves", () => {
|
|
@@ -38,6 +38,7 @@ describe("normalizeObservation", () => {
|
|
|
38
38
|
resources: { a: meta() },
|
|
39
39
|
unobserved: { b: { reason: "read-failed" } },
|
|
40
40
|
queried: {},
|
|
41
|
+
sources: {},
|
|
41
42
|
notes: [],
|
|
42
43
|
});
|
|
43
44
|
});
|
|
@@ -50,7 +51,7 @@ describe("normalizeObservation", () => {
|
|
|
50
51
|
});
|
|
51
52
|
|
|
52
53
|
test("undefined normalizes to empty maps", () => {
|
|
53
|
-
expect(normalizeObservation(undefined)).toEqual({ resources: {}, unobserved: {}, queried: {}, notes: [] });
|
|
54
|
+
expect(normalizeObservation(undefined)).toEqual({ resources: {}, unobserved: {}, queried: {}, sources: {}, notes: [] });
|
|
54
55
|
});
|
|
55
56
|
|
|
56
57
|
test("the envelope carries the queried addresses through normalization (#1620)", () => {
|
|
@@ -92,8 +93,8 @@ describe("unobservedAll", () => {
|
|
|
92
93
|
describe("mergeObservations (multi-stack)", () => {
|
|
93
94
|
test("present beats not-observed beats absent", () => {
|
|
94
95
|
const merged = mergeObservations([
|
|
95
|
-
{ resources: {}, unobserved: { a: { reason: "read-failed" }, b: { reason: "no-binding" } }, queried: {}, notes: [] },
|
|
96
|
-
{ resources: { a: meta() }, unobserved: {}, queried: {}, notes: [] },
|
|
96
|
+
{ resources: {}, unobserved: { a: { reason: "read-failed" }, b: { reason: "no-binding" } }, queried: {}, sources: {}, notes: [] },
|
|
97
|
+
{ resources: { a: meta() }, unobserved: {}, queried: {}, sources: {}, notes: [] },
|
|
97
98
|
]);
|
|
98
99
|
expect(Object.keys(merged.resources)).toEqual(["a"]);
|
|
99
100
|
expect(Object.keys(merged.unobserved)).toEqual(["b"]);
|
|
@@ -102,26 +103,37 @@ describe("mergeObservations (multi-stack)", () => {
|
|
|
102
103
|
test("the same note from four stacks is one note (#1265)", () => {
|
|
103
104
|
const note = "ownership filter unavailable";
|
|
104
105
|
const merged = mergeObservations(
|
|
105
|
-
["a", "b", "c", "d"].map((k) => ({ resources: { [k]: meta() }, unobserved: {}, queried: {}, notes: [note] })),
|
|
106
|
+
["a", "b", "c", "d"].map((k) => ({ resources: { [k]: meta() }, unobserved: {}, queried: {}, sources: {}, notes: [note] })),
|
|
106
107
|
);
|
|
107
108
|
expect(merged.notes).toEqual([note]);
|
|
108
109
|
});
|
|
109
110
|
|
|
110
111
|
test("an entity nobody looked for in any stack stays absent", () => {
|
|
111
112
|
const merged = mergeObservations([
|
|
112
|
-
{ resources: { a: meta() }, unobserved: {}, queried: {}, notes: [] },
|
|
113
|
-
{ resources: { b: meta() }, unobserved: {}, queried: {}, notes: [] },
|
|
113
|
+
{ resources: { a: meta() }, unobserved: {}, queried: {}, sources: {}, notes: [] },
|
|
114
|
+
{ resources: { b: meta() }, unobserved: {}, queried: {}, sources: {}, notes: [] },
|
|
114
115
|
]);
|
|
115
116
|
expect(merged.unobserved).toEqual({});
|
|
116
117
|
});
|
|
117
118
|
|
|
118
119
|
test("queried addresses union across stacks (#1620)", () => {
|
|
119
120
|
const merged = mergeObservations([
|
|
120
|
-
{ resources: {}, unobserved: {}, queried: { a: "stack-1/a" }, notes: [] },
|
|
121
|
-
{ resources: { b: meta() }, unobserved: {}, queried: { b: "stack-2/b" }, notes: [] },
|
|
121
|
+
{ resources: {}, unobserved: {}, queried: { a: "stack-1/a" }, sources: {}, notes: [] },
|
|
122
|
+
{ resources: { b: meta() }, unobserved: {}, queried: { b: "stack-2/b" }, sources: {}, notes: [] },
|
|
122
123
|
]);
|
|
123
124
|
expect(merged.queried).toEqual({ a: "stack-1/a", b: "stack-2/b" });
|
|
124
125
|
});
|
|
126
|
+
|
|
127
|
+
test("which read answered unions across parts (#2267)", () => {
|
|
128
|
+
// Parts read disjoint entity sets (one per stack, or per terraform root),
|
|
129
|
+
// so the union is the whole answer and there is nothing to reconcile. A
|
|
130
|
+
// project mixing a stock root and a live one gets both values here.
|
|
131
|
+
const merged = mergeObservations([
|
|
132
|
+
{ resources: { a: meta() }, unobserved: {}, queried: {}, sources: { a: "state" }, notes: [] },
|
|
133
|
+
{ resources: { b: meta() }, unobserved: {}, queried: {}, sources: { b: "live" }, notes: [] },
|
|
134
|
+
]);
|
|
135
|
+
expect(merged.sources).toEqual({ a: "state", b: "live" });
|
|
136
|
+
});
|
|
125
137
|
});
|
|
126
138
|
|
|
127
139
|
describe("reason totality", () => {
|
package/src/observation.ts
CHANGED
|
@@ -112,6 +112,24 @@ export interface ObservationResult {
|
|
|
112
112
|
* namespace, endpoint or region was read, not the one the resource lives in.
|
|
113
113
|
*/
|
|
114
114
|
queried?: Record<string, string>;
|
|
115
|
+
/**
|
|
116
|
+
* Which read answered for each declared entity (#2267), keyed by chant
|
|
117
|
+
* entity name. The value is a short token the lexicon defines and documents;
|
|
118
|
+
* core carries it and never interprets it, so nothing here switches on one.
|
|
119
|
+
*
|
|
120
|
+
* Additive metadata over the tri-state, exactly like {@link queried}, and
|
|
121
|
+
* present for every verdict including OBSERVED-ABSENT and NOT-OBSERVED,
|
|
122
|
+
* since which read was attempted is a fact whatever the read came back with.
|
|
123
|
+
*
|
|
124
|
+
* A lexicon with one read path has nothing to say here and omits it. This is
|
|
125
|
+
* for a lexicon with two, where the two answer different questions: a stock
|
|
126
|
+
* Terraform root is read with `terraform show -json` over its state, which
|
|
127
|
+
* says what the last apply recorded, and a live root is read with
|
|
128
|
+
* `choudoufu live-plan -json`, which says what the account holds. A renderer
|
|
129
|
+
* that paints an overlay as drift must not paint the first one green, and
|
|
130
|
+
* before this field an observation gave it no way to tell them apart.
|
|
131
|
+
*/
|
|
132
|
+
sources?: Record<string, string>;
|
|
115
133
|
/**
|
|
116
134
|
* Notices about the read as a whole, not about any one entity (#1265) —
|
|
117
135
|
* "the ownership filter could not be applied on this surface" is the
|
|
@@ -144,6 +162,8 @@ export interface NormalizedObservation {
|
|
|
144
162
|
unobserved: Record<string, UnobservedEntity>;
|
|
145
163
|
/** Resolved query address per entity name (#1620). Empty when the lexicon reported none. */
|
|
146
164
|
queried: Record<string, string>;
|
|
165
|
+
/** Which read answered, per entity name (#2267). Empty when the lexicon reported none. */
|
|
166
|
+
sources: Record<string, string>;
|
|
147
167
|
/** Run-level notices (#1265), distinct. Empty when the lexicon reported none. */
|
|
148
168
|
notes: string[];
|
|
149
169
|
/** Per-stack exports (#1279), keyed by stack name. Absent when the lexicon reported none. */
|
|
@@ -169,12 +189,14 @@ export function observation(
|
|
|
169
189
|
queried?: Record<string, string>,
|
|
170
190
|
notes?: string[],
|
|
171
191
|
stackExports?: Record<string, Record<string, unknown>>,
|
|
192
|
+
sources?: Record<string, string>,
|
|
172
193
|
): ObservationResult {
|
|
173
194
|
return {
|
|
174
195
|
observation: "v1",
|
|
175
196
|
resources,
|
|
176
197
|
...(unobserved && Object.keys(unobserved).length > 0 ? { unobserved } : {}),
|
|
177
198
|
...(queried && Object.keys(queried).length > 0 ? { queried } : {}),
|
|
199
|
+
...(sources && Object.keys(sources).length > 0 ? { sources } : {}),
|
|
178
200
|
...(notes && notes.length > 0 ? { notes } : {}),
|
|
179
201
|
...(stackExports && Object.keys(stackExports).length > 0 ? { stackExports } : {}),
|
|
180
202
|
};
|
|
@@ -187,17 +209,18 @@ export function observation(
|
|
|
187
209
|
* {@link unobservedAll} rather than returning nothing.
|
|
188
210
|
*/
|
|
189
211
|
export function normalizeObservation(value: DescribeResourcesResult | undefined): NormalizedObservation {
|
|
190
|
-
if (!value) return { resources: {}, unobserved: {}, queried: {}, notes: [] };
|
|
212
|
+
if (!value) return { resources: {}, unobserved: {}, queried: {}, sources: {}, notes: [] };
|
|
191
213
|
if (isObservationResult(value)) {
|
|
192
214
|
return {
|
|
193
215
|
resources: value.resources ?? {},
|
|
194
216
|
unobserved: value.unobserved ?? {},
|
|
195
217
|
queried: value.queried ?? {},
|
|
218
|
+
sources: value.sources ?? {},
|
|
196
219
|
notes: [...new Set(value.notes ?? [])],
|
|
197
220
|
...(value.stackExports && Object.keys(value.stackExports).length > 0 ? { stackExports: value.stackExports } : {}),
|
|
198
221
|
};
|
|
199
222
|
}
|
|
200
|
-
return { resources: value, unobserved: {}, queried: {}, notes: [] };
|
|
223
|
+
return { resources: value, unobserved: {}, queried: {}, sources: {}, notes: [] };
|
|
201
224
|
}
|
|
202
225
|
|
|
203
226
|
/**
|
|
@@ -237,6 +260,10 @@ export function mergeObservations(parts: Iterable<NormalizedObservation>): Norma
|
|
|
237
260
|
const resources: Record<string, ResourceMetadata> = {};
|
|
238
261
|
const unobserved: Record<string, UnobservedEntity> = {};
|
|
239
262
|
const queried: Record<string, string> = {};
|
|
263
|
+
// Which read answered (#2267) merges the same way `queried` does: it is a
|
|
264
|
+
// fact about one entity's read, and the parts being merged are reads of
|
|
265
|
+
// disjoint entity sets (one per stack, or per terraform root).
|
|
266
|
+
const sources: Record<string, string> = {};
|
|
240
267
|
// A note is about the read, not a stack; four stacks saying the same thing
|
|
241
268
|
// is one note (#1265).
|
|
242
269
|
const notes = new Set<string>();
|
|
@@ -245,6 +272,7 @@ export function mergeObservations(parts: Iterable<NormalizedObservation>): Norma
|
|
|
245
272
|
Object.assign(resources, part.resources);
|
|
246
273
|
Object.assign(unobserved, part.unobserved);
|
|
247
274
|
Object.assign(queried, part.queried);
|
|
275
|
+
Object.assign(sources, part.sources);
|
|
248
276
|
for (const n of part.notes) notes.add(n);
|
|
249
277
|
Object.assign(stackExports, part.stackExports ?? {});
|
|
250
278
|
}
|
|
@@ -255,6 +283,7 @@ export function mergeObservations(parts: Iterable<NormalizedObservation>): Norma
|
|
|
255
283
|
resources,
|
|
256
284
|
unobserved,
|
|
257
285
|
queried,
|
|
286
|
+
sources,
|
|
258
287
|
notes: [...notes],
|
|
259
288
|
...(Object.keys(stackExports).length > 0 ? { stackExports } : {}),
|
|
260
289
|
};
|
|
@@ -43,7 +43,14 @@ export type { EnvTeardownArgs, EnvTeardownResult, EnvTeardownDeps } from "./env-
|
|
|
43
43
|
// the registry imports this module statically, so a project that installs
|
|
44
44
|
// nothing but chant still resolves every step below.
|
|
45
45
|
|
|
46
|
-
export {
|
|
46
|
+
export {
|
|
47
|
+
reconcilePr,
|
|
48
|
+
commentMarker,
|
|
49
|
+
pullRequestContextFrom,
|
|
50
|
+
resolvePullRequestContext,
|
|
51
|
+
mergeRequestContextFrom,
|
|
52
|
+
gitlabNoteTokenFrom,
|
|
53
|
+
} from "./reconcile";
|
|
47
54
|
export type { ReconcilePrArgs, ReconcileResult, ReconcileMode, ReconcileEntry, PullRequestContext } from "./reconcile";
|
|
48
55
|
|
|
49
56
|
export { nativeApply, compensateApply, hasNativeRollback } from "./apply";
|