@intentius/chant 0.33.0 → 0.34.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/cli/commands/onboard.d.ts.map +1 -1
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/search.d.ts +72 -0
- package/dist/cli/handlers/search.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +26 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/graph-effective.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +21 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-refs.d.ts +19 -0
- package/dist/graph-refs.d.ts.map +1 -1
- package/dist/lexicon.d.ts +141 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/deep-observe.d.ts +4 -0
- package/dist/lifecycle/deep-observe.d.ts.map +1 -1
- package/dist/lifecycle/live-diff.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts +55 -1
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lifecycle/replay.d.ts +47 -0
- package/dist/lifecycle/replay.d.ts.map +1 -0
- package/dist/lifecycle/snapshot.d.ts +6 -0
- package/dist/lifecycle/snapshot.d.ts.map +1 -1
- package/dist/lifecycle/types.d.ts +46 -0
- package/dist/lifecycle/types.d.ts.map +1 -1
- package/dist/observation.d.ts +71 -0
- package/dist/observation.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/onboard.ts +10 -25
- package/src/cli/handlers/graph.test.ts +74 -0
- package/src/cli/handlers/graph.ts +77 -36
- package/src/cli/handlers/lifecycle.test.ts +86 -0
- package/src/cli/handlers/lifecycle.ts +43 -10
- package/src/cli/handlers/search.test.ts +246 -4
- package/src/cli/handlers/search.ts +432 -27
- package/src/cli/main.ts +9 -0
- package/src/cli/registry.ts +27 -0
- package/src/codegen/lexicon-wiring.test.ts +53 -0
- package/src/codegen/release-wiring.test.ts +174 -0
- package/src/graph-effective.ts +7 -1
- package/src/graph-ir-live.test.ts +83 -0
- package/src/graph-ir.ts +58 -1
- package/src/graph-refs.test.ts +59 -0
- package/src/graph-refs.ts +39 -8
- package/src/lexicon.ts +145 -0
- package/src/lifecycle/deep-observe.ts +5 -0
- package/src/lifecycle/live-diff.test.ts +38 -0
- package/src/lifecycle/live-diff.ts +45 -2
- package/src/lifecycle/observe.ts +186 -4
- package/src/lifecycle/replay.ts +141 -0
- package/src/lifecycle/snapshot.test.ts +179 -0
- package/src/lifecycle/snapshot.ts +88 -3
- package/src/lifecycle/types.ts +47 -0
- package/src/observation.test.ts +135 -0
- package/src/observation.ts +151 -0
package/src/lexicon.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { DriverComponent } from "./components/driver";
|
|
|
11
11
|
import type { EmulatorCapability } from "./op/emulator-lifecycle";
|
|
12
12
|
import type { RuleMeta } from "./audit/catalog";
|
|
13
13
|
import type { ReferenceCatalog } from "./graph-refs";
|
|
14
|
+
import type { IREdge } from "./graph-ir";
|
|
14
15
|
import type { DescribeResourcesResult } from "./observation";
|
|
15
16
|
import type { DeepNormalizationHooks, DeepObservationResult } from "./deep-observation";
|
|
16
17
|
import type { OwnerChainVerdict } from "./owner-chain";
|
|
@@ -24,6 +25,10 @@ export type { CommandGroup, CommandGroupCommand, CommandGroupContext } from "./c
|
|
|
24
25
|
// `@intentius/chant/lexicon` entry they import the plugin contract from.
|
|
25
26
|
export type { ReferenceCatalog, IdentityRule, RefRule } from "./graph-refs";
|
|
26
27
|
|
|
28
|
+
// An observation can report relationships (#1271/#1273), so a lexicon needs the
|
|
29
|
+
// edge type from the same entry it imports the plugin contract from.
|
|
30
|
+
export type { IREdge } from "./graph-ir";
|
|
31
|
+
|
|
27
32
|
// The observation contract (#1089), re-exported from the same entry so a
|
|
28
33
|
// lexicon's `describeResources` can report NOT-OBSERVED without a second
|
|
29
34
|
// import path. Runtime helpers live in `@intentius/chant/observation`.
|
|
@@ -641,6 +646,84 @@ export interface LexiconPlugin {
|
|
|
641
646
|
* Throwing is the whole-lexicon failure, same as the thin read: core turns it
|
|
642
647
|
* into `read-failed` for every declared entity.
|
|
643
648
|
*/
|
|
649
|
+
/**
|
|
650
|
+
* Report the undeclared resources this estate *depends on* (#1273), as
|
|
651
|
+
* opposed to the ones it manages.
|
|
652
|
+
*
|
|
653
|
+
* `describeResources` is scoped to what the stack declares. Anything the
|
|
654
|
+
* estate references but does not declare — an account's default VPC route
|
|
655
|
+
* tables, a shared subnet, networking owned by another team — is invisible to
|
|
656
|
+
* it, so it never becomes a node, so no edge can reach it and no fold can
|
|
657
|
+
* traverse it. That is why derived facts about un-modelled topology have had
|
|
658
|
+
* to be computed inside lexicons and injected as attributes.
|
|
659
|
+
*
|
|
660
|
+
* The closure rule is depth one by reference, plus whatever chains this
|
|
661
|
+
* lexicon's {@link referenceCatalog} declares as meaningful — so AWS follows
|
|
662
|
+
* `SubnetId` → `RouteTableId` → `GatewayId` because the catalog says those
|
|
663
|
+
* references matter, not because they happen to be reachable. Without a rule
|
|
664
|
+
* a VPC transitively reaches most of an account.
|
|
665
|
+
*
|
|
666
|
+
* Every returned resource must carry {@link ResourceMetadata.referencedBy},
|
|
667
|
+
* naming the nodes that pulled it in. A dependency with no referrer is
|
|
668
|
+
* unbounded discovery, which is what the closure rule exists to prevent.
|
|
669
|
+
*
|
|
670
|
+
* Optional and additive. A lexicon that does not implement it behaves exactly
|
|
671
|
+
* as before, and a consumer that ignores dependencies sees what it always saw.
|
|
672
|
+
*/
|
|
673
|
+
observeDependencies?(options: {
|
|
674
|
+
environment: string;
|
|
675
|
+
/** Declared entities, for a lexicon that resolves references from source. */
|
|
676
|
+
entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
677
|
+
/** What {@link describeResources} just found — the roots of the closure. */
|
|
678
|
+
observed: Record<string, ResourceMetadata>;
|
|
679
|
+
stack?: string;
|
|
680
|
+
region?: string;
|
|
681
|
+
}): Promise<DependencyObservation>;
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Report resources of a kind this estate manages that exist in the account
|
|
685
|
+
* without being declared or referenced (#1278).
|
|
686
|
+
*
|
|
687
|
+
* `describeResources` answers "what do I manage" and `observeDependencies`
|
|
688
|
+
* answers "what do I rely on". Neither can see a resource that is simply
|
|
689
|
+
* *there* — an unattached security group, an orphaned volume — because both
|
|
690
|
+
* resolve outward from the declared estate and an unused resource is reached
|
|
691
|
+
* by nothing.
|
|
692
|
+
*
|
|
693
|
+
* That is a real question with no other answer: "which of my security groups
|
|
694
|
+
* are unused" cannot be resolved from a state file at all, because a state
|
|
695
|
+
* file knows only what it created. A lexicon that can enumerate a kind can
|
|
696
|
+
* answer it.
|
|
697
|
+
*
|
|
698
|
+
* `kinds` bounds the scan to types the project actually declares, so a
|
|
699
|
+
* project managing security groups is not made to enumerate the account. A
|
|
700
|
+
* lexicon returns resources marked {@link ResourceMetadata.ambient}, and must
|
|
701
|
+
* exclude anything already in `observed` — those are managed, not ambient.
|
|
702
|
+
*
|
|
703
|
+
* Optional and opt-in. A lexicon that does not implement it, or a caller that
|
|
704
|
+
* does not ask, sees exactly what it saw before.
|
|
705
|
+
*/
|
|
706
|
+
/**
|
|
707
|
+
* Kinds this lexicon can enumerate beyond the declared estate (#1278).
|
|
708
|
+
*
|
|
709
|
+
* Declared separately from {@link observeAmbient} so a caller can say that
|
|
710
|
+
* ambient resources of a kind are POSSIBLE without paying for a scan to find
|
|
711
|
+
* out. `chant search` uses it to point out that `--ambient` is relevant to
|
|
712
|
+
* the kind just queried — an agent asking which security groups are unused
|
|
713
|
+
* has no way to know that some are not in the answer at all.
|
|
714
|
+
*/
|
|
715
|
+
ambientKinds?(): string[];
|
|
716
|
+
|
|
717
|
+
observeAmbient?(options: {
|
|
718
|
+
environment: string;
|
|
719
|
+
/** Entity types the project declares — the bound on what to enumerate. */
|
|
720
|
+
kinds: string[];
|
|
721
|
+
/** Already-observed managed resources, to exclude. */
|
|
722
|
+
observed: Record<string, ResourceMetadata>;
|
|
723
|
+
stack?: string;
|
|
724
|
+
region?: string;
|
|
725
|
+
}): Promise<Record<string, ResourceMetadata>>;
|
|
726
|
+
|
|
644
727
|
observeResourcesDeep?(options: {
|
|
645
728
|
environment: string;
|
|
646
729
|
buildOutput: string;
|
|
@@ -648,6 +731,10 @@ export interface LexiconPlugin {
|
|
|
648
731
|
entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
649
732
|
/** Deployed stack to observe, for a multi-stack project (see `stacks` in {@link ChantConfig}). */
|
|
650
733
|
stack?: string;
|
|
734
|
+
/** Region the stack is deployed in (#1267), mirroring the thin path's
|
|
735
|
+
* `describeResources` (#1261). When omitted, an implementation keeps its
|
|
736
|
+
* ambient-region default. */
|
|
737
|
+
region?: string;
|
|
651
738
|
/** Restrict to chant-owned resources (#119). A lexicon with no marker channel on this path says so. */
|
|
652
739
|
owned?: boolean;
|
|
653
740
|
}): Promise<DeepObservationResult>;
|
|
@@ -799,6 +886,31 @@ export type ExportedTemplate = TemplateIR & {
|
|
|
799
886
|
/**
|
|
800
887
|
* Metadata about a deployed resource, returned by describeResources.
|
|
801
888
|
*/
|
|
889
|
+
/**
|
|
890
|
+
* What a lexicon saw outside its declared estate (#1273): the resources
|
|
891
|
+
* something declared references, and how they connect.
|
|
892
|
+
*
|
|
893
|
+
* Kept as its own result rather than folded into `describeResources` so every
|
|
894
|
+
* consumer downstream can tell "what I manage" from "what I depend on" without
|
|
895
|
+
* disentangling them — the same reason the change set separates `orphan` from
|
|
896
|
+
* `runtimeChildren`.
|
|
897
|
+
*/
|
|
898
|
+
export interface DependencyObservation {
|
|
899
|
+
/**
|
|
900
|
+
* Undeclared resources, keyed by a stable id. Physical id is the natural
|
|
901
|
+
* choice: these have no logical name, and the key has to match between a live
|
|
902
|
+
* read and a snapshot replay or an overlay double-counts them.
|
|
903
|
+
*/
|
|
904
|
+
resources: Record<string, ResourceMetadata>;
|
|
905
|
+
/**
|
|
906
|
+
* Relationships among the dependencies and back to the declared nodes that
|
|
907
|
+
* reached them. Reported rather than reconstructed, because a reference
|
|
908
|
+
* catalog can only resolve what it has an identity index for, and part of the
|
|
909
|
+
* point here is expressing a hop the catalog does not model.
|
|
910
|
+
*/
|
|
911
|
+
edges?: IREdge[];
|
|
912
|
+
}
|
|
913
|
+
|
|
802
914
|
export interface ResourceMetadata {
|
|
803
915
|
/** Entity type (e.g. AWS::S3::Bucket, K8s::Apps::Deployment) */
|
|
804
916
|
type: string;
|
|
@@ -832,6 +944,39 @@ export interface ResourceMetadata {
|
|
|
832
944
|
* undeclared live resource stays `orphan`.
|
|
833
945
|
*/
|
|
834
946
|
ownerChain?: OwnerChainVerdict;
|
|
947
|
+
/**
|
|
948
|
+
* The declared node ids that reference this resource (#1273), set on an
|
|
949
|
+
* undeclared resource observed only because something declared points at it —
|
|
950
|
+
* the account's default VPC route table an instance routes through, a shared
|
|
951
|
+
* subnet, a network someone else owns.
|
|
952
|
+
*
|
|
953
|
+
* A third kind of observed-but-undeclared resource, beside {@link ownership}
|
|
954
|
+
* and {@link ownerChain}. Not an orphan: nobody is going to adopt or delete
|
|
955
|
+
* the account's default VPC, so offering it as a delete candidate is wrong.
|
|
956
|
+
* Not a runtime child either — nothing declared created it. It gets a runtime
|
|
957
|
+
* child's drift treatment for the same reason: it is not yours, it changes on
|
|
958
|
+
* its own, and alerting on it is noise.
|
|
959
|
+
*
|
|
960
|
+
* Carries the referrers rather than a bare flag so the reason a resource was
|
|
961
|
+
* pulled in is answerable, and so a closure can be walked back to the declared
|
|
962
|
+
* entity that justified it.
|
|
963
|
+
*/
|
|
964
|
+
referencedBy?: string[];
|
|
965
|
+
/**
|
|
966
|
+
* Observed in the account, of a kind this estate manages, but neither
|
|
967
|
+
* declared nor referenced by anything declared (#1278).
|
|
968
|
+
*
|
|
969
|
+
* The third and last category of observed-but-undeclared resource, after
|
|
970
|
+
* {@link ownerChain}'s runtime children and {@link referencedBy}'s
|
|
971
|
+
* dependencies. A default security group AWS creates per VPC is the type
|
|
972
|
+
* case: nothing declares it, nothing points at it, and it is exactly what
|
|
973
|
+
* "which of my security groups are unused" is asking about.
|
|
974
|
+
*
|
|
975
|
+
* Opt-in, because finding these means asking the provider what exists rather
|
|
976
|
+
* than resolving out from what is declared, which is a broader read and a
|
|
977
|
+
* different claim about what an observation is.
|
|
978
|
+
*/
|
|
979
|
+
ambient?: boolean;
|
|
835
980
|
}
|
|
836
981
|
|
|
837
982
|
/**
|
|
@@ -39,6 +39,10 @@ export interface DeepObserveOptions {
|
|
|
39
39
|
entities: DeclaredEntities;
|
|
40
40
|
/** Deployed stack for a multi-stack project (#932). */
|
|
41
41
|
stack?: string;
|
|
42
|
+
/** Region that stack is deployed in (#1267). Same contract as the thin path
|
|
43
|
+
* (#1261): a multi-region estate reads each stack in its own region, not in
|
|
44
|
+
* whichever one the shell is set to. */
|
|
45
|
+
region?: string;
|
|
42
46
|
/** Component projects deploy one stack per component; read them all and merge. */
|
|
43
47
|
componentStacks?: string[];
|
|
44
48
|
owned?: boolean;
|
|
@@ -80,6 +84,7 @@ export async function observeDeep(
|
|
|
80
84
|
buildOutput: opts.buildOutput,
|
|
81
85
|
entityNames,
|
|
82
86
|
entities: opts.entities,
|
|
87
|
+
...(opts.region ? { region: opts.region } : {}),
|
|
83
88
|
...(opts.owned !== undefined ? { owned: opts.owned } : {}),
|
|
84
89
|
};
|
|
85
90
|
try {
|
|
@@ -336,3 +336,41 @@ describe("diffSnapshots (#822)", () => {
|
|
|
336
336
|
expect(d.added).toEqual(["a", "b"]);
|
|
337
337
|
});
|
|
338
338
|
});
|
|
339
|
+
|
|
340
|
+
describe("attribute comparison is key-order insensitive (#1279)", () => {
|
|
341
|
+
const inst = (attributes: Record<string, unknown>): ResourceMetadata => ({
|
|
342
|
+
type: "AWS::EC2::Instance",
|
|
343
|
+
status: "OBSERVED",
|
|
344
|
+
physicalId: "i-1",
|
|
345
|
+
attributes,
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("does not drift when a provider reorders the keys of a nested object", () => {
|
|
349
|
+
// Exactly what AWS did between two reads of the same untouched instance.
|
|
350
|
+
const result = diffLive({
|
|
351
|
+
declared: new Set(["one"]),
|
|
352
|
+
observedNow: { one: inst({ Placement: { Tenancy: "default", AvailabilityZone: "us-east-1c" } }) },
|
|
353
|
+
observedThen: { one: inst({ Placement: { AvailabilityZone: "us-east-1c", Tenancy: "default" } }) },
|
|
354
|
+
});
|
|
355
|
+
expect(result.driftedSinceSnapshot).toEqual([]);
|
|
356
|
+
expect(result.unchanged).toEqual(["one"]);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("still reports a real change to a nested value", () => {
|
|
360
|
+
const result = diffLive({
|
|
361
|
+
declared: new Set(["one"]),
|
|
362
|
+
observedNow: { one: inst({ Placement: { AvailabilityZone: "us-east-1d" } }) },
|
|
363
|
+
observedThen: { one: inst({ Placement: { AvailabilityZone: "us-east-1c" } }) },
|
|
364
|
+
});
|
|
365
|
+
expect(result.driftedSinceSnapshot.map((d) => d.changes[0].path)).toEqual(["attributes.Placement"]);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("keeps array order significant — for a list, order is part of the value", () => {
|
|
369
|
+
const result = diffLive({
|
|
370
|
+
declared: new Set(["one"]),
|
|
371
|
+
observedNow: { one: inst({ Rules: [{ p: 80 }, { p: 22 }] }) },
|
|
372
|
+
observedThen: { one: inst({ Rules: [{ p: 22 }, { p: 80 }] }) },
|
|
373
|
+
});
|
|
374
|
+
expect(result.driftedSinceSnapshot).toHaveLength(1);
|
|
375
|
+
});
|
|
376
|
+
});
|
|
@@ -133,11 +133,31 @@ function compareMetadata(
|
|
|
133
133
|
return changes;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Value equality that does not care what order a provider listed the keys in.
|
|
138
|
+
*
|
|
139
|
+
* This compared with `JSON.stringify`, which is key-order sensitive. That held
|
|
140
|
+
* while observed attributes were flat strings, and broke the moment they carried
|
|
141
|
+
* nested objects (#1279): a provider returning `{AvailabilityZone, Tenancy}` on
|
|
142
|
+
* one read and `{Tenancy, AvailabilityZone}` on the next made an unchanged
|
|
143
|
+
* instance drift on every single run. Order is not a fact about the resource,
|
|
144
|
+
* and reporting it as drift is exactly the noise this module exists to remove.
|
|
145
|
+
*
|
|
146
|
+
* Arrays stay order-sensitive — for a list, order is part of the value.
|
|
147
|
+
*/
|
|
136
148
|
function shallowEqual(a: unknown, b: unknown): boolean {
|
|
137
149
|
if (a === b) return true;
|
|
138
150
|
if (a == null || b == null) return false;
|
|
139
151
|
if (typeof a !== "object" || typeof b !== "object") return false;
|
|
140
|
-
return
|
|
152
|
+
return canonical(a) === canonical(b);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** JSON with object keys sorted at every depth, so equal values stringify equally. */
|
|
156
|
+
function canonical(value: unknown): string {
|
|
157
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
158
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
159
|
+
const entries = Object.entries(value as Record<string, unknown>).sort(([x], [y]) => (x < y ? -1 : x > y ? 1 : 0));
|
|
160
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`;
|
|
141
161
|
}
|
|
142
162
|
|
|
143
163
|
/** The delta between two saved snapshots (#822): a two-way observed diff. */
|
|
@@ -227,8 +247,28 @@ export function diffLive(input: DiffLiveInput): LiveDiffResult {
|
|
|
227
247
|
// same as `unowned`/`foreign` — composing with #1168's tri-state precedent:
|
|
228
248
|
// an incomplete read never earns the more confident classification.
|
|
229
249
|
const runtimeChildNames = new Set<string>();
|
|
250
|
+
const dependencyNames = new Set<string>();
|
|
230
251
|
for (const name of observedNowNames) {
|
|
231
252
|
if (declared.has(name)) continue;
|
|
253
|
+
// A referenced dependency (#1273) is observed only because something
|
|
254
|
+
// declared points at it — an account's default VPC route table, a shared
|
|
255
|
+
// subnet. Offering it as a delete/adopt candidate is wrong: it is not
|
|
256
|
+
// yours, and it changes on its own, so counting it as drift is noise.
|
|
257
|
+
// Same treatment as a runtime child, for the same reason, arrived at from
|
|
258
|
+
// the other direction — a child is something declared created, a
|
|
259
|
+
// dependency is something declared relies on.
|
|
260
|
+
if ((observedNow[name]?.referencedBy?.length ?? 0) > 0) {
|
|
261
|
+
dependencyNames.add(name);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
// Ambient (#1278): observed because it exists, not because anything points
|
|
265
|
+
// at it. Reported so a caller can ask about it — "which of these are
|
|
266
|
+
// unused" is the whole point — but never counted as drift, since chant
|
|
267
|
+
// neither created it nor tracks its changes.
|
|
268
|
+
if (observedNow[name]?.ambient) {
|
|
269
|
+
dependencyNames.add(name);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
232
272
|
const chain = observedNow[name]?.ownerChain;
|
|
233
273
|
if (chain?.root === "declared") {
|
|
234
274
|
runtimeChildNames.add(name);
|
|
@@ -256,7 +296,10 @@ export function diffLive(input: DiffLiveInput): LiveDiffResult {
|
|
|
256
296
|
// happened to record the same name (e.g. a StatefulSet's stable pod
|
|
257
297
|
// identity) must not turn its ordinary churn into `driftedSinceSnapshot`.
|
|
258
298
|
for (const name of observedNowNames) {
|
|
259
|
-
|
|
299
|
+
// Referenced dependencies (#1273) are excluded for the same reason runtime
|
|
300
|
+
// children are: they are observed to complete the picture, not to be
|
|
301
|
+
// governed, and their ordinary churn is somebody else's.
|
|
302
|
+
if (runtimeChildNames.has(name) || dependencyNames.has(name)) continue;
|
|
260
303
|
const now = observedNow[name];
|
|
261
304
|
const then = observedThenMap[name];
|
|
262
305
|
if (!then) {
|
package/src/lifecycle/observe.ts
CHANGED
|
@@ -14,7 +14,8 @@ import type { BuildResult } from "../build";
|
|
|
14
14
|
import { build as buildProject } from "../build";
|
|
15
15
|
import { resolve as resolvePath } from "node:path";
|
|
16
16
|
import type { SerializerResult } from "../serializer";
|
|
17
|
-
import type { LiveObservation } from "../graph-ir";
|
|
17
|
+
import type { LiveObservation, IREdge } from "../graph-ir";
|
|
18
|
+
import type { ResourceMetadata } from "../lexicon";
|
|
18
19
|
import {
|
|
19
20
|
mergeObservations,
|
|
20
21
|
normalizeObservation,
|
|
@@ -73,10 +74,18 @@ export async function observeResources(
|
|
|
73
74
|
environment: string,
|
|
74
75
|
plugins: ObservationLexicon[],
|
|
75
76
|
buildResult: BuildResult,
|
|
76
|
-
opts?: {
|
|
77
|
+
opts?: {
|
|
78
|
+
owned?: boolean;
|
|
79
|
+
stacks?: Array<string | { name: string; region?: string; src?: string }>;
|
|
80
|
+
/** Also report resources of a managed kind that nothing declares or
|
|
81
|
+
* references (#1278). Opt-in: it asks the provider what exists rather than
|
|
82
|
+
* resolving out from what is declared. */
|
|
83
|
+
ambient?: boolean;
|
|
84
|
+
},
|
|
77
85
|
): Promise<ObserveResult> {
|
|
78
86
|
const owned = opts?.owned ?? true;
|
|
79
87
|
const stacks = (opts?.stacks ?? []).map((st) => (typeof st === "string" ? { name: st } : st));
|
|
88
|
+
const includeAmbient = opts?.ambient ?? false;
|
|
80
89
|
// A stack's `src` (multi-stack, #1162) is built SCOPED to recover that stack's
|
|
81
90
|
// BARE entity names — the names it actually deploys. Matching deployed bare
|
|
82
91
|
// LogicalResourceIds against the whole-project build's DISAMBIGUATED names
|
|
@@ -177,7 +186,38 @@ export async function observeResources(
|
|
|
177
186
|
}),
|
|
178
187
|
);
|
|
179
188
|
}
|
|
180
|
-
|
|
189
|
+
// What the estate depends on but does not declare (#1273). Read after the
|
|
190
|
+
// managed resources, because the declared observation is the closure's
|
|
191
|
+
// roots — there is nothing to reference out from until it exists.
|
|
192
|
+
const dependencies = await collectDependencies(plugin, {
|
|
193
|
+
environment,
|
|
194
|
+
entities,
|
|
195
|
+
observed: observed.resources,
|
|
196
|
+
stacks,
|
|
197
|
+
});
|
|
198
|
+
for (const message of dependencies.warnings) warnings.push(message);
|
|
199
|
+
// Resources of a managed kind that nothing declares or references (#1278).
|
|
200
|
+
// Bounded by what this lexicon's declared entities actually are, so a
|
|
201
|
+
// project managing security groups is not made to enumerate the account.
|
|
202
|
+
const ambient = includeAmbient
|
|
203
|
+
? await collectAmbient(plugin, {
|
|
204
|
+
environment,
|
|
205
|
+
kinds: [...new Set([...entities.values()].map((e) => e.entityType))],
|
|
206
|
+
observed: observed.resources,
|
|
207
|
+
stacks,
|
|
208
|
+
warnings,
|
|
209
|
+
})
|
|
210
|
+
: {};
|
|
211
|
+
for (const [id, meta] of Object.entries(ambient)) dependencies.resources[id] ??= meta;
|
|
212
|
+
pushObservation(
|
|
213
|
+
observations,
|
|
214
|
+
warnings,
|
|
215
|
+
plugin.name,
|
|
216
|
+
observed,
|
|
217
|
+
environment,
|
|
218
|
+
entityNames.length,
|
|
219
|
+
dependencies,
|
|
220
|
+
);
|
|
181
221
|
} catch (err) {
|
|
182
222
|
// A thrown read is the whole-lexicon failure: every declared entity is
|
|
183
223
|
// NOT-OBSERVED, not absent (#1089). Emitting nothing here is what made a
|
|
@@ -201,6 +241,141 @@ export async function observeResources(
|
|
|
201
241
|
return { observations, warnings, errors };
|
|
202
242
|
}
|
|
203
243
|
|
|
244
|
+
/**
|
|
245
|
+
* The subset of an observation belonging to one stack.
|
|
246
|
+
*
|
|
247
|
+
* A scoped stack's ids are qualified `${stack}::${id}` (#1162), so the prefix is
|
|
248
|
+
* the whole test. An unqualified observation — single-stack, or a bare-string
|
|
249
|
+
* stack sharing one id space — has no way to be split and is returned whole,
|
|
250
|
+
* which is what it already was.
|
|
251
|
+
*/
|
|
252
|
+
function scopeToStack(
|
|
253
|
+
resources: Record<string, ResourceMetadata>,
|
|
254
|
+
stack: string | undefined,
|
|
255
|
+
): Record<string, ResourceMetadata> {
|
|
256
|
+
if (!stack) return resources;
|
|
257
|
+
const prefix = `${stack}::`;
|
|
258
|
+
const scoped = Object.fromEntries(
|
|
259
|
+
Object.entries(resources)
|
|
260
|
+
.filter(([id]) => id.startsWith(prefix))
|
|
261
|
+
.map(([id, meta]) => [id, meta] as const),
|
|
262
|
+
);
|
|
263
|
+
// No qualified ids at all means this observation was never stack-scoped.
|
|
264
|
+
return Object.keys(scoped).length > 0 ? scoped : resources;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Ask a lexicon what exists of the kinds it manages, beyond what is declared
|
|
269
|
+
* (#1278). Once per stack for the region, merged by physical id — the same
|
|
270
|
+
* ambient resource seen from two stacks is one resource.
|
|
271
|
+
*/
|
|
272
|
+
export async function collectAmbient(
|
|
273
|
+
plugin: ObservationLexicon,
|
|
274
|
+
opts: {
|
|
275
|
+
environment: string;
|
|
276
|
+
kinds: string[];
|
|
277
|
+
observed: Record<string, ResourceMetadata>;
|
|
278
|
+
stacks: Array<{ name: string; region?: string; src?: string }>;
|
|
279
|
+
warnings: string[];
|
|
280
|
+
},
|
|
281
|
+
): Promise<Record<string, ResourceMetadata>> {
|
|
282
|
+
if (!plugin.observeAmbient || opts.kinds.length === 0) return {};
|
|
283
|
+
const found: Record<string, ResourceMetadata> = {};
|
|
284
|
+
const refs = opts.stacks.length > 0 ? opts.stacks : [{ name: undefined, region: undefined }];
|
|
285
|
+
for (const ref of refs) {
|
|
286
|
+
try {
|
|
287
|
+
const part = await plugin.observeAmbient({
|
|
288
|
+
environment: opts.environment,
|
|
289
|
+
kinds: opts.kinds,
|
|
290
|
+
observed: opts.observed,
|
|
291
|
+
...(ref.name ? { stack: ref.name } : {}),
|
|
292
|
+
...(ref.region ? { region: ref.region } : {}),
|
|
293
|
+
});
|
|
294
|
+
for (const [id, meta] of Object.entries(part)) found[id] ??= meta;
|
|
295
|
+
} catch (err) {
|
|
296
|
+
opts.warnings.push(
|
|
297
|
+
`${plugin.name}: ambient resources not read${ref.name ? ` for stack "${ref.name}"` : ""} — ${err instanceof Error ? err.message : String(err)}`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return found;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Dependencies collected across a lexicon's stacks, plus anything to report. */
|
|
305
|
+
export interface CollectedDependencies {
|
|
306
|
+
resources: Record<string, ResourceMetadata>;
|
|
307
|
+
edges: IREdge[];
|
|
308
|
+
warnings: string[];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const NO_DEPENDENCIES: CollectedDependencies = { resources: {}, edges: [], warnings: [] };
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Ask a lexicon what its declared estate references but does not manage (#1273).
|
|
315
|
+
*
|
|
316
|
+
* Called once per stack, because the closure roots and the region differ per
|
|
317
|
+
* stack, and merged by key. Dependencies are keyed by physical id and are
|
|
318
|
+
* deliberately NOT stack-qualified: the account's default VPC route table is the
|
|
319
|
+
* same resource whichever stack routes through it, and qualifying it would
|
|
320
|
+
* produce one node per referrer and an edge to each.
|
|
321
|
+
*
|
|
322
|
+
* Best-effort. A lexicon that does not implement the hook, or one whose read
|
|
323
|
+
* fails, contributes nothing — the managed observation is already complete and
|
|
324
|
+
* useful on its own, and failing it because an ambient dependency could not be
|
|
325
|
+
* read would trade a whole answer for a partial one.
|
|
326
|
+
*/
|
|
327
|
+
export async function collectDependencies(
|
|
328
|
+
plugin: ObservationLexicon,
|
|
329
|
+
opts: {
|
|
330
|
+
environment: string;
|
|
331
|
+
entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
332
|
+
observed: Record<string, ResourceMetadata>;
|
|
333
|
+
stacks: Array<{ name: string; region?: string; src?: string }>;
|
|
334
|
+
},
|
|
335
|
+
): Promise<CollectedDependencies> {
|
|
336
|
+
if (!plugin.observeDependencies) return NO_DEPENDENCIES;
|
|
337
|
+
|
|
338
|
+
const resources: Record<string, ResourceMetadata> = {};
|
|
339
|
+
const edges: IREdge[] = [];
|
|
340
|
+
const warnings: string[] = [];
|
|
341
|
+
const refs = opts.stacks.length > 0 ? opts.stacks : [{ name: undefined, region: undefined }];
|
|
342
|
+
|
|
343
|
+
for (const ref of refs) {
|
|
344
|
+
// Only this stack's resources are the closure's roots. Handing a lexicon
|
|
345
|
+
// the whole estate makes it resolve out from resources that live somewhere
|
|
346
|
+
// else — for AWS that means `describe-instances` in one region with another
|
|
347
|
+
// region's instance ids, which fails outright with InvalidInstanceID and
|
|
348
|
+
// takes the whole read down with it.
|
|
349
|
+
const roots = scopeToStack(opts.observed, ref.name);
|
|
350
|
+
if (Object.keys(roots).length === 0) continue;
|
|
351
|
+
try {
|
|
352
|
+
const found = await plugin.observeDependencies({
|
|
353
|
+
environment: opts.environment,
|
|
354
|
+
entities: opts.entities,
|
|
355
|
+
observed: roots,
|
|
356
|
+
...(ref.name ? { stack: ref.name } : {}),
|
|
357
|
+
...(ref.region ? { region: ref.region } : {}),
|
|
358
|
+
});
|
|
359
|
+
for (const [id, meta] of Object.entries(found.resources)) {
|
|
360
|
+
// Merge referrers rather than overwrite: two stacks routing through the
|
|
361
|
+
// same table is one node reached twice, and the reason it is here is
|
|
362
|
+
// both of them.
|
|
363
|
+
const existing = resources[id];
|
|
364
|
+
resources[id] = existing
|
|
365
|
+
? { ...existing, referencedBy: [...new Set([...(existing.referencedBy ?? []), ...(meta.referencedBy ?? [])])] }
|
|
366
|
+
: meta;
|
|
367
|
+
}
|
|
368
|
+
edges.push(...(found.edges ?? []));
|
|
369
|
+
} catch (err) {
|
|
370
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
371
|
+
warnings.push(
|
|
372
|
+
`${plugin.name}: dependencies not read${ref.name ? ` for stack "${ref.name}"` : ""} — ${message}`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return { resources, edges, warnings };
|
|
377
|
+
}
|
|
378
|
+
|
|
204
379
|
/** Record one lexicon's observation, warning once per unobserved entity. */
|
|
205
380
|
function pushObservation(
|
|
206
381
|
observations: LiveObservation[],
|
|
@@ -209,6 +384,7 @@ function pushObservation(
|
|
|
209
384
|
observed: NormalizedObservation,
|
|
210
385
|
environment: string,
|
|
211
386
|
declaredCount: number,
|
|
387
|
+
dependencies: CollectedDependencies = NO_DEPENDENCIES,
|
|
212
388
|
): void {
|
|
213
389
|
const hasResources = Object.keys(observed.resources).length > 0;
|
|
214
390
|
const unobservedNames = Object.keys(observed.unobserved);
|
|
@@ -225,9 +401,15 @@ function pushObservation(
|
|
|
225
401
|
if (notice) warnings.push(notice);
|
|
226
402
|
return;
|
|
227
403
|
}
|
|
404
|
+
// Dependencies ride alongside the managed resources so they become nodes, and
|
|
405
|
+
// carry `referencedBy` so every consumer can still tell the two apart.
|
|
406
|
+
const hasDependencies = Object.keys(dependencies.resources).length > 0;
|
|
228
407
|
observations.push({
|
|
229
408
|
lexicon,
|
|
230
|
-
resources:
|
|
409
|
+
resources: hasDependencies
|
|
410
|
+
? { ...observed.resources, ...dependencies.resources }
|
|
411
|
+
: observed.resources,
|
|
231
412
|
...(unobservedNames.length > 0 ? { unobserved: observed.unobserved } : {}),
|
|
413
|
+
...(dependencies.edges.length > 0 ? { edges: dependencies.edges } : {}),
|
|
232
414
|
});
|
|
233
415
|
}
|