@intentius/chant-lexicon-aws 0.0.8 → 0.0.9
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/integrity.json +25 -10
- package/dist/manifest.json +1 -1
- package/dist/meta.json +5743 -896
- package/dist/rules/cf-refs.ts +99 -0
- package/dist/rules/ext001.ts +30 -21
- package/dist/rules/hardcoded-region.ts +1 -0
- package/dist/rules/iam-wildcard.ts +1 -0
- package/dist/rules/s3-encryption.ts +1 -0
- package/dist/rules/waw016.ts +86 -0
- package/dist/rules/waw017.ts +53 -0
- package/dist/rules/waw018.ts +71 -0
- package/dist/rules/waw019.ts +82 -0
- package/dist/rules/waw020.ts +64 -0
- package/dist/rules/waw021.ts +53 -0
- package/dist/rules/waw022.ts +43 -0
- package/dist/rules/waw023.ts +47 -0
- package/dist/rules/waw024.ts +54 -0
- package/dist/rules/waw025.ts +43 -0
- package/dist/rules/waw026.ts +46 -0
- package/dist/rules/waw027.ts +50 -0
- package/dist/rules/waw028.ts +47 -0
- package/dist/rules/waw029.ts +62 -0
- package/dist/rules/waw030.ts +246 -0
- package/dist/skills/chant-aws.md +388 -30
- package/dist/types/index.d.ts +1552 -1528
- package/package.json +2 -2
- package/src/actions/actions.test.ts +75 -0
- package/src/actions/dynamodb.ts +36 -0
- package/src/actions/ecr.ts +9 -0
- package/src/actions/ecs.ts +5 -0
- package/src/actions/iam.ts +3 -0
- package/src/actions/index.ts +9 -0
- package/src/actions/lambda.ts +11 -0
- package/src/actions/logs.ts +4 -0
- package/src/actions/s3.ts +34 -0
- package/src/actions/sns.ts +5 -0
- package/src/actions/sqs.ts +15 -0
- package/src/codegen/__snapshots__/snapshot.test.ts.snap +2 -2
- package/src/codegen/docs-links.test.ts +143 -0
- package/src/codegen/docs.ts +247 -132
- package/src/codegen/generate-lexicon.ts +8 -0
- package/src/codegen/generate-typescript.ts +25 -1
- package/src/composites/composites.test.ts +442 -0
- package/src/composites/fargate-alb.ts +253 -0
- package/src/composites/index.ts +20 -0
- package/src/composites/lambda-api.ts +20 -0
- package/src/composites/lambda-dynamodb.ts +64 -0
- package/src/composites/lambda-eventbridge.ts +36 -0
- package/src/composites/lambda-function.ts +76 -0
- package/src/composites/lambda-s3.ts +72 -0
- package/src/composites/lambda-sns.ts +30 -0
- package/src/composites/lambda-sqs.ts +44 -0
- package/src/composites/scheduled-lambda.ts +37 -0
- package/src/composites/vpc-default.ts +148 -0
- package/src/default-tags.test.ts +38 -0
- package/src/default-tags.ts +77 -0
- package/src/generated/index.d.ts +1552 -1528
- package/src/generated/lexicon-aws.json +5743 -896
- package/src/import/roundtrip-fixtures.test.ts +1 -1
- package/src/index.ts +21 -0
- package/src/integration.test.ts +71 -0
- package/src/intrinsics.ts +24 -13
- package/src/lint/post-synth/cf-refs.ts +99 -0
- package/src/lint/post-synth/ext001.test.ts +214 -31
- package/src/lint/post-synth/ext001.ts +30 -21
- package/src/lint/post-synth/waw013.test.ts +120 -0
- package/src/lint/post-synth/waw014.test.ts +121 -0
- package/src/lint/post-synth/waw015.test.ts +147 -0
- package/src/lint/post-synth/waw016.test.ts +141 -0
- package/src/lint/post-synth/waw016.ts +86 -0
- package/src/lint/post-synth/waw017.test.ts +130 -0
- package/src/lint/post-synth/waw017.ts +53 -0
- package/src/lint/post-synth/waw018.test.ts +109 -0
- package/src/lint/post-synth/waw018.ts +71 -0
- package/src/lint/post-synth/waw019.test.ts +138 -0
- package/src/lint/post-synth/waw019.ts +82 -0
- package/src/lint/post-synth/waw020.test.ts +125 -0
- package/src/lint/post-synth/waw020.ts +64 -0
- package/src/lint/post-synth/waw021.test.ts +81 -0
- package/src/lint/post-synth/waw021.ts +53 -0
- package/src/lint/post-synth/waw022.test.ts +54 -0
- package/src/lint/post-synth/waw022.ts +43 -0
- package/src/lint/post-synth/waw023.test.ts +53 -0
- package/src/lint/post-synth/waw023.ts +47 -0
- package/src/lint/post-synth/waw024.test.ts +64 -0
- package/src/lint/post-synth/waw024.ts +54 -0
- package/src/lint/post-synth/waw025.test.ts +42 -0
- package/src/lint/post-synth/waw025.ts +43 -0
- package/src/lint/post-synth/waw026.test.ts +54 -0
- package/src/lint/post-synth/waw026.ts +46 -0
- package/src/lint/post-synth/waw027.test.ts +63 -0
- package/src/lint/post-synth/waw027.ts +50 -0
- package/src/lint/post-synth/waw028.test.ts +68 -0
- package/src/lint/post-synth/waw028.ts +47 -0
- package/src/lint/post-synth/waw029.test.ts +179 -0
- package/src/lint/post-synth/waw029.ts +62 -0
- package/src/lint/post-synth/waw030.test.ts +800 -0
- package/src/lint/post-synth/waw030.ts +246 -0
- package/src/lint/rules/hardcoded-region.ts +1 -0
- package/src/lint/rules/iam-wildcard.ts +1 -0
- package/src/lint/rules/s3-encryption.ts +1 -0
- package/src/lsp/hover.ts +15 -0
- package/src/nested-stack-integration.test.ts +100 -0
- package/src/nested-stack.ts +1 -1
- package/src/plugin.ts +468 -36
- package/src/serializer.test.ts +330 -2
- package/src/serializer.ts +62 -1
- package/src/spec/fetch.ts +10 -0
- package/src/spec/parse.test.ts +141 -0
- package/src/spec/parse.ts +40 -0
- package/src/taggable.ts +44 -0
- package/src/testdata/nested-stacks/app.ts +26 -0
- package/src/testdata/nested-stacks/network/outputs.ts +17 -0
- package/src/testdata/nested-stacks/network/security.ts +17 -0
- package/src/testdata/nested-stacks/network/vpc.ts +54 -0
|
@@ -5,7 +5,7 @@ import { CFParser } from "./parser";
|
|
|
5
5
|
import { CFGenerator } from "./generator";
|
|
6
6
|
import { build } from "@intentius/chant/build";
|
|
7
7
|
import { awsSerializer } from "../serializer";
|
|
8
|
-
// Dynamic import to get all export keys for validation (not a runtime
|
|
8
|
+
// Dynamic import to get all export keys for validation (not a runtime re-export)
|
|
9
9
|
const awsLexicon = await import("../index");
|
|
10
10
|
|
|
11
11
|
const parser = new CFParser();
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// Parameter
|
|
2
2
|
export { Parameter } from "./parameter";
|
|
3
3
|
|
|
4
|
+
// Default Tags
|
|
5
|
+
export { defaultTags, isDefaultTags, DEFAULT_TAGS_MARKER } from "./default-tags";
|
|
6
|
+
export type { DefaultTags, TagEntry } from "./default-tags";
|
|
7
|
+
|
|
4
8
|
// Serializer
|
|
5
9
|
export { awsSerializer } from "./serializer";
|
|
6
10
|
|
|
@@ -64,6 +68,23 @@ export type { CFNSchema, SchemaProperty, SchemaDefinition } from "./spec/fetch";
|
|
|
64
68
|
export { parseCFNSchema, cfnShortName, cfnServiceName } from "./spec/parse";
|
|
65
69
|
export type { SchemaParseResult, ParsedResource, ParsedProperty, ParsedAttribute, ParsedPropertyType, ParsedEnum, PropertyConstraints } from "./spec/parse";
|
|
66
70
|
|
|
71
|
+
// Action constants
|
|
72
|
+
export { S3Actions, LambdaActions, DynamoDBActions, SQSActions, SNSActions, IAMActions, ECRActions, LogsActions, ECSActions } from "./actions/index";
|
|
73
|
+
|
|
74
|
+
// Built-in composites
|
|
75
|
+
export {
|
|
76
|
+
LambdaFunction, LambdaNode, LambdaPython, NodeLambda, PythonLambda,
|
|
77
|
+
LambdaApi,
|
|
78
|
+
LambdaScheduled, ScheduledLambda,
|
|
79
|
+
LambdaSqs, LambdaEventBridge, LambdaDynamoDB, LambdaS3, LambdaSns,
|
|
80
|
+
VpcDefault, FargateAlb,
|
|
81
|
+
} from "./composites/index";
|
|
82
|
+
export type {
|
|
83
|
+
LambdaFunctionProps, LambdaApiProps, ScheduledLambdaProps,
|
|
84
|
+
LambdaSqsProps, LambdaEventBridgeProps, LambdaDynamoDBProps, LambdaS3Props, LambdaSnsProps,
|
|
85
|
+
VpcDefaultProps, FargateAlbProps,
|
|
86
|
+
} from "./composites/index";
|
|
87
|
+
|
|
67
88
|
// Code generation pipeline
|
|
68
89
|
export { generate, writeGeneratedFiles } from "./codegen/generate";
|
|
69
90
|
export { packageLexicon } from "./codegen/package";
|
package/src/integration.test.ts
CHANGED
|
@@ -100,6 +100,77 @@ describe("AWS Integration", () => {
|
|
|
100
100
|
});
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
describe("Resource-level attributes (second constructor arg)", () => {
|
|
104
|
+
test("DependsOn with Declarable reference in real generated class", () => {
|
|
105
|
+
const bucket = new (Bucket as any)({ BucketName: "data" });
|
|
106
|
+
const fn = new (Function as any)(
|
|
107
|
+
{
|
|
108
|
+
Runtime: "nodejs20.x",
|
|
109
|
+
Handler: "index.handler",
|
|
110
|
+
Code: { S3Bucket: "my-bucket", S3Key: "code.zip" },
|
|
111
|
+
Role: "arn:aws:iam::123456789012:role/role",
|
|
112
|
+
},
|
|
113
|
+
{ DependsOn: [bucket] },
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
expect(fn.attributes).toBeDefined();
|
|
117
|
+
expect(fn.attributes.DependsOn).toEqual([bucket]);
|
|
118
|
+
|
|
119
|
+
const entities = new Map<string, Declarable>();
|
|
120
|
+
entities.set("DataBucket", bucket);
|
|
121
|
+
entities.set("Handler", fn);
|
|
122
|
+
|
|
123
|
+
const output = awsSerializer.serialize(entities);
|
|
124
|
+
const template = JSON.parse(output);
|
|
125
|
+
expect(template.Resources.Handler.DependsOn).toBe("DataBucket");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("DeletionPolicy and Condition on real generated class", () => {
|
|
129
|
+
const bucket = new (Bucket as any)(
|
|
130
|
+
{ BucketName: "important-data" },
|
|
131
|
+
{ DeletionPolicy: "Retain", Condition: "CreateBucket" },
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const entities = new Map<string, Declarable>();
|
|
135
|
+
entities.set("DataBucket", bucket);
|
|
136
|
+
|
|
137
|
+
const output = awsSerializer.serialize(entities);
|
|
138
|
+
const template = JSON.parse(output);
|
|
139
|
+
|
|
140
|
+
expect(template.Resources.DataBucket.DeletionPolicy).toBe("Retain");
|
|
141
|
+
expect(template.Resources.DataBucket.Condition).toBe("CreateBucket");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("resource without attributes works unchanged", () => {
|
|
145
|
+
const bucket = new (Bucket as any)({ BucketName: "simple" });
|
|
146
|
+
expect(bucket.attributes).toEqual({});
|
|
147
|
+
|
|
148
|
+
const entities = new Map<string, Declarable>();
|
|
149
|
+
entities.set("SimpleBucket", bucket);
|
|
150
|
+
|
|
151
|
+
const output = awsSerializer.serialize(entities);
|
|
152
|
+
const template = JSON.parse(output);
|
|
153
|
+
expect(template.Resources.SimpleBucket.DeletionPolicy).toBeUndefined();
|
|
154
|
+
expect(template.Resources.SimpleBucket.Condition).toBeUndefined();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("Metadata with intrinsics resolves correctly", () => {
|
|
158
|
+
const bucket = new (Bucket as any)(
|
|
159
|
+
{ BucketName: "meta" },
|
|
160
|
+
{ Metadata: { DeployedWith: Sub`${AWS.StackName}-chant` } },
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const entities = new Map<string, Declarable>();
|
|
164
|
+
entities.set("MetaBucket", bucket);
|
|
165
|
+
|
|
166
|
+
const output = awsSerializer.serialize(entities);
|
|
167
|
+
const template = JSON.parse(output);
|
|
168
|
+
expect(template.Resources.MetaBucket.Metadata.DeployedWith).toEqual({
|
|
169
|
+
"Fn::Sub": "${AWS::StackName}-chant",
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
103
174
|
describe("AttrRefs", () => {
|
|
104
175
|
test("Bucket has expected AttrRefs", () => {
|
|
105
176
|
const bucket = new (Bucket as any)({});
|
package/src/intrinsics.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { INTRINSIC_MARKER, resolveIntrinsicValue, type Intrinsic } from "@intentius/chant/intrinsic";
|
|
2
2
|
import { buildInterpolatedString, defaultInterpolationSerializer } from "@intentius/chant/intrinsic-interpolation";
|
|
3
|
+
import { type Declarable } from "@intentius/chant/declarable";
|
|
4
|
+
import { getLogicalName } from "@intentius/chant/utils";
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Fn::Sub intrinsic function implementation
|
|
@@ -37,26 +39,32 @@ export function Sub(
|
|
|
37
39
|
|
|
38
40
|
/**
|
|
39
41
|
* Ref intrinsic function
|
|
40
|
-
* References a parameter or resource by logical name
|
|
42
|
+
* References a parameter or resource by logical name.
|
|
43
|
+
* Accepts either a string name or a Declarable entity (e.g. Parameter).
|
|
44
|
+
* When given a Declarable, the logical name is resolved at serialization time.
|
|
41
45
|
*/
|
|
42
46
|
export class RefIntrinsic implements Intrinsic {
|
|
43
47
|
readonly [INTRINSIC_MARKER] = true as const;
|
|
44
|
-
private
|
|
48
|
+
private target: string | Declarable;
|
|
45
49
|
|
|
46
|
-
constructor(
|
|
47
|
-
this.
|
|
50
|
+
constructor(target: string | Declarable) {
|
|
51
|
+
this.target = target;
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
toJSON(): { Ref: string } {
|
|
51
|
-
|
|
55
|
+
if (typeof this.target === "string") {
|
|
56
|
+
return { Ref: this.target };
|
|
57
|
+
}
|
|
58
|
+
return { Ref: getLogicalName(this.target) };
|
|
52
59
|
}
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
/**
|
|
56
|
-
* Create a Ref intrinsic
|
|
63
|
+
* Create a Ref intrinsic.
|
|
64
|
+
* Pass a string for direct parameter/resource names, or a Declarable (e.g. Parameter) for type-safe references.
|
|
57
65
|
*/
|
|
58
|
-
export function Ref(
|
|
59
|
-
return new RefIntrinsic(
|
|
66
|
+
export function Ref(target: string | Declarable): RefIntrinsic {
|
|
67
|
+
return new RefIntrinsic(target);
|
|
60
68
|
}
|
|
61
69
|
|
|
62
70
|
/**
|
|
@@ -147,22 +155,25 @@ export function Join(delimiter: string, values: unknown[]): JoinIntrinsic {
|
|
|
147
155
|
export class SelectIntrinsic implements Intrinsic {
|
|
148
156
|
readonly [INTRINSIC_MARKER] = true as const;
|
|
149
157
|
private index: number;
|
|
150
|
-
private values: unknown[];
|
|
158
|
+
private values: unknown[] | Intrinsic;
|
|
151
159
|
|
|
152
|
-
constructor(index: number, values: unknown[]) {
|
|
160
|
+
constructor(index: number, values: unknown[] | Intrinsic) {
|
|
153
161
|
this.index = index;
|
|
154
162
|
this.values = values;
|
|
155
163
|
}
|
|
156
164
|
|
|
157
|
-
toJSON(): { "Fn::Select": [string, unknown
|
|
158
|
-
|
|
165
|
+
toJSON(): { "Fn::Select": [string, unknown] } {
|
|
166
|
+
const resolvedValues = Array.isArray(this.values)
|
|
167
|
+
? this.values.map(resolveIntrinsicValue)
|
|
168
|
+
: (this.values as Intrinsic & { toJSON(): unknown }).toJSON();
|
|
169
|
+
return { "Fn::Select": [String(this.index), resolvedValues] };
|
|
159
170
|
}
|
|
160
171
|
}
|
|
161
172
|
|
|
162
173
|
/**
|
|
163
174
|
* Create a Select intrinsic
|
|
164
175
|
*/
|
|
165
|
-
export function Select(index: number, values: unknown[]): SelectIntrinsic {
|
|
176
|
+
export function Select(index: number, values: unknown[] | Intrinsic): SelectIntrinsic {
|
|
166
177
|
return new SelectIntrinsic(index, values);
|
|
167
178
|
}
|
|
168
179
|
|
|
@@ -50,6 +50,105 @@ export function findResourceRefs(value: unknown): Set<string> {
|
|
|
50
50
|
return refs;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Check if a value is a CloudFormation intrinsic function (Ref, Fn::*, etc.)
|
|
55
|
+
* that cannot be statically evaluated.
|
|
56
|
+
*/
|
|
57
|
+
export function isIntrinsic(value: unknown): boolean {
|
|
58
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
59
|
+
const obj = value as Record<string, unknown>;
|
|
60
|
+
return "Ref" in obj || Object.keys(obj).some((k) => k.startsWith("Fn::"));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Walk IAM policy statements from a resource's properties.
|
|
65
|
+
* Handles IAM::Policy, IAM::Role, and IAM::ManagedPolicy layouts.
|
|
66
|
+
*/
|
|
67
|
+
export function walkPolicyStatements(
|
|
68
|
+
resource: CFResource,
|
|
69
|
+
): Array<Record<string, unknown>> {
|
|
70
|
+
const statements: Array<Record<string, unknown>> = [];
|
|
71
|
+
const props = resource.Properties ?? {};
|
|
72
|
+
|
|
73
|
+
// PolicyDocument.Statement (IAM::Policy, IAM::ManagedPolicy)
|
|
74
|
+
collectStatements(props.PolicyDocument, statements);
|
|
75
|
+
|
|
76
|
+
// AssumeRolePolicyDocument.Statement (IAM::Role)
|
|
77
|
+
collectStatements(props.AssumeRolePolicyDocument, statements);
|
|
78
|
+
|
|
79
|
+
// Policies[].PolicyDocument.Statement (IAM::Role inline policies)
|
|
80
|
+
if (Array.isArray(props.Policies)) {
|
|
81
|
+
for (const policy of props.Policies) {
|
|
82
|
+
if (typeof policy === "object" && policy !== null) {
|
|
83
|
+
collectStatements((policy as Record<string, unknown>).PolicyDocument, statements);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return statements;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function collectStatements(
|
|
92
|
+
policyDoc: unknown,
|
|
93
|
+
out: Array<Record<string, unknown>>,
|
|
94
|
+
): void {
|
|
95
|
+
if (typeof policyDoc !== "object" || policyDoc === null) return;
|
|
96
|
+
const doc = policyDoc as Record<string, unknown>;
|
|
97
|
+
if (Array.isArray(doc.Statement)) {
|
|
98
|
+
for (const stmt of doc.Statement) {
|
|
99
|
+
if (typeof stmt === "object" && stmt !== null) {
|
|
100
|
+
out.push(stmt as Record<string, unknown>);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Normalize security group ingress rules from inline SecurityGroupIngress
|
|
108
|
+
* property and standalone SecurityGroupIngress resources.
|
|
109
|
+
*/
|
|
110
|
+
export function getSecurityGroupIngress(
|
|
111
|
+
resource: CFResource,
|
|
112
|
+
): Array<Record<string, unknown>> {
|
|
113
|
+
const rules: Array<Record<string, unknown>> = [];
|
|
114
|
+
const props = resource.Properties ?? {};
|
|
115
|
+
|
|
116
|
+
if (Array.isArray(props.SecurityGroupIngress)) {
|
|
117
|
+
for (const rule of props.SecurityGroupIngress) {
|
|
118
|
+
if (typeof rule === "object" && rule !== null) {
|
|
119
|
+
rules.push(rule as Record<string, unknown>);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return rules;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Check if a port range [fromPort, toPort] contains any of the sensitive ports.
|
|
129
|
+
*/
|
|
130
|
+
export function portRangeContainsSensitive(
|
|
131
|
+
fromPort: unknown,
|
|
132
|
+
toPort: unknown,
|
|
133
|
+
sensitivePorts: number[],
|
|
134
|
+
): boolean {
|
|
135
|
+
// Missing ports means all ports
|
|
136
|
+
if (fromPort === undefined && toPort === undefined) return true;
|
|
137
|
+
|
|
138
|
+
const from = typeof fromPort === "number" ? fromPort : -1;
|
|
139
|
+
const to = typeof toPort === "number" ? toPort : -1;
|
|
140
|
+
|
|
141
|
+
// If either is an intrinsic, we can't statically verify
|
|
142
|
+
if (isIntrinsic(fromPort) || isIntrinsic(toPort)) return false;
|
|
143
|
+
|
|
144
|
+
if (from === -1 && to === -1) return true;
|
|
145
|
+
|
|
146
|
+
for (const port of sensitivePorts) {
|
|
147
|
+
if (from <= port && port <= to) return true;
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
53
152
|
function walkValue(value: unknown, refs: Set<string>): void {
|
|
54
153
|
if (value === null || value === undefined) return;
|
|
55
154
|
if (typeof value !== "object") return;
|
|
@@ -1,68 +1,251 @@
|
|
|
1
1
|
import { describe, test, expect } from "bun:test";
|
|
2
|
-
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
2
|
import { createPostSynthContext } from "@intentius/chant-test-utils";
|
|
4
|
-
import { ext001 } from "./ext001";
|
|
3
|
+
import { ext001, checkExtensionConstraints, type ExtensionConstraint } from "./ext001";
|
|
5
4
|
|
|
6
5
|
function makeCtx(template: object) {
|
|
7
6
|
return createPostSynthContext({ aws: template });
|
|
8
7
|
}
|
|
9
8
|
|
|
9
|
+
/** Synthetic constraint map — no disk dependency. */
|
|
10
|
+
function fakeConstraints(): Map<string, ExtensionConstraint[]> {
|
|
11
|
+
return new Map([
|
|
12
|
+
["AWS::EC2::Instance", [
|
|
13
|
+
{
|
|
14
|
+
name: "SubnetRequired",
|
|
15
|
+
type: "if_then",
|
|
16
|
+
condition: { required: ["InstanceType"] },
|
|
17
|
+
requirement: { required: ["SubnetId"] },
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: "SpotExcludesPlacement",
|
|
21
|
+
type: "dependent_excluded",
|
|
22
|
+
requirement: { InstanceMarketOptions: ["PlacementGroup"] },
|
|
23
|
+
},
|
|
24
|
+
]],
|
|
25
|
+
["AWS::ECS::Service", [
|
|
26
|
+
{
|
|
27
|
+
name: "LaunchTypeOrCapacity",
|
|
28
|
+
type: "required_xor",
|
|
29
|
+
requirement: ["LaunchType", "CapacityProviderStrategy"],
|
|
30
|
+
},
|
|
31
|
+
]],
|
|
32
|
+
["AWS::S3::Bucket", [
|
|
33
|
+
{
|
|
34
|
+
name: "EncryptionOrNotification",
|
|
35
|
+
type: "required_or",
|
|
36
|
+
requirement: ["BucketEncryption", "NotificationConfiguration"],
|
|
37
|
+
},
|
|
38
|
+
]],
|
|
39
|
+
]);
|
|
40
|
+
}
|
|
41
|
+
|
|
10
42
|
describe("EXT001: Extension Constraint Violation", () => {
|
|
11
43
|
test("check metadata", () => {
|
|
12
44
|
expect(ext001.id).toBe("EXT001");
|
|
13
45
|
expect(ext001.description).toContain("constraint");
|
|
14
46
|
});
|
|
15
47
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
48
|
+
// --- if_then ---
|
|
49
|
+
|
|
50
|
+
test("if_then: flags when condition matches and requirement missing", () => {
|
|
51
|
+
const ctx = makeCtx({
|
|
52
|
+
Resources: {
|
|
53
|
+
MyInstance: {
|
|
54
|
+
Type: "AWS::EC2::Instance",
|
|
55
|
+
Properties: { InstanceType: "t3.micro" },
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
const diags = checkExtensionConstraints(ctx, fakeConstraints());
|
|
60
|
+
expect(diags).toHaveLength(1);
|
|
61
|
+
expect(diags[0].checkId).toBe("EXT001");
|
|
62
|
+
expect(diags[0].severity).toBe("error");
|
|
63
|
+
expect(diags[0].message).toContain("SubnetRequired");
|
|
64
|
+
expect(diags[0].message).toContain("SubnetId");
|
|
65
|
+
expect(diags[0].entity).toBe("MyInstance");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("if_then: no diagnostic when requirement satisfied", () => {
|
|
69
|
+
const ctx = makeCtx({
|
|
70
|
+
Resources: {
|
|
71
|
+
MyInstance: {
|
|
72
|
+
Type: "AWS::EC2::Instance",
|
|
73
|
+
Properties: { InstanceType: "t3.micro", SubnetId: "subnet-123" },
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
const diags = checkExtensionConstraints(ctx, fakeConstraints());
|
|
78
|
+
// SubnetRequired satisfied; SpotExcludesPlacement not triggered (no InstanceMarketOptions)
|
|
79
|
+
expect(diags).toHaveLength(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("if_then: no diagnostic when condition does not match", () => {
|
|
83
|
+
const ctx = makeCtx({
|
|
84
|
+
Resources: {
|
|
85
|
+
MyInstance: {
|
|
86
|
+
Type: "AWS::EC2::Instance",
|
|
87
|
+
Properties: { ImageId: "ami-123" },
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
const diags = checkExtensionConstraints(ctx, fakeConstraints());
|
|
19
92
|
expect(diags).toHaveLength(0);
|
|
20
93
|
});
|
|
21
94
|
|
|
22
|
-
|
|
95
|
+
// --- dependent_excluded ---
|
|
96
|
+
|
|
97
|
+
test("dependent_excluded: flags when both present", () => {
|
|
98
|
+
const ctx = makeCtx({
|
|
99
|
+
Resources: {
|
|
100
|
+
MyInstance: {
|
|
101
|
+
Type: "AWS::EC2::Instance",
|
|
102
|
+
Properties: {
|
|
103
|
+
InstanceMarketOptions: { MarketType: "spot" },
|
|
104
|
+
PlacementGroup: "my-group",
|
|
105
|
+
SubnetId: "subnet-123",
|
|
106
|
+
InstanceType: "t3.micro",
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
const diags = checkExtensionConstraints(ctx, fakeConstraints());
|
|
112
|
+
const excluded = diags.filter((d) => d.message.includes("excludes"));
|
|
113
|
+
expect(excluded).toHaveLength(1);
|
|
114
|
+
expect(excluded[0].message).toContain("InstanceMarketOptions");
|
|
115
|
+
expect(excluded[0].message).toContain("PlacementGroup");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("dependent_excluded: no diagnostic when excluded prop absent", () => {
|
|
119
|
+
const ctx = makeCtx({
|
|
120
|
+
Resources: {
|
|
121
|
+
MyInstance: {
|
|
122
|
+
Type: "AWS::EC2::Instance",
|
|
123
|
+
Properties: {
|
|
124
|
+
InstanceMarketOptions: { MarketType: "spot" },
|
|
125
|
+
SubnetId: "subnet-123",
|
|
126
|
+
InstanceType: "t3.micro",
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
const diags = checkExtensionConstraints(ctx, fakeConstraints());
|
|
132
|
+
const excluded = diags.filter((d) => d.message.includes("excludes"));
|
|
133
|
+
expect(excluded).toHaveLength(0);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// --- required_xor ---
|
|
137
|
+
|
|
138
|
+
test("required_xor: flags when neither present", () => {
|
|
139
|
+
const ctx = makeCtx({
|
|
140
|
+
Resources: {
|
|
141
|
+
MySvc: {
|
|
142
|
+
Type: "AWS::ECS::Service",
|
|
143
|
+
Properties: { ServiceName: "my-svc" },
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
const diags = checkExtensionConstraints(ctx, fakeConstraints());
|
|
148
|
+
expect(diags).toHaveLength(1);
|
|
149
|
+
expect(diags[0].message).toContain("exactly one of");
|
|
150
|
+
expect(diags[0].message).toContain("found 0");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("required_xor: flags when both present", () => {
|
|
23
154
|
const ctx = makeCtx({
|
|
24
155
|
Resources: {
|
|
25
|
-
|
|
26
|
-
Type: "
|
|
27
|
-
Properties: {
|
|
156
|
+
MySvc: {
|
|
157
|
+
Type: "AWS::ECS::Service",
|
|
158
|
+
Properties: {
|
|
159
|
+
LaunchType: "FARGATE",
|
|
160
|
+
CapacityProviderStrategy: [{}],
|
|
161
|
+
},
|
|
28
162
|
},
|
|
29
163
|
},
|
|
30
164
|
});
|
|
31
|
-
const diags =
|
|
165
|
+
const diags = checkExtensionConstraints(ctx, fakeConstraints());
|
|
166
|
+
expect(diags).toHaveLength(1);
|
|
167
|
+
expect(diags[0].message).toContain("found 2");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("required_xor: no diagnostic when exactly one present", () => {
|
|
171
|
+
const ctx = makeCtx({
|
|
172
|
+
Resources: {
|
|
173
|
+
MySvc: {
|
|
174
|
+
Type: "AWS::ECS::Service",
|
|
175
|
+
Properties: { LaunchType: "FARGATE" },
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
const diags = checkExtensionConstraints(ctx, fakeConstraints());
|
|
32
180
|
expect(diags).toHaveLength(0);
|
|
33
181
|
});
|
|
34
182
|
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
183
|
+
// --- required_or ---
|
|
184
|
+
|
|
185
|
+
test("required_or: flags when none present", () => {
|
|
186
|
+
const ctx = makeCtx({
|
|
187
|
+
Resources: {
|
|
188
|
+
MyBucket: {
|
|
189
|
+
Type: "AWS::S3::Bucket",
|
|
190
|
+
Properties: { BucketName: "test" },
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
const diags = checkExtensionConstraints(ctx, fakeConstraints());
|
|
195
|
+
expect(diags).toHaveLength(1);
|
|
196
|
+
expect(diags[0].message).toContain("at least one of");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("required_or: no diagnostic when one present", () => {
|
|
41
200
|
const ctx = makeCtx({
|
|
42
201
|
Resources: {
|
|
43
202
|
MyBucket: {
|
|
44
203
|
Type: "AWS::S3::Bucket",
|
|
204
|
+
Properties: { BucketEncryption: {} },
|
|
45
205
|
},
|
|
46
206
|
},
|
|
47
207
|
});
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
208
|
+
const diags = checkExtensionConstraints(ctx, fakeConstraints());
|
|
209
|
+
expect(diags).toHaveLength(0);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// --- Edge cases ---
|
|
213
|
+
|
|
214
|
+
test("no diagnostics for resource type not in constraints", () => {
|
|
215
|
+
const ctx = makeCtx({
|
|
216
|
+
Resources: {
|
|
217
|
+
MyRole: {
|
|
218
|
+
Type: "AWS::IAM::Role",
|
|
219
|
+
Properties: { RoleName: "test" },
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
const diags = checkExtensionConstraints(ctx, fakeConstraints());
|
|
224
|
+
expect(diags).toHaveLength(0);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("no diagnostics on empty template", () => {
|
|
228
|
+
const ctx = makeCtx({ Resources: {} });
|
|
229
|
+
const diags = checkExtensionConstraints(ctx, fakeConstraints());
|
|
230
|
+
expect(diags).toHaveLength(0);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("returns empty when constraint map is empty", () => {
|
|
234
|
+
const ctx = makeCtx({
|
|
235
|
+
Resources: {
|
|
236
|
+
MyInstance: {
|
|
237
|
+
Type: "AWS::EC2::Instance",
|
|
238
|
+
Properties: { InstanceType: "t3.micro" },
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
const diags = checkExtensionConstraints(ctx, new Map());
|
|
243
|
+
expect(diags).toHaveLength(0);
|
|
51
244
|
});
|
|
52
245
|
|
|
53
246
|
test("handles invalid JSON output gracefully", () => {
|
|
54
|
-
const ctx:
|
|
55
|
-
|
|
56
|
-
entities: new Map(),
|
|
57
|
-
buildResult: {
|
|
58
|
-
outputs: new Map([["aws", "not json"]]),
|
|
59
|
-
entities: new Map(),
|
|
60
|
-
warnings: [],
|
|
61
|
-
errors: [],
|
|
62
|
-
sourceFileCount: 0,
|
|
63
|
-
},
|
|
64
|
-
};
|
|
65
|
-
const diags = ext001.check(ctx);
|
|
247
|
+
const ctx = createPostSynthContext({ aws: "not json" as unknown as object });
|
|
248
|
+
const diags = checkExtensionConstraints(ctx, fakeConstraints());
|
|
66
249
|
expect(diags).toHaveLength(0);
|
|
67
250
|
});
|
|
68
251
|
});
|
|
@@ -16,7 +16,7 @@ import { join } from "path";
|
|
|
16
16
|
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
17
17
|
import { parseCFTemplate, type CFResource } from "./cf-refs";
|
|
18
18
|
|
|
19
|
-
interface ExtensionConstraint {
|
|
19
|
+
export interface ExtensionConstraint {
|
|
20
20
|
name: string;
|
|
21
21
|
type: "if_then" | "dependent_excluded" | "required_or" | "required_xor";
|
|
22
22
|
condition?: unknown;
|
|
@@ -25,7 +25,7 @@ interface ExtensionConstraint {
|
|
|
25
25
|
|
|
26
26
|
interface LexiconEntry {
|
|
27
27
|
kind: string;
|
|
28
|
-
|
|
28
|
+
resourceType: string;
|
|
29
29
|
constraints?: ExtensionConstraint[];
|
|
30
30
|
[key: string]: unknown;
|
|
31
31
|
}
|
|
@@ -43,8 +43,8 @@ function loadLexiconConstraints(): Map<string, ExtensionConstraint[]> {
|
|
|
43
43
|
const data = JSON.parse(content) as Record<string, LexiconEntry>;
|
|
44
44
|
|
|
45
45
|
for (const [_name, entry] of Object.entries(data)) {
|
|
46
|
-
if (entry.kind === "resource" && entry.
|
|
47
|
-
map.set(entry.
|
|
46
|
+
if (entry.kind === "resource" && entry.resourceType && entry.constraints && entry.constraints.length > 0) {
|
|
47
|
+
map.set(entry.resourceType, entry.constraints);
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
} catch {
|
|
@@ -193,28 +193,37 @@ function validateResource(
|
|
|
193
193
|
return diagnostics;
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
196
|
+
/**
|
|
197
|
+
* Core detection logic — exported for direct testing with synthetic data.
|
|
198
|
+
*/
|
|
199
|
+
export function checkExtensionConstraints(
|
|
200
|
+
ctx: PostSynthContext,
|
|
201
|
+
constraintMap: Map<string, ExtensionConstraint[]>,
|
|
202
|
+
): PostSynthDiagnostic[] {
|
|
203
|
+
if (constraintMap.size === 0) return [];
|
|
203
204
|
|
|
204
|
-
|
|
205
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
205
206
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
207
|
+
for (const [_lexicon, output] of ctx.outputs) {
|
|
208
|
+
const template = parseCFTemplate(output);
|
|
209
|
+
if (!template?.Resources) continue;
|
|
209
210
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
211
|
+
for (const [logicalId, resource] of Object.entries(template.Resources)) {
|
|
212
|
+
const constraints = constraintMap.get(resource.Type);
|
|
213
|
+
if (!constraints) continue;
|
|
213
214
|
|
|
214
|
-
|
|
215
|
-
}
|
|
215
|
+
diagnostics.push(...validateResource(logicalId, resource, constraints));
|
|
216
216
|
}
|
|
217
|
+
}
|
|
217
218
|
|
|
218
|
-
|
|
219
|
+
return diagnostics;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export const ext001: PostSynthCheck = {
|
|
223
|
+
id: "EXT001",
|
|
224
|
+
description: "Extension constraint violation — cross-property validation from cfn-lint extension schemas",
|
|
225
|
+
|
|
226
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
227
|
+
return checkExtensionConstraints(ctx, loadLexiconConstraints());
|
|
219
228
|
},
|
|
220
229
|
};
|