@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
package/src/serializer.ts
CHANGED
|
@@ -3,9 +3,11 @@ import { isPropertyDeclarable } from "@intentius/chant/declarable";
|
|
|
3
3
|
import type { Serializer, SerializerResult } from "@intentius/chant/serializer";
|
|
4
4
|
import type { LexiconOutput } from "@intentius/chant/lexicon-output";
|
|
5
5
|
import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
|
|
6
|
-
import { toPascalCase } from "@intentius/chant/codegen/case";
|
|
7
6
|
import { isChildProject, type ChildProjectInstance } from "@intentius/chant/child-project";
|
|
8
7
|
import { isStackOutput, type StackOutput } from "@intentius/chant/stack-output";
|
|
8
|
+
import { resolveDependsOn } from "@intentius/chant/resource-attributes";
|
|
9
|
+
import { isDefaultTags, type TagEntry } from "./default-tags";
|
|
10
|
+
import { loadTaggableResources } from "./taggable";
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* Check if a declarable is a CoreParameter
|
|
@@ -50,6 +52,10 @@ interface CFResource {
|
|
|
50
52
|
Properties?: Record<string, unknown>;
|
|
51
53
|
DependsOn?: string | string[];
|
|
52
54
|
Condition?: string;
|
|
55
|
+
DeletionPolicy?: string;
|
|
56
|
+
UpdateReplacePolicy?: string;
|
|
57
|
+
UpdatePolicy?: unknown;
|
|
58
|
+
CreationPolicy?: unknown;
|
|
53
59
|
Metadata?: Record<string, unknown>;
|
|
54
60
|
}
|
|
55
61
|
|
|
@@ -68,7 +74,7 @@ interface CFOutput {
|
|
|
68
74
|
*/
|
|
69
75
|
function cfnVisitor(entityNames: Map<Declarable, string>): SerializerVisitor {
|
|
70
76
|
return {
|
|
71
|
-
attrRef: (name, attr) => ({ "Fn::
|
|
77
|
+
attrRef: (name, attr) => ({ "Fn::GetAtt": [name, attr] }),
|
|
72
78
|
resourceRef: (name) => ({ Ref: name }),
|
|
73
79
|
propertyDeclarable: (entity, walk) => {
|
|
74
80
|
if (!("props" in entity) || typeof entity.props !== "object" || entity.props === null) {
|
|
@@ -78,26 +84,19 @@ function cfnVisitor(entityNames: Map<Declarable, string>): SerializerVisitor {
|
|
|
78
84
|
const cfProps: Record<string, unknown> = {};
|
|
79
85
|
for (const [key, value] of Object.entries(props)) {
|
|
80
86
|
if (value !== undefined) {
|
|
81
|
-
|
|
82
|
-
cfProps[cfKey] = walk(value);
|
|
87
|
+
cfProps[key] = walk(value);
|
|
83
88
|
}
|
|
84
89
|
}
|
|
85
90
|
return Object.keys(cfProps).length > 0 ? cfProps : undefined;
|
|
86
91
|
},
|
|
87
|
-
transformKey: toPascalCase,
|
|
88
92
|
};
|
|
89
93
|
}
|
|
90
94
|
|
|
91
95
|
/**
|
|
92
96
|
* Convert a value to CF-compatible JSON using the generic walker.
|
|
93
97
|
*/
|
|
94
|
-
function toCFValue(value: unknown, entityNames: Map<Declarable, string
|
|
95
|
-
|
|
96
|
-
if (!convertKeys) {
|
|
97
|
-
// When not converting keys, use a visitor without transformKey
|
|
98
|
-
return walkValue(value, entityNames, { ...visitor, transformKey: undefined });
|
|
99
|
-
}
|
|
100
|
-
return walkValue(value, entityNames, visitor);
|
|
98
|
+
function toCFValue(value: unknown, entityNames: Map<Declarable, string>): unknown {
|
|
99
|
+
return walkValue(value, entityNames, cfnVisitor(entityNames));
|
|
101
100
|
}
|
|
102
101
|
|
|
103
102
|
/**
|
|
@@ -116,8 +115,7 @@ function toProperties(
|
|
|
116
115
|
|
|
117
116
|
for (const [key, value] of Object.entries(props)) {
|
|
118
117
|
if (value !== undefined) {
|
|
119
|
-
|
|
120
|
-
cfProps[cfKey] = toCFValue(value, entityNames, true);
|
|
118
|
+
cfProps[key] = toCFValue(value, entityNames);
|
|
121
119
|
}
|
|
122
120
|
}
|
|
123
121
|
|
|
@@ -150,6 +148,14 @@ function serializeToTemplate(
|
|
|
150
148
|
entityNames.set(entity, name);
|
|
151
149
|
}
|
|
152
150
|
|
|
151
|
+
// Collect default tags
|
|
152
|
+
const defaultTagEntries: TagEntry[] = [];
|
|
153
|
+
for (const [, entity] of entities) {
|
|
154
|
+
if (isDefaultTags(entity)) {
|
|
155
|
+
defaultTagEntries.push(...entity.tags);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
153
159
|
// Process entities
|
|
154
160
|
for (const [name, entity] of entities) {
|
|
155
161
|
// Skip StackOutput entities — they go in the Outputs section
|
|
@@ -157,6 +163,11 @@ function serializeToTemplate(
|
|
|
157
163
|
continue;
|
|
158
164
|
}
|
|
159
165
|
|
|
166
|
+
// Skip DefaultTags entities — handled via tag injection below
|
|
167
|
+
if (isDefaultTags(entity)) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
160
171
|
if (isCoreParameter(entity)) {
|
|
161
172
|
if (!template.Parameters) {
|
|
162
173
|
template.Parameters = {};
|
|
@@ -211,15 +222,56 @@ function serializeToTemplate(
|
|
|
211
222
|
Type: entity.entityType,
|
|
212
223
|
};
|
|
213
224
|
|
|
225
|
+
// Read resource-level attributes from the second constructor arg
|
|
226
|
+
const attrs = ("attributes" in entity && typeof entity.attributes === "object" && entity.attributes !== null)
|
|
227
|
+
? entity.attributes as Record<string, unknown>
|
|
228
|
+
: undefined;
|
|
229
|
+
|
|
230
|
+
if (attrs) {
|
|
231
|
+
// DependsOn — resolve Declarable refs to logical names
|
|
232
|
+
if (attrs.DependsOn !== undefined) {
|
|
233
|
+
const resolved = resolveDependsOn(attrs.DependsOn, entityNames, name);
|
|
234
|
+
if (resolved.length > 0) {
|
|
235
|
+
resource.DependsOn = resolved.length === 1 ? resolved[0] : resolved;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// Pass-through attributes
|
|
239
|
+
if (attrs.Condition) resource.Condition = attrs.Condition as string;
|
|
240
|
+
if (attrs.DeletionPolicy) resource.DeletionPolicy = attrs.DeletionPolicy as string;
|
|
241
|
+
if (attrs.UpdateReplacePolicy) resource.UpdateReplacePolicy = attrs.UpdateReplacePolicy as string;
|
|
242
|
+
if (attrs.UpdatePolicy) resource.UpdatePolicy = attrs.UpdatePolicy;
|
|
243
|
+
if (attrs.CreationPolicy) resource.CreationPolicy = attrs.CreationPolicy;
|
|
244
|
+
if (attrs.Metadata) resource.Metadata = toCFValue(attrs.Metadata, entityNames) as Record<string, unknown>;
|
|
245
|
+
}
|
|
246
|
+
|
|
214
247
|
const properties = toProperties(entity, entityNames);
|
|
215
248
|
if (properties) {
|
|
216
|
-
|
|
249
|
+
if (Object.keys(properties).length > 0) {
|
|
250
|
+
resource.Properties = properties;
|
|
251
|
+
}
|
|
217
252
|
}
|
|
218
253
|
|
|
219
254
|
template.Resources[name] = resource;
|
|
220
255
|
}
|
|
221
256
|
}
|
|
222
257
|
|
|
258
|
+
// Inject default tags into taggable resources
|
|
259
|
+
if (defaultTagEntries.length > 0) {
|
|
260
|
+
const taggable = loadTaggableResources();
|
|
261
|
+
for (const [, resource] of Object.entries(template.Resources)) {
|
|
262
|
+
if (!taggable.has(resource.Type)) continue;
|
|
263
|
+
const resolved = defaultTagEntries.map(t => ({
|
|
264
|
+
Key: t.Key,
|
|
265
|
+
Value: toCFValue(t.Value, entityNames),
|
|
266
|
+
}));
|
|
267
|
+
const explicit = (resource.Properties?.Tags ?? []) as Array<{ Key: string }>;
|
|
268
|
+
const explicitKeys = new Set(explicit.map(t => t.Key));
|
|
269
|
+
const merged = [...resolved.filter(t => !explicitKeys.has(t.Key)), ...explicit];
|
|
270
|
+
if (!resource.Properties) resource.Properties = {};
|
|
271
|
+
resource.Properties.Tags = merged;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
223
275
|
// Emit StackOutput entities as CF Outputs
|
|
224
276
|
for (const [name, entity] of entities) {
|
|
225
277
|
if (isStackOutput(entity)) {
|
|
@@ -231,7 +283,7 @@ function serializeToTemplate(
|
|
|
231
283
|
const logicalName = ref.getLogicalName();
|
|
232
284
|
if (logicalName) {
|
|
233
285
|
const output: CFOutput = {
|
|
234
|
-
Value: { "Fn::
|
|
286
|
+
Value: { "Fn::GetAtt": [logicalName, ref.attribute] },
|
|
235
287
|
};
|
|
236
288
|
if (stackOutput.description) {
|
|
237
289
|
output.Description = stackOutput.description;
|
|
@@ -247,7 +299,7 @@ function serializeToTemplate(
|
|
|
247
299
|
for (const output of outputs) {
|
|
248
300
|
template.Outputs[output.outputName] = {
|
|
249
301
|
Value: {
|
|
250
|
-
"Fn::
|
|
302
|
+
"Fn::GetAtt": [output.sourceEntity, output.sourceAttribute],
|
|
251
303
|
},
|
|
252
304
|
};
|
|
253
305
|
}
|
package/src/spec/fetch.ts
CHANGED
|
@@ -15,6 +15,16 @@ export interface CFNSchema {
|
|
|
15
15
|
createOnlyProperties?: string[];
|
|
16
16
|
writeOnlyProperties?: string[];
|
|
17
17
|
primaryIdentifier?: string[];
|
|
18
|
+
deprecatedProperties?: string[];
|
|
19
|
+
conditionalCreateOnlyProperties?: string[];
|
|
20
|
+
replacementStrategy?: string;
|
|
21
|
+
tagging?: {
|
|
22
|
+
taggable?: boolean;
|
|
23
|
+
tagOnCreate?: boolean;
|
|
24
|
+
tagUpdatable?: boolean;
|
|
25
|
+
cloudFormationSystemTags?: boolean;
|
|
26
|
+
tagProperty?: string;
|
|
27
|
+
};
|
|
18
28
|
additionalProperties?: boolean;
|
|
19
29
|
}
|
|
20
30
|
|
package/src/spec/parse.test.ts
CHANGED
|
@@ -20,6 +20,7 @@ const sampleBucketSchema = JSON.stringify({
|
|
|
20
20
|
},
|
|
21
21
|
AccessControl: {
|
|
22
22
|
type: "string",
|
|
23
|
+
description: "This is a legacy property, and it is not recommended for most use cases.",
|
|
23
24
|
enum: ["Private", "PublicRead", "PublicReadWrite"],
|
|
24
25
|
},
|
|
25
26
|
},
|
|
@@ -42,6 +43,7 @@ const sampleBucketSchema = JSON.stringify({
|
|
|
42
43
|
additionalProperties: false,
|
|
43
44
|
},
|
|
44
45
|
},
|
|
46
|
+
deprecatedProperties: ["/properties/AccessControl"],
|
|
45
47
|
readOnlyProperties: [
|
|
46
48
|
"/properties/Arn",
|
|
47
49
|
"/properties/DomainName",
|
|
@@ -134,6 +136,145 @@ describe("parseCFNSchema", () => {
|
|
|
134
136
|
expect(result.resource.properties).toEqual([]);
|
|
135
137
|
expect(result.resource.attributes).toEqual([]);
|
|
136
138
|
});
|
|
139
|
+
|
|
140
|
+
// --- Deprecated properties ---
|
|
141
|
+
|
|
142
|
+
test("parses explicit deprecatedProperties", () => {
|
|
143
|
+
const result = parseCFNSchema(sampleBucketSchema);
|
|
144
|
+
expect(result.resource.deprecatedProperties).toContain("AccessControl");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("mines deprecation from property description", () => {
|
|
148
|
+
const schema = JSON.stringify({
|
|
149
|
+
typeName: "AWS::Test::DescMined",
|
|
150
|
+
properties: {
|
|
151
|
+
OldProp: { type: "string", description: "This property is deprecated. Use NewProp instead." },
|
|
152
|
+
NewProp: { type: "string", description: "The replacement property" },
|
|
153
|
+
},
|
|
154
|
+
additionalProperties: false,
|
|
155
|
+
});
|
|
156
|
+
const result = parseCFNSchema(schema);
|
|
157
|
+
expect(result.resource.deprecatedProperties).toContain("OldProp");
|
|
158
|
+
expect(result.resource.deprecatedProperties).not.toContain("NewProp");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("mines 'legacy' keyword from description", () => {
|
|
162
|
+
const schema = JSON.stringify({
|
|
163
|
+
typeName: "AWS::Test::Legacy",
|
|
164
|
+
properties: {
|
|
165
|
+
LegacyProp: { type: "string", description: "This is a legacy property, not recommended." },
|
|
166
|
+
},
|
|
167
|
+
additionalProperties: false,
|
|
168
|
+
});
|
|
169
|
+
const result = parseCFNSchema(schema);
|
|
170
|
+
expect(result.resource.deprecatedProperties).toContain("LegacyProp");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("deduplicates when both explicit and description flag same property", () => {
|
|
174
|
+
// sampleBucketSchema has AccessControl in both deprecatedProperties array and description
|
|
175
|
+
const result = parseCFNSchema(sampleBucketSchema);
|
|
176
|
+
const count = result.resource.deprecatedProperties.filter((p) => p === "AccessControl").length;
|
|
177
|
+
expect(count).toBe(1);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("empty deprecatedProperties when none found", () => {
|
|
181
|
+
const schema = JSON.stringify({
|
|
182
|
+
typeName: "AWS::Test::Clean",
|
|
183
|
+
properties: {
|
|
184
|
+
Name: { type: "string", description: "A normal property" },
|
|
185
|
+
},
|
|
186
|
+
additionalProperties: false,
|
|
187
|
+
});
|
|
188
|
+
const result = parseCFNSchema(schema);
|
|
189
|
+
expect(result.resource.deprecatedProperties).toEqual([]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// --- Tagging metadata ---
|
|
193
|
+
|
|
194
|
+
test("parses tagging metadata when taggable", () => {
|
|
195
|
+
const schema = JSON.stringify({
|
|
196
|
+
typeName: "AWS::Test::Taggable",
|
|
197
|
+
properties: { Tags: { type: "array" } },
|
|
198
|
+
tagging: { taggable: true, tagOnCreate: true, tagUpdatable: true },
|
|
199
|
+
additionalProperties: false,
|
|
200
|
+
});
|
|
201
|
+
const result = parseCFNSchema(schema);
|
|
202
|
+
expect(result.resource.tagging).toEqual({
|
|
203
|
+
taggable: true,
|
|
204
|
+
tagOnCreate: true,
|
|
205
|
+
tagUpdatable: true,
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("omits tagging when not taggable", () => {
|
|
210
|
+
const schema = JSON.stringify({
|
|
211
|
+
typeName: "AWS::Test::NotTaggable",
|
|
212
|
+
properties: { Name: { type: "string" } },
|
|
213
|
+
tagging: { taggable: false },
|
|
214
|
+
additionalProperties: false,
|
|
215
|
+
});
|
|
216
|
+
const result = parseCFNSchema(schema);
|
|
217
|
+
expect(result.resource.tagging).toBeUndefined();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("omits tagging when absent", () => {
|
|
221
|
+
const schema = JSON.stringify({
|
|
222
|
+
typeName: "AWS::Test::NoTagging",
|
|
223
|
+
properties: { Name: { type: "string" } },
|
|
224
|
+
additionalProperties: false,
|
|
225
|
+
});
|
|
226
|
+
const result = parseCFNSchema(schema);
|
|
227
|
+
expect(result.resource.tagging).toBeUndefined();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// --- Replacement strategy ---
|
|
231
|
+
|
|
232
|
+
test("parses replacementStrategy", () => {
|
|
233
|
+
const schema = JSON.stringify({
|
|
234
|
+
typeName: "AWS::Test::DeleteFirst",
|
|
235
|
+
properties: { Name: { type: "string" } },
|
|
236
|
+
replacementStrategy: "delete_then_create",
|
|
237
|
+
additionalProperties: false,
|
|
238
|
+
});
|
|
239
|
+
const result = parseCFNSchema(schema);
|
|
240
|
+
expect(result.resource.replacementStrategy).toBe("delete_then_create");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("omits replacementStrategy when absent", () => {
|
|
244
|
+
const schema = JSON.stringify({
|
|
245
|
+
typeName: "AWS::Test::NoStrategy",
|
|
246
|
+
properties: { Name: { type: "string" } },
|
|
247
|
+
additionalProperties: false,
|
|
248
|
+
});
|
|
249
|
+
const result = parseCFNSchema(schema);
|
|
250
|
+
expect(result.resource.replacementStrategy).toBeUndefined();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// --- Conditional create-only ---
|
|
254
|
+
|
|
255
|
+
test("parses conditionalCreateOnlyProperties", () => {
|
|
256
|
+
const schema = JSON.stringify({
|
|
257
|
+
typeName: "AWS::Test::ConditionalCreate",
|
|
258
|
+
properties: {
|
|
259
|
+
Name: { type: "string" },
|
|
260
|
+
Engine: { type: "string" },
|
|
261
|
+
},
|
|
262
|
+
conditionalCreateOnlyProperties: ["/properties/Engine"],
|
|
263
|
+
additionalProperties: false,
|
|
264
|
+
});
|
|
265
|
+
const result = parseCFNSchema(schema);
|
|
266
|
+
expect(result.resource.conditionalCreateOnly).toContain("Engine");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("empty conditionalCreateOnly when absent", () => {
|
|
270
|
+
const schema = JSON.stringify({
|
|
271
|
+
typeName: "AWS::Test::NoConditional",
|
|
272
|
+
properties: { Name: { type: "string" } },
|
|
273
|
+
additionalProperties: false,
|
|
274
|
+
});
|
|
275
|
+
const result = parseCFNSchema(schema);
|
|
276
|
+
expect(result.resource.conditionalCreateOnly).toEqual([]);
|
|
277
|
+
});
|
|
137
278
|
});
|
|
138
279
|
|
|
139
280
|
describe("cfnShortName", () => {
|
package/src/spec/parse.ts
CHANGED
|
@@ -52,6 +52,10 @@ export interface ParsedResource {
|
|
|
52
52
|
createOnly: string[];
|
|
53
53
|
writeOnly: string[];
|
|
54
54
|
primaryIdentifier: string[];
|
|
55
|
+
deprecatedProperties: string[];
|
|
56
|
+
conditionalCreateOnly: string[];
|
|
57
|
+
replacementStrategy?: "delete_then_create" | "create_then_delete";
|
|
58
|
+
tagging?: { taggable: boolean; tagOnCreate: boolean; tagUpdatable: boolean };
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
export interface SchemaParseResult {
|
|
@@ -137,6 +141,38 @@ export function parseCFNSchema(data: string | Buffer): SchemaParseResult {
|
|
|
137
141
|
}
|
|
138
142
|
}
|
|
139
143
|
|
|
144
|
+
// --- Deprecated properties: explicit + description-mined ---
|
|
145
|
+
const deprecatedSet = new Set<string>(
|
|
146
|
+
stripPointerPaths(schema.deprecatedProperties ?? []),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const DEPRECATION_RE = /\bdeprecated\b|\blegacy\b|no longer (available|recommended|used|supported)|is not recommended|has been discontinued/i;
|
|
150
|
+
|
|
151
|
+
// Mine top-level property descriptions
|
|
152
|
+
if (schema.properties) {
|
|
153
|
+
for (const [name, prop] of Object.entries(schema.properties)) {
|
|
154
|
+
if (prop.description && DEPRECATION_RE.test(prop.description)) {
|
|
155
|
+
deprecatedSet.add(name);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// --- Tagging ---
|
|
161
|
+
let tagging: ParsedResource["tagging"];
|
|
162
|
+
if (schema.tagging && schema.tagging.taggable) {
|
|
163
|
+
tagging = {
|
|
164
|
+
taggable: true,
|
|
165
|
+
tagOnCreate: schema.tagging.tagOnCreate ?? false,
|
|
166
|
+
tagUpdatable: schema.tagging.tagUpdatable ?? false,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// --- Replacement strategy ---
|
|
171
|
+
let replacementStrategy: ParsedResource["replacementStrategy"];
|
|
172
|
+
if (schema.replacementStrategy === "delete_then_create" || schema.replacementStrategy === "create_then_delete") {
|
|
173
|
+
replacementStrategy = schema.replacementStrategy;
|
|
174
|
+
}
|
|
175
|
+
|
|
140
176
|
return {
|
|
141
177
|
resource: {
|
|
142
178
|
typeName: schema.typeName,
|
|
@@ -145,6 +181,10 @@ export function parseCFNSchema(data: string | Buffer): SchemaParseResult {
|
|
|
145
181
|
createOnly: stripPointerPaths(schema.createOnlyProperties ?? []),
|
|
146
182
|
writeOnly: stripPointerPaths(schema.writeOnlyProperties ?? []),
|
|
147
183
|
primaryIdentifier: stripPointerPaths(schema.primaryIdentifier ?? []),
|
|
184
|
+
deprecatedProperties: [...deprecatedSet],
|
|
185
|
+
conditionalCreateOnly: stripPointerPaths(schema.conditionalCreateOnlyProperties ?? []),
|
|
186
|
+
...(replacementStrategy && { replacementStrategy }),
|
|
187
|
+
...(tagging && { tagging }),
|
|
148
188
|
},
|
|
149
189
|
propertyTypes,
|
|
150
190
|
enums,
|
package/src/taggable.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared taggable resource lookup — loads from the generated lexicon JSON.
|
|
3
|
+
*
|
|
4
|
+
* Lazy-loaded and cached for the lifetime of the process.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from "fs";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
|
|
10
|
+
interface LexiconEntry {
|
|
11
|
+
kind: string;
|
|
12
|
+
resourceType: string;
|
|
13
|
+
tagging?: { taggable: boolean; tagOnCreate: boolean; tagUpdatable: boolean };
|
|
14
|
+
[key: string]: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let _cached: Set<string> | undefined;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Load taggable resource types from the lexicon JSON.
|
|
21
|
+
* Result is cached after first call.
|
|
22
|
+
*/
|
|
23
|
+
export function loadTaggableResources(): Set<string> {
|
|
24
|
+
if (_cached) return _cached;
|
|
25
|
+
|
|
26
|
+
const set = new Set<string>();
|
|
27
|
+
try {
|
|
28
|
+
const pkgDir = join(__dirname, "..");
|
|
29
|
+
const lexiconPath = join(pkgDir, "src", "generated", "lexicon-aws.json");
|
|
30
|
+
const content = readFileSync(lexiconPath, "utf-8");
|
|
31
|
+
const data = JSON.parse(content) as Record<string, LexiconEntry>;
|
|
32
|
+
|
|
33
|
+
for (const [_name, entry] of Object.entries(data)) {
|
|
34
|
+
if (entry.kind === "resource" && entry.resourceType && entry.tagging?.taggable) {
|
|
35
|
+
set.add(entry.resourceType);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// Lexicon not available — skip
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
_cached = set;
|
|
43
|
+
return set;
|
|
44
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App layer — Lambda function in the parent template that references
|
|
3
|
+
* the network nested stack's outputs via cross-stack references
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Function, Sub, AWS, Ref, nestedStack } from "@intentius/chant-lexicon-aws";
|
|
7
|
+
|
|
8
|
+
// nestedStack() references a child project directory
|
|
9
|
+
const network = nestedStack("network", import.meta.dirname + "/network", {
|
|
10
|
+
parameters: { Environment: "prod" },
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const handler = new Function({
|
|
14
|
+
FunctionName: Sub`${AWS.StackName}-handler`,
|
|
15
|
+
Runtime: "nodejs20.x",
|
|
16
|
+
Handler: "index.handler",
|
|
17
|
+
Role: Ref("LambdaExecutionRole"),
|
|
18
|
+
Code: { ZipFile: "exports.handler = async () => ({ statusCode: 200 });" },
|
|
19
|
+
VpcConfig: {
|
|
20
|
+
SubnetIds: [network.outputs.subnetId],
|
|
21
|
+
SecurityGroupIds: [network.outputs.lambdaSgId],
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Re-export so discovery picks it up as an entity
|
|
26
|
+
export { network };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-stack outputs — values the parent can reference
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { stackOutput } from "@intentius/chant";
|
|
6
|
+
import { vpc, subnet } from "./vpc";
|
|
7
|
+
import { lambdaSg } from "./security";
|
|
8
|
+
|
|
9
|
+
export const vpcId = stackOutput(vpc.VpcId, {
|
|
10
|
+
description: "VPC ID",
|
|
11
|
+
});
|
|
12
|
+
export const subnetId = stackOutput(subnet.SubnetId, {
|
|
13
|
+
description: "Public subnet ID",
|
|
14
|
+
});
|
|
15
|
+
export const lambdaSgId = stackOutput(lambdaSg.GroupId, {
|
|
16
|
+
description: "Lambda security group ID",
|
|
17
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security group for the Lambda function in the parent stack
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { SecurityGroup, Sub, AWS } from "@intentius/chant-lexicon-aws";
|
|
6
|
+
import { vpc } from "./vpc";
|
|
7
|
+
|
|
8
|
+
export const lambdaSg = new SecurityGroup({
|
|
9
|
+
GroupDescription: Sub`${AWS.StackName} Lambda security group`,
|
|
10
|
+
VpcId: vpc.VpcId,
|
|
11
|
+
SecurityGroupEgress: [{
|
|
12
|
+
IpProtocol: "-1",
|
|
13
|
+
CidrIp: "0.0.0.0/0",
|
|
14
|
+
Description: "Allow all outbound",
|
|
15
|
+
}],
|
|
16
|
+
Tags: [{ Key: "Name", Value: Sub`${AWS.StackName}-lambda-sg` }],
|
|
17
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Network resources — VPC, subnet, internet gateway, and routing
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
Vpc,
|
|
7
|
+
Subnet,
|
|
8
|
+
InternetGateway,
|
|
9
|
+
VPCGatewayAttachment,
|
|
10
|
+
RouteTable,
|
|
11
|
+
EC2Route,
|
|
12
|
+
SubnetRouteTableAssociation,
|
|
13
|
+
Sub,
|
|
14
|
+
AWS,
|
|
15
|
+
} from "@intentius/chant-lexicon-aws";
|
|
16
|
+
|
|
17
|
+
export const vpc = new Vpc({
|
|
18
|
+
CidrBlock: "10.0.0.0/16",
|
|
19
|
+
EnableDnsSupport: true,
|
|
20
|
+
EnableDnsHostnames: true,
|
|
21
|
+
Tags: [{ Key: "Name", Value: Sub`${AWS.StackName}-vpc` }],
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export const subnet = new Subnet({
|
|
25
|
+
VpcId: vpc.VpcId,
|
|
26
|
+
CidrBlock: "10.0.1.0/24",
|
|
27
|
+
MapPublicIpOnLaunch: true,
|
|
28
|
+
Tags: [{ Key: "Name", Value: Sub`${AWS.StackName}-public` }],
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const igw = new InternetGateway({
|
|
32
|
+
Tags: [{ Key: "Name", Value: Sub`${AWS.StackName}-igw` }],
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export const igwAttachment = new VPCGatewayAttachment({
|
|
36
|
+
VpcId: vpc.VpcId,
|
|
37
|
+
InternetGatewayId: igw.InternetGatewayId,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export const routeTable = new RouteTable({
|
|
41
|
+
VpcId: vpc.VpcId,
|
|
42
|
+
Tags: [{ Key: "Name", Value: Sub`${AWS.StackName}-public-rt` }],
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export const defaultRoute = new EC2Route({
|
|
46
|
+
RouteTableId: routeTable.RouteTableId,
|
|
47
|
+
DestinationCidrBlock: "0.0.0.0/0",
|
|
48
|
+
GatewayId: igw.InternetGatewayId,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export const subnetRouteTableAssoc = new SubnetRouteTableAssociation({
|
|
52
|
+
SubnetId: subnet.SubnetId,
|
|
53
|
+
RouteTableId: routeTable.RouteTableId,
|
|
54
|
+
});
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: aws-cloudformation
|
|
3
|
-
description: AWS CloudFormation best practices and common patterns
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# AWS CloudFormation with Chant
|
|
7
|
-
|
|
8
|
-
## Common Resource Types
|
|
9
|
-
|
|
10
|
-
- `AWS::S3::Bucket` — Object storage
|
|
11
|
-
- `AWS::Lambda::Function` — Serverless compute
|
|
12
|
-
- `AWS::DynamoDB::Table` — NoSQL database
|
|
13
|
-
- `AWS::IAM::Role` — Identity and access management
|
|
14
|
-
- `AWS::SNS::Topic` — Pub/sub messaging
|
|
15
|
-
- `AWS::SQS::Queue` — Message queue
|
|
16
|
-
- `AWS::EC2::SecurityGroup` — Network firewall rules
|
|
17
|
-
|
|
18
|
-
## Intrinsic Functions
|
|
19
|
-
|
|
20
|
-
- `Sub` — String interpolation with `${}` syntax
|
|
21
|
-
- `Ref` — Reference a resource or parameter
|
|
22
|
-
- `GetAtt` — Get a resource attribute (e.g. ARN)
|
|
23
|
-
- `If` — Conditional value based on a condition
|
|
24
|
-
- `Join` — Join strings with a delimiter
|
|
25
|
-
- `Select` — Pick an item from a list by index
|
|
26
|
-
|
|
27
|
-
## Pseudo Parameters
|
|
28
|
-
|
|
29
|
-
- `AWS::StackName` — Name of the current stack
|
|
30
|
-
- `AWS::Region` — Current deployment region
|
|
31
|
-
- `AWS::AccountId` — Current AWS account ID
|
|
32
|
-
- `AWS::Partition` — Partition (aws, aws-cn, aws-us-gov)
|
|
33
|
-
|
|
34
|
-
## Best Practices
|
|
35
|
-
|
|
36
|
-
1. **Always enable encryption** — Use `BucketEncryption` for S3, `SSESpecification` for DynamoDB
|
|
37
|
-
2. **Block public access** — Set `PublicAccessBlockConfiguration` on all S3 buckets
|
|
38
|
-
3. **Use least-privilege IAM** — Avoid `*` in IAM policy actions and resources
|
|
39
|
-
4. **Enable versioning** — Turn on `VersioningConfiguration` for data buckets
|
|
40
|
-
5. **Use Sub for dynamic names** — `Sub\`\${AWS::StackName}-suffix\`` for unique naming
|
|
41
|
-
6. **Share config via direct imports** — Put common settings in a config file and import directly
|