@intentius/chant-lexicon-aws 0.124.48 → 0.124.50
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/import/parser.d.ts +2 -0
- package/dist/import/parser.d.ts.map +1 -1
- package/dist/import/parser.js +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +17 -0
- package/dist/workspace-member.d.ts +34 -0
- package/dist/workspace-member.d.ts.map +1 -0
- package/dist/workspace-member.js +179 -0
- package/package.json +6 -3
- package/schemas/cdk.schema.json +14 -0
- package/src/import/parser.ts +1 -1
- package/src/plugin.ts +16 -0
- package/src/testdata/cdk-member/cdk.json +1 -0
- package/src/testdata/cdk-member/cdk.out/AppStack.template.json +20 -0
- package/src/testdata/cdk-member/cdk.out/manifest.json +1 -0
- package/src/testdata/cdk-unsynthesized/cdk.json +1 -0
- package/src/testdata/cfn-member/README.txt +1 -0
- package/src/testdata/cfn-member/stack.yaml +36 -0
- package/src/workspace-kinds.test.ts +47 -0
- package/src/workspace-member.test.ts +94 -0
- package/src/workspace-member.ts +197 -0
- package/workspace-kinds.json +66 -0
package/dist/import/parser.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { TemplateParser, TemplateIR } from "@intentius/chant/import/parser";
|
|
2
2
|
import { BaseValueParser } from "@intentius/chant/import/base-parser";
|
|
3
|
+
import yaml from "js-yaml";
|
|
4
|
+
export declare const CF_SCHEMA: yaml.Schema;
|
|
3
5
|
/**
|
|
4
6
|
* Parser for CloudFormation JSON templates.
|
|
5
7
|
* Extends BaseValueParser for generic recursive value walking;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../../src/import/parser.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,cAAc,EACd,UAAU,EAKX,MAAM,gCAAgC,CAAC;AACxC,OAAO,EAAE,eAAe,EAAE,MAAM,qCAAqC,CAAC;
|
|
1
|
+
{"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../../src/import/parser.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,cAAc,EACd,UAAU,EAKX,MAAM,gCAAgC,CAAC;AACxC,OAAO,EAAE,eAAe,EAAE,MAAM,qCAAqC,CAAC;AACtE,OAAO,IAAI,MAAM,SAAS,CAAC;AAyG3B,eAAO,MAAM,SAAS,aAA2C,CAAC;AAkElE;;;;GAIG;AACH,qBAAa,QAAS,SAAQ,eAAgB,YAAW,cAAc;IACrE;;OAEG;IACH,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,UAAU,CA4BjC;IAED;;;OAGG;IACH,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,gBAAgB,CASrC;IAEH,OAAO,CAAC,6BAA6B;IAUrC;;;;;;OAMG;IACH,OAAO,CAAC,qBAAqB,CAAS;IAEtC,OAAO,CAAC,eAAe;IAWvB;;OAEG;IACH,OAAO,CAAC,YAAY;IAYpB;;OAEG;IACH,OAAO,CAAC,eAAe;IAgBvB;;OAEG;IACH,OAAO,CAAC,cAAc;IAkBtB;;OAEG;IACH,OAAO,CAAC,eAAe;IAUvB;;OAEG;IACH,SAAS,CAAC,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,GAAG,IAAI,CAuJtG;CACF"}
|
package/dist/import/parser.js
CHANGED
|
@@ -102,7 +102,7 @@ const cfnYamlTypes = [
|
|
|
102
102
|
construct: (data) => ({ Condition: data }),
|
|
103
103
|
}),
|
|
104
104
|
];
|
|
105
|
-
const CF_SCHEMA = yaml.DEFAULT_SCHEMA.extend(cfnYamlTypes);
|
|
105
|
+
export const CF_SCHEMA = yaml.DEFAULT_SCHEMA.extend(cfnYamlTypes);
|
|
106
106
|
/** Parameter keys import carries besides Type, Description and Default. */
|
|
107
107
|
const PARAMETER_CONSTRAINT_KEYS = [
|
|
108
108
|
"AllowedValues",
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "4a3087a310d3cd55913c467b95ca4517826f602a5a340ae180a5a341a4effbf8",
|
|
5
5
|
"meta.json": "6f48557ed4203cbdeafdb47e7270fdf3533f6d6e6189987246301ed8e72170e9",
|
|
6
6
|
"types/index.d.ts": "8651f9ae166668f4311eea1fe61c3fe235f345947951c1d23d1df24a4fd3384f",
|
|
7
7
|
"rules/hardcoded-region.ts": "957eadcaa9f39cc17d21437565d7a94607d9b082cf28918b178fa067f07498a9",
|
|
@@ -75,5 +75,5 @@
|
|
|
75
75
|
"skills/chant-aws-eks.md": "8789255709ff004ad0a875fd5999edcdc66fc6e33d710db058d9f42703bcfdfe",
|
|
76
76
|
"skills/chant-aws-carve-terraform.md": "8780b94ce60aded935938d5ef20ea667738354051da1bf9ca6d70d9886f65d21"
|
|
77
77
|
},
|
|
78
|
-
"composite": "
|
|
78
|
+
"composite": "5ee7754d291ad70890b469056715efa4c073d671b48b932ee5b71eb13de5bd1d"
|
|
79
79
|
}
|
package/dist/manifest.json
CHANGED
package/dist/plugin.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,aAAa,EAAqQ,MAAM,0BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,aAAa,EAAqQ,MAAM,0BAA0B,CAAC;AAyCjU;yEACyE;AACzE,OAAO,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAiBnD,eAAO,MAAM,SAAS,EAAE,aA+kCvB,CAAC"}
|
package/dist/plugin.js
CHANGED
|
@@ -24,6 +24,7 @@ import { canDescribe, describeOwnProperties, stampRegion } from "./properties.js
|
|
|
24
24
|
import { stampProviderDefaults } from "./defaults.js";
|
|
25
25
|
import { resolveTemplateAttrs } from "./live-attrs.js";
|
|
26
26
|
import { CFParser } from "./import/parser.js";
|
|
27
|
+
import { lastMemberReport, readCloudFormationMember, setLastMemberReport } from "./workspace-member.js";
|
|
27
28
|
import { CFGenerator } from "./import/generator.js";
|
|
28
29
|
import { parseStackTemplate } from "./import/live-export.js";
|
|
29
30
|
import { awsCompletions } from "./lsp/completions.js";
|
|
@@ -49,6 +50,22 @@ const OWNERSHIP_UNRESOLVED_NOTE = "ownership filter not applied on describeResou
|
|
|
49
50
|
export const awsPlugin = {
|
|
50
51
|
behaviourKinds: awsBehaviourKinds,
|
|
51
52
|
name: "aws",
|
|
53
|
+
// A cloudformation workspace member's reader project (#3179): the template's
|
|
54
|
+
// resources as nodes, its Ref, GetAtt, Sub and DependsOn as edges. See
|
|
55
|
+
// ./workspace-member.ts.
|
|
56
|
+
async buildRoots(ctx) {
|
|
57
|
+
setLastMemberReport(undefined);
|
|
58
|
+
const member = ctx.config.aws?.member;
|
|
59
|
+
if (!member)
|
|
60
|
+
return { entities: new Map() };
|
|
61
|
+
const read = readCloudFormationMember({ member });
|
|
62
|
+
setLastMemberReport(read.report);
|
|
63
|
+
return { entities: read.entities, warnings: read.warnings };
|
|
64
|
+
},
|
|
65
|
+
graphMeta() {
|
|
66
|
+
const report = lastMemberReport();
|
|
67
|
+
return report ? { awsMember: report } : undefined;
|
|
68
|
+
},
|
|
52
69
|
// The thin read's marker channel is the STACK's own tags (#1998), read off
|
|
53
70
|
// the DescribeStacks call it already makes for the outputs.
|
|
54
71
|
// describe-stack-resources carries no per-resource tags, so the stack is the
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type Declarable } from "@intentius/chant/declarable";
|
|
2
|
+
export interface AwsMemberConfig {
|
|
3
|
+
/** `cloudformation` (the default) or `cdk`. */
|
|
4
|
+
kind?: "cloudformation" | "cdk";
|
|
5
|
+
/** The member's directory, absolute. */
|
|
6
|
+
dir: string;
|
|
7
|
+
/** Names the entities (`<label>/Bucket`): the member's name. */
|
|
8
|
+
label?: string;
|
|
9
|
+
}
|
|
10
|
+
/** What the read found, carried on the graph's `meta.awsMember`. */
|
|
11
|
+
export interface AwsMemberReport {
|
|
12
|
+
/** The template files read, relative to the member's directory (or its assembly's). */
|
|
13
|
+
templates: string[];
|
|
14
|
+
objects: number;
|
|
15
|
+
/** CDK only: false when the app has no synthesized assembly to read. */
|
|
16
|
+
synthesized?: boolean;
|
|
17
|
+
/** Why nothing was read; set with `synthesized: false`. */
|
|
18
|
+
reason?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function readCloudFormationMember(opts: {
|
|
21
|
+
member: AwsMemberConfig;
|
|
22
|
+
}): {
|
|
23
|
+
entities: Map<string, Declarable>;
|
|
24
|
+
warnings: string[];
|
|
25
|
+
report: AwsMemberReport;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* The report of the read in this process. `graphMeta` receives only the
|
|
29
|
+
* entities, so the hook that read the member leaves its report here for the
|
|
30
|
+
* hook that publishes it; `chant graph` runs the two in order in one process.
|
|
31
|
+
*/
|
|
32
|
+
export declare function setLastMemberReport(report: AwsMemberReport | undefined): void;
|
|
33
|
+
export declare function lastMemberReport(): AwsMemberReport | undefined;
|
|
34
|
+
//# sourceMappingURL=workspace-member.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workspace-member.d.ts","sourceRoot":"","sources":["../src/workspace-member.ts"],"names":[],"mappings":"AA0BA,OAAO,EAAqB,KAAK,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAKjF,MAAM,WAAW,eAAe;IAC9B,+CAA+C;IAC/C,IAAI,CAAC,EAAE,gBAAgB,GAAG,KAAK,CAAC;IAChC,wCAAwC;IACxC,GAAG,EAAE,MAAM,CAAC;IACZ,gEAAgE;IAChE,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,oEAAoE;AACpE,MAAM,WAAW,eAAe;IAC9B,uFAAuF;IACvF,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,wEAAwE;IACxE,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,2DAA2D;IAC3D,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAoED,wBAAgB,wBAAwB,CAAC,IAAI,EAAE;IAAE,MAAM,EAAE,eAAe,CAAA;CAAE,GAAG;IAC3E,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;IAClC,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,MAAM,EAAE,eAAe,CAAC;CACzB,CA4DA;AAID;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,eAAe,GAAG,SAAS,GAAG,IAAI,CAE7E;AAED,wBAAgB,gBAAgB,IAAI,eAAe,GAAG,SAAS,CAE9D"}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a cloudformation workspace member into the graph (#3179).
|
|
3
|
+
*
|
|
4
|
+
* `chant workspace graph` writes a reader project for a member of kind
|
|
5
|
+
* `cloudformation`, with `aws.member` in its config (see `workspace-kinds.json`).
|
|
6
|
+
* The plugin's `buildRoots` hook calls {@link readCloudFormationMember}: each
|
|
7
|
+
* CloudFormation template directly in the member's directory is parsed with the
|
|
8
|
+
* import parser's YAML schema (short-form tags included), and every resource
|
|
9
|
+
* becomes a node keyed `<label>/<LogicalId>`.
|
|
10
|
+
*
|
|
11
|
+
* Edges come from what the template says: `Ref`, `Fn::GetAtt` and the `${...}`
|
|
12
|
+
* references in `Fn::Sub`, wherever they sit in a resource's properties, plus
|
|
13
|
+
* `DependsOn`. Parameters, pseudo parameters and names that are not a resource
|
|
14
|
+
* of the same template (`AWS::Region`, an `Fn::ImportValue`) draw no edge.
|
|
15
|
+
* Nothing runs and no AWS call is made.
|
|
16
|
+
*
|
|
17
|
+
* A `cdk` member is read the same way from the cloud assembly its app already
|
|
18
|
+
* synthesized (`cdk.out`, or the `output` its cdk.json names): the assembly's
|
|
19
|
+
* `*.template.json` files are CloudFormation templates, and core's CDK
|
|
20
|
+
* assembly reader says whether the directory is one. chant does not run the
|
|
21
|
+
* app. With no assembly there is nothing to read, and the report says
|
|
22
|
+
* `synthesized: false` with the reason.
|
|
23
|
+
*/
|
|
24
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
25
|
+
import { basename, extname, join } from "node:path";
|
|
26
|
+
import yaml from "js-yaml";
|
|
27
|
+
import { DECLARABLE_MARKER } from "@intentius/chant/declarable";
|
|
28
|
+
import { isCloudAssembly } from "@intentius/chant/cdk/assembly";
|
|
29
|
+
import { CF_SCHEMA } from "./import/parser.js";
|
|
30
|
+
function parseTemplate(text) {
|
|
31
|
+
let parsed;
|
|
32
|
+
try {
|
|
33
|
+
parsed = JSON.parse(text);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
try {
|
|
37
|
+
parsed = yaml.load(text, { schema: CF_SCHEMA });
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
44
|
+
return undefined;
|
|
45
|
+
const t = parsed;
|
|
46
|
+
// The probe's two claims: a format version, or a Resources section.
|
|
47
|
+
const resources = t.Resources;
|
|
48
|
+
if (t.AWSTemplateFormatVersion === undefined && (!resources || typeof resources !== "object"))
|
|
49
|
+
return undefined;
|
|
50
|
+
return t;
|
|
51
|
+
}
|
|
52
|
+
/** Logical ids named by `${X}` and `${X.Attr}` in an `Fn::Sub` string, with the attribute when one is named. */
|
|
53
|
+
function subReferences(text) {
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const m of text.matchAll(/\$\{([^}!][^}]*)\}/g)) {
|
|
56
|
+
const [id, ...rest] = m[1].trim().split(".");
|
|
57
|
+
if (id)
|
|
58
|
+
out.push({ id, ...(rest.length ? { attr: rest.join(".") } : {}) });
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/** Every name a value reaches through Ref, Fn::GetAtt or Fn::Sub. */
|
|
63
|
+
function collectNames(value, out) {
|
|
64
|
+
if (Array.isArray(value)) {
|
|
65
|
+
for (const v of value)
|
|
66
|
+
collectNames(v, out);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (!value || typeof value !== "object")
|
|
70
|
+
return;
|
|
71
|
+
for (const [k, v] of Object.entries(value)) {
|
|
72
|
+
if (k === "Ref" && typeof v === "string") {
|
|
73
|
+
out.push({ id: v });
|
|
74
|
+
}
|
|
75
|
+
else if (k === "Fn::GetAtt") {
|
|
76
|
+
const [id, attr] = Array.isArray(v) ? v : typeof v === "string" ? [v.slice(0, v.indexOf(".")), v.slice(v.indexOf(".") + 1)] : [];
|
|
77
|
+
if (typeof id === "string" && id)
|
|
78
|
+
out.push({ id, ...(typeof attr === "string" && attr ? { attr } : {}) });
|
|
79
|
+
}
|
|
80
|
+
else if (k === "Fn::Sub") {
|
|
81
|
+
const text = Array.isArray(v) ? v[0] : v;
|
|
82
|
+
if (typeof text === "string")
|
|
83
|
+
out.push(...subReferences(text));
|
|
84
|
+
if (Array.isArray(v))
|
|
85
|
+
collectNames(v[1], out);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
collectNames(v, out);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** The directory a CDK app synthesizes into: cdk.json's `output`, else `cdk.out`. */
|
|
93
|
+
function cdkOutput(dir) {
|
|
94
|
+
try {
|
|
95
|
+
const output = JSON.parse(readFileSync(join(dir, "cdk.json"), "utf-8")).output;
|
|
96
|
+
if (typeof output === "string" && output)
|
|
97
|
+
return output;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
/* no readable cdk.json: the default */
|
|
101
|
+
}
|
|
102
|
+
return "cdk.out";
|
|
103
|
+
}
|
|
104
|
+
export function readCloudFormationMember(opts) {
|
|
105
|
+
const { member } = opts;
|
|
106
|
+
const label = member.label ?? basename(member.dir);
|
|
107
|
+
const warnings = [];
|
|
108
|
+
const templates = [];
|
|
109
|
+
let source = member.dir;
|
|
110
|
+
if (member.kind === "cdk") {
|
|
111
|
+
source = join(member.dir, cdkOutput(member.dir));
|
|
112
|
+
if (!isCloudAssembly(source)) {
|
|
113
|
+
const reason = `no synthesized cloud assembly at ${source}; run \`cdk synth\` in the app, and chant will read the templates it writes`;
|
|
114
|
+
warnings.push(`cdk member "${label}" was not read: ${reason}`);
|
|
115
|
+
return { entities: new Map(), warnings, report: { templates: [], objects: 0, synthesized: false, reason } };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const pattern = member.kind === "cdk" ? /\.template\.json$/ : /\.(ya?ml|json|template)$/;
|
|
119
|
+
for (const file of readdirSync(source).filter((f) => pattern.test(f)).sort()) {
|
|
120
|
+
const template = parseTemplate(readFileSync(join(source, file), "utf-8"));
|
|
121
|
+
if (template)
|
|
122
|
+
templates.push({ file, template });
|
|
123
|
+
}
|
|
124
|
+
const entities = new Map();
|
|
125
|
+
for (const { file, template } of templates) {
|
|
126
|
+
// One template per member is the usual case and keeps ids short; two share a directory, so the file name qualifies them.
|
|
127
|
+
const prefix = templates.length === 1 ? label : `${label}/${basename(file, extname(file)).replace(/\.template$/, "")}`;
|
|
128
|
+
const resources = Object.entries(template.Resources ?? {}).filter(([, r]) => typeof r?.Type === "string");
|
|
129
|
+
const keyOf = new Map(resources.map(([id]) => [id, `${prefix}/${id}`]));
|
|
130
|
+
for (const [id, resource] of resources) {
|
|
131
|
+
const references = new Map();
|
|
132
|
+
const add = (name, via) => {
|
|
133
|
+
const to = keyOf.get(name.id);
|
|
134
|
+
if (!to || to === keyOf.get(id))
|
|
135
|
+
return;
|
|
136
|
+
references.set(`${to}|${via}|${name.attr ?? ""}`, { to, viaAttr: via, ...(name.attr ? { toAttr: name.attr } : {}) });
|
|
137
|
+
};
|
|
138
|
+
for (const [prop, value] of Object.entries(resource.Properties ?? {})) {
|
|
139
|
+
const names = [];
|
|
140
|
+
collectNames(value, names);
|
|
141
|
+
for (const n of names)
|
|
142
|
+
add(n, prop);
|
|
143
|
+
}
|
|
144
|
+
const dependsOn = resource.DependsOn;
|
|
145
|
+
for (const d of Array.isArray(dependsOn) ? dependsOn : dependsOn === undefined ? [] : [dependsOn]) {
|
|
146
|
+
if (typeof d === "string")
|
|
147
|
+
add({ id: d }, "DependsOn");
|
|
148
|
+
}
|
|
149
|
+
const entity = {
|
|
150
|
+
lexicon: "aws",
|
|
151
|
+
entityType: resource.Type,
|
|
152
|
+
kind: "resource",
|
|
153
|
+
props: { ...(resource.Properties ?? {}) },
|
|
154
|
+
references: [...references.values()],
|
|
155
|
+
[DECLARABLE_MARKER]: true,
|
|
156
|
+
};
|
|
157
|
+
entities.set(keyOf.get(id), entity);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (templates.length === 0)
|
|
161
|
+
warnings.push(`${member.kind ?? "cloudformation"} member "${label}" has no CloudFormation template directly in ${source}`);
|
|
162
|
+
return {
|
|
163
|
+
entities,
|
|
164
|
+
warnings,
|
|
165
|
+
report: { templates: templates.map((t) => t.file), objects: entities.size, ...(member.kind === "cdk" ? { synthesized: true } : {}) },
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
let lastReport;
|
|
169
|
+
/**
|
|
170
|
+
* The report of the read in this process. `graphMeta` receives only the
|
|
171
|
+
* entities, so the hook that read the member leaves its report here for the
|
|
172
|
+
* hook that publishes it; `chant graph` runs the two in order in one process.
|
|
173
|
+
*/
|
|
174
|
+
export function setLastMemberReport(report) {
|
|
175
|
+
lastReport = report;
|
|
176
|
+
}
|
|
177
|
+
export function lastMemberReport() {
|
|
178
|
+
return lastReport;
|
|
179
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-aws",
|
|
3
|
-
"version": "0.124.
|
|
3
|
+
"version": "0.124.50",
|
|
4
4
|
"description": "AWS CloudFormation lexicon for chant — declarative IaC in TypeScript",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://intentius.io/chant",
|
|
@@ -23,12 +23,15 @@
|
|
|
23
23
|
"type": "module",
|
|
24
24
|
"files": [
|
|
25
25
|
"src/",
|
|
26
|
-
"dist/"
|
|
26
|
+
"dist/",
|
|
27
|
+
"schemas/",
|
|
28
|
+
"workspace-kinds.json"
|
|
27
29
|
],
|
|
28
30
|
"publishConfig": {
|
|
29
31
|
"access": "public"
|
|
30
32
|
},
|
|
31
33
|
"exports": {
|
|
34
|
+
"./workspace-kinds": "./workspace-kinds.json",
|
|
32
35
|
".": {
|
|
33
36
|
"development": "./src/index.ts",
|
|
34
37
|
"types": "./dist/index.d.ts",
|
|
@@ -80,6 +83,6 @@
|
|
|
80
83
|
"typescript": "^5.9.3"
|
|
81
84
|
},
|
|
82
85
|
"peerDependencies": {
|
|
83
|
-
"@intentius/chant": "^0.124.
|
|
86
|
+
"@intentius/chant": "^0.124.50"
|
|
84
87
|
}
|
|
85
88
|
}
|
package/src/import/parser.ts
CHANGED
package/src/plugin.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { canDescribe, describeOwnProperties, stampRegion } from "./properties";
|
|
|
36
36
|
import { stampProviderDefaults } from "./defaults";
|
|
37
37
|
import { resolveTemplateAttrs } from "./live-attrs";
|
|
38
38
|
import { CFParser } from "./import/parser";
|
|
39
|
+
import { lastMemberReport, readCloudFormationMember, setLastMemberReport } from "./workspace-member";
|
|
39
40
|
import { CFGenerator } from "./import/generator";
|
|
40
41
|
import { parseStackTemplate } from "./import/live-export";
|
|
41
42
|
import { awsCompletions } from "./lsp/completions";
|
|
@@ -65,6 +66,21 @@ const OWNERSHIP_UNRESOLVED_NOTE =
|
|
|
65
66
|
export const awsPlugin: LexiconPlugin = {
|
|
66
67
|
behaviourKinds: awsBehaviourKinds,
|
|
67
68
|
name: "aws",
|
|
69
|
+
// A cloudformation workspace member's reader project (#3179): the template's
|
|
70
|
+
// resources as nodes, its Ref, GetAtt, Sub and DependsOn as edges. See
|
|
71
|
+
// ./workspace-member.ts.
|
|
72
|
+
async buildRoots(ctx) {
|
|
73
|
+
setLastMemberReport(undefined);
|
|
74
|
+
const member = (ctx.config as { aws?: { member?: { kind?: "cloudformation" | "cdk"; dir: string; label?: string } } }).aws?.member;
|
|
75
|
+
if (!member) return { entities: new Map() };
|
|
76
|
+
const read = readCloudFormationMember({ member });
|
|
77
|
+
setLastMemberReport(read.report);
|
|
78
|
+
return { entities: read.entities, warnings: read.warnings };
|
|
79
|
+
},
|
|
80
|
+
graphMeta() {
|
|
81
|
+
const report = lastMemberReport();
|
|
82
|
+
return report ? { awsMember: report } : undefined;
|
|
83
|
+
},
|
|
68
84
|
// The thin read's marker channel is the STACK's own tags (#1998), read off
|
|
69
85
|
// the DescribeStacks call it already makes for the outputs.
|
|
70
86
|
// describe-stack-resources carries no per-resource tags, so the stack is the
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"app": "npx ts-node bin/app.ts"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"Resources": {
|
|
3
|
+
"QueueABC123": {
|
|
4
|
+
"Type": "AWS::SQS::Queue",
|
|
5
|
+
"Properties": { "VisibilityTimeout": 30 }
|
|
6
|
+
},
|
|
7
|
+
"HandlerRoleDEF456": {
|
|
8
|
+
"Type": "AWS::IAM::Role",
|
|
9
|
+
"Properties": { "AssumeRolePolicyDocument": { "Statement": [] } }
|
|
10
|
+
},
|
|
11
|
+
"HandlerGHI789": {
|
|
12
|
+
"Type": "AWS::Lambda::Function",
|
|
13
|
+
"Properties": {
|
|
14
|
+
"Role": { "Fn::GetAtt": ["HandlerRoleDEF456", "Arn"] },
|
|
15
|
+
"Environment": { "Variables": { "QUEUE_URL": { "Ref": "QueueABC123" } } }
|
|
16
|
+
},
|
|
17
|
+
"DependsOn": ["HandlerRoleDEF456"]
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version": "36.0.0", "artifacts": {}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"app": "npx ts-node bin/app.ts"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
not a template
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
AWSTemplateFormatVersion: "2010-09-09"
|
|
2
|
+
Parameters:
|
|
3
|
+
Env:
|
|
4
|
+
Type: String
|
|
5
|
+
Resources:
|
|
6
|
+
Assets:
|
|
7
|
+
Type: AWS::S3::Bucket
|
|
8
|
+
Properties:
|
|
9
|
+
BucketName: !Sub "assets-${Env}-${AWS::Region}"
|
|
10
|
+
WebRole:
|
|
11
|
+
Type: AWS::IAM::Role
|
|
12
|
+
Properties:
|
|
13
|
+
AssumeRolePolicyDocument:
|
|
14
|
+
Statement: []
|
|
15
|
+
WebPolicy:
|
|
16
|
+
Type: AWS::IAM::Policy
|
|
17
|
+
Properties:
|
|
18
|
+
PolicyName: web
|
|
19
|
+
Roles:
|
|
20
|
+
- !Ref WebRole
|
|
21
|
+
PolicyDocument:
|
|
22
|
+
Statement:
|
|
23
|
+
- Effect: Allow
|
|
24
|
+
Action: s3:GetObject
|
|
25
|
+
Resource: !Sub "${Assets.Arn}/*"
|
|
26
|
+
Db:
|
|
27
|
+
Type: AWS::RDS::DBInstance
|
|
28
|
+
DependsOn: WebRole
|
|
29
|
+
Properties:
|
|
30
|
+
DBInstanceClass: db.t3.micro
|
|
31
|
+
Tags:
|
|
32
|
+
- Key: bucket
|
|
33
|
+
Value: !GetAtt Assets.DomainName
|
|
34
|
+
Outputs:
|
|
35
|
+
Bucket:
|
|
36
|
+
Value: !Ref Assets
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cloudformation and cdk member kinds this lexicon supplies to a chant
|
|
3
|
+
* workspace (#3179): data in `workspace-kinds.json`, exported at
|
|
4
|
+
* `./workspace-kinds`.
|
|
5
|
+
*/
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { describe, expect, it } from "vitest";
|
|
8
|
+
import { describeWorkspaceKindConformance } from "@intentius/chant-test-utils";
|
|
9
|
+
import { checkMemberFile, loadMemberFileSchema, readPackageKinds } from "../../../packages/core/src/workspace/kinds";
|
|
10
|
+
|
|
11
|
+
const packageDir = join(import.meta.dirname, "..");
|
|
12
|
+
|
|
13
|
+
/** What each kind's own file must do: pass for a good one, fail for a bad one (#3672). */
|
|
14
|
+
function memberFileChecks(kind: string, good: string, bad: string): void {
|
|
15
|
+
describe(`${kind} member file`, () => {
|
|
16
|
+
const memberFile = readPackageKinds(packageDir).kinds.find((k) => k.name === kind)!.memberFile!;
|
|
17
|
+
it("names a schema that loads", () => {
|
|
18
|
+
expect(loadMemberFileSchema(memberFile, packageDir)).toBeUndefined();
|
|
19
|
+
});
|
|
20
|
+
it("passes a valid file", () => {
|
|
21
|
+
expect(checkMemberFile(memberFile, memberFile.path, good)).toEqual([]);
|
|
22
|
+
});
|
|
23
|
+
it("fails an invalid one", () => {
|
|
24
|
+
expect(checkMemberFile(memberFile, memberFile.path, bad).length).toBeGreaterThan(0);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const template = "AWSTemplateFormatVersion: '2010-09-09'\nResources:\n B:\n Type: AWS::S3::Bucket\n";
|
|
30
|
+
|
|
31
|
+
describeWorkspaceKindConformance({
|
|
32
|
+
packageDir,
|
|
33
|
+
scenarios: [
|
|
34
|
+
{ name: "a YAML template", kind: "cloudformation", files: { "stack.yaml": template }, claims: true },
|
|
35
|
+
{ name: "a JSON template with only Resources", kind: "cloudformation", files: { "stack.json": '{"Resources": {}}' }, claims: true },
|
|
36
|
+
{ name: "a YAML template using short-form tags", kind: "cloudformation", files: { "stack.yml": template + "Outputs:\n N:\n Value: !Ref B\n Export:\n Name: !Sub \"${AWS::StackName}-n\"\n" }, claims: true },
|
|
37
|
+
{ name: "a template one level down", kind: "cloudformation", files: { "infra/stack.yaml": template }, claims: false },
|
|
38
|
+
{ name: "a Kubernetes manifest", kind: "cloudformation", files: { "deploy.yaml": "apiVersion: apps/v1\nkind: Deployment\n" }, claims: false },
|
|
39
|
+
{ name: "a package.json", kind: "cloudformation", files: { "package.json": '{"name": "x"}' }, claims: false },
|
|
40
|
+
{ name: "a CDK app", kind: "cdk", files: { "cdk.json": '{"app": "npx ts-node bin/app.ts"}', "package.json": "{}" }, claims: true },
|
|
41
|
+
{ name: "a CDK app one level down", kind: "cdk", files: { "infra/cdk.json": "{}" }, claims: false },
|
|
42
|
+
// cloudformation (320) outranks cdk (315) when both claim a directory.
|
|
43
|
+
{ name: "a CDK app with a template beside it", kind: "cloudformation", files: { "cdk.json": '{"app": "x"}', "stack.yaml": template }, claims: true },
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
memberFileChecks("cdk", '{"app": "npx ts-node bin/app.ts"}', '{"context": {}}');
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cloudformation member kind's graph block (#3179): the reader project the
|
|
3
|
+
* kind's graph config describes, run through chant graph over a fixture
|
|
4
|
+
* template. Nothing here calls AWS.
|
|
5
|
+
*/
|
|
6
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import type { GraphIR } from "@intentius/chant/graph-ir";
|
|
12
|
+
import { runGraph } from "@intentius/chant/cli/handlers/graph";
|
|
13
|
+
import { readPackageKinds, substituteGraphConfig } from "../../../packages/core/src/workspace/kinds";
|
|
14
|
+
import { awsPlugin } from "./plugin";
|
|
15
|
+
|
|
16
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const packageDir = join(here, "..");
|
|
18
|
+
const member = join(here, "testdata", "cfn-member");
|
|
19
|
+
|
|
20
|
+
const scratch: string[] = [];
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
vi.restoreAllMocks();
|
|
23
|
+
await Promise.all(scratch.splice(0).map((d) => rm(d, { recursive: true, force: true })));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
async function graphOf(dir: string, kind = "cloudformation"): Promise<{ ir: GraphIR; err: string[]; exit: number }> {
|
|
27
|
+
const graph = readPackageKinds(packageDir).kinds.find((k) => k.name === kind)!.graph!;
|
|
28
|
+
expect(graph.lexicon).toBe("aws");
|
|
29
|
+
const config = substituteGraphConfig(graph.config, { member: "net", dir, workspace: dirname(dir) });
|
|
30
|
+
const project = await mkdtemp(join(tmpdir(), "chant-cfn-member-"));
|
|
31
|
+
scratch.push(project);
|
|
32
|
+
await writeFile(join(project, "chant.config.json"), JSON.stringify({ lexicons: ["aws"], aws: config, graph: { lint: "report" } }));
|
|
33
|
+
const out: string[] = [];
|
|
34
|
+
const err: string[] = [];
|
|
35
|
+
vi.spyOn(console, "log").mockImplementation((line: unknown) => void out.push(String(line)));
|
|
36
|
+
vi.spyOn(console, "error").mockImplementation((line: unknown) => void err.push(String(line)));
|
|
37
|
+
const cwd = process.cwd();
|
|
38
|
+
process.chdir(project);
|
|
39
|
+
try {
|
|
40
|
+
const exit = await runGraph({ args: { command: "graph", path: ".", format: "ir" } as never, plugins: [awsPlugin], serializers: [] });
|
|
41
|
+
return { ir: exit === 0 ? (JSON.parse(out.join("\n")) as GraphIR) : { nodes: [], edges: [], groups: {} }, err, exit };
|
|
42
|
+
} finally {
|
|
43
|
+
process.chdir(cwd);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("cloudformation member graph block (#3179)", () => {
|
|
48
|
+
test("a template's resources are nodes", async () => {
|
|
49
|
+
const { ir, err, exit } = await graphOf(member);
|
|
50
|
+
expect(exit, err.join("\n")).toBe(0);
|
|
51
|
+
expect(ir.nodes.map((n) => [n.id, n.kind]).sort()).toEqual([
|
|
52
|
+
["net/Assets", "AWS::S3::Bucket"],
|
|
53
|
+
["net/Db", "AWS::RDS::DBInstance"],
|
|
54
|
+
["net/WebPolicy", "AWS::IAM::Policy"],
|
|
55
|
+
["net/WebRole", "AWS::IAM::Role"],
|
|
56
|
+
]);
|
|
57
|
+
expect(ir.meta?.awsMember).toEqual({ templates: ["stack.yaml"], objects: 4 });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("Ref, GetAtt, Sub and DependsOn are edges; parameters and pseudo parameters are not", async () => {
|
|
61
|
+
const { ir } = await graphOf(member);
|
|
62
|
+
const edges = ir.edges.map((e) => `${e.from} -> ${e.to} via ${e.viaAttr}${e.toAttr ? ` (${e.toAttr})` : ""}`).sort();
|
|
63
|
+
expect(edges).toEqual([
|
|
64
|
+
"net/Db -> net/Assets via Tags (DomainName)",
|
|
65
|
+
"net/Db -> net/WebRole via DependsOn",
|
|
66
|
+
"net/WebPolicy -> net/Assets via PolicyDocument (Arn)",
|
|
67
|
+
"net/WebPolicy -> net/WebRole via Roles",
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("cdk member graph block (#3179)", () => {
|
|
73
|
+
test("the synthesized assembly's templates are read; the app is not run", async () => {
|
|
74
|
+
const { ir, err, exit } = await graphOf(join(here, "testdata", "cdk-member"), "cdk");
|
|
75
|
+
expect(exit, err.join("\n")).toBe(0);
|
|
76
|
+
expect(ir.nodes.map((n) => n.id).sort()).toEqual(["net/HandlerGHI789", "net/HandlerRoleDEF456", "net/QueueABC123"]);
|
|
77
|
+
expect(ir.edges.map((e) => `${e.from} -> ${e.to} via ${e.viaAttr}`).sort()).toEqual([
|
|
78
|
+
"net/HandlerGHI789 -> net/HandlerRoleDEF456 via DependsOn",
|
|
79
|
+
"net/HandlerGHI789 -> net/HandlerRoleDEF456 via Role",
|
|
80
|
+
"net/HandlerGHI789 -> net/QueueABC123 via Environment",
|
|
81
|
+
]);
|
|
82
|
+
expect(ir.meta?.awsMember).toEqual({ templates: ["AppStack.template.json"], objects: 3, synthesized: true });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("an app with no assembly says synthesized: false and what to run", async () => {
|
|
86
|
+
const { ir, err, exit } = await graphOf(join(here, "testdata", "cdk-unsynthesized"), "cdk");
|
|
87
|
+
expect(exit, err.join("\n")).toBe(0);
|
|
88
|
+
expect(ir.nodes).toEqual([]);
|
|
89
|
+
const report = ir.meta?.awsMember as { synthesized: boolean; reason: string };
|
|
90
|
+
expect(report.synthesized).toBe(false);
|
|
91
|
+
expect(report.reason).toMatch(/cdk synth/);
|
|
92
|
+
expect(err.join("\n")).toMatch(/was not read/);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a cloudformation workspace member into the graph (#3179).
|
|
3
|
+
*
|
|
4
|
+
* `chant workspace graph` writes a reader project for a member of kind
|
|
5
|
+
* `cloudformation`, with `aws.member` in its config (see `workspace-kinds.json`).
|
|
6
|
+
* The plugin's `buildRoots` hook calls {@link readCloudFormationMember}: each
|
|
7
|
+
* CloudFormation template directly in the member's directory is parsed with the
|
|
8
|
+
* import parser's YAML schema (short-form tags included), and every resource
|
|
9
|
+
* becomes a node keyed `<label>/<LogicalId>`.
|
|
10
|
+
*
|
|
11
|
+
* Edges come from what the template says: `Ref`, `Fn::GetAtt` and the `${...}`
|
|
12
|
+
* references in `Fn::Sub`, wherever they sit in a resource's properties, plus
|
|
13
|
+
* `DependsOn`. Parameters, pseudo parameters and names that are not a resource
|
|
14
|
+
* of the same template (`AWS::Region`, an `Fn::ImportValue`) draw no edge.
|
|
15
|
+
* Nothing runs and no AWS call is made.
|
|
16
|
+
*
|
|
17
|
+
* A `cdk` member is read the same way from the cloud assembly its app already
|
|
18
|
+
* synthesized (`cdk.out`, or the `output` its cdk.json names): the assembly's
|
|
19
|
+
* `*.template.json` files are CloudFormation templates, and core's CDK
|
|
20
|
+
* assembly reader says whether the directory is one. chant does not run the
|
|
21
|
+
* app. With no assembly there is nothing to read, and the report says
|
|
22
|
+
* `synthesized: false` with the reason.
|
|
23
|
+
*/
|
|
24
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
25
|
+
import { basename, extname, join } from "node:path";
|
|
26
|
+
import yaml from "js-yaml";
|
|
27
|
+
import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
|
|
28
|
+
import type { EntityReference } from "@intentius/chant/graph-ir";
|
|
29
|
+
import { isCloudAssembly } from "@intentius/chant/cdk/assembly";
|
|
30
|
+
import { CF_SCHEMA } from "./import/parser";
|
|
31
|
+
|
|
32
|
+
export interface AwsMemberConfig {
|
|
33
|
+
/** `cloudformation` (the default) or `cdk`. */
|
|
34
|
+
kind?: "cloudformation" | "cdk";
|
|
35
|
+
/** The member's directory, absolute. */
|
|
36
|
+
dir: string;
|
|
37
|
+
/** Names the entities (`<label>/Bucket`): the member's name. */
|
|
38
|
+
label?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What the read found, carried on the graph's `meta.awsMember`. */
|
|
42
|
+
export interface AwsMemberReport {
|
|
43
|
+
/** The template files read, relative to the member's directory (or its assembly's). */
|
|
44
|
+
templates: string[];
|
|
45
|
+
objects: number;
|
|
46
|
+
/** CDK only: false when the app has no synthesized assembly to read. */
|
|
47
|
+
synthesized?: boolean;
|
|
48
|
+
/** Why nothing was read; set with `synthesized: false`. */
|
|
49
|
+
reason?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type Template = { Resources?: Record<string, { Type?: unknown; Properties?: Record<string, unknown>; DependsOn?: unknown }> };
|
|
53
|
+
type Name = { id: string; attr?: string };
|
|
54
|
+
|
|
55
|
+
function parseTemplate(text: string): Template | undefined {
|
|
56
|
+
let parsed: unknown;
|
|
57
|
+
try {
|
|
58
|
+
parsed = JSON.parse(text);
|
|
59
|
+
} catch {
|
|
60
|
+
try {
|
|
61
|
+
parsed = yaml.load(text, { schema: CF_SCHEMA });
|
|
62
|
+
} catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
67
|
+
const t = parsed as Record<string, unknown>;
|
|
68
|
+
// The probe's two claims: a format version, or a Resources section.
|
|
69
|
+
const resources = t.Resources;
|
|
70
|
+
if (t.AWSTemplateFormatVersion === undefined && (!resources || typeof resources !== "object")) return undefined;
|
|
71
|
+
return t as Template;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Logical ids named by `${X}` and `${X.Attr}` in an `Fn::Sub` string, with the attribute when one is named. */
|
|
75
|
+
function subReferences(text: string): Name[] {
|
|
76
|
+
const out: Name[] = [];
|
|
77
|
+
for (const m of text.matchAll(/\$\{([^}!][^}]*)\}/g)) {
|
|
78
|
+
const [id, ...rest] = m[1]!.trim().split(".");
|
|
79
|
+
if (id) out.push({ id, ...(rest.length ? { attr: rest.join(".") } : {}) });
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Every name a value reaches through Ref, Fn::GetAtt or Fn::Sub. */
|
|
85
|
+
function collectNames(value: unknown, out: Name[]): void {
|
|
86
|
+
if (Array.isArray(value)) {
|
|
87
|
+
for (const v of value) collectNames(v, out);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (!value || typeof value !== "object") return;
|
|
91
|
+
for (const [k, v] of Object.entries(value)) {
|
|
92
|
+
if (k === "Ref" && typeof v === "string") {
|
|
93
|
+
out.push({ id: v });
|
|
94
|
+
} else if (k === "Fn::GetAtt") {
|
|
95
|
+
const [id, attr] = Array.isArray(v) ? v : typeof v === "string" ? [v.slice(0, v.indexOf(".")), v.slice(v.indexOf(".") + 1)] : [];
|
|
96
|
+
if (typeof id === "string" && id) out.push({ id, ...(typeof attr === "string" && attr ? { attr } : {}) });
|
|
97
|
+
} else if (k === "Fn::Sub") {
|
|
98
|
+
const text = Array.isArray(v) ? v[0] : v;
|
|
99
|
+
if (typeof text === "string") out.push(...subReferences(text));
|
|
100
|
+
if (Array.isArray(v)) collectNames(v[1], out);
|
|
101
|
+
} else {
|
|
102
|
+
collectNames(v, out);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The directory a CDK app synthesizes into: cdk.json's `output`, else `cdk.out`. */
|
|
108
|
+
function cdkOutput(dir: string): string {
|
|
109
|
+
try {
|
|
110
|
+
const output = (JSON.parse(readFileSync(join(dir, "cdk.json"), "utf-8")) as { output?: unknown }).output;
|
|
111
|
+
if (typeof output === "string" && output) return output;
|
|
112
|
+
} catch {
|
|
113
|
+
/* no readable cdk.json: the default */
|
|
114
|
+
}
|
|
115
|
+
return "cdk.out";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function readCloudFormationMember(opts: { member: AwsMemberConfig }): {
|
|
119
|
+
entities: Map<string, Declarable>;
|
|
120
|
+
warnings: string[];
|
|
121
|
+
report: AwsMemberReport;
|
|
122
|
+
} {
|
|
123
|
+
const { member } = opts;
|
|
124
|
+
const label = member.label ?? basename(member.dir);
|
|
125
|
+
const warnings: string[] = [];
|
|
126
|
+
const templates: Array<{ file: string; template: Template }> = [];
|
|
127
|
+
let source = member.dir;
|
|
128
|
+
if (member.kind === "cdk") {
|
|
129
|
+
source = join(member.dir, cdkOutput(member.dir));
|
|
130
|
+
if (!isCloudAssembly(source)) {
|
|
131
|
+
const reason = `no synthesized cloud assembly at ${source}; run \`cdk synth\` in the app, and chant will read the templates it writes`;
|
|
132
|
+
warnings.push(`cdk member "${label}" was not read: ${reason}`);
|
|
133
|
+
return { entities: new Map(), warnings, report: { templates: [], objects: 0, synthesized: false, reason } };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const pattern = member.kind === "cdk" ? /\.template\.json$/ : /\.(ya?ml|json|template)$/;
|
|
137
|
+
for (const file of readdirSync(source).filter((f) => pattern.test(f)).sort()) {
|
|
138
|
+
const template = parseTemplate(readFileSync(join(source, file), "utf-8"));
|
|
139
|
+
if (template) templates.push({ file, template });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const entities = new Map<string, Declarable>();
|
|
143
|
+
for (const { file, template } of templates) {
|
|
144
|
+
// One template per member is the usual case and keeps ids short; two share a directory, so the file name qualifies them.
|
|
145
|
+
const prefix = templates.length === 1 ? label : `${label}/${basename(file, extname(file)).replace(/\.template$/, "")}`;
|
|
146
|
+
const resources = Object.entries(template.Resources ?? {}).filter(([, r]) => typeof r?.Type === "string");
|
|
147
|
+
const keyOf = new Map(resources.map(([id]) => [id, `${prefix}/${id}`]));
|
|
148
|
+
for (const [id, resource] of resources) {
|
|
149
|
+
const references = new Map<string, EntityReference>();
|
|
150
|
+
const add = (name: Name, via: string) => {
|
|
151
|
+
const to = keyOf.get(name.id);
|
|
152
|
+
if (!to || to === keyOf.get(id)) return;
|
|
153
|
+
references.set(`${to}|${via}|${name.attr ?? ""}`, { to, viaAttr: via, ...(name.attr ? { toAttr: name.attr } : {}) });
|
|
154
|
+
};
|
|
155
|
+
for (const [prop, value] of Object.entries(resource.Properties ?? {})) {
|
|
156
|
+
const names: Name[] = [];
|
|
157
|
+
collectNames(value, names);
|
|
158
|
+
for (const n of names) add(n, prop);
|
|
159
|
+
}
|
|
160
|
+
const dependsOn = resource.DependsOn;
|
|
161
|
+
for (const d of Array.isArray(dependsOn) ? dependsOn : dependsOn === undefined ? [] : [dependsOn]) {
|
|
162
|
+
if (typeof d === "string") add({ id: d }, "DependsOn");
|
|
163
|
+
}
|
|
164
|
+
const entity = {
|
|
165
|
+
lexicon: "aws",
|
|
166
|
+
entityType: resource.Type as string,
|
|
167
|
+
kind: "resource",
|
|
168
|
+
props: { ...(resource.Properties ?? {}) },
|
|
169
|
+
references: [...references.values()],
|
|
170
|
+
[DECLARABLE_MARKER]: true,
|
|
171
|
+
} as unknown as Declarable;
|
|
172
|
+
entities.set(keyOf.get(id)!, entity);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (templates.length === 0) warnings.push(`${member.kind ?? "cloudformation"} member "${label}" has no CloudFormation template directly in ${source}`);
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
entities,
|
|
179
|
+
warnings,
|
|
180
|
+
report: { templates: templates.map((t) => t.file), objects: entities.size, ...(member.kind === "cdk" ? { synthesized: true } : {}) },
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let lastReport: AwsMemberReport | undefined;
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The report of the read in this process. `graphMeta` receives only the
|
|
188
|
+
* entities, so the hook that read the member leaves its report here for the
|
|
189
|
+
* hook that publishes it; `chant graph` runs the two in order in one process.
|
|
190
|
+
*/
|
|
191
|
+
export function setLastMemberReport(report: AwsMemberReport | undefined): void {
|
|
192
|
+
lastReport = report;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function lastMemberReport(): AwsMemberReport | undefined {
|
|
196
|
+
return lastReport;
|
|
197
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "Member kinds this lexicon supplies to a chant workspace (#3179). chant reads this file as data and never imports the lexicon. The cloudformation graph block (#3179) reads the template as data (src/workspace-member.ts). The cdk graph block reads the cloud assembly the app already synthesized (cdk.out, or the output cdk.json names) with the same template reader; chant never runs the app, and with no assembly the member says synthesized: false on meta.awsMember.",
|
|
3
|
+
"schema": 1,
|
|
4
|
+
"kinds": [
|
|
5
|
+
{
|
|
6
|
+
"name": "cloudformation",
|
|
7
|
+
"description": "a CloudFormation template: a YAML or JSON file with AWSTemplateFormatVersion or Resources at its top",
|
|
8
|
+
"precedence": 320,
|
|
9
|
+
"probe": {
|
|
10
|
+
"anyDocument": {
|
|
11
|
+
"in": [
|
|
12
|
+
"*.yaml",
|
|
13
|
+
"*.yml",
|
|
14
|
+
"*.json",
|
|
15
|
+
"*.template"
|
|
16
|
+
],
|
|
17
|
+
"match": [
|
|
18
|
+
{
|
|
19
|
+
"/AWSTemplateFormatVersion": [
|
|
20
|
+
"*"
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"/Resources": [
|
|
25
|
+
"*"
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"graph": {
|
|
32
|
+
"lexicon": "aws",
|
|
33
|
+
"config": {
|
|
34
|
+
"member": {
|
|
35
|
+
"dir": "{dir}",
|
|
36
|
+
"label": "{member}"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"name": "cdk",
|
|
43
|
+
"description": "an AWS CDK app, with a cdk.json in its directory",
|
|
44
|
+
"precedence": 315,
|
|
45
|
+
"probe": {
|
|
46
|
+
"anyFile": [
|
|
47
|
+
"cdk.json"
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
"memberFile": {
|
|
51
|
+
"path": "cdk.json",
|
|
52
|
+
"schema": "schemas/cdk.schema.json"
|
|
53
|
+
},
|
|
54
|
+
"graph": {
|
|
55
|
+
"lexicon": "aws",
|
|
56
|
+
"config": {
|
|
57
|
+
"member": {
|
|
58
|
+
"kind": "cdk",
|
|
59
|
+
"dir": "{dir}",
|
|
60
|
+
"label": "{member}"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
]
|
|
66
|
+
}
|