@intentius/chant-lexicon-aws 0.49.0 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/codegen/generate-lexicon.d.ts +5 -0
- package/dist/codegen/generate-lexicon.d.ts.map +1 -1
- package/dist/components/host-delivery.d.ts +4 -3
- package/dist/components/host-delivery.d.ts.map +1 -1
- package/dist/disruption.d.ts +26 -0
- package/dist/disruption.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +9 -4
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/waw016.d.ts +10 -2
- package/dist/lint/post-synth/waw016.d.ts.map +1 -1
- package/dist/lint/post-synth/waw064.d.ts +17 -0
- package/dist/lint/post-synth/waw064.d.ts.map +1 -0
- package/dist/lint/post-synth/waw065.d.ts +23 -0
- package/dist/lint/post-synth/waw065.d.ts.map +1 -0
- package/dist/lint/post-synth/waw066.d.ts +25 -0
- package/dist/lint/post-synth/waw066.d.ts.map +1 -0
- package/dist/lint/post-synth/waw067.d.ts +24 -0
- package/dist/lint/post-synth/waw067.d.ts.map +1 -0
- package/dist/lint/post-synth/waw068.d.ts +20 -0
- package/dist/lint/post-synth/waw068.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/meta.json +34 -0
- package/dist/okf/index.md +5 -0
- package/dist/okf/rules/WAW064.md +18 -0
- package/dist/okf/rules/WAW065.md +25 -0
- package/dist/okf/rules/WAW066.md +22 -0
- package/dist/okf/rules/WAW067.md +20 -0
- package/dist/okf/rules/WAW068.md +19 -0
- package/dist/okf/types/EC2Route.md +2 -0
- package/dist/okf/types/InternetGateway.md +4 -0
- package/dist/okf/types/Map.md +4 -0
- package/dist/okf/types/NatGateway.md +4 -0
- package/dist/okf/types/RouteTable.md +4 -0
- package/dist/okf/types/Subnet.md +2 -0
- package/dist/okf/types/SubnetRouteTableAssociation.md +5 -0
- package/dist/okf/types/Table.md +3 -0
- package/dist/okf/types/TransitGateway.md +6 -0
- package/dist/okf/types/TransitGatewayAttachment.md +4 -0
- package/dist/okf/types/TransitGatewayPeeringAttachment.md +4 -0
- package/dist/okf/types/TransitGatewayRoute.md +5 -0
- package/dist/okf/types/TransitGatewayRouteTable.md +4 -0
- package/dist/okf/types/TransitGatewayRouteTableAssociation.md +4 -0
- package/dist/okf/types/TransitGatewayRouteTablePropagation.md +4 -0
- package/dist/okf/types/TransitGatewayVpcAttachment.md +4 -0
- package/dist/okf/types/Type.md +5 -0
- package/dist/okf/types/VPNConnection.md +4 -0
- package/dist/okf/types/VPNGateway.md +4 -0
- package/dist/op/builders.d.ts +47 -0
- package/dist/op/builders.d.ts.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rules/waw016.ts +37 -16
- package/dist/rules/waw064.ts +52 -0
- package/dist/rules/waw065.ts +110 -0
- package/dist/rules/waw066.ts +155 -0
- package/dist/rules/waw067.ts +114 -0
- package/dist/rules/waw068.ts +78 -0
- package/dist/spec/parse.d.ts +7 -0
- package/dist/spec/parse.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/codegen/fallback.ts +1 -0
- package/src/codegen/generate-lexicon.ts +6 -0
- package/src/codegen/sam.ts +9 -0
- package/src/components/host-delivery.ts +4 -3
- package/src/disruption.test.ts +198 -0
- package/src/disruption.ts +154 -0
- package/src/generated/lexicon-aws.json +34 -0
- package/src/index.ts +8 -0
- package/src/lint/audit-catalog.ts +10 -0
- package/src/lint/post-synth/index.ts +10 -0
- package/src/lint/post-synth/waw016.test.ts +61 -4
- package/src/lint/post-synth/waw016.ts +37 -16
- package/src/lint/post-synth/waw064.test.ts +79 -0
- package/src/lint/post-synth/waw064.ts +52 -0
- package/src/lint/post-synth/waw065.test.ts +90 -0
- package/src/lint/post-synth/waw065.ts +110 -0
- package/src/lint/post-synth/waw066.test.ts +108 -0
- package/src/lint/post-synth/waw066.ts +155 -0
- package/src/lint/post-synth/waw067.test.ts +79 -0
- package/src/lint/post-synth/waw067.ts +114 -0
- package/src/lint/post-synth/waw068.test.ts +74 -0
- package/src/lint/post-synth/waw068.ts +78 -0
- package/src/lsp/hover.ts +14 -2
- package/src/op/builders.test.ts +61 -0
- package/src/op/builders.ts +72 -0
- package/src/plugin.ts +16 -1
- package/src/spec/parse.test.ts +74 -0
- package/src/spec/parse.ts +26 -6
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAW067: Single-AZ NAT Gateway Serves Multi-AZ Private Subnets
|
|
3
|
+
*
|
|
4
|
+
* Cross-resource join across four resource kinds: a NAT gateway lives in one
|
|
5
|
+
* subnet, which lives in one Availability Zone; a route table's default
|
|
6
|
+
* route names the NAT gateway it forwards to; a subnet is pinned to a route
|
|
7
|
+
* table by an `AWS::EC2::SubnetRouteTableAssociation`. Follow that chain and
|
|
8
|
+
* a common reliability gap falls out — private subnets in two or more AZs
|
|
9
|
+
* all defaulting to the *same* NAT gateway. CloudFormation has no opinion on
|
|
10
|
+
* this; it deploys cleanly. But a NAT gateway lives entirely inside one AZ,
|
|
11
|
+
* so if that AZ has an outage, every subnet depending on it loses egress —
|
|
12
|
+
* including the subnets in AZs that were otherwise healthy. The
|
|
13
|
+
* Well-Architected fix is one NAT gateway per AZ, each serving only its own
|
|
14
|
+
* AZ's subnets.
|
|
15
|
+
*
|
|
16
|
+
* Only fires when the Availability Zones involved are literal strings —
|
|
17
|
+
* an `Fn::GetAZs`/`Fn::Select`-derived AZ can't be compared statically, so a
|
|
18
|
+
* subnet without a literal `AvailabilityZone` is silently excluded rather
|
|
19
|
+
* than guessed at.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
23
|
+
import { parseCFTemplate, findResourceRefs, type CFTemplate } from "./cf-refs";
|
|
24
|
+
|
|
25
|
+
export function checkNatGatewaySingleAz(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
26
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
27
|
+
|
|
28
|
+
for (const [_lexicon, output] of ctx.outputs) {
|
|
29
|
+
const template: CFTemplate | null = parseCFTemplate(output);
|
|
30
|
+
if (!template?.Resources) continue;
|
|
31
|
+
const resources = template.Resources;
|
|
32
|
+
|
|
33
|
+
// NAT gateway id -> its own subnet's literal AZ.
|
|
34
|
+
const natGatewayAz = new Map<string, string>();
|
|
35
|
+
for (const [logicalId, resource] of Object.entries(resources)) {
|
|
36
|
+
if (resource.Type !== "AWS::EC2::NatGateway") continue;
|
|
37
|
+
const subnetRefs = findResourceRefs(resource.Properties?.SubnetId);
|
|
38
|
+
if (subnetRefs.size !== 1) continue;
|
|
39
|
+
const [subnetId] = subnetRefs;
|
|
40
|
+
const az = resources[subnetId]?.Properties?.AvailabilityZone;
|
|
41
|
+
if (typeof az === "string") natGatewayAz.set(logicalId, az);
|
|
42
|
+
}
|
|
43
|
+
if (natGatewayAz.size === 0) continue;
|
|
44
|
+
|
|
45
|
+
// route table id -> the NAT gateway its default route forwards to.
|
|
46
|
+
const routeTableNatTarget = new Map<string, string>();
|
|
47
|
+
for (const resource of Object.values(resources)) {
|
|
48
|
+
if (resource.Type !== "AWS::EC2::Route") continue;
|
|
49
|
+
const props = resource.Properties ?? {};
|
|
50
|
+
if (props.DestinationCidrBlock !== "0.0.0.0/0") continue;
|
|
51
|
+
const natRefs = findResourceRefs(props.NatGatewayId);
|
|
52
|
+
if (natRefs.size !== 1) continue;
|
|
53
|
+
const [natId] = natRefs;
|
|
54
|
+
if (!natGatewayAz.has(natId)) continue;
|
|
55
|
+
for (const rtId of findResourceRefs(props.RouteTableId)) {
|
|
56
|
+
routeTableNatTarget.set(rtId, natId);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// subnet id -> route table id, from explicit associations only.
|
|
61
|
+
const subnetRouteTable = new Map<string, string>();
|
|
62
|
+
for (const resource of Object.values(resources)) {
|
|
63
|
+
if (resource.Type !== "AWS::EC2::SubnetRouteTableAssociation") continue;
|
|
64
|
+
const props = resource.Properties ?? {};
|
|
65
|
+
const subnetRefs = findResourceRefs(props.SubnetId);
|
|
66
|
+
const rtRefs = findResourceRefs(props.RouteTableId);
|
|
67
|
+
if (subnetRefs.size !== 1 || rtRefs.size !== 1) continue;
|
|
68
|
+
const [subnetId] = subnetRefs;
|
|
69
|
+
const [rtId] = rtRefs;
|
|
70
|
+
subnetRouteTable.set(subnetId, rtId);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// NAT gateway id -> the set of subnets that default-route through it.
|
|
74
|
+
const servedSubnets = new Map<string, Set<string>>();
|
|
75
|
+
for (const [subnetId, rtId] of subnetRouteTable) {
|
|
76
|
+
const natId = routeTableNatTarget.get(rtId);
|
|
77
|
+
if (!natId) continue;
|
|
78
|
+
let set = servedSubnets.get(natId);
|
|
79
|
+
if (!set) {
|
|
80
|
+
set = new Set();
|
|
81
|
+
servedSubnets.set(natId, set);
|
|
82
|
+
}
|
|
83
|
+
set.add(subnetId);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const [natId, subnetIds] of servedSubnets) {
|
|
87
|
+
const azs = new Set<string>();
|
|
88
|
+
for (const subnetId of subnetIds) {
|
|
89
|
+
const az = resources[subnetId]?.Properties?.AvailabilityZone;
|
|
90
|
+
if (typeof az === "string") azs.add(az);
|
|
91
|
+
}
|
|
92
|
+
if (azs.size > 1) {
|
|
93
|
+
diagnostics.push({
|
|
94
|
+
checkId: "WAW067",
|
|
95
|
+
severity: "warning",
|
|
96
|
+
message: `NAT Gateway "${natId}" (in ${natGatewayAz.get(natId)}) is the default-route egress for private subnets across ${azs.size} Availability Zones (${[...azs].sort().join(", ")}) — a single-AZ NAT gateway is a single point of failure for cross-AZ egress; add one NAT gateway per AZ`,
|
|
97
|
+
entity: natId,
|
|
98
|
+
lexicon: "aws",
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return diagnostics;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const waw067: PostSynthCheck = {
|
|
108
|
+
id: "WAW067",
|
|
109
|
+
description: "A single-AZ NAT gateway serves private subnets across multiple Availability Zones — single point of failure for cross-AZ egress",
|
|
110
|
+
|
|
111
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
112
|
+
return checkNatGatewaySingleAz(ctx);
|
|
113
|
+
},
|
|
114
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAW068: VPN Gateway Has A Single VPN Connection
|
|
3
|
+
*
|
|
4
|
+
* An `AWS::EC2::VPNConnection` always gets two tunnels from AWS, but that
|
|
5
|
+
* redundancy only covers the tunnel pair inside one connection — it does
|
|
6
|
+
* nothing if the connection's Customer Gateway, on-prem device, or the
|
|
7
|
+
* single site it terminates at goes down. The reliability boundary that
|
|
8
|
+
* matters is the connection itself: a `AWS::EC2::VPNGateway` (or a Transit
|
|
9
|
+
* Gateway used for VPN attachment) with only one `VPNConnection` attached
|
|
10
|
+
* has no fallback path for hybrid connectivity if that connection's
|
|
11
|
+
* Customer Gateway or on-prem side fails.
|
|
12
|
+
*
|
|
13
|
+
* Cross-resource join: group every declared `VPNConnection` by the gateway
|
|
14
|
+
* (`VpnGatewayId` or `TransitGatewayId`) it attaches to, and flag any
|
|
15
|
+
* gateway left with exactly one.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
19
|
+
import { parseCFTemplate, findResourceRefs, type CFTemplate } from "./cf-refs";
|
|
20
|
+
|
|
21
|
+
export function checkVpnGatewayRedundancy(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
22
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
23
|
+
|
|
24
|
+
for (const [_lexicon, output] of ctx.outputs) {
|
|
25
|
+
const template: CFTemplate | null = parseCFTemplate(output);
|
|
26
|
+
if (!template?.Resources) continue;
|
|
27
|
+
const resources = template.Resources;
|
|
28
|
+
|
|
29
|
+
// gateway logical id -> the VPNConnection logical ids attached to it.
|
|
30
|
+
const connectionsByGateway = new Map<string, string[]>();
|
|
31
|
+
|
|
32
|
+
for (const [logicalId, resource] of Object.entries(resources)) {
|
|
33
|
+
if (resource.Type !== "AWS::EC2::VPNConnection") continue;
|
|
34
|
+
const props = resource.Properties ?? {};
|
|
35
|
+
|
|
36
|
+
const gatewayProp = props.VpnGatewayId !== undefined ? props.VpnGatewayId : props.TransitGatewayId;
|
|
37
|
+
if (gatewayProp === undefined) continue; // no declared gateway target — can't group, stay quiet
|
|
38
|
+
|
|
39
|
+
const gwRefs = findResourceRefs(gatewayProp);
|
|
40
|
+
if (gwRefs.size !== 1) continue;
|
|
41
|
+
const [gwId] = gwRefs;
|
|
42
|
+
|
|
43
|
+
const gw = resources[gwId];
|
|
44
|
+
if (!gw || (gw.Type !== "AWS::EC2::VPNGateway" && gw.Type !== "AWS::EC2::TransitGateway")) continue;
|
|
45
|
+
|
|
46
|
+
let list = connectionsByGateway.get(gwId);
|
|
47
|
+
if (!list) {
|
|
48
|
+
list = [];
|
|
49
|
+
connectionsByGateway.set(gwId, list);
|
|
50
|
+
}
|
|
51
|
+
list.push(logicalId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (const [gwId, connectionIds] of connectionsByGateway) {
|
|
55
|
+
if (connectionIds.length !== 1) continue;
|
|
56
|
+
|
|
57
|
+
const kind = resources[gwId].Type === "AWS::EC2::VPNGateway" ? "VPN Gateway" : "Transit Gateway";
|
|
58
|
+
diagnostics.push({
|
|
59
|
+
checkId: "WAW068",
|
|
60
|
+
severity: "warning",
|
|
61
|
+
message: `${kind} "${gwId}" has only one VPN connection ("${connectionIds[0]}") attached — no redundant path if this connection, its tunnels, or its Customer Gateway fail; attach a second VPNConnection (ideally to a separate Customer Gateway) for hybrid-connectivity redundancy`,
|
|
62
|
+
entity: gwId,
|
|
63
|
+
lexicon: "aws",
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return diagnostics;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const waw068: PostSynthCheck = {
|
|
72
|
+
id: "WAW068",
|
|
73
|
+
description: "VPN Gateway or Transit Gateway has only one attached VPN Connection — no redundant path for hybrid connectivity",
|
|
74
|
+
|
|
75
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
76
|
+
return checkVpnGatewayRedundancy(ctx);
|
|
77
|
+
},
|
|
78
|
+
};
|
package/dist/spec/parse.d.ts
CHANGED
|
@@ -36,6 +36,13 @@ export interface ParsedResource {
|
|
|
36
36
|
writeOnly: string[];
|
|
37
37
|
primaryIdentifier: string[];
|
|
38
38
|
deprecatedProperties: string[];
|
|
39
|
+
/**
|
|
40
|
+
* The subset of `deprecatedProperties` that no upstream declaration backs.
|
|
41
|
+
* These names come from {@link DEPRECATION_RE} matching the property
|
|
42
|
+
* description, so they are an inference rather than a reading. Kept apart so
|
|
43
|
+
* a consumer can tell the two apart and calibrate what it says (#1701).
|
|
44
|
+
*/
|
|
45
|
+
inferredDeprecations: string[];
|
|
39
46
|
conditionalCreateOnly: string[];
|
|
40
47
|
replacementStrategy?: "delete_then_create" | "create_then_delete";
|
|
41
48
|
tagging?: {
|
package/dist/spec/parse.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../../src/spec/parse.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,EAGL,kBAAkB,IAAI,sBAAsB,EAE5C,KAAK,mBAAmB,EAIzB,MAAM,sCAAsC,CAAC;AAG9C,YAAY,EAAE,mBAAmB,EAAE,MAAM,sCAAsC,CAAC;
|
|
1
|
+
{"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../../src/spec/parse.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,EAGL,kBAAkB,IAAI,sBAAsB,EAE5C,KAAK,mBAAmB,EAIzB,MAAM,sCAAsC,CAAC;AAG9C,YAAY,EAAE,mBAAmB,EAAE,MAAM,sCAAsC,CAAC;AAkBhF,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,OAAO,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,WAAW,EAAE,mBAAmB,CAAC;CAClC;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,cAAc,EAAE,CAAC;CAC9B;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,cAAc,EAAE,CAAC;IAC7B,UAAU,EAAE,eAAe,EAAE,CAAC;IAC9B,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,iBAAiB,EAAE,MAAM,EAAE,CAAC;IAC5B,oBAAoB,EAAE,MAAM,EAAE,CAAC;IAC/B;;;;;OAKG;IACH,oBAAoB,EAAE,MAAM,EAAE,CAAC;IAC/B,qBAAqB,EAAE,MAAM,EAAE,CAAC;IAChC,mBAAmB,CAAC,EAAE,oBAAoB,GAAG,oBAAoB,CAAC;IAClE,OAAO,CAAC,EAAE;QAAE,QAAQ,EAAE,OAAO,CAAC;QAAC,WAAW,EAAE,OAAO,CAAC;QAAC,YAAY,EAAE,OAAO,CAAA;KAAE,CAAC;CAC9E;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,cAAc,CAAC;IACzB,aAAa,EAAE,kBAAkB,EAAE,CAAC;IACpC,KAAK,EAAE,UAAU,EAAE,CAAC;CACrB;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,iBAAiB,CAmJvE;AAiBD,eAAO,MAAM,kBAAkB,+BAAyB,CAAC;AAMzD;;GAEG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAGrD;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAGvD"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-aws",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.51.0",
|
|
4
4
|
"description": "AWS CloudFormation lexicon for chant — declarative IaC in TypeScript",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://intentius.io/chant",
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
"typescript": "^5.9.3"
|
|
81
81
|
},
|
|
82
82
|
"peerDependencies": {
|
|
83
|
-
"@intentius/chant": "^0.
|
|
83
|
+
"@intentius/chant": "^0.51.0",
|
|
84
84
|
"typescript": "^5.9.3"
|
|
85
85
|
}
|
|
86
86
|
}
|
package/src/codegen/fallback.ts
CHANGED
|
@@ -26,6 +26,11 @@ export interface LexiconEntry {
|
|
|
26
26
|
writeOnly?: string[];
|
|
27
27
|
primaryIdentifier?: string[];
|
|
28
28
|
deprecatedProperties?: string[];
|
|
29
|
+
/**
|
|
30
|
+
* Subset of `deprecatedProperties` that only a regex over the property
|
|
31
|
+
* description supports; the rest is declared by the Registry schema (#1701).
|
|
32
|
+
*/
|
|
33
|
+
inferredDeprecations?: string[];
|
|
29
34
|
conditionalCreateOnly?: string[];
|
|
30
35
|
replacementStrategy?: "delete_then_create" | "create_then_delete";
|
|
31
36
|
tagging?: { taggable: boolean; tagOnCreate: boolean; tagUpdatable: boolean };
|
|
@@ -72,6 +77,7 @@ export function generateLexiconJSON(
|
|
|
72
77
|
...(r.resource.writeOnly.length > 0 && { writeOnly: r.resource.writeOnly }),
|
|
73
78
|
...(r.resource.primaryIdentifier.length > 0 && { primaryIdentifier: r.resource.primaryIdentifier }),
|
|
74
79
|
...(r.resource.deprecatedProperties?.length && { deprecatedProperties: r.resource.deprecatedProperties }),
|
|
80
|
+
...(r.resource.inferredDeprecations?.length && { inferredDeprecations: r.resource.inferredDeprecations }),
|
|
75
81
|
...(r.resource.conditionalCreateOnly?.length && { conditionalCreateOnly: r.resource.conditionalCreateOnly }),
|
|
76
82
|
...(r.resource.replacementStrategy && { replacementStrategy: r.resource.replacementStrategy }),
|
|
77
83
|
...(r.resource.tagging && { tagging: r.resource.tagging }),
|
package/src/codegen/sam.ts
CHANGED
|
@@ -59,6 +59,7 @@ function samFunction(): SchemaParseResult {
|
|
|
59
59
|
writeOnly: [],
|
|
60
60
|
primaryIdentifier: [],
|
|
61
61
|
deprecatedProperties: [],
|
|
62
|
+
inferredDeprecations: [],
|
|
62
63
|
conditionalCreateOnly: [],
|
|
63
64
|
},
|
|
64
65
|
propertyTypes: [
|
|
@@ -147,6 +148,7 @@ function samApi(): SchemaParseResult {
|
|
|
147
148
|
writeOnly: [],
|
|
148
149
|
primaryIdentifier: [],
|
|
149
150
|
deprecatedProperties: [],
|
|
151
|
+
inferredDeprecations: [],
|
|
150
152
|
conditionalCreateOnly: [],
|
|
151
153
|
},
|
|
152
154
|
propertyTypes: [
|
|
@@ -200,6 +202,7 @@ function samHttpApi(): SchemaParseResult {
|
|
|
200
202
|
writeOnly: [],
|
|
201
203
|
primaryIdentifier: [],
|
|
202
204
|
deprecatedProperties: [],
|
|
205
|
+
inferredDeprecations: [],
|
|
203
206
|
conditionalCreateOnly: [],
|
|
204
207
|
},
|
|
205
208
|
propertyTypes: [
|
|
@@ -236,6 +239,7 @@ function samSimpleTable(): SchemaParseResult {
|
|
|
236
239
|
writeOnly: [],
|
|
237
240
|
primaryIdentifier: [],
|
|
238
241
|
deprecatedProperties: [],
|
|
242
|
+
inferredDeprecations: [],
|
|
239
243
|
conditionalCreateOnly: [],
|
|
240
244
|
},
|
|
241
245
|
propertyTypes: [
|
|
@@ -273,6 +277,7 @@ function samLayerVersion(): SchemaParseResult {
|
|
|
273
277
|
writeOnly: [],
|
|
274
278
|
primaryIdentifier: [],
|
|
275
279
|
deprecatedProperties: [],
|
|
280
|
+
inferredDeprecations: [],
|
|
276
281
|
conditionalCreateOnly: [],
|
|
277
282
|
},
|
|
278
283
|
propertyTypes: [],
|
|
@@ -306,6 +311,7 @@ function samStateMachine(): SchemaParseResult {
|
|
|
306
311
|
writeOnly: [],
|
|
307
312
|
primaryIdentifier: [],
|
|
308
313
|
deprecatedProperties: [],
|
|
314
|
+
inferredDeprecations: [],
|
|
309
315
|
conditionalCreateOnly: [],
|
|
310
316
|
},
|
|
311
317
|
propertyTypes: [
|
|
@@ -339,6 +345,7 @@ function samApplication(): SchemaParseResult {
|
|
|
339
345
|
writeOnly: [],
|
|
340
346
|
primaryIdentifier: [],
|
|
341
347
|
deprecatedProperties: [],
|
|
348
|
+
inferredDeprecations: [],
|
|
342
349
|
conditionalCreateOnly: [],
|
|
343
350
|
},
|
|
344
351
|
propertyTypes: [],
|
|
@@ -360,6 +367,7 @@ function samConnector(): SchemaParseResult {
|
|
|
360
367
|
writeOnly: [],
|
|
361
368
|
primaryIdentifier: [],
|
|
362
369
|
deprecatedProperties: [],
|
|
370
|
+
inferredDeprecations: [],
|
|
363
371
|
conditionalCreateOnly: [],
|
|
364
372
|
},
|
|
365
373
|
propertyTypes: [],
|
|
@@ -397,6 +405,7 @@ function samGraphQLApi(): SchemaParseResult {
|
|
|
397
405
|
writeOnly: [],
|
|
398
406
|
primaryIdentifier: [],
|
|
399
407
|
deprecatedProperties: [],
|
|
408
|
+
inferredDeprecations: [],
|
|
400
409
|
conditionalCreateOnly: [],
|
|
401
410
|
},
|
|
402
411
|
propertyTypes: [],
|
|
@@ -73,9 +73,10 @@ function resolveTargets(
|
|
|
73
73
|
* Deploy a revision to a host (fleet) via AWS CodeDeploy: create the
|
|
74
74
|
* deployment, then wait for a terminal status. Rollback re-invokes
|
|
75
75
|
* `stopAndRollback` against the deployment id this step created (captured in
|
|
76
|
-
* a closure-scoped map keyed by application/deploymentGroup
|
|
77
|
-
*
|
|
78
|
-
*
|
|
76
|
+
* a closure-scoped map keyed by application/deploymentGroup; the driver now
|
|
77
|
+
* also passes `run`'s output as `rollback`'s optional third arg (#1944), but
|
|
78
|
+
* this capability's input-derived key needs neither channel to resolve
|
|
79
|
+
* identity) — riding CodeDeploy's own native automatic rollback to the
|
|
79
80
|
* last known-good revision rather than a chant-scripted compensation, the
|
|
80
81
|
* "auto" rollback style the Neo4j pilot's docs comment contrasts with the
|
|
81
82
|
* ALB/ECS pilot's component-declared one.
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { awsDisruption, classifyAwsChange, type DisruptionSpec } from "./disruption";
|
|
3
|
+
import type { DisruptionQuery } from "@intentius/chant/lexicon";
|
|
4
|
+
|
|
5
|
+
const specs = new Map<string, DisruptionSpec>([
|
|
6
|
+
[
|
|
7
|
+
"AWS::EC2::Instance",
|
|
8
|
+
{
|
|
9
|
+
resourceType: "AWS::EC2::Instance",
|
|
10
|
+
kind: "resource",
|
|
11
|
+
attrs: { InstanceId: "InstanceId", PublicIp: "PublicIp" },
|
|
12
|
+
createOnly: ["ImageId", "SubnetId", "KeyName"],
|
|
13
|
+
conditionalCreateOnly: ["InstanceType"],
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
[
|
|
17
|
+
"AWS::RDS::DBInstance",
|
|
18
|
+
{
|
|
19
|
+
resourceType: "AWS::RDS::DBInstance",
|
|
20
|
+
kind: "resource",
|
|
21
|
+
createOnly: ["DBInstanceIdentifier"],
|
|
22
|
+
replacementStrategy: "delete_then_create",
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
[
|
|
26
|
+
"AWS::S3::BucketPolicy",
|
|
27
|
+
{ resourceType: "AWS::S3::BucketPolicy", kind: "resource" },
|
|
28
|
+
],
|
|
29
|
+
[
|
|
30
|
+
"AWS::EC2::Instance.BlockDeviceMapping",
|
|
31
|
+
{ resourceType: "AWS::EC2::Instance.BlockDeviceMapping", kind: "property" },
|
|
32
|
+
],
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
function query(type: string | undefined, paths: string[]): DisruptionQuery {
|
|
36
|
+
return {
|
|
37
|
+
name: "thing",
|
|
38
|
+
...(type ? { type } : {}),
|
|
39
|
+
deltas: paths.map((path) => ({ path, oldValue: "a", newValue: "b" })),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("classifyAwsChange (#1665)", () => {
|
|
44
|
+
test("a create-only property is a replace, and says which one", () => {
|
|
45
|
+
const v = classifyAwsChange(query("AWS::EC2::Instance", ["attributes.ImageId"]), specs);
|
|
46
|
+
expect(v.disruption).toBe("replace");
|
|
47
|
+
expect(v.because).toEqual(["attributes.ImageId"]);
|
|
48
|
+
expect(v.detail).toContain("ImageId is create-only");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// `delete_then_create` is the destructive form: the old resource is gone
|
|
52
|
+
// before the new one exists.
|
|
53
|
+
test("delete_then_create replacement is a destroy", () => {
|
|
54
|
+
const v = classifyAwsChange(
|
|
55
|
+
query("AWS::RDS::DBInstance", ["attributes.DBInstanceIdentifier"]),
|
|
56
|
+
specs,
|
|
57
|
+
);
|
|
58
|
+
expect(v.disruption).toBe("destroy");
|
|
59
|
+
expect(v.detail).toContain("deleting first");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a mutable property is in-place", () => {
|
|
63
|
+
const v = classifyAwsChange(query("AWS::EC2::Instance", ["attributes.Monitoring"]), specs);
|
|
64
|
+
expect(v.disruption).toBe("in-place");
|
|
65
|
+
expect(v.detail).toContain("no create-only property of AWS::EC2::Instance changed");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("replacement wins over an in-place property changing alongside it", () => {
|
|
69
|
+
const v = classifyAwsChange(
|
|
70
|
+
query("AWS::EC2::Instance", ["attributes.Monitoring", "attributes.SubnetId"]),
|
|
71
|
+
specs,
|
|
72
|
+
);
|
|
73
|
+
expect(v.disruption).toBe("replace");
|
|
74
|
+
expect(v.because).toEqual(["attributes.SubnetId"]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// The schema says "depends on the value", which is not an answer. Reporting
|
|
78
|
+
// that as in-place would be the exact failure the `unknown` level exists for.
|
|
79
|
+
test("a conditionally create-only property is unknown, not in-place", () => {
|
|
80
|
+
const v = classifyAwsChange(
|
|
81
|
+
query("AWS::EC2::Instance", ["attributes.InstanceType"]),
|
|
82
|
+
specs,
|
|
83
|
+
);
|
|
84
|
+
expect(v.disruption).toBe("unknown");
|
|
85
|
+
expect(v.because).toEqual(["attributes.InstanceType"]);
|
|
86
|
+
expect(v.detail).toContain("conditionally create-only");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("a type with no schema on record is unknown", () => {
|
|
90
|
+
const v = classifyAwsChange(query("AWS::Nowhere::Thing", ["attributes.Anything"]), specs);
|
|
91
|
+
expect(v.disruption).toBe("unknown");
|
|
92
|
+
expect(v.detail).toContain("no CloudFormation registry schema on record");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a property-type entry is not a resource, so it is unknown", () => {
|
|
96
|
+
const v = classifyAwsChange(
|
|
97
|
+
query("AWS::EC2::Instance.BlockDeviceMapping", ["attributes.Ebs"]),
|
|
98
|
+
specs,
|
|
99
|
+
);
|
|
100
|
+
expect(v.disruption).toBe("unknown");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("an entry with no observed type is unknown", () => {
|
|
104
|
+
const v = classifyAwsChange(query(undefined, ["attributes.ImageId"]), specs);
|
|
105
|
+
expect(v.disruption).toBe("unknown");
|
|
106
|
+
expect(v.detail).toContain("no resource type");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// A read-only attribute changing is a symptom of something else, never the
|
|
110
|
+
// cause of a replacement — a re-created instance reports a new PublicIp.
|
|
111
|
+
test("read-only attributes are not evidence of anything", () => {
|
|
112
|
+
const v = classifyAwsChange(query("AWS::EC2::Instance", ["attributes.PublicIp"]), specs);
|
|
113
|
+
expect(v.disruption).toBe("in-place");
|
|
114
|
+
expect(v.detail).toContain("only status, identity, or read-only attributes changed");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("core's own observation fields are not properties", () => {
|
|
118
|
+
const v = classifyAwsChange(
|
|
119
|
+
query("AWS::EC2::Instance", ["status", "lastUpdated", "physicalId"]),
|
|
120
|
+
specs,
|
|
121
|
+
);
|
|
122
|
+
expect(v.disruption).toBe("in-place");
|
|
123
|
+
expect(v.detail).toContain("only status, identity, or read-only attributes changed");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("a type whose schema declares no create-only property is in-place", () => {
|
|
127
|
+
const v = classifyAwsChange(query("AWS::S3::BucketPolicy", ["attributes.PolicyDocument"]), specs);
|
|
128
|
+
expect(v.disruption).toBe("in-place");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// createOnlyProperties can point at a nested pointer path (`Config/Engine`);
|
|
132
|
+
// the observation reports the top-level property.
|
|
133
|
+
test("a nested create-only pointer matches on its head segment", () => {
|
|
134
|
+
const nested = new Map<string, DisruptionSpec>([
|
|
135
|
+
[
|
|
136
|
+
"AWS::Fake::Thing",
|
|
137
|
+
{ resourceType: "AWS::Fake::Thing", kind: "resource", createOnly: ["Config/Engine"] },
|
|
138
|
+
],
|
|
139
|
+
]);
|
|
140
|
+
const v = classifyAwsChange(query("AWS::Fake::Thing", ["attributes.Config"]), nested);
|
|
141
|
+
expect(v.disruption).toBe("replace");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("duplicate hits name the property once but keep every path", () => {
|
|
145
|
+
const v = classifyAwsChange(
|
|
146
|
+
query("AWS::EC2::Instance", ["attributes.ImageId", "attributes.KeyName"]),
|
|
147
|
+
specs,
|
|
148
|
+
);
|
|
149
|
+
expect(v.because).toEqual(["attributes.ImageId", "attributes.KeyName"]);
|
|
150
|
+
expect(v.detail).toContain("ImageId, KeyName");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// The plugin method against the registry the codegen actually compiled — the
|
|
155
|
+
// evidence that the verdicts come from CloudFormation's own schema and not
|
|
156
|
+
// from a table in this file.
|
|
157
|
+
describe("awsDisruption over the generated registry", () => {
|
|
158
|
+
const change = (name: string, type: string, paths: string[]): DisruptionQuery => ({
|
|
159
|
+
name,
|
|
160
|
+
type,
|
|
161
|
+
deltas: paths.map((path) => ({ path, oldValue: "a", newValue: "b" })),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("classifies a whole batch off the compiled schema", () => {
|
|
165
|
+
const out = awsDisruption({
|
|
166
|
+
environment: "prod",
|
|
167
|
+
changes: [
|
|
168
|
+
// createOnlyProperties
|
|
169
|
+
change("web", "AWS::EC2::Instance", ["attributes.ImageId"]),
|
|
170
|
+
// conditionalCreateOnlyProperties — the schema says "it depends"
|
|
171
|
+
change("sized", "AWS::EC2::Instance", ["attributes.InstanceType"]),
|
|
172
|
+
// not create-only for this type
|
|
173
|
+
change("mon", "AWS::EC2::Instance", ["attributes.Monitoring"]),
|
|
174
|
+
// no schema on record
|
|
175
|
+
change("nope", "AWS::Nowhere::Thing", ["attributes.Whatever"]),
|
|
176
|
+
],
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
expect(out.web.disruption).toBe("replace");
|
|
180
|
+
expect(out.web.because).toEqual(["attributes.ImageId"]);
|
|
181
|
+
expect(out.sized.disruption).toBe("unknown");
|
|
182
|
+
expect(out.sized.detail).toContain("conditionally create-only");
|
|
183
|
+
expect(out.mon.disruption).toBe("in-place");
|
|
184
|
+
expect(out.nope.disruption).toBe("unknown");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("every query gets a verdict, so nothing silently drops out of the plan", () => {
|
|
188
|
+
const changes = [
|
|
189
|
+
change("a", "AWS::S3::Bucket", ["attributes.BucketName"]),
|
|
190
|
+
change("b", "AWS::IAM::Role", ["attributes.RoleName"]),
|
|
191
|
+
];
|
|
192
|
+
const out = awsDisruption({ environment: "prod", changes });
|
|
193
|
+
expect(Object.keys(out).sort()).toEqual(["a", "b"]);
|
|
194
|
+
// BucketName and RoleName are both create-only in the Registry schema.
|
|
195
|
+
expect(out.a.disruption).toBe("replace");
|
|
196
|
+
expect(out.b.disruption).toBe("replace");
|
|
197
|
+
});
|
|
198
|
+
});
|