@intentius/chant-lexicon-aws 0.15.3 → 0.16.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/generated/index.d.ts +11 -0
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/integrity.json +4 -4
- package/dist/live-attrs.d.ts +4 -0
- package/dist/live-attrs.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/meta.json +118 -11
- package/dist/op/activities/aws-apply.d.ts +8 -1
- package/dist/op/activities/aws-apply.d.ts.map +1 -1
- package/dist/op/activities/floci.d.ts +11 -19
- package/dist/op/activities/floci.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/reference-catalog.d.ts +14 -0
- package/dist/reference-catalog.d.ts.map +1 -0
- package/dist/types/index.d.ts +170 -19
- package/package.json +2 -2
- package/src/generated/index.d.ts +170 -19
- package/src/generated/index.ts +13 -2
- package/src/generated/lexicon-aws.json +118 -11
- package/src/live-attrs.test.ts +54 -0
- package/src/live-attrs.ts +40 -0
- package/src/op/activities/aws-apply.ts +8 -1
- package/src/op/activities/floci.ts +49 -91
- package/src/plugin.ts +13 -0
- package/src/reference-catalog.test.ts +74 -0
- package/src/reference-catalog.ts +82 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { resolveTemplateAttrs } from "./live-attrs";
|
|
3
|
+
import { reconstructEdges } from "@intentius/chant/graph-refs";
|
|
4
|
+
import type { IRNode } from "@intentius/chant/graph-ir";
|
|
5
|
+
import { awsReferenceCatalog } from "./reference-catalog";
|
|
6
|
+
import type { ExportedTemplate } from "@intentius/chant/lexicon";
|
|
7
|
+
|
|
8
|
+
const tmpl = (resources: Array<{ logicalId: string; type: string; properties: Record<string, unknown> }>): ExportedTemplate =>
|
|
9
|
+
({ resources, parameters: [], outputs: {} }) as unknown as ExportedTemplate;
|
|
10
|
+
|
|
11
|
+
describe("resolveTemplateAttrs", () => {
|
|
12
|
+
it("resolves {Ref} and {Fn::GetAtt} intrinsics to bare logical ids", () => {
|
|
13
|
+
const attrs = resolveTemplateAttrs(
|
|
14
|
+
tmpl([
|
|
15
|
+
{ logicalId: "Subnet", type: "AWS::EC2::Subnet", properties: { VpcId: { Ref: "Vpc" }, CidrBlock: "10.0.1.0/24" } },
|
|
16
|
+
{ logicalId: "Listener", type: "AWS::ElasticLoadBalancingV2::Listener", properties: { LoadBalancerArn: { "Fn::GetAtt": ["Alb", "Arn"] } } },
|
|
17
|
+
]),
|
|
18
|
+
);
|
|
19
|
+
expect(attrs.Subnet).toEqual({ VpcId: "Vpc", CidrBlock: "10.0.1.0/24" });
|
|
20
|
+
expect(attrs.Listener).toEqual({ LoadBalancerArn: "Alb" });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("resolves intrinsics nested in arrays and objects", () => {
|
|
24
|
+
const attrs = resolveTemplateAttrs(
|
|
25
|
+
tmpl([
|
|
26
|
+
{ logicalId: "Inst", type: "AWS::EC2::Instance", properties: { SecurityGroupIds: [{ Ref: "SgA" }, { Ref: "SgB" }], Nested: { SubnetId: { Ref: "Sn" } } } },
|
|
27
|
+
]),
|
|
28
|
+
);
|
|
29
|
+
expect(attrs.Inst.SecurityGroupIds).toEqual(["SgA", "SgB"]);
|
|
30
|
+
expect(attrs.Inst.Nested).toEqual({ SubnetId: "Sn" });
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("live edges from a CloudFormation template (#784)", () => {
|
|
35
|
+
it("reconstructs containment + edges from resolved template attrs", () => {
|
|
36
|
+
const template = tmpl([
|
|
37
|
+
{ logicalId: "Vpc", type: "AWS::EC2::VPC", properties: { CidrBlock: "10.0.0.0/16" } },
|
|
38
|
+
{ logicalId: "Subnet", type: "AWS::EC2::Subnet", properties: { VpcId: { Ref: "Vpc" } } },
|
|
39
|
+
{ logicalId: "Sg", type: "AWS::EC2::SecurityGroup", properties: { VpcId: { Ref: "Vpc" } } },
|
|
40
|
+
{ logicalId: "Inst", type: "AWS::EC2::Instance", properties: { SubnetId: { Ref: "Subnet" }, SecurityGroupIds: [{ Ref: "Sg" }] } },
|
|
41
|
+
]);
|
|
42
|
+
const attrsById = resolveTemplateAttrs(template);
|
|
43
|
+
const nodes: IRNode[] = template.resources.map((r) => ({ id: r.logicalId, kind: r.type, lexicon: "aws", attrs: attrsById[r.logicalId] }));
|
|
44
|
+
|
|
45
|
+
const { edges, containment, dangling } = reconstructEdges(nodes, awsReferenceCatalog);
|
|
46
|
+
// containment: subnet and sg are inside the VPC; the instance is inside the subnet
|
|
47
|
+
expect(containment).toContainEqual({ child: "Subnet", parent: "Vpc", label: "in VPC" });
|
|
48
|
+
expect(containment).toContainEqual({ child: "Sg", parent: "Vpc", label: "in VPC" });
|
|
49
|
+
expect(containment).toContainEqual({ child: "Inst", parent: "Subnet", label: "in subnet" });
|
|
50
|
+
// edge: the instance references the security group
|
|
51
|
+
expect(edges).toContainEqual({ from: "Inst", to: "Sg", kind: "ref", viaAttr: "sg" });
|
|
52
|
+
expect(dangling).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ExportedTemplate } from "@intentius/chant/lexicon";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve a live CloudFormation template's per-resource properties into flat
|
|
5
|
+
* attributes for `chant graph --live` edge reconstruction (#784).
|
|
6
|
+
*
|
|
7
|
+
* `describeResources()` returns thin metadata (stack outputs), so the reference
|
|
8
|
+
* resolver (#778) had nothing to match on. `exportResources()` returns the
|
|
9
|
+
* deployed template, where resources reference each other by **logical id** via
|
|
10
|
+
* `{Ref: LogicalId}` / `{Fn::GetAtt: [LogicalId, …]}`. Logical id == the IR node
|
|
11
|
+
* id (both keyed by the CloudFormation logical name), so resolving those
|
|
12
|
+
* intrinsics to the bare logical-id string turns each reference into a value the
|
|
13
|
+
* resolver matches directly (it indexes every node by its own id).
|
|
14
|
+
*/
|
|
15
|
+
function resolveIntrinsics(v: unknown): unknown {
|
|
16
|
+
if (Array.isArray(v)) return v.map(resolveIntrinsics);
|
|
17
|
+
if (v && typeof v === "object") {
|
|
18
|
+
const o = v as Record<string, unknown>;
|
|
19
|
+
const keys = Object.keys(o);
|
|
20
|
+
if (keys.length === 1 && typeof o.Ref === "string") return o.Ref;
|
|
21
|
+
if (keys.length === 1 && o["Fn::GetAtt"] !== undefined) {
|
|
22
|
+
const g = o["Fn::GetAtt"];
|
|
23
|
+
if (Array.isArray(g) && typeof g[0] === "string") return g[0]; // ["LogicalId", "Attr"]
|
|
24
|
+
if (typeof g === "string") return g.split(".")[0]; // "LogicalId.Attr"
|
|
25
|
+
}
|
|
26
|
+
const out: Record<string, unknown> = {};
|
|
27
|
+
for (const [k, val] of Object.entries(o)) out[k] = resolveIntrinsics(val);
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
return v;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** logical id → resolved properties (references become bare logical-id strings). */
|
|
34
|
+
export function resolveTemplateAttrs(template: ExportedTemplate): Record<string, Record<string, unknown>> {
|
|
35
|
+
const out: Record<string, Record<string, unknown>> = {};
|
|
36
|
+
for (const r of template.resources) {
|
|
37
|
+
out[r.logicalId] = resolveIntrinsics(r.properties) as Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
@@ -54,7 +54,14 @@ export function capabilityParams(capabilities: string[]): Record<string, string>
|
|
|
54
54
|
return out;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* First `<tag>…</tag>` text in a CFN XML response.
|
|
59
|
+
*
|
|
60
|
+
* Assumes flat scalar fields — the CloudFormation Query API returns simple
|
|
61
|
+
* `<StackStatus>…</StackStatus>` style leaves, so `[^<]*` is sufficient. It does
|
|
62
|
+
* NOT handle nested tags or XML entities in the value; if a field ever carries
|
|
63
|
+
* either, replace this with a real XML parser.
|
|
64
|
+
*/
|
|
58
65
|
export function xmlField(xml: string, tag: string): string | undefined {
|
|
59
66
|
return xml.match(new RegExp(`<${tag}>([^<]*)</${tag}>`))?.[1];
|
|
60
67
|
}
|
|
@@ -1,14 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { promisify } from "node:util";
|
|
3
|
-
import { safeHeartbeat, sleep } from "@intentius/chant/op";
|
|
4
|
-
|
|
5
|
-
const execAsync = promisify(exec);
|
|
1
|
+
import { emulatorLifecycle } from "@intentius/chant/op";
|
|
6
2
|
|
|
7
3
|
const DEFAULT_NAME = "chant-floci";
|
|
8
4
|
const DEFAULT_PORT = 4566;
|
|
9
5
|
const DEFAULT_IMAGE = "floci/floci:latest";
|
|
10
6
|
const DEFAULT_REGION = "us-east-1";
|
|
11
7
|
const DEFAULT_READY_SERVICE = "cloudformation";
|
|
8
|
+
const DOCKER_SOCK = ["-v", "/var/run/docker.sock:/var/run/docker.sock"] as const;
|
|
12
9
|
|
|
13
10
|
export interface FlociUpArgs {
|
|
14
11
|
/** Container name. Default: `chant-floci`. */
|
|
@@ -34,30 +31,9 @@ export interface FlociDownArgs {
|
|
|
34
31
|
name?: string;
|
|
35
32
|
}
|
|
36
33
|
|
|
37
|
-
/**
|
|
38
|
-
export function
|
|
39
|
-
return `
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Build the `docker run` command that boots Floci. */
|
|
43
|
-
export function flociRunCommand(args: FlociUpArgs): string {
|
|
44
|
-
const name = args.name ?? DEFAULT_NAME;
|
|
45
|
-
const port = args.port ?? DEFAULT_PORT;
|
|
46
|
-
const image = args.image ?? DEFAULT_IMAGE;
|
|
47
|
-
const parts = ["docker", "run", "-d", "--rm", "--name", name, "-p", `${port}:4566`];
|
|
48
|
-
if (args.dockerSocket) parts.push("-v", "/var/run/docker.sock:/var/run/docker.sock");
|
|
49
|
-
parts.push(image);
|
|
50
|
-
return parts.join(" ");
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Build the `docker rm -f` command. */
|
|
54
|
-
export function flociRmCommand(name: string): string {
|
|
55
|
-
return `docker rm -f ${name}`;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** The Floci health endpoint URL for a host port. */
|
|
59
|
-
export function flociHealthUrl(port: number): string {
|
|
60
|
-
return `http://localhost:${port}/_localstack/health`;
|
|
34
|
+
/** True once the health body reports the required service (e.g. `"cloudformation"`). */
|
|
35
|
+
export function isFlociReady(healthBody: string, service: string): boolean {
|
|
36
|
+
return healthBody.includes(`"${service}"`);
|
|
61
37
|
}
|
|
62
38
|
|
|
63
39
|
/** The AWS env vars that point the `aws` CLI / SDK at a local Floci endpoint. */
|
|
@@ -70,9 +46,32 @@ export function flociEnv(port: number, region: string): Record<string, string> {
|
|
|
70
46
|
};
|
|
71
47
|
}
|
|
72
48
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
49
|
+
// AWS runs the LocalStack-compatible floci/floci image, so readiness is gated on
|
|
50
|
+
// a service (`cloudformation`) appearing in `/_localstack/health` — unlike the
|
|
51
|
+
// bespoke az/gcp fakes. Shared lifecycle: emulatorLifecycle (#746).
|
|
52
|
+
const flociFor = (readyService: string) =>
|
|
53
|
+
emulatorLifecycle({
|
|
54
|
+
name: DEFAULT_NAME,
|
|
55
|
+
image: DEFAULT_IMAGE,
|
|
56
|
+
containerPort: DEFAULT_PORT,
|
|
57
|
+
healthPath: "/_localstack/health",
|
|
58
|
+
ready: (body) => isFlociReady(body, readyService),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const builders = flociFor(DEFAULT_READY_SERVICE);
|
|
62
|
+
|
|
63
|
+
export const flociExistsCommand = builders.existsCommand;
|
|
64
|
+
export const flociRmCommand = builders.rmCommand;
|
|
65
|
+
export const flociHealthUrl = builders.healthUrl;
|
|
66
|
+
|
|
67
|
+
/** Build the `docker run` command that boots Floci. */
|
|
68
|
+
export function flociRunCommand(args: FlociUpArgs = {}): string {
|
|
69
|
+
return builders.runCommand({
|
|
70
|
+
name: args.name,
|
|
71
|
+
port: args.port,
|
|
72
|
+
image: args.image,
|
|
73
|
+
extraArgs: args.dockerSocket ? [...DOCKER_SOCK] : [],
|
|
74
|
+
});
|
|
76
75
|
}
|
|
77
76
|
|
|
78
77
|
/**
|
|
@@ -81,69 +80,28 @@ export function isFlociReady(healthBody: string, service: string): boolean {
|
|
|
81
80
|
* Idempotent: reuses a running container of the same name. Waits for the health
|
|
82
81
|
* endpoint to report `readyService`, then sets `AWS_ENDPOINT_URL` + test creds in
|
|
83
82
|
* the process environment so a following `nativeApply`/`cfn-deploy` targets the
|
|
84
|
-
* emulator. Env injection assumes the in-process
|
|
83
|
+
* emulator. Env injection assumes the in-process local executor; under a
|
|
85
84
|
* distributed Temporal worker, pass the endpoint explicitly instead.
|
|
86
|
-
*
|
|
87
|
-
* Uses longInfra profile — 20m timeout, heartbeat every poll (the image may pull).
|
|
88
85
|
*/
|
|
89
|
-
export async function flociUp(args: FlociUpArgs, signal?: AbortSignal): Promise<{ endpoint: string }> {
|
|
90
|
-
const name = args.name ?? DEFAULT_NAME;
|
|
91
|
-
const port = args.port ?? DEFAULT_PORT;
|
|
86
|
+
export async function flociUp(args: FlociUpArgs = {}, signal?: AbortSignal): Promise<{ endpoint: string }> {
|
|
92
87
|
const region = args.region ?? DEFAULT_REGION;
|
|
93
88
|
const service = args.readyService ?? DEFAULT_READY_SERVICE;
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
} else {
|
|
108
|
-
await execAsync(flociRunCommand({ ...args, name, port }), { signal });
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const url = flociHealthUrl(port);
|
|
112
|
-
const deadline = Date.now() + timeoutMs;
|
|
113
|
-
let ready = false;
|
|
114
|
-
while (Date.now() < deadline) {
|
|
115
|
-
if (signal?.aborted) throw new Error("flociUp aborted");
|
|
116
|
-
safeHeartbeat({ step: "flociUp", container: name });
|
|
117
|
-
try {
|
|
118
|
-
const res = await fetch(url, { signal });
|
|
119
|
-
if (res.ok && isFlociReady(await res.text(), service)) {
|
|
120
|
-
ready = true;
|
|
121
|
-
break;
|
|
122
|
-
}
|
|
123
|
-
} catch {
|
|
124
|
-
// Not up yet (connection refused / non-2xx) — retry.
|
|
125
|
-
}
|
|
126
|
-
await sleep(intervalMs, signal);
|
|
127
|
-
}
|
|
128
|
-
if (!ready) {
|
|
129
|
-
throw new Error(`Floci "${name}" did not become ready within ${timeoutMs}ms`);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const env = flociEnv(port, region);
|
|
133
|
-
Object.assign(process.env, env);
|
|
134
|
-
console.log(`Floci ready on ${env.AWS_ENDPOINT_URL} (service: ${service})`);
|
|
135
|
-
return { endpoint: env.AWS_ENDPOINT_URL };
|
|
89
|
+
const { endpoint } = await flociFor(service).up(
|
|
90
|
+
{
|
|
91
|
+
name: args.name,
|
|
92
|
+
port: args.port,
|
|
93
|
+
image: args.image,
|
|
94
|
+
timeoutMs: args.timeoutMs,
|
|
95
|
+
intervalMs: args.intervalMs,
|
|
96
|
+
extraArgs: args.dockerSocket ? [...DOCKER_SOCK] : [],
|
|
97
|
+
},
|
|
98
|
+
signal,
|
|
99
|
+
);
|
|
100
|
+
Object.assign(process.env, flociEnv(args.port ?? DEFAULT_PORT, region));
|
|
101
|
+
return { endpoint };
|
|
136
102
|
}
|
|
137
103
|
|
|
138
|
-
/**
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
*/
|
|
142
|
-
export async function flociDown(args: FlociDownArgs, signal?: AbortSignal): Promise<void> {
|
|
143
|
-
const name = args.name ?? DEFAULT_NAME;
|
|
144
|
-
try {
|
|
145
|
-
await execAsync(flociRmCommand(name), { signal });
|
|
146
|
-
} catch {
|
|
147
|
-
// Already removed (`--rm` on exit, or never started) — treat as success.
|
|
148
|
-
}
|
|
104
|
+
/** Stop and remove the local Floci emulator container (no-op if already gone). */
|
|
105
|
+
export async function flociDown(args: FlociDownArgs = {}, signal?: AbortSignal): Promise<void> {
|
|
106
|
+
return builders.down(args, signal);
|
|
149
107
|
}
|
package/src/plugin.ts
CHANGED
|
@@ -14,6 +14,8 @@ import { readFileSync } from "fs";
|
|
|
14
14
|
import { join, dirname } from "path";
|
|
15
15
|
import { fileURLToPath } from "url";
|
|
16
16
|
import { awsSerializer } from "./serializer";
|
|
17
|
+
import { awsReferenceCatalog } from "./reference-catalog";
|
|
18
|
+
import { resolveTemplateAttrs } from "./live-attrs";
|
|
17
19
|
import { CFParser } from "./import/parser";
|
|
18
20
|
import { CFGenerator } from "./import/generator";
|
|
19
21
|
import { parseStackTemplate } from "./import/live-export";
|
|
@@ -31,6 +33,8 @@ export const awsPlugin: LexiconPlugin = {
|
|
|
31
33
|
serializer: awsSerializer,
|
|
32
34
|
// Audit rule metadata for this lexicon's WAW* checks (#687).
|
|
33
35
|
auditCatalog: () => awsAuditCatalog,
|
|
36
|
+
// Live edge reconstruction for `chant graph --live` (#778).
|
|
37
|
+
referenceCatalog: awsReferenceCatalog,
|
|
34
38
|
|
|
35
39
|
lintRules(): LintRule[] {
|
|
36
40
|
const rulesDir = join(dirname(fileURLToPath(import.meta.url)), "lint", "rules");
|
|
@@ -580,6 +584,15 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
580
584
|
return parseStackTemplate(parsed.TemplateBody, options.selector, options.owned);
|
|
581
585
|
},
|
|
582
586
|
|
|
587
|
+
// Live attribute enrichment for `chant graph --live` edge reconstruction (#784).
|
|
588
|
+
// describe-stack-resources is too thin; the deployed template (exportResources)
|
|
589
|
+
// carries the references. Resolve its `{Ref}`/`{Fn::GetAtt}` intrinsics to bare
|
|
590
|
+
// logical ids so the reference resolver matches them.
|
|
591
|
+
async enrichLiveAttrs(options: { environment: string; owned?: boolean }): Promise<Record<string, Record<string, unknown>>> {
|
|
592
|
+
const template = await this.exportResources!({ environment: options.environment, owned: options.owned });
|
|
593
|
+
return resolveTemplateAttrs(template);
|
|
594
|
+
},
|
|
595
|
+
|
|
583
596
|
mcpTools() {
|
|
584
597
|
return [
|
|
585
598
|
{
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { reconstructEdges, containmentGroups } from "@intentius/chant/graph-refs";
|
|
3
|
+
import type { IRNode } from "@intentius/chant/graph-ir";
|
|
4
|
+
import { awsReferenceCatalog } from "./reference-catalog";
|
|
5
|
+
|
|
6
|
+
const n = (id: string, kind: string, attrs: Record<string, unknown>): IRNode => ({ id, kind, lexicon: "aws", attrs });
|
|
7
|
+
|
|
8
|
+
// A canonical 3-tier VPC (managed subgraph): internet → ALB (public subnets) →
|
|
9
|
+
// ECS service (private subnets) → RDS, with SG attachments. Attributes are the
|
|
10
|
+
// per-resource describe/export shape the catalog targets.
|
|
11
|
+
const nodes: IRNode[] = [
|
|
12
|
+
n("vpc", "AWS::EC2::VPC", { VpcId: "vpc-1" }),
|
|
13
|
+
n("pubA", "AWS::EC2::Subnet", { SubnetId: "subnet-pa", VpcId: "vpc-1" }),
|
|
14
|
+
n("pubB", "AWS::EC2::Subnet", { SubnetId: "subnet-pb", VpcId: "vpc-1" }),
|
|
15
|
+
n("privA", "AWS::EC2::Subnet", { SubnetId: "subnet-qa", VpcId: "vpc-1" }),
|
|
16
|
+
n("privB", "AWS::EC2::Subnet", { SubnetId: "subnet-qb", VpcId: "vpc-1" }),
|
|
17
|
+
n("albSg", "AWS::EC2::SecurityGroup", { GroupId: "sg-alb", VpcId: "vpc-1" }),
|
|
18
|
+
n("appSg", "AWS::EC2::SecurityGroup", { GroupId: "sg-app", VpcId: "vpc-1", IpPermissions: [{ UserIdGroupPairs: [{ GroupId: "sg-alb" }] }] }),
|
|
19
|
+
n("alb", "AWS::ElasticLoadBalancingV2::LoadBalancer", { LoadBalancerArn: "arn-alb", AvailabilityZones: [{ SubnetId: "subnet-pa" }, { SubnetId: "subnet-pb" }], SecurityGroups: ["sg-alb"] }),
|
|
20
|
+
n("tg", "AWS::ElasticLoadBalancingV2::TargetGroup", { TargetGroupArn: "arn-tg", VpcId: "vpc-1" }),
|
|
21
|
+
n("listener", "AWS::ElasticLoadBalancingV2::Listener", { LoadBalancerArn: "arn-alb", DefaultActions: [{ TargetGroupArn: "arn-tg" }] }),
|
|
22
|
+
n("cluster", "AWS::ECS::Cluster", { ClusterArn: "arn-cl" }),
|
|
23
|
+
n("taskdef", "AWS::ECS::TaskDefinition", { TaskDefinitionArn: "arn-td" }),
|
|
24
|
+
n("svc", "AWS::ECS::Service", { ServiceArn: "arn-svc", ClusterArn: "arn-cl", TaskDefinition: "arn-td", LoadBalancers: [{ TargetGroupArn: "arn-tg" }], NetworkConfiguration: { AwsvpcConfiguration: { Subnets: ["subnet-qa", "subnet-qb"], SecurityGroups: ["sg-app"] } } }),
|
|
25
|
+
n("rds", "AWS::RDS::DBInstance", { DBInstanceArn: "arn-rds", DBSubnetGroup: { Subnets: [{ SubnetIdentifier: "subnet-qa" }, { SubnetIdentifier: "subnet-qb" }] }, VpcSecurityGroups: [{ VpcSecurityGroupId: "sg-app" }] }),
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
describe("awsReferenceCatalog — golden 3-tier VPC", () => {
|
|
29
|
+
const { edges, containment, dangling } = reconstructEdges(nodes, awsReferenceCatalog);
|
|
30
|
+
const hasEdge = (from: string, to: string, via?: string) =>
|
|
31
|
+
edges.some((e) => e.from === from && e.to === to && (via === undefined || e.viaAttr === via));
|
|
32
|
+
const hasCont = (child: string, parent: string) =>
|
|
33
|
+
containment.some((c) => c.child === child && c.parent === parent);
|
|
34
|
+
|
|
35
|
+
it("reconstructs the topology edges", () => {
|
|
36
|
+
expect(hasEdge("svc", "cluster", "in cluster")).toBe(true);
|
|
37
|
+
expect(hasEdge("svc", "taskdef", "runs")).toBe(true);
|
|
38
|
+
expect(hasEdge("svc", "tg", "registered in")).toBe(true);
|
|
39
|
+
expect(hasEdge("svc", "appSg", "sg")).toBe(true);
|
|
40
|
+
expect(hasEdge("listener", "alb", "on")).toBe(true);
|
|
41
|
+
expect(hasEdge("listener", "tg", "forwards to")).toBe(true);
|
|
42
|
+
expect(hasEdge("alb", "albSg", "sg")).toBe(true);
|
|
43
|
+
expect(hasEdge("appSg", "albSg", "allows")).toBe(true);
|
|
44
|
+
expect(hasEdge("rds", "appSg", "sg")).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("reconstructs network containment (→ #779 boundaries), not as edges", () => {
|
|
48
|
+
for (const s of ["pubA", "pubB", "privA", "privB", "albSg", "appSg", "tg"]) expect(hasCont(s, "vpc")).toBe(true);
|
|
49
|
+
expect(hasCont("alb", "pubA")).toBe(true);
|
|
50
|
+
expect(hasCont("alb", "pubB")).toBe(true);
|
|
51
|
+
expect(hasCont("svc", "privA")).toBe(true);
|
|
52
|
+
expect(hasCont("rds", "privB")).toBe(true);
|
|
53
|
+
// containment is never an edge
|
|
54
|
+
expect(edges.some((e) => e.to === "vpc")).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("has no dangling references — every target is in the observed set", () => {
|
|
58
|
+
expect(dangling).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("reference edges point holder → referenced", () => {
|
|
62
|
+
// e.g. the service references the cluster, not the reverse
|
|
63
|
+
expect(hasEdge("cluster", "svc")).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("containment groups nest VPC → subnets → resources (#779)", () => {
|
|
67
|
+
const groups = containmentGroups(containment);
|
|
68
|
+
// the VPC contains its subnets, SGs, and target group
|
|
69
|
+
expect(groups.vpc).toEqual(expect.arrayContaining(["pubA", "pubB", "privA", "privB", "albSg", "appSg", "tg"]));
|
|
70
|
+
// public subnets contain the ALB; private subnets contain the service + RDS
|
|
71
|
+
expect(groups.pubA).toContain("alb");
|
|
72
|
+
expect(groups.privA).toEqual(expect.arrayContaining(["svc", "rds"]));
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { ReferenceCatalog } from "@intentius/chant/lexicon";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AWS reference catalog (#778, epic #776 v1) — how observed AWS resources
|
|
5
|
+
* reference each other, so `chant graph --live` can reconstruct the topology.
|
|
6
|
+
*
|
|
7
|
+
* Keyed to the per-resource describe/export attribute shape (`VpcId`,
|
|
8
|
+
* `SecurityGroups[].GroupId`, `TargetGroupArn`, …), not the thin
|
|
9
|
+
* `describe-stack-resources` metadata. Enough to draw the canonical 3-tier VPC
|
|
10
|
+
* (internet → ALB → ECS service → RDS, with SG attachments). `containment`
|
|
11
|
+
* relations (subnet ∈ VPC) feed #779's boundary boxes; `reference` relations are
|
|
12
|
+
* edges.
|
|
13
|
+
*/
|
|
14
|
+
export const awsReferenceCatalog: ReferenceCatalog = {
|
|
15
|
+
identities: [
|
|
16
|
+
{ kind: "AWS::EC2::VPC", ids: ["VpcId"] },
|
|
17
|
+
{ kind: "AWS::EC2::Subnet", ids: ["SubnetId"] },
|
|
18
|
+
{ kind: "AWS::EC2::SecurityGroup", ids: ["GroupId"] },
|
|
19
|
+
{ kind: "AWS::EC2::Instance", ids: ["InstanceId"] },
|
|
20
|
+
{ kind: "AWS::EC2::InternetGateway", ids: ["InternetGatewayId"] },
|
|
21
|
+
{ kind: "AWS::EC2::NatGateway", ids: ["NatGatewayId"] },
|
|
22
|
+
{ kind: "AWS::EC2::RouteTable", ids: ["RouteTableId"] },
|
|
23
|
+
{ kind: "AWS::ElasticLoadBalancingV2::LoadBalancer", ids: ["LoadBalancerArn", "DNSName"] },
|
|
24
|
+
{ kind: "AWS::ElasticLoadBalancingV2::TargetGroup", ids: ["TargetGroupArn"] },
|
|
25
|
+
{ kind: "AWS::ECS::Cluster", ids: ["ClusterArn", "ClusterName"] },
|
|
26
|
+
{ kind: "AWS::ECS::Service", ids: ["ServiceArn"] },
|
|
27
|
+
{ kind: "AWS::ECS::TaskDefinition", ids: ["TaskDefinitionArn"] },
|
|
28
|
+
{ kind: "AWS::RDS::DBInstance", ids: ["DBInstanceArn", "Endpoint.Address"] },
|
|
29
|
+
],
|
|
30
|
+
refs: [
|
|
31
|
+
// ── containment (→ boundary boxes, #779) ──
|
|
32
|
+
{ from: "AWS::EC2::Subnet", path: "VpcId", targetKind: "AWS::EC2::VPC", relation: "containment", label: "in VPC" },
|
|
33
|
+
{ from: "AWS::EC2::SecurityGroup", path: "VpcId", targetKind: "AWS::EC2::VPC", relation: "containment", label: "in VPC" },
|
|
34
|
+
{ 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" },
|
|
36
|
+
{ from: "AWS::EC2::NatGateway", path: "SubnetId", targetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet" },
|
|
37
|
+
{ from: "AWS::ElasticLoadBalancingV2::LoadBalancer", path: "AvailabilityZones[].SubnetId", targetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet" },
|
|
38
|
+
{ from: "AWS::ElasticLoadBalancingV2::TargetGroup", path: "VpcId", targetKind: "AWS::EC2::VPC", relation: "containment", label: "in VPC" },
|
|
39
|
+
{ from: "AWS::ECS::Service", path: "NetworkConfiguration.AwsvpcConfiguration.Subnets[]", targetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet" },
|
|
40
|
+
{ from: "AWS::RDS::DBInstance", path: "DBSubnetGroup.Subnets[].SubnetIdentifier", targetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet" },
|
|
41
|
+
|
|
42
|
+
// ── references (→ edges) ──
|
|
43
|
+
{ from: "AWS::EC2::Instance", path: "SecurityGroups[].GroupId", targetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "sg" },
|
|
44
|
+
{ from: "AWS::EC2::SecurityGroup", path: "IpPermissions[].UserIdGroupPairs[].GroupId", targetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "allows" },
|
|
45
|
+
{ from: "AWS::EC2::Route", path: "GatewayId", targetKind: "AWS::EC2::InternetGateway", relation: "reference", label: "via" },
|
|
46
|
+
{ from: "AWS::EC2::Route", path: "NatGatewayId", targetKind: "AWS::EC2::NatGateway", relation: "reference", label: "via" },
|
|
47
|
+
{ from: "AWS::ElasticLoadBalancingV2::LoadBalancer", path: "SecurityGroups[]", targetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "sg" },
|
|
48
|
+
{ from: "AWS::ElasticLoadBalancingV2::Listener", path: "LoadBalancerArn", targetKind: "AWS::ElasticLoadBalancingV2::LoadBalancer", relation: "reference", label: "on" },
|
|
49
|
+
{ from: "AWS::ElasticLoadBalancingV2::Listener", path: "DefaultActions[].TargetGroupArn", targetKind: "AWS::ElasticLoadBalancingV2::TargetGroup", relation: "reference", label: "forwards to" },
|
|
50
|
+
{ from: "AWS::ECS::Service", path: "ClusterArn", targetKind: "AWS::ECS::Cluster", relation: "reference", label: "in cluster" },
|
|
51
|
+
{ from: "AWS::ECS::Service", path: "TaskDefinition", targetKind: "AWS::ECS::TaskDefinition", relation: "reference", label: "runs" },
|
|
52
|
+
{ from: "AWS::ECS::Service", path: "LoadBalancers[].TargetGroupArn", targetKind: "AWS::ElasticLoadBalancingV2::TargetGroup", relation: "reference", label: "registered in" },
|
|
53
|
+
{ from: "AWS::ECS::Service", path: "NetworkConfiguration.AwsvpcConfiguration.SecurityGroups[]", targetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "sg" },
|
|
54
|
+
{ from: "AWS::RDS::DBInstance", path: "VpcSecurityGroups[].VpcSecurityGroupId", targetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "sg" },
|
|
55
|
+
|
|
56
|
+
// ── CloudFormation *template* property paths (#784) ──
|
|
57
|
+
// `enrichLiveAttrs` resolves the deployed template's `{Ref}`/`{Fn::GetAtt}`
|
|
58
|
+
// intrinsics to bare logical ids (= node ids), so these match by node id; the
|
|
59
|
+
// property names are the CFN template shape (SecurityGroupIds, Cluster, …),
|
|
60
|
+
// which differs from the describe/SDK shape above. No targetKind needed —
|
|
61
|
+
// logical ids are unique.
|
|
62
|
+
{ from: "AWS::EC2::Subnet", path: "VpcId", relation: "containment", label: "in VPC" },
|
|
63
|
+
{ from: "AWS::EC2::SecurityGroup", path: "VpcId", relation: "containment", label: "in VPC" },
|
|
64
|
+
{ from: "AWS::EC2::RouteTable", path: "VpcId", relation: "containment", label: "in VPC" },
|
|
65
|
+
{ from: "AWS::EC2::Instance", path: "SubnetId", relation: "containment", label: "in subnet" },
|
|
66
|
+
{ from: "AWS::EC2::Instance", path: "SecurityGroupIds[]", relation: "reference", label: "sg" },
|
|
67
|
+
{ from: "AWS::EC2::NatGateway", path: "SubnetId", relation: "containment", label: "in subnet" },
|
|
68
|
+
{ from: "AWS::EC2::Route", path: "RouteTableId", relation: "reference", label: "in" },
|
|
69
|
+
{ from: "AWS::EC2::Route", path: "GatewayId", relation: "reference", label: "via" },
|
|
70
|
+
{ from: "AWS::EC2::Route", path: "NatGatewayId", relation: "reference", label: "via" },
|
|
71
|
+
{ from: "AWS::ElasticLoadBalancingV2::LoadBalancer", path: "Subnets[]", relation: "containment", label: "in subnet" },
|
|
72
|
+
{ from: "AWS::ElasticLoadBalancingV2::LoadBalancer", path: "SecurityGroups[]", relation: "reference", label: "sg" },
|
|
73
|
+
{ from: "AWS::ElasticLoadBalancingV2::TargetGroup", path: "VpcId", relation: "containment", label: "in VPC" },
|
|
74
|
+
{ from: "AWS::ElasticLoadBalancingV2::Listener", path: "LoadBalancerArn", relation: "reference", label: "on" },
|
|
75
|
+
{ from: "AWS::ElasticLoadBalancingV2::Listener", path: "DefaultActions[].TargetGroupArn", relation: "reference", label: "forwards to" },
|
|
76
|
+
{ from: "AWS::ECS::Service", path: "Cluster", relation: "reference", label: "in cluster" },
|
|
77
|
+
{ from: "AWS::ECS::Service", path: "TaskDefinition", relation: "reference", label: "runs" },
|
|
78
|
+
{ from: "AWS::ECS::Service", path: "LoadBalancers[].TargetGroupArn", relation: "reference", label: "registered in" },
|
|
79
|
+
{ from: "AWS::RDS::DBInstance", path: "DBSubnetGroupName", relation: "reference", label: "subnets" },
|
|
80
|
+
{ from: "AWS::RDS::DBInstance", path: "VPCSecurityGroups[]", relation: "reference", label: "sg" },
|
|
81
|
+
],
|
|
82
|
+
};
|