@intentius/chant-lexicon-aws 0.0.6 → 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 +9444 -4597
- package/dist/rules/cf-refs.ts +99 -0
- package/dist/rules/ext001.ts +32 -25
- package/dist/rules/hardcoded-region.ts +1 -0
- package/dist/rules/iam-wildcard.ts +1 -0
- package/dist/rules/s3-encryption.ts +3 -3
- 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 +430 -0
- package/dist/types/index.d.ts +58525 -58501
- 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 +20 -20
- package/src/codegen/docs-links.test.ts +143 -0
- package/src/codegen/docs.ts +294 -124
- package/src/codegen/generate-lexicon.ts +8 -0
- package/src/codegen/generate-typescript.ts +25 -1
- package/src/codegen/generate.ts +1 -13
- package/src/codegen/package.ts +2 -0
- package/src/codegen/typecheck.test.ts +1 -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 +58525 -58501
- package/src/generated/index.ts +1351 -1351
- package/src/generated/lexicon-aws.json +9444 -4597
- package/src/import/generator.test.ts +5 -5
- package/src/import/generator.ts +4 -4
- package/src/import/roundtrip-fixtures.test.ts +2 -1
- package/src/import/roundtrip.test.ts +5 -5
- package/src/index.ts +21 -0
- package/src/integration.test.ts +92 -21
- 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 +32 -25
- 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/rules.test.ts +8 -8
- package/src/lint/rules/s3-encryption.ts +3 -3
- package/src/lsp/completions.ts +2 -0
- package/src/lsp/hover.ts +17 -0
- package/src/nested-stack-integration.test.ts +100 -0
- package/src/nested-stack.ts +2 -2
- package/src/plugin.test.ts +13 -15
- package/src/plugin.ts +552 -114
- package/src/serializer.test.ts +370 -43
- package/src/serializer.ts +69 -17
- 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
- package/dist/skills/aws-cloudformation.md +0 -41
- package/src/codegen/rollback.test.ts +0 -80
- package/src/codegen/rollback.ts +0 -20
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAW030: Missing DependsOn for Known Patterns
|
|
3
|
+
*
|
|
4
|
+
* Detects resources that are likely missing a required DependsOn
|
|
5
|
+
* based on well-known CloudFormation ordering requirements:
|
|
6
|
+
*
|
|
7
|
+
* - ECS Service with LoadBalancers but no DependsOn on a Listener
|
|
8
|
+
* - EC2 Route with GatewayId but no DependsOn on VPCGatewayAttachment
|
|
9
|
+
* - API Gateway Deployment with no DependsOn on any Method
|
|
10
|
+
* - API Gateway V2 Deployment with no DependsOn on any Route
|
|
11
|
+
* - DynamoDB ScalableTarget with no DependsOn on the Table
|
|
12
|
+
* - ECS ScalableTarget with no DependsOn on the ECS Service
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
16
|
+
import { parseCFTemplate } from "./cf-refs";
|
|
17
|
+
|
|
18
|
+
export function checkMissingDependsOn(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
19
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
20
|
+
|
|
21
|
+
for (const [_lexicon, output] of ctx.outputs) {
|
|
22
|
+
const template = parseCFTemplate(output);
|
|
23
|
+
if (!template?.Resources) continue;
|
|
24
|
+
|
|
25
|
+
const resources = template.Resources;
|
|
26
|
+
|
|
27
|
+
// Collect logical IDs by type
|
|
28
|
+
const listenerIds: string[] = [];
|
|
29
|
+
const vpcGatewayAttachmentIds: string[] = [];
|
|
30
|
+
const methodIds: string[] = [];
|
|
31
|
+
const deploymentIds: string[] = [];
|
|
32
|
+
const routeV2Ids: string[] = [];
|
|
33
|
+
const deploymentV2Ids: string[] = [];
|
|
34
|
+
const dynamoTableIds: string[] = [];
|
|
35
|
+
const ecsServiceIds: string[] = [];
|
|
36
|
+
const scalableTargetEntries: { logicalId: string; namespace: string }[] = [];
|
|
37
|
+
|
|
38
|
+
for (const [logicalId, resource] of Object.entries(resources)) {
|
|
39
|
+
if (resource.Type === "AWS::ElasticLoadBalancingV2::Listener") {
|
|
40
|
+
listenerIds.push(logicalId);
|
|
41
|
+
}
|
|
42
|
+
if (resource.Type === "AWS::EC2::VPCGatewayAttachment") {
|
|
43
|
+
vpcGatewayAttachmentIds.push(logicalId);
|
|
44
|
+
}
|
|
45
|
+
if (resource.Type === "AWS::ApiGateway::Method") {
|
|
46
|
+
methodIds.push(logicalId);
|
|
47
|
+
}
|
|
48
|
+
if (resource.Type === "AWS::ApiGateway::Deployment") {
|
|
49
|
+
deploymentIds.push(logicalId);
|
|
50
|
+
}
|
|
51
|
+
if (resource.Type === "AWS::ApiGatewayV2::Route") {
|
|
52
|
+
routeV2Ids.push(logicalId);
|
|
53
|
+
}
|
|
54
|
+
if (resource.Type === "AWS::ApiGatewayV2::Deployment") {
|
|
55
|
+
deploymentV2Ids.push(logicalId);
|
|
56
|
+
}
|
|
57
|
+
if (resource.Type === "AWS::DynamoDB::Table") {
|
|
58
|
+
dynamoTableIds.push(logicalId);
|
|
59
|
+
}
|
|
60
|
+
if (resource.Type === "AWS::ECS::Service") {
|
|
61
|
+
ecsServiceIds.push(logicalId);
|
|
62
|
+
}
|
|
63
|
+
if (resource.Type === "AWS::ApplicationAutoScaling::ScalableTarget") {
|
|
64
|
+
const props = resource.Properties ?? {};
|
|
65
|
+
const ns = inferScalingNamespace(props);
|
|
66
|
+
if (ns) {
|
|
67
|
+
scalableTargetEntries.push({ logicalId, namespace: ns });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const [logicalId, resource] of Object.entries(resources)) {
|
|
73
|
+
// Pattern 1: ECS Service with LoadBalancers but no DependsOn on Listener
|
|
74
|
+
if (resource.Type === "AWS::ECS::Service" && listenerIds.length > 0) {
|
|
75
|
+
const props = resource.Properties ?? {};
|
|
76
|
+
if (props.LoadBalancers && Array.isArray(props.LoadBalancers) && props.LoadBalancers.length > 0) {
|
|
77
|
+
const deps = getDependsOnSet(resource);
|
|
78
|
+
const hasListenerDep = listenerIds.some((id) => deps.has(id));
|
|
79
|
+
if (!hasListenerDep) {
|
|
80
|
+
diagnostics.push({
|
|
81
|
+
checkId: "WAW030",
|
|
82
|
+
severity: "warning",
|
|
83
|
+
message: `ECS Service "${logicalId}" has LoadBalancers but no DependsOn on a Listener — the Service may fail to create if the Listener isn't ready`,
|
|
84
|
+
entity: logicalId,
|
|
85
|
+
lexicon: "aws",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Pattern 2: EC2 Route with GatewayId but no DependsOn on VPCGatewayAttachment
|
|
92
|
+
if (resource.Type === "AWS::EC2::Route" && vpcGatewayAttachmentIds.length > 0) {
|
|
93
|
+
const props = resource.Properties ?? {};
|
|
94
|
+
if (props.GatewayId) {
|
|
95
|
+
const deps = getDependsOnSet(resource);
|
|
96
|
+
const hasAttachmentDep = vpcGatewayAttachmentIds.some((id) => deps.has(id));
|
|
97
|
+
// Also check if any property refs point to the attachment
|
|
98
|
+
const propRefs = collectPropertyRefs(resource);
|
|
99
|
+
const hasAttachmentRef = vpcGatewayAttachmentIds.some((id) => propRefs.has(id));
|
|
100
|
+
if (!hasAttachmentDep && !hasAttachmentRef) {
|
|
101
|
+
diagnostics.push({
|
|
102
|
+
checkId: "WAW030",
|
|
103
|
+
severity: "warning",
|
|
104
|
+
message: `Route "${logicalId}" uses a Gateway but has no dependency on VPCGatewayAttachment — the route may fail if the gateway isn't attached yet`,
|
|
105
|
+
entity: logicalId,
|
|
106
|
+
lexicon: "aws",
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Pattern 3: API Gateway Deployment with no DependsOn on any Method
|
|
113
|
+
if (resource.Type === "AWS::ApiGateway::Deployment" && methodIds.length > 0) {
|
|
114
|
+
const deps = getDependsOnSet(resource);
|
|
115
|
+
const hasMethodDep = methodIds.some((id) => deps.has(id));
|
|
116
|
+
const propRefs = collectPropertyRefs(resource);
|
|
117
|
+
const hasMethodRef = methodIds.some((id) => propRefs.has(id));
|
|
118
|
+
if (!hasMethodDep && !hasMethodRef) {
|
|
119
|
+
diagnostics.push({
|
|
120
|
+
checkId: "WAW030",
|
|
121
|
+
severity: "warning",
|
|
122
|
+
message: `API Gateway Deployment "${logicalId}" has no DependsOn on any Method — the deployment may fail with "REST API doesn't contain any methods"`,
|
|
123
|
+
entity: logicalId,
|
|
124
|
+
lexicon: "aws",
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Pattern 4: API Gateway V2 Deployment with no DependsOn on any Route
|
|
130
|
+
if (resource.Type === "AWS::ApiGatewayV2::Deployment" && routeV2Ids.length > 0) {
|
|
131
|
+
const deps = getDependsOnSet(resource);
|
|
132
|
+
const hasRouteDep = routeV2Ids.some((id) => deps.has(id));
|
|
133
|
+
const propRefs = collectPropertyRefs(resource);
|
|
134
|
+
const hasRouteRef = routeV2Ids.some((id) => propRefs.has(id));
|
|
135
|
+
if (!hasRouteDep && !hasRouteRef) {
|
|
136
|
+
diagnostics.push({
|
|
137
|
+
checkId: "WAW030",
|
|
138
|
+
severity: "warning",
|
|
139
|
+
message: `API Gateway V2 Deployment "${logicalId}" has no DependsOn on any Route — the deployment may fail if no routes exist yet`,
|
|
140
|
+
entity: logicalId,
|
|
141
|
+
lexicon: "aws",
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Pattern 5 & 6: ScalableTarget with no DependsOn on the target resource
|
|
148
|
+
for (const entry of scalableTargetEntries) {
|
|
149
|
+
const resource = resources[entry.logicalId];
|
|
150
|
+
const deps = getDependsOnSet(resource);
|
|
151
|
+
const propRefs = collectPropertyRefs(resource);
|
|
152
|
+
|
|
153
|
+
if (entry.namespace === "dynamodb" && dynamoTableIds.length > 0) {
|
|
154
|
+
const hasTableDep = dynamoTableIds.some((id) => deps.has(id));
|
|
155
|
+
const hasTableRef = dynamoTableIds.some((id) => propRefs.has(id));
|
|
156
|
+
if (!hasTableDep && !hasTableRef) {
|
|
157
|
+
diagnostics.push({
|
|
158
|
+
checkId: "WAW030",
|
|
159
|
+
severity: "warning",
|
|
160
|
+
message: `ScalableTarget "${entry.logicalId}" targets DynamoDB but has no DependsOn on any Table — scaling registration may fail if the table doesn't exist yet`,
|
|
161
|
+
entity: entry.logicalId,
|
|
162
|
+
lexicon: "aws",
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (entry.namespace === "ecs" && ecsServiceIds.length > 0) {
|
|
168
|
+
const hasServiceDep = ecsServiceIds.some((id) => deps.has(id));
|
|
169
|
+
const hasServiceRef = ecsServiceIds.some((id) => propRefs.has(id));
|
|
170
|
+
if (!hasServiceDep && !hasServiceRef) {
|
|
171
|
+
diagnostics.push({
|
|
172
|
+
checkId: "WAW030",
|
|
173
|
+
severity: "warning",
|
|
174
|
+
message: `ScalableTarget "${entry.logicalId}" targets ECS but has no DependsOn on any ECS Service — scaling registration may fail if the service doesn't exist yet`,
|
|
175
|
+
entity: entry.logicalId,
|
|
176
|
+
lexicon: "aws",
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return diagnostics;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Infer the scaling namespace from a ScalableTarget's properties. */
|
|
187
|
+
function inferScalingNamespace(props: Record<string, unknown>): string | null {
|
|
188
|
+
if (typeof props.ServiceNamespace === "string") {
|
|
189
|
+
return props.ServiceNamespace;
|
|
190
|
+
}
|
|
191
|
+
if (typeof props.ScalableDimension === "string") {
|
|
192
|
+
const prefix = props.ScalableDimension.split(":")[0];
|
|
193
|
+
if (prefix) return prefix;
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Extract DependsOn entries as a Set of strings. */
|
|
199
|
+
function getDependsOnSet(resource: { DependsOn?: string | string[] }): Set<string> {
|
|
200
|
+
if (!resource.DependsOn) return new Set();
|
|
201
|
+
const deps = Array.isArray(resource.DependsOn)
|
|
202
|
+
? resource.DependsOn
|
|
203
|
+
: [resource.DependsOn];
|
|
204
|
+
return new Set(deps.filter((d): d is string => typeof d === "string"));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Collect all Ref and Fn::GetAtt target logical IDs from resource properties. */
|
|
208
|
+
function collectPropertyRefs(resource: { Properties?: Record<string, unknown> }): Set<string> {
|
|
209
|
+
const refs = new Set<string>();
|
|
210
|
+
if (!resource.Properties) return refs;
|
|
211
|
+
|
|
212
|
+
function walk(value: unknown): void {
|
|
213
|
+
if (typeof value !== "object" || value === null) return;
|
|
214
|
+
if (Array.isArray(value)) {
|
|
215
|
+
for (const item of value) walk(item);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const obj = value as Record<string, unknown>;
|
|
219
|
+
if ("Ref" in obj && typeof obj.Ref === "string") {
|
|
220
|
+
refs.add(obj.Ref);
|
|
221
|
+
}
|
|
222
|
+
if ("Fn::GetAtt" in obj) {
|
|
223
|
+
const getAtt = obj["Fn::GetAtt"];
|
|
224
|
+
if (Array.isArray(getAtt) && typeof getAtt[0] === "string") {
|
|
225
|
+
refs.add(getAtt[0]);
|
|
226
|
+
} else if (typeof getAtt === "string") {
|
|
227
|
+
// Dot-delimited form: "LogicalId.Attribute"
|
|
228
|
+
const logicalId = getAtt.split(".")[0];
|
|
229
|
+
if (logicalId) refs.add(logicalId);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
for (const val of Object.values(obj)) walk(val);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
walk(resource.Properties);
|
|
236
|
+
return refs;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export const waw030: PostSynthCheck = {
|
|
240
|
+
id: "WAW030",
|
|
241
|
+
description: "Missing DependsOn for known CloudFormation ordering patterns",
|
|
242
|
+
|
|
243
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
244
|
+
return checkMissingDependsOn(ctx);
|
|
245
|
+
},
|
|
246
|
+
};
|
|
@@ -11,6 +11,7 @@ export const hardcodedRegionRule: LintRule = {
|
|
|
11
11
|
id: "WAW001",
|
|
12
12
|
severity: "warning",
|
|
13
13
|
category: "security",
|
|
14
|
+
description: "Detects hardcoded AWS region strings — use AWS.Region pseudo-parameter instead",
|
|
14
15
|
|
|
15
16
|
check(context: LintContext): LintDiagnostic[] {
|
|
16
17
|
const { sourceFile } = context;
|
|
@@ -11,6 +11,7 @@ export const iamWildcardRule: LintRule = {
|
|
|
11
11
|
id: "WAW009",
|
|
12
12
|
severity: "warning",
|
|
13
13
|
category: "security",
|
|
14
|
+
description: "Detects IAM policies with wildcard (*) resources — specify explicit resource ARNs for better security",
|
|
14
15
|
|
|
15
16
|
check(context: LintContext): LintDiagnostic[] {
|
|
16
17
|
const { sourceFile } = context;
|
|
@@ -65,7 +65,7 @@ describe("AWS Lint Rules", () => {
|
|
|
65
65
|
describe("WAW006: S3 Encryption", () => {
|
|
66
66
|
test("warns on Bucket without encryption", () => {
|
|
67
67
|
const context = createContext(`
|
|
68
|
-
const bucket = new Bucket({
|
|
68
|
+
const bucket = new Bucket({ BucketName: "my-bucket" });
|
|
69
69
|
`);
|
|
70
70
|
const diagnostics = s3EncryptionRule.check(context);
|
|
71
71
|
expect(diagnostics).toHaveLength(1);
|
|
@@ -73,22 +73,22 @@ describe("AWS Lint Rules", () => {
|
|
|
73
73
|
expect(diagnostics[0].message).toContain("encryption");
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
-
test("passes with
|
|
76
|
+
test("passes with BucketEncryption property", () => {
|
|
77
77
|
const context = createContext(`
|
|
78
78
|
const bucket = new Bucket({
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
BucketName: "my-bucket",
|
|
80
|
+
BucketEncryption: { ServerSideEncryptionConfiguration: [] },
|
|
81
81
|
});
|
|
82
82
|
`);
|
|
83
83
|
const diagnostics = s3EncryptionRule.check(context);
|
|
84
84
|
expect(diagnostics).toHaveLength(0);
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
-
test("passes with
|
|
87
|
+
test("passes with ServerSideEncryptionConfiguration property", () => {
|
|
88
88
|
const context = createContext(`
|
|
89
89
|
const bucket = new Bucket({
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
BucketName: "my-bucket",
|
|
91
|
+
ServerSideEncryptionConfiguration: [],
|
|
92
92
|
});
|
|
93
93
|
`);
|
|
94
94
|
const diagnostics = s3EncryptionRule.check(context);
|
|
@@ -97,7 +97,7 @@ describe("AWS Lint Rules", () => {
|
|
|
97
97
|
|
|
98
98
|
test("ignores non-Bucket constructors", () => {
|
|
99
99
|
const context = createContext(`
|
|
100
|
-
const fn = new Function({
|
|
100
|
+
const fn = new Function({ FunctionName: "my-fn" });
|
|
101
101
|
`);
|
|
102
102
|
const diagnostics = s3EncryptionRule.check(context);
|
|
103
103
|
expect(diagnostics).toHaveLength(0);
|
|
@@ -11,6 +11,7 @@ export const s3EncryptionRule: LintRule = {
|
|
|
11
11
|
id: "WAW006",
|
|
12
12
|
severity: "warning",
|
|
13
13
|
category: "security",
|
|
14
|
+
description: "Detects S3 Bucket creation without encryption configuration — all buckets should have server-side encryption enabled",
|
|
14
15
|
|
|
15
16
|
check(context: LintContext): LintDiagnostic[] {
|
|
16
17
|
const { sourceFile } = context;
|
|
@@ -38,9 +39,8 @@ export const s3EncryptionRule: LintRule = {
|
|
|
38
39
|
if (ts.isObjectLiteralExpression(props)) {
|
|
39
40
|
const hasEncryption = props.properties.some((prop) => {
|
|
40
41
|
if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
|
|
41
|
-
return prop.name.text === "
|
|
42
|
-
prop.name.text === "
|
|
43
|
-
prop.name.text === "serverSideEncryptionConfiguration";
|
|
42
|
+
return prop.name.text === "BucketEncryption" ||
|
|
43
|
+
prop.name.text === "ServerSideEncryptionConfiguration";
|
|
44
44
|
}
|
|
45
45
|
return false;
|
|
46
46
|
});
|
package/src/lsp/completions.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { createRequire } from "module";
|
|
1
2
|
import type { CompletionContext, CompletionItem } from "@intentius/chant/lsp/types";
|
|
2
3
|
import { LexiconIndex, lexiconCompletions, type LexiconEntry } from "@intentius/chant/lsp/lexicon-providers";
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
3
5
|
|
|
4
6
|
let cachedIndex: LexiconIndex | null = null;
|
|
5
7
|
|
package/src/lsp/hover.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { createRequire } from "module";
|
|
1
2
|
import type { HoverContext, HoverInfo } from "@intentius/chant/lsp/types";
|
|
2
3
|
import { LexiconIndex, lexiconHover, type LexiconEntry } from "@intentius/chant/lsp/lexicon-providers";
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
3
5
|
|
|
4
6
|
let cachedIndex: LexiconIndex | null = null;
|
|
5
7
|
|
|
@@ -49,5 +51,20 @@ function resourceHover(className: string, entry: LexiconEntry): HoverInfo | unde
|
|
|
49
51
|
lines.push(`**Write-only:** ${entry.writeOnly.map((p) => `\`${p}\``).join(", ")}`);
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
if (entry.replacementStrategy === "delete_then_create" && entry.createOnly?.length) {
|
|
55
|
+
lines.push("");
|
|
56
|
+
lines.push("**Replacement:** Modifying create-only properties causes delete-then-create replacement");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (entry.conditionalCreateOnly?.length) {
|
|
60
|
+
lines.push("");
|
|
61
|
+
lines.push(`**Conditionally immutable:** ${entry.conditionalCreateOnly.map((p) => `\`${p}\``).join(", ")}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (entry.deprecatedProperties?.length) {
|
|
65
|
+
lines.push("");
|
|
66
|
+
lines.push(`**Deprecated properties:** ${entry.deprecatedProperties.map((p) => `\`${p}\``).join(", ")}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
52
69
|
return { contents: lines.join("\n") };
|
|
53
70
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { build } from "../../../packages/core/src/build";
|
|
3
|
+
import { resolve } from "path";
|
|
4
|
+
import { awsSerializer } from "./serializer";
|
|
5
|
+
import type { SerializerResult } from "../../../packages/core/src/serializer";
|
|
6
|
+
|
|
7
|
+
const srcDir = resolve(import.meta.dir, "testdata/nested-stacks");
|
|
8
|
+
|
|
9
|
+
describe("nested-stacks integration", () => {
|
|
10
|
+
test("build produces valid CloudFormation with nested stack", async () => {
|
|
11
|
+
const result = await build(srcDir, [awsSerializer]);
|
|
12
|
+
|
|
13
|
+
expect(result.errors).toHaveLength(0);
|
|
14
|
+
|
|
15
|
+
const output = result.outputs.get("aws");
|
|
16
|
+
expect(output).toBeDefined();
|
|
17
|
+
|
|
18
|
+
// Should be a SerializerResult with files
|
|
19
|
+
expect(typeof output).toBe("object");
|
|
20
|
+
const sr = output as SerializerResult;
|
|
21
|
+
|
|
22
|
+
// Parse parent template
|
|
23
|
+
const parent = JSON.parse(sr.primary);
|
|
24
|
+
expect(parent.AWSTemplateFormatVersion).toBe("2010-09-09");
|
|
25
|
+
expect(parent.Resources).toBeDefined();
|
|
26
|
+
|
|
27
|
+
// Parent should have TemplateBasePath parameter
|
|
28
|
+
expect(parent.Parameters?.TemplateBasePath).toBeDefined();
|
|
29
|
+
expect(parent.Parameters.TemplateBasePath.Default).toBe(".");
|
|
30
|
+
|
|
31
|
+
// Parent should have network as AWS::CloudFormation::Stack
|
|
32
|
+
expect(parent.Resources.network).toBeDefined();
|
|
33
|
+
expect(parent.Resources.network.Type).toBe("AWS::CloudFormation::Stack");
|
|
34
|
+
expect(parent.Resources.network.Properties.TemplateURL).toEqual({
|
|
35
|
+
"Fn::Sub": "${TemplateBasePath}/network.template.json",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Parent should have TemplateBasePath propagated to child
|
|
39
|
+
expect(parent.Resources.network.Properties.Parameters.TemplateBasePath).toEqual({
|
|
40
|
+
Ref: "TemplateBasePath",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Parent should have explicit parameters passed
|
|
44
|
+
expect(parent.Resources.network.Properties.Parameters.Environment).toBe("prod");
|
|
45
|
+
|
|
46
|
+
// Parent should have the handler Lambda function
|
|
47
|
+
expect(parent.Resources.handler).toBeDefined();
|
|
48
|
+
expect(parent.Resources.handler.Type).toBe("AWS::Lambda::Function");
|
|
49
|
+
|
|
50
|
+
// Cross-stack ref: handler VpcConfig should reference network stack outputs
|
|
51
|
+
const vpcConfig = parent.Resources.handler.Properties.VpcConfig;
|
|
52
|
+
expect(vpcConfig).toBeDefined();
|
|
53
|
+
const subnetRef = vpcConfig.SubnetIds[0];
|
|
54
|
+
expect(subnetRef).toEqual({
|
|
55
|
+
"Fn::GetAtt": ["network", "Outputs.subnetId"],
|
|
56
|
+
});
|
|
57
|
+
const sgRef = vpcConfig.SecurityGroupIds[0];
|
|
58
|
+
expect(sgRef).toEqual({
|
|
59
|
+
"Fn::GetAtt": ["network", "Outputs.lambdaSgId"],
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Child template should exist
|
|
63
|
+
expect(sr.files).toBeDefined();
|
|
64
|
+
expect(sr.files!["network.template.json"]).toBeDefined();
|
|
65
|
+
|
|
66
|
+
const child = JSON.parse(sr.files!["network.template.json"]);
|
|
67
|
+
expect(child.AWSTemplateFormatVersion).toBe("2010-09-09");
|
|
68
|
+
|
|
69
|
+
// Child should have VPC and Subnet resources
|
|
70
|
+
expect(child.Resources.vpc).toBeDefined();
|
|
71
|
+
expect(child.Resources.vpc.Type).toBe("AWS::EC2::VPC");
|
|
72
|
+
expect(child.Resources.subnet).toBeDefined();
|
|
73
|
+
expect(child.Resources.subnet.Type).toBe("AWS::EC2::Subnet");
|
|
74
|
+
|
|
75
|
+
// Child should have internet gateway and routing resources
|
|
76
|
+
expect(child.Resources.igw).toBeDefined();
|
|
77
|
+
expect(child.Resources.igw.Type).toBe("AWS::EC2::InternetGateway");
|
|
78
|
+
expect(child.Resources.igwAttachment).toBeDefined();
|
|
79
|
+
expect(child.Resources.igwAttachment.Type).toBe("AWS::EC2::VPCGatewayAttachment");
|
|
80
|
+
expect(child.Resources.routeTable).toBeDefined();
|
|
81
|
+
expect(child.Resources.routeTable.Type).toBe("AWS::EC2::RouteTable");
|
|
82
|
+
expect(child.Resources.defaultRoute).toBeDefined();
|
|
83
|
+
expect(child.Resources.defaultRoute.Type).toBe("AWS::EC2::Route");
|
|
84
|
+
expect(child.Resources.subnetRouteTableAssoc).toBeDefined();
|
|
85
|
+
expect(child.Resources.subnetRouteTableAssoc.Type).toBe("AWS::EC2::SubnetRouteTableAssociation");
|
|
86
|
+
|
|
87
|
+
// Child should have security group
|
|
88
|
+
expect(child.Resources.lambdaSg).toBeDefined();
|
|
89
|
+
expect(child.Resources.lambdaSg.Type).toBe("AWS::EC2::SecurityGroup");
|
|
90
|
+
|
|
91
|
+
// Child should have Outputs for stackOutput() declarations
|
|
92
|
+
expect(child.Outputs).toBeDefined();
|
|
93
|
+
expect(child.Outputs.vpcId).toBeDefined();
|
|
94
|
+
expect(child.Outputs.vpcId.Description).toBe("VPC ID");
|
|
95
|
+
expect(child.Outputs.subnetId).toBeDefined();
|
|
96
|
+
expect(child.Outputs.subnetId.Description).toBe("Public subnet ID");
|
|
97
|
+
expect(child.Outputs.lambdaSgId).toBeDefined();
|
|
98
|
+
expect(child.Outputs.lambdaSgId.Description).toBe("Lambda security group ID");
|
|
99
|
+
});
|
|
100
|
+
});
|
package/src/nested-stack.ts
CHANGED
|
@@ -74,7 +74,7 @@ export function isNestedStackInstance(value: unknown): value is NestedStackInsta
|
|
|
74
74
|
/**
|
|
75
75
|
* Create a nested stack that references a child project directory.
|
|
76
76
|
*
|
|
77
|
-
* The child directory must contain its own
|
|
77
|
+
* The child directory must contain its own resource files.
|
|
78
78
|
* It can be built independently with `chant build`. Cross-stack outputs
|
|
79
79
|
* are declared in the child via `stackOutput()`.
|
|
80
80
|
*
|
|
@@ -85,7 +85,7 @@ export function isNestedStackInstance(value: unknown): value is NestedStackInsta
|
|
|
85
85
|
*
|
|
86
86
|
* @example
|
|
87
87
|
* ```ts
|
|
88
|
-
* const network = _.nestedStack("network", import.meta.
|
|
88
|
+
* const network = _.nestedStack("network", import.meta.dirname + "/network", {
|
|
89
89
|
* parameters: { Environment: "prod" },
|
|
90
90
|
* });
|
|
91
91
|
*
|
package/src/plugin.test.ts
CHANGED
|
@@ -96,17 +96,17 @@ describe("awsPlugin", () => {
|
|
|
96
96
|
expect(skills.length).toBeGreaterThanOrEqual(1);
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
-
test("aws
|
|
99
|
+
test("chant-aws skill has required fields", () => {
|
|
100
100
|
const skills = awsPlugin.skills!();
|
|
101
|
-
const cfnSkill = skills.find((s) => s.name === "aws
|
|
101
|
+
const cfnSkill = skills.find((s) => s.name === "chant-aws");
|
|
102
102
|
expect(cfnSkill).toBeDefined();
|
|
103
103
|
expect(cfnSkill!.description.length).toBeGreaterThan(0);
|
|
104
104
|
expect(cfnSkill!.content.length).toBeGreaterThan(0);
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
-
test("aws
|
|
107
|
+
test("chant-aws skill has triggers", () => {
|
|
108
108
|
const skills = awsPlugin.skills!();
|
|
109
|
-
const cfnSkill = skills.find((s) => s.name === "aws
|
|
109
|
+
const cfnSkill = skills.find((s) => s.name === "chant-aws")!;
|
|
110
110
|
expect(cfnSkill.triggers).toBeDefined();
|
|
111
111
|
expect(cfnSkill.triggers!.length).toBeGreaterThanOrEqual(1);
|
|
112
112
|
|
|
@@ -119,9 +119,9 @@ describe("awsPlugin", () => {
|
|
|
119
119
|
expect(contextTrigger!.value).toBe("aws");
|
|
120
120
|
});
|
|
121
121
|
|
|
122
|
-
test("aws
|
|
122
|
+
test("chant-aws skill has parameters", () => {
|
|
123
123
|
const skills = awsPlugin.skills!();
|
|
124
|
-
const cfnSkill = skills.find((s) => s.name === "aws
|
|
124
|
+
const cfnSkill = skills.find((s) => s.name === "chant-aws")!;
|
|
125
125
|
expect(cfnSkill.parameters).toBeDefined();
|
|
126
126
|
expect(cfnSkill.parameters!.length).toBeGreaterThanOrEqual(1);
|
|
127
127
|
|
|
@@ -131,9 +131,9 @@ describe("awsPlugin", () => {
|
|
|
131
131
|
expect(resourceTypeParam!.type).toBe("string");
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
-
test("aws
|
|
134
|
+
test("chant-aws skill has examples", () => {
|
|
135
135
|
const skills = awsPlugin.skills!();
|
|
136
|
-
const cfnSkill = skills.find((s) => s.name === "aws
|
|
136
|
+
const cfnSkill = skills.find((s) => s.name === "chant-aws")!;
|
|
137
137
|
expect(cfnSkill.examples).toBeDefined();
|
|
138
138
|
expect(cfnSkill.examples!.length).toBeGreaterThanOrEqual(1);
|
|
139
139
|
|
|
@@ -145,11 +145,12 @@ describe("awsPlugin", () => {
|
|
|
145
145
|
|
|
146
146
|
test("skill content is valid markdown with frontmatter", () => {
|
|
147
147
|
const skills = awsPlugin.skills!();
|
|
148
|
-
const cfnSkill = skills.find((s) => s.name === "aws
|
|
148
|
+
const cfnSkill = skills.find((s) => s.name === "chant-aws")!;
|
|
149
149
|
expect(cfnSkill.content).toContain("---");
|
|
150
|
-
expect(cfnSkill.content).toContain("
|
|
151
|
-
expect(cfnSkill.content).toContain("
|
|
152
|
-
expect(cfnSkill.content).toContain("
|
|
150
|
+
expect(cfnSkill.content).toContain("skill: chant-aws");
|
|
151
|
+
expect(cfnSkill.content).toContain("user-invocable: true");
|
|
152
|
+
expect(cfnSkill.content).toContain("chant build");
|
|
153
|
+
expect(cfnSkill.content).toContain("aws cloudformation deploy");
|
|
153
154
|
});
|
|
154
155
|
});
|
|
155
156
|
|
|
@@ -174,9 +175,6 @@ describe("awsPlugin", () => {
|
|
|
174
175
|
expect(typeof awsPlugin.package).toBe("function");
|
|
175
176
|
});
|
|
176
177
|
|
|
177
|
-
test("rollback is a function", () => {
|
|
178
|
-
expect(typeof awsPlugin.rollback).toBe("function");
|
|
179
|
-
});
|
|
180
178
|
});
|
|
181
179
|
|
|
182
180
|
// -----------------------------------------------------------------------
|