@intentius/chant-lexicon-aws 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/ambient.d.ts +39 -0
- package/dist/ambient.d.ts.map +1 -0
- package/dist/deep-observe.d.ts +5 -0
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/defaults.d.ts +44 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/dependencies.d.ts +60 -0
- package/dist/dependencies.d.ts.map +1 -0
- package/dist/generated/index.d.ts +61 -136
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/integrity.json +4 -4
- package/dist/manifest.json +1 -1
- package/dist/meta.json +577 -2759
- package/dist/plugin.d.ts.map +1 -1
- package/dist/properties.d.ts +48 -0
- package/dist/properties.d.ts.map +1 -0
- package/dist/reference-catalog.d.ts.map +1 -1
- package/dist/types/index.d.ts +1206 -2296
- package/package.json +2 -2
- package/src/ambient.test.ts +71 -0
- package/src/ambient.ts +147 -0
- package/src/deep-observe.ts +9 -0
- package/src/defaults.test.ts +73 -0
- package/src/defaults.ts +97 -0
- package/src/dependencies.test.ts +208 -0
- package/src/dependencies.ts +323 -0
- package/src/generated/index.d.ts +1206 -2296
- package/src/generated/index.ts +62 -137
- package/src/generated/lexicon-aws.json +577 -2759
- package/src/plugin.ts +65 -64
- package/src/properties.test.ts +113 -0
- package/src/properties.ts +166 -0
- package/src/reference-catalog.ts +24 -3
package/src/plugin.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createRequire } from "module";
|
|
2
2
|
import { detectTemplate } from "./detect";
|
|
3
|
-
import type { LexiconPlugin, IntrinsicDef, ObservationResult, DeepObservationResult, ResourceMetadata, ExportedTemplate, ResourceSelector, InitTemplateSet, StackStatusObservation } from "@intentius/chant/lexicon";
|
|
3
|
+
import type { LexiconPlugin, IntrinsicDef, ObservationResult, DeepObservationResult, DependencyObservation, ResourceMetadata, ExportedTemplate, ResourceSelector, InitTemplateSet, StackStatusObservation } from "@intentius/chant/lexicon";
|
|
4
4
|
const require = createRequire(import.meta.url);
|
|
5
5
|
import type { LintRule } from "@intentius/chant/lint/rule";
|
|
6
6
|
import type { TemplateParser } from "@intentius/chant/import/parser";
|
|
@@ -19,6 +19,9 @@ import { applyAwsEndpointArgv } from "./components/cloud-executor";
|
|
|
19
19
|
import { stackDoesNotExist } from "./stack-errors";
|
|
20
20
|
import { awsDeepNormalizationHooks, observeResourcesDeepAws } from "./deep-observe";
|
|
21
21
|
import { awsReferenceCatalog } from "./reference-catalog";
|
|
22
|
+
import { AMBIENT_KINDS } from "./ambient";
|
|
23
|
+
import { describeOwnProperties, stampRegion } from "./properties";
|
|
24
|
+
import { stampProviderDefaults } from "./defaults";
|
|
22
25
|
import { resolveTemplateAttrs } from "./live-attrs";
|
|
23
26
|
import { CFParser } from "./import/parser";
|
|
24
27
|
import { CFGenerator } from "./import/generator";
|
|
@@ -36,58 +39,8 @@ export { stackDoesNotExist } from "./stack-errors";
|
|
|
36
39
|
* Provides serializer, lint rules, template detection,
|
|
37
40
|
* import parsing, and code generation for AWS CloudFormation.
|
|
38
41
|
*/
|
|
39
|
-
/**
|
|
40
|
-
|
|
41
|
-
* subnet is internet-facing iff its route table (an explicit association, else
|
|
42
|
-
* the VPC's main table) has a default route to an Internet Gateway. This covers
|
|
43
|
-
* the account's default VPC, whose routing chant does not model declaratively
|
|
44
|
-
* (the instance references it through a parameter). Best-effort: any failure
|
|
45
|
-
* returns what it has, so search still works on the declared topology.
|
|
46
|
-
*/
|
|
47
|
-
async function liveInternetFacing(regionArgs: string[], stackName: string): Promise<Record<string, string>> {
|
|
48
|
-
const { getRuntime } = await import("@intentius/chant/runtime-adapter");
|
|
49
|
-
const rt = getRuntime();
|
|
50
|
-
const run = (args: string[]) =>
|
|
51
|
-
rt.spawn(applyAwsEndpointArgv(["aws", ...args, ...regionArgs, "--output", "json"], process.env.AWS_ENDPOINT_URL));
|
|
52
|
-
const out: Record<string, string> = {};
|
|
53
|
-
try {
|
|
54
|
-
const res = await run(["cloudformation", "describe-stack-resources", "--stack-name", stackName]);
|
|
55
|
-
if (res.exitCode !== 0) return out;
|
|
56
|
-
const stackRes = (JSON.parse(res.stdout).StackResources ?? []) as Array<{ LogicalResourceId: string; ResourceType: string; PhysicalResourceId: string }>;
|
|
57
|
-
const instByLogical = new Map<string, string>();
|
|
58
|
-
for (const r of stackRes) if (r.ResourceType === "AWS::EC2::Instance") instByLogical.set(r.LogicalResourceId, r.PhysicalResourceId);
|
|
59
|
-
if (instByLogical.size === 0) return out;
|
|
60
|
-
|
|
61
|
-
const di = await run(["ec2", "describe-instances", "--instance-ids", ...instByLogical.values()]);
|
|
62
|
-
if (di.exitCode !== 0) return out;
|
|
63
|
-
const locByInst = new Map<string, { subnet?: string; vpc?: string }>();
|
|
64
|
-
for (const rsv of (JSON.parse(di.stdout).Reservations ?? []) as Array<{ Instances?: Array<{ InstanceId: string; SubnetId?: string; VpcId?: string }> }>)
|
|
65
|
-
for (const i of rsv.Instances ?? []) locByInst.set(i.InstanceId, { subnet: i.SubnetId, vpc: i.VpcId });
|
|
66
|
-
|
|
67
|
-
const rtRes = await run(["ec2", "describe-route-tables"]);
|
|
68
|
-
if (rtRes.exitCode !== 0) return out;
|
|
69
|
-
const subnetIgw = new Map<string, string>();
|
|
70
|
-
const vpcMainIgw = new Map<string, string>();
|
|
71
|
-
for (const t of (JSON.parse(rtRes.stdout).RouteTables ?? []) as Array<{ RouteTableId?: string; VpcId?: string; Routes?: Array<{ GatewayId?: string }>; Associations?: Array<{ SubnetId?: string; Main?: boolean }> }>) {
|
|
72
|
-
const igwRoute = (t.Routes ?? []).find((r) => typeof r.GatewayId === "string" && r.GatewayId.startsWith("igw-"));
|
|
73
|
-
if (!igwRoute) continue;
|
|
74
|
-
const ev = `${t.RouteTableId ?? "rtb"} → ${igwRoute.GatewayId}`;
|
|
75
|
-
for (const a of t.Associations ?? []) {
|
|
76
|
-
if (a.SubnetId) subnetIgw.set(a.SubnetId, ev);
|
|
77
|
-
if (a.Main && t.VpcId) vpcMainIgw.set(t.VpcId, ev);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
for (const [logical, instId] of instByLogical) {
|
|
82
|
-
const loc = locByInst.get(instId);
|
|
83
|
-
const ev = loc ? ((loc.subnet && subnetIgw.get(loc.subnet)) || (loc.vpc && vpcMainIgw.get(loc.vpc))) : undefined;
|
|
84
|
-
if (ev) out[logical] = ev;
|
|
85
|
-
}
|
|
86
|
-
} catch {
|
|
87
|
-
/* best-effort */
|
|
88
|
-
}
|
|
89
|
-
return out;
|
|
90
|
-
}
|
|
42
|
+
/** #1265 — the ownership notice is about the environment, so it is said once. */
|
|
43
|
+
let warnedOwnership = false;
|
|
91
44
|
|
|
92
45
|
export const awsPlugin: LexiconPlugin = {
|
|
93
46
|
name: "aws",
|
|
@@ -598,10 +551,17 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
598
551
|
if (options.owned) {
|
|
599
552
|
// describe-stack-resources does not return tags, so ownership cannot be
|
|
600
553
|
// determined here. Degrade to detect-only rather than silently filtering.
|
|
601
|
-
//
|
|
602
|
-
|
|
554
|
+
//
|
|
555
|
+
// Once per process, not once per stack (#1265). It is a property of the
|
|
556
|
+
// environment, not of each stack, and a four-stack project printed four
|
|
557
|
+
// identical copies ahead of every answer — enough that an agent piping
|
|
558
|
+
// `graph --format ir` with `2>&1` had to skip lines to find the JSON.
|
|
559
|
+
warnedOwnership ||
|
|
560
|
+
// eslint-disable-next-line no-console
|
|
561
|
+
console.warn(
|
|
603
562
|
"[aws] ownership filter unavailable on describeResources (no tags from describe-stack-resources) — returning all, each with an explicit `unknown` verdict; use `chant import --from <env> --owned` for ownership-filtered export",
|
|
604
|
-
|
|
563
|
+
);
|
|
564
|
+
warnedOwnership = true;
|
|
605
565
|
}
|
|
606
566
|
|
|
607
567
|
// Derive stack name. A multi-stack project passes the explicit CloudFormation
|
|
@@ -708,10 +668,17 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
708
668
|
};
|
|
709
669
|
}
|
|
710
670
|
|
|
671
|
+
// Each resource's OWN properties, on top of the stack outputs above (#1279).
|
|
672
|
+
// Until this, a node's `attrs` were the stack's exports replicated onto
|
|
673
|
+
// every member, so no instance carried its own `VpcId`.
|
|
674
|
+
const withProperties = stampProviderDefaults(
|
|
675
|
+
stampRegion(await describeOwnProperties(resources, options.region), options.region),
|
|
676
|
+
);
|
|
677
|
+
|
|
711
678
|
// Every entity the stack answered for was answered for: an entity the
|
|
712
679
|
// template doesn't carry is genuinely not in this stack, which is an
|
|
713
680
|
// absence, not a hole.
|
|
714
|
-
return observation(
|
|
681
|
+
return observation(withProperties);
|
|
715
682
|
},
|
|
716
683
|
|
|
717
684
|
/**
|
|
@@ -719,12 +686,52 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
719
686
|
* CloudFormation's view of the world, into the resource as the service
|
|
720
687
|
* actually holds it. Implementation in ./deep-observe.ts.
|
|
721
688
|
*/
|
|
689
|
+
/**
|
|
690
|
+
* The routing this estate depends on but does not declare (#1273) — an
|
|
691
|
+
* instance in the account's default VPC routes through a table nobody wrote.
|
|
692
|
+
* Reporting the resources lets the graph fold derive `internetFacing`, rather
|
|
693
|
+
* than this lexicon computing it and injecting the conclusion.
|
|
694
|
+
*/
|
|
695
|
+
async observeDependencies(options: {
|
|
696
|
+
environment: string;
|
|
697
|
+
entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
698
|
+
observed: Record<string, ResourceMetadata>;
|
|
699
|
+
stack?: string;
|
|
700
|
+
region?: string;
|
|
701
|
+
}): Promise<DependencyObservation> {
|
|
702
|
+
const { observeAwsDependencies } = await import("./dependencies");
|
|
703
|
+
return observeAwsDependencies({ observed: options.observed, region: options.region });
|
|
704
|
+
},
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Resources of a managed kind that exist without being declared or
|
|
708
|
+
* referenced (#1278) — the account's default security groups, an unattached
|
|
709
|
+
* one someone left behind. Nothing else in the observation can see them,
|
|
710
|
+
* because everything else resolves outward from what is declared.
|
|
711
|
+
*/
|
|
712
|
+
/** #1278 — the kinds `observeAmbient` can enumerate, from the same source. */
|
|
713
|
+
ambientKinds(): string[] {
|
|
714
|
+
return AMBIENT_KINDS;
|
|
715
|
+
},
|
|
716
|
+
|
|
717
|
+
async observeAmbient(options: {
|
|
718
|
+
environment: string;
|
|
719
|
+
kinds: string[];
|
|
720
|
+
observed: Record<string, ResourceMetadata>;
|
|
721
|
+
stack?: string;
|
|
722
|
+
region?: string;
|
|
723
|
+
}): Promise<Record<string, ResourceMetadata>> {
|
|
724
|
+
const { observeAwsAmbient } = await import("./ambient");
|
|
725
|
+
return observeAwsAmbient({ kinds: options.kinds, observed: options.observed, region: options.region });
|
|
726
|
+
},
|
|
727
|
+
|
|
722
728
|
async observeResourcesDeep(options: {
|
|
723
729
|
environment: string;
|
|
724
730
|
buildOutput: string;
|
|
725
731
|
entityNames: string[];
|
|
726
732
|
entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
727
733
|
stack?: string;
|
|
734
|
+
region?: string;
|
|
728
735
|
owned?: boolean;
|
|
729
736
|
}): Promise<DeepObservationResult> {
|
|
730
737
|
return observeResourcesDeepAws({
|
|
@@ -732,6 +739,7 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
732
739
|
entityNames: options.entityNames,
|
|
733
740
|
entities: options.entities,
|
|
734
741
|
stack: options.stack,
|
|
742
|
+
region: options.region,
|
|
735
743
|
owned: options.owned,
|
|
736
744
|
});
|
|
737
745
|
},
|
|
@@ -827,13 +835,6 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
827
835
|
for (const [logicalId, v] of Object.entries(attrs)) {
|
|
828
836
|
merged[multi ? `${ref.name}::${logicalId}` : logicalId] = v;
|
|
829
837
|
}
|
|
830
|
-
// Resolve internetFacing from live route tables (covers the default VPC),
|
|
831
|
-
// carrying the route-table → IGW evidence so search can justify the match.
|
|
832
|
-
const facing = await liveInternetFacing(ref.region ? ["--region", ref.region] : [], ref.name);
|
|
833
|
-
for (const [logicalId, via] of Object.entries(facing)) {
|
|
834
|
-
const key = multi ? `${ref.name}::${logicalId}` : logicalId;
|
|
835
|
-
merged[key] = { ...merged[key], internetFacing: true, internetFacingVia: via };
|
|
836
|
-
}
|
|
837
838
|
} catch {
|
|
838
839
|
// A stack that isn't deployed yet contributes no live attrs — skip it.
|
|
839
840
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
const spawnMock = vi.fn();
|
|
4
|
+
vi.mock("@intentius/chant/runtime-adapter", async (importOriginal) => {
|
|
5
|
+
const actual = await importOriginal<typeof import("@intentius/chant/runtime-adapter")>();
|
|
6
|
+
return { ...actual, getRuntime: () => ({ ...actual.getRuntime(), spawn: spawnMock }) };
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
const { describeOwnProperties, canDescribe, stampRegion } = await import("./properties");
|
|
10
|
+
|
|
11
|
+
const ok = (body: unknown) => ({ stdout: JSON.stringify(body), stderr: "", exitCode: 0 });
|
|
12
|
+
const fail = { stdout: "", stderr: "InvalidInstanceID.NotFound", exitCode: 255 };
|
|
13
|
+
|
|
14
|
+
const instance = (id: string, vpc: string) => ({ InstanceId: id, VpcId: vpc });
|
|
15
|
+
const reservations = (...rows: Array<Record<string, unknown>>) => ({
|
|
16
|
+
Reservations: [{ Instances: rows }],
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// #1279 — `describe-stack-resources` returns identity and nothing about the
|
|
20
|
+
// resource, so the observation had been filling `attributes` with the *stack's*
|
|
21
|
+
// outputs, copied onto every member. No node carried its own VpcId.
|
|
22
|
+
describe("describeOwnProperties (#1279)", () => {
|
|
23
|
+
beforeEach(() => spawnMock.mockReset());
|
|
24
|
+
|
|
25
|
+
const observed = {
|
|
26
|
+
web: { type: "AWS::EC2::Instance", status: "OK", physicalId: "i-1" },
|
|
27
|
+
api: { type: "AWS::EC2::Instance", status: "OK", physicalId: "i-2" },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
it("joins each resource's own properties back by physical id", async () => {
|
|
31
|
+
spawnMock.mockResolvedValue(ok(reservations(instance("i-1", "vpc-a"), instance("i-2", "vpc-b"))));
|
|
32
|
+
const merged = await describeOwnProperties(observed);
|
|
33
|
+
expect(merged.web.attributes?.VpcId).toBe("vpc-a");
|
|
34
|
+
expect(merged.api.attributes?.VpcId).toBe("vpc-b");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("reads a kind once for the whole observation, not once per resource", async () => {
|
|
38
|
+
spawnMock.mockResolvedValue(ok(reservations(instance("i-1", "vpc-a"), instance("i-2", "vpc-b"))));
|
|
39
|
+
await describeOwnProperties(observed);
|
|
40
|
+
expect(spawnMock).toHaveBeenCalledTimes(1);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("keeps stack outputs, and lets the resource's own property win the name", async () => {
|
|
44
|
+
const withOutputs = {
|
|
45
|
+
web: {
|
|
46
|
+
type: "AWS::EC2::Instance",
|
|
47
|
+
status: "OK",
|
|
48
|
+
physicalId: "i-1",
|
|
49
|
+
attributes: { expVpcId: "vpc-exported", VpcId: "stale" },
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
spawnMock.mockResolvedValue(ok(reservations(instance("i-1", "vpc-a"))));
|
|
53
|
+
const merged = await describeOwnProperties(withOutputs);
|
|
54
|
+
expect(merged.web.attributes?.expVpcId).toBe("vpc-exported");
|
|
55
|
+
expect(merged.web.attributes?.VpcId).toBe("vpc-a");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("falls back to one call per id when the batch fails on a single bad id", async () => {
|
|
59
|
+
// AWS fails the whole call on one unknown id. A snapshot naming an instance
|
|
60
|
+
// that has since been terminated would otherwise take every other
|
|
61
|
+
// instance's properties down with it — and the empty result is
|
|
62
|
+
// indistinguishable from "the account has nothing to say".
|
|
63
|
+
spawnMock
|
|
64
|
+
.mockResolvedValueOnce(fail) // the batch, killed by i-2
|
|
65
|
+
.mockResolvedValueOnce(ok(reservations(instance("i-1", "vpc-a"))))
|
|
66
|
+
.mockResolvedValueOnce(fail); // i-2 really is gone
|
|
67
|
+
const merged = await describeOwnProperties(observed);
|
|
68
|
+
expect(merged.web.attributes?.VpcId).toBe("vpc-a");
|
|
69
|
+
expect(merged.api.attributes).toBeUndefined();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("leaves the observation untouched when the kind cannot be read at all", async () => {
|
|
73
|
+
spawnMock.mockResolvedValue(fail);
|
|
74
|
+
const merged = await describeOwnProperties({ web: observed.web });
|
|
75
|
+
expect(merged.web.attributes).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("does not call out for a kind it cannot describe", async () => {
|
|
79
|
+
const merged = await describeOwnProperties({
|
|
80
|
+
fn: { type: "AWS::Lambda::Function", status: "OK", physicalId: "fn-1" },
|
|
81
|
+
});
|
|
82
|
+
expect(spawnMock).not.toHaveBeenCalled();
|
|
83
|
+
expect(merged.fn.attributes).toBeUndefined();
|
|
84
|
+
expect(canDescribe("AWS::Lambda::Function")).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// #1279 — the observation is scoped per stack and each stack declares its
|
|
89
|
+
// region, so the reader knew this and threw it away.
|
|
90
|
+
describe("stampRegion (#1279)", () => {
|
|
91
|
+
const one = { web: { type: "AWS::EC2::Instance", status: "OK", physicalId: "i-1" } };
|
|
92
|
+
|
|
93
|
+
it("records the region the resource was observed in", () => {
|
|
94
|
+
expect(stampRegion(one, "us-west-2").web.attributes?.region).toBe("us-west-2");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("keeps the properties already read", () => {
|
|
98
|
+
const withProps = { web: { ...one.web, attributes: { VpcId: "vpc-a" } } };
|
|
99
|
+
const out = stampRegion(withProps, "us-west-2").web.attributes;
|
|
100
|
+
expect(out).toMatchObject({ VpcId: "vpc-a", region: "us-west-2" });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("falls back to the region the call would have used", () => {
|
|
104
|
+
const prev = process.env.AWS_REGION;
|
|
105
|
+
process.env.AWS_REGION = "eu-west-1";
|
|
106
|
+
try {
|
|
107
|
+
expect(stampRegion(one).web.attributes?.region).toBe("eu-west-1");
|
|
108
|
+
} finally {
|
|
109
|
+
if (prev === undefined) delete process.env.AWS_REGION;
|
|
110
|
+
else process.env.AWS_REGION = prev;
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A managed resource's own properties (#1279).
|
|
3
|
+
*
|
|
4
|
+
* `describe-stack-resources` returns identity and status — logical id, physical
|
|
5
|
+
* id, type, timestamp — and nothing about the resource itself. So the managed
|
|
6
|
+
* observation filled `attributes` with the *stack's* outputs instead, copied
|
|
7
|
+
* onto every resource in the stack. Every node in a stack came out carrying the
|
|
8
|
+
* same `expVpcId`/`expWebIp` keys, and no node carried its own `VpcId`.
|
|
9
|
+
*
|
|
10
|
+
* That is invisible until something asks. `search --show VpcId` printed a blank
|
|
11
|
+
* column for six instances, which reads as "the estate has no VPCs" rather than
|
|
12
|
+
* "chant never read that". An agent asked which instances were outside the
|
|
13
|
+
* default VPC concluded all six were, because nothing in the graph said
|
|
14
|
+
* otherwise.
|
|
15
|
+
*
|
|
16
|
+
* A deep read (Cloud Control) answers this properly but is a per-resource call
|
|
17
|
+
* and is not available on every endpoint. This is the cheap middle: one describe
|
|
18
|
+
* per kind for the whole observation, joined back by physical id. Stack outputs
|
|
19
|
+
* are kept — they were the only attributes for a long time and queries lean on
|
|
20
|
+
* them — but a resource's own properties win a name collision, because they are
|
|
21
|
+
* the resource's.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { applyAwsEndpointArgv } from "./components/cloud-executor";
|
|
25
|
+
import type { ResourceMetadata } from "@intentius/chant/lexicon";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* How to read each kind in bulk, and which field joins back to the physical id.
|
|
29
|
+
*
|
|
30
|
+
* Extending this is the way to widen coverage. Deliberately batch calls: one
|
|
31
|
+
* `describe-instances` for every instance in the stack, not one per instance.
|
|
32
|
+
*/
|
|
33
|
+
const DESCRIBE: Record<
|
|
34
|
+
string,
|
|
35
|
+
{ argv: string[]; idFlag: string; key: string; id: string; nested?: string }
|
|
36
|
+
> = {
|
|
37
|
+
"AWS::EC2::Instance": {
|
|
38
|
+
argv: ["ec2", "describe-instances"],
|
|
39
|
+
idFlag: "--instance-ids",
|
|
40
|
+
key: "Reservations",
|
|
41
|
+
nested: "Instances",
|
|
42
|
+
id: "InstanceId",
|
|
43
|
+
},
|
|
44
|
+
"AWS::EC2::VPC": { argv: ["ec2", "describe-vpcs"], idFlag: "--vpc-ids", key: "Vpcs", id: "VpcId" },
|
|
45
|
+
"AWS::EC2::Subnet": {
|
|
46
|
+
argv: ["ec2", "describe-subnets"],
|
|
47
|
+
idFlag: "--subnet-ids",
|
|
48
|
+
key: "Subnets",
|
|
49
|
+
id: "SubnetId",
|
|
50
|
+
},
|
|
51
|
+
"AWS::EC2::SecurityGroup": {
|
|
52
|
+
argv: ["ec2", "describe-security-groups"],
|
|
53
|
+
idFlag: "--group-ids",
|
|
54
|
+
key: "SecurityGroups",
|
|
55
|
+
id: "GroupId",
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Stamp the region each resource was observed in (#1279).
|
|
61
|
+
*
|
|
62
|
+
* The observation is already scoped per stack and each stack declares its
|
|
63
|
+
* region, so the reader knows this and was throwing it away. Without it the
|
|
64
|
+
* only route to "which region is this in" was parsing
|
|
65
|
+
* `Placement.AvailabilityZone` and trimming the last character — a trick that
|
|
66
|
+
* happens to work for EC2 and for nothing else. Region is a dimension of the
|
|
67
|
+
* estate, not a substring of an availability zone.
|
|
68
|
+
*/
|
|
69
|
+
export function stampRegion(
|
|
70
|
+
resources: Record<string, ResourceMetadata>,
|
|
71
|
+
region?: string,
|
|
72
|
+
): Record<string, ResourceMetadata> {
|
|
73
|
+
// Fall back to the region the call would actually have used, so a
|
|
74
|
+
// single-region project gets the same attribute a multi-region one does.
|
|
75
|
+
const value = region ?? process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION;
|
|
76
|
+
if (!value) return resources;
|
|
77
|
+
const stamped: Record<string, ResourceMetadata> = {};
|
|
78
|
+
for (const [name, meta] of Object.entries(resources)) {
|
|
79
|
+
stamped[name] = { ...meta, attributes: { ...(meta.attributes ?? {}), region: value } };
|
|
80
|
+
}
|
|
81
|
+
return stamped;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** True when this lexicon can read the kind's own properties. */
|
|
85
|
+
export function canDescribe(kind: string): boolean {
|
|
86
|
+
return kind in DESCRIBE;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Merge each resource's own properties into an observation, in place of nothing.
|
|
91
|
+
*
|
|
92
|
+
* Best-effort per kind, and per kind only: an endpoint that cannot answer
|
|
93
|
+
* `describe-subnets` still yields instance properties, and a total failure
|
|
94
|
+
* leaves the observation exactly as it arrived. The managed observation is
|
|
95
|
+
* already complete without any of this — these are additional facts about
|
|
96
|
+
* resources chant has already identified, so a miss costs detail, never
|
|
97
|
+
* correctness.
|
|
98
|
+
*/
|
|
99
|
+
export async function describeOwnProperties(
|
|
100
|
+
resources: Record<string, ResourceMetadata>,
|
|
101
|
+
region?: string,
|
|
102
|
+
): Promise<Record<string, ResourceMetadata>> {
|
|
103
|
+
// Group the physical ids to look up by kind, so each kind is one call.
|
|
104
|
+
const wanted = new Map<string, Map<string, string[]>>();
|
|
105
|
+
for (const [name, meta] of Object.entries(resources)) {
|
|
106
|
+
if (!meta.physicalId || !canDescribe(meta.type)) continue;
|
|
107
|
+
const byId = wanted.get(meta.type) ?? new Map<string, string[]>();
|
|
108
|
+
byId.set(meta.physicalId, [...(byId.get(meta.physicalId) ?? []), name]);
|
|
109
|
+
wanted.set(meta.type, byId);
|
|
110
|
+
}
|
|
111
|
+
if (wanted.size === 0) return resources;
|
|
112
|
+
|
|
113
|
+
const { getRuntime } = await import("@intentius/chant/runtime-adapter");
|
|
114
|
+
const rt = getRuntime();
|
|
115
|
+
const regionArgs = region ? ["--region", region] : [];
|
|
116
|
+
const merged = { ...resources };
|
|
117
|
+
|
|
118
|
+
/** One describe for a set of ids. `null` when the call itself failed. */
|
|
119
|
+
const read = async (spec: (typeof DESCRIBE)[string], ids: string[]) => {
|
|
120
|
+
const result = await rt.spawn(
|
|
121
|
+
applyAwsEndpointArgv(
|
|
122
|
+
["aws", ...spec.argv, spec.idFlag, ...ids, ...regionArgs, "--output", "json"],
|
|
123
|
+
process.env.AWS_ENDPOINT_URL,
|
|
124
|
+
),
|
|
125
|
+
);
|
|
126
|
+
if (result.exitCode !== 0) return null;
|
|
127
|
+
return (JSON.parse(result.stdout)[spec.key] ?? []) as Array<Record<string, unknown>>;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
for (const [kind, byId] of wanted) {
|
|
131
|
+
const spec = DESCRIBE[kind];
|
|
132
|
+
try {
|
|
133
|
+
const ids = [...byId.keys()];
|
|
134
|
+
let top = await read(spec, ids);
|
|
135
|
+
// AWS fails the whole call on one bad id — a snapshot naming an instance
|
|
136
|
+
// that has since been terminated takes every other instance's properties
|
|
137
|
+
// down with it, and the result is indistinguishable from "the account has
|
|
138
|
+
// nothing to say". Retry one at a time so the damage stops at the bad id.
|
|
139
|
+
if (top === null && ids.length > 1) {
|
|
140
|
+
top = [];
|
|
141
|
+
for (const id of ids) top.push(...((await read(spec, [id])) ?? []));
|
|
142
|
+
}
|
|
143
|
+
if (top === null) continue;
|
|
144
|
+
// `describe-instances` buries instances one level down under reservations;
|
|
145
|
+
// the others return the resources directly.
|
|
146
|
+
const rows = spec.nested
|
|
147
|
+
? top.flatMap((r) => (r[spec.nested as string] ?? []) as Array<Record<string, unknown>>)
|
|
148
|
+
: top;
|
|
149
|
+
for (const row of rows) {
|
|
150
|
+
const id = row[spec.id];
|
|
151
|
+
if (typeof id !== "string") continue;
|
|
152
|
+
for (const name of byId.get(id) ?? []) {
|
|
153
|
+
merged[name] = {
|
|
154
|
+
...merged[name],
|
|
155
|
+
// Own properties last: a resource's `VpcId` outranks a stack output
|
|
156
|
+
// that happens to share the name.
|
|
157
|
+
attributes: { ...(merged[name].attributes ?? {}), ...row },
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
} catch {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return merged;
|
|
166
|
+
}
|
package/src/reference-catalog.ts
CHANGED
|
@@ -16,10 +16,12 @@ export const awsReferenceCatalog: ReferenceCatalog = {
|
|
|
16
16
|
{ kind: "AWS::EC2::VPC", ids: ["VpcId"] },
|
|
17
17
|
{ kind: "AWS::EC2::Subnet", ids: ["SubnetId"] },
|
|
18
18
|
{ kind: "AWS::EC2::SecurityGroup", ids: ["GroupId"] },
|
|
19
|
+
{ kind: "AWS::EC2::NetworkInterface", ids: ["NetworkInterfaceId"] },
|
|
19
20
|
{ kind: "AWS::EC2::Instance", ids: ["InstanceId"] },
|
|
20
21
|
{ kind: "AWS::EC2::InternetGateway", ids: ["InternetGatewayId"] },
|
|
21
22
|
{ kind: "AWS::EC2::NatGateway", ids: ["NatGatewayId"] },
|
|
22
23
|
{ kind: "AWS::EC2::RouteTable", ids: ["RouteTableId"] },
|
|
24
|
+
{ kind: "AWS::EC2::LaunchTemplate", ids: ["LaunchTemplateId", "LaunchTemplateName"] },
|
|
23
25
|
{ kind: "AWS::ElasticLoadBalancingV2::LoadBalancer", ids: ["LoadBalancerArn", "DNSName"] },
|
|
24
26
|
{ kind: "AWS::ElasticLoadBalancingV2::TargetGroup", ids: ["TargetGroupArn"] },
|
|
25
27
|
{ kind: "AWS::ECS::Cluster", ids: ["ClusterArn", "ClusterName"] },
|
|
@@ -32,7 +34,7 @@ export const awsReferenceCatalog: ReferenceCatalog = {
|
|
|
32
34
|
{ from: "AWS::EC2::Subnet", path: "VpcId", targetKind: "AWS::EC2::VPC", relation: "containment", label: "in VPC" },
|
|
33
35
|
{ from: "AWS::EC2::SecurityGroup", path: "VpcId", targetKind: "AWS::EC2::VPC", relation: "containment", label: "in VPC" },
|
|
34
36
|
{ from: "AWS::EC2::RouteTable", path: "VpcId", targetKind: "AWS::EC2::VPC", relation: "containment", label: "in VPC" },
|
|
35
|
-
{ from: "AWS::EC2::Instance", path: "SubnetId", targetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet" },
|
|
37
|
+
{ from: "AWS::EC2::Instance", path: "SubnetId", targetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet", viaAttr: "SubnetId" },
|
|
36
38
|
{ from: "AWS::EC2::NatGateway", path: "SubnetId", targetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet" },
|
|
37
39
|
{ from: "AWS::ElasticLoadBalancingV2::LoadBalancer", path: "AvailabilityZones[].SubnetId", targetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet" },
|
|
38
40
|
{ from: "AWS::ElasticLoadBalancingV2::TargetGroup", path: "VpcId", targetKind: "AWS::EC2::VPC", relation: "containment", label: "in VPC" },
|
|
@@ -40,9 +42,28 @@ export const awsReferenceCatalog: ReferenceCatalog = {
|
|
|
40
42
|
{ from: "AWS::RDS::DBInstance", path: "DBSubnetGroup.Subnets[].SubnetIdentifier", targetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet" },
|
|
41
43
|
|
|
42
44
|
// ── references (→ edges) ──
|
|
43
|
-
{ from: "AWS::EC2::Instance", path: "SecurityGroups[].GroupId", targetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "sg" },
|
|
45
|
+
{ from: "AWS::EC2::Instance", path: "SecurityGroups[].GroupId", targetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "sg", viaAttr: "SecurityGroupIds" },
|
|
46
|
+
{ from: "AWS::EC2::Instance", path: "SecurityGroupIds[]", targetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "sg", viaAttr: "SecurityGroupIds" },
|
|
47
|
+
// Security groups reached indirectly, through a launch template — the hop a
|
|
48
|
+
// flat describe-instances sweep misses, and the reason `effectiveIngress`
|
|
49
|
+
// exists as a fold rather than a passthrough.
|
|
50
|
+
{ from: "AWS::EC2::Instance", path: "LaunchTemplate.LaunchTemplateId", targetKind: "AWS::EC2::LaunchTemplate", relation: "reference", label: "from template", viaAttr: "LaunchTemplateId" },
|
|
51
|
+
{ from: "AWS::EC2::Instance", path: "LaunchTemplateId", targetKind: "AWS::EC2::LaunchTemplate", relation: "reference", label: "from template", viaAttr: "LaunchTemplateId" },
|
|
52
|
+
{ from: "AWS::EC2::LaunchTemplate", path: "LaunchTemplateData.SecurityGroupIds[]", targetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "sg", viaAttr: "SecurityGroupIds" },
|
|
53
|
+
{ from: "AWS::EC2::LaunchTemplate", path: "SecurityGroupIds[]", targetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "sg", viaAttr: "SecurityGroupIds" },
|
|
54
|
+
// The routing chain `internetFacing` walks. Route and association carry no
|
|
55
|
+
// physical id of their own, so they are only ever the `from` side.
|
|
56
|
+
{ from: "AWS::EC2::Route", path: "RouteTableId", targetKind: "AWS::EC2::RouteTable", relation: "reference", label: "in table", viaAttr: "RouteTableId" },
|
|
57
|
+
{ from: "AWS::EC2::SubnetRouteTableAssociation", path: "SubnetId", targetKind: "AWS::EC2::Subnet", relation: "reference", label: "associates", viaAttr: "SubnetId" },
|
|
58
|
+
{ from: "AWS::EC2::SubnetRouteTableAssociation", path: "RouteTableId", targetKind: "AWS::EC2::RouteTable", relation: "reference", label: "to table", viaAttr: "RouteTableId" },
|
|
44
59
|
{ from: "AWS::EC2::SecurityGroup", path: "IpPermissions[].UserIdGroupPairs[].GroupId", targetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "allows" },
|
|
45
|
-
|
|
60
|
+
// An ENI is where a security group is attached and where an instance's
|
|
61
|
+
// networking actually lives, so both edges are what make "unused" and
|
|
62
|
+
// "reachable" answerable from the graph rather than from a provider sweep.
|
|
63
|
+
{ from: "AWS::EC2::NetworkInterface", path: "Groups[].GroupId", targetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "sg", viaAttr: "Groups" },
|
|
64
|
+
{ from: "AWS::EC2::NetworkInterface", path: "Attachment.InstanceId", targetKind: "AWS::EC2::Instance", relation: "reference", label: "attached to", viaAttr: "Attachment" },
|
|
65
|
+
{ from: "AWS::EC2::NetworkInterface", path: "SubnetId", targetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet" },
|
|
66
|
+
{ from: "AWS::EC2::Route", path: "GatewayId", targetKind: "AWS::EC2::InternetGateway", relation: "reference", label: "via", viaAttr: "GatewayId" },
|
|
46
67
|
{ from: "AWS::EC2::Route", path: "NatGatewayId", targetKind: "AWS::EC2::NatGateway", relation: "reference", label: "via" },
|
|
47
68
|
{ from: "AWS::ElasticLoadBalancingV2::LoadBalancer", path: "SecurityGroups[]", targetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "sg" },
|
|
48
69
|
{ from: "AWS::ElasticLoadBalancingV2::Listener", path: "LoadBalancerArn", targetKind: "AWS::ElasticLoadBalancingV2::LoadBalancer", relation: "reference", label: "on" },
|