@intentius/chant-lexicon-aws 0.15.2 → 0.16.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/generated/index.d.ts +11 -0
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/integrity.json +4 -4
- package/dist/live-attrs.d.ts +4 -0
- package/dist/live-attrs.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/meta.json +118 -11
- package/dist/op/activities/aws-apply.d.ts +76 -0
- package/dist/op/activities/aws-apply.d.ts.map +1 -0
- package/dist/op/activities/floci.d.ts +11 -19
- package/dist/op/activities/floci.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +5 -1
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/reference-catalog.d.ts +14 -0
- package/dist/reference-catalog.d.ts.map +1 -0
- package/dist/types/index.d.ts +174 -19
- package/package.json +2 -2
- package/src/generated/index.d.ts +174 -19
- package/src/generated/index.ts +13 -2
- package/src/generated/lexicon-aws.json +118 -11
- package/src/live-attrs.test.ts +54 -0
- package/src/live-attrs.ts +40 -0
- package/src/op/activities/aws-apply.test.ts +143 -0
- package/src/op/activities/aws-apply.ts +194 -0
- package/src/op/activities/floci.ts +49 -91
- package/src/op/activities/index.ts +22 -1
- package/src/plugin.ts +13 -0
- package/src/reference-catalog.test.ts +74 -0
- package/src/reference-catalog.ts +82 -0
|
@@ -8672,6 +8672,16 @@
|
|
|
8672
8672
|
"kind": "property",
|
|
8673
8673
|
"lexicon": "aws"
|
|
8674
8674
|
},
|
|
8675
|
+
"AzureProviderConfiguration": {
|
|
8676
|
+
"resourceType": "AWS::SecurityHub::Connector.AzureProviderConfiguration",
|
|
8677
|
+
"kind": "property",
|
|
8678
|
+
"lexicon": "aws"
|
|
8679
|
+
},
|
|
8680
|
+
"AzureScopeConfiguration": {
|
|
8681
|
+
"resourceType": "AWS::SecurityHub::Connector.AzureScopeConfiguration",
|
|
8682
|
+
"kind": "property",
|
|
8683
|
+
"lexicon": "aws"
|
|
8684
|
+
},
|
|
8675
8685
|
"B2BICapability": {
|
|
8676
8686
|
"resourceType": "AWS::B2BI::Capability",
|
|
8677
8687
|
"kind": "resource",
|
|
@@ -18201,6 +18211,9 @@
|
|
|
18201
18211
|
"resourceType": "AWS::Config::ConformancePack",
|
|
18202
18212
|
"kind": "resource",
|
|
18203
18213
|
"lexicon": "aws",
|
|
18214
|
+
"attrs": {
|
|
18215
|
+
"ConformancePackArn": "ConformancePackArn"
|
|
18216
|
+
},
|
|
18204
18217
|
"propertyConstraints": {
|
|
18205
18218
|
"ConformancePackName": {
|
|
18206
18219
|
"pattern": "[a-zA-Z][-a-zA-Z0-9]*",
|
|
@@ -18235,13 +18248,23 @@
|
|
|
18235
18248
|
],
|
|
18236
18249
|
"primaryIdentifier": [
|
|
18237
18250
|
"ConformancePackName"
|
|
18238
|
-
]
|
|
18251
|
+
],
|
|
18252
|
+
"tagging": {
|
|
18253
|
+
"taggable": true,
|
|
18254
|
+
"tagOnCreate": true,
|
|
18255
|
+
"tagUpdatable": true
|
|
18256
|
+
}
|
|
18239
18257
|
},
|
|
18240
18258
|
"ConformancePack_ConformancePackInputParameter": {
|
|
18241
18259
|
"resourceType": "AWS::Config::ConformancePack.ConformancePackInputParameter",
|
|
18242
18260
|
"kind": "property",
|
|
18243
18261
|
"lexicon": "aws"
|
|
18244
18262
|
},
|
|
18263
|
+
"ConformancePack_Tag": {
|
|
18264
|
+
"resourceType": "AWS::Config::ConformancePack.Tag",
|
|
18265
|
+
"kind": "property",
|
|
18266
|
+
"lexicon": "aws"
|
|
18267
|
+
},
|
|
18245
18268
|
"ConnectAction": {
|
|
18246
18269
|
"resourceType": "AWS::SES::ReceiptRule.ConnectAction",
|
|
18247
18270
|
"kind": "property",
|
|
@@ -19800,7 +19823,10 @@
|
|
|
19800
19823
|
"ConnectorStatus": "ConnectorStatus",
|
|
19801
19824
|
"LastUpdatedAt": "LastUpdatedAt",
|
|
19802
19825
|
"LastCheckedAt": "LastCheckedAt",
|
|
19803
|
-
"CreatedAt": "CreatedAt"
|
|
19826
|
+
"CreatedAt": "CreatedAt",
|
|
19827
|
+
"EnablementStatus": "EnablementStatus",
|
|
19828
|
+
"EnablementStatusReason": "EnablementStatusReason",
|
|
19829
|
+
"Issues": "Issues"
|
|
19804
19830
|
},
|
|
19805
19831
|
"propertyConstraints": {
|
|
19806
19832
|
"Name": {
|
|
@@ -19818,14 +19844,6 @@
|
|
|
19818
19844
|
"minLength": 20,
|
|
19819
19845
|
"maxLength": 2048
|
|
19820
19846
|
},
|
|
19821
|
-
"ConnectorStatus": {
|
|
19822
|
-
"enum": [
|
|
19823
|
-
"CONNECTED",
|
|
19824
|
-
"FAILED_TO_CONNECT",
|
|
19825
|
-
"PENDING_AUTHORIZATION",
|
|
19826
|
-
"PENDING_CONFIGURATION"
|
|
19827
|
-
]
|
|
19828
|
-
},
|
|
19829
19847
|
"ConnectorArn": {
|
|
19830
19848
|
"pattern": "^arn:aws\\S*:securityhub:[a-z0-9-]+:[0-9]{12}:connectorv2/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
|
19831
19849
|
},
|
|
@@ -19836,7 +19854,8 @@
|
|
|
19836
19854
|
"createOnly": [
|
|
19837
19855
|
"Name",
|
|
19838
19856
|
"KmsKeyArn",
|
|
19839
|
-
"Provider/ServiceNow/InstanceName"
|
|
19857
|
+
"Provider/ServiceNow/InstanceName",
|
|
19858
|
+
"Provider/Azure/AWSConfigConnectorArn"
|
|
19840
19859
|
],
|
|
19841
19860
|
"primaryIdentifier": [
|
|
19842
19861
|
"ConnectorArn"
|
|
@@ -19847,6 +19866,11 @@
|
|
|
19847
19866
|
"tagUpdatable": true
|
|
19848
19867
|
}
|
|
19849
19868
|
},
|
|
19869
|
+
"ConnectorV2_HealthIssue": {
|
|
19870
|
+
"resourceType": "AWS::SecurityHub::ConnectorV2.HealthIssue",
|
|
19871
|
+
"kind": "property",
|
|
19872
|
+
"lexicon": "aws"
|
|
19873
|
+
},
|
|
19850
19874
|
"ConnectorVpcLatticeEgressConfig": {
|
|
19851
19875
|
"resourceType": "AWS::Transfer::Connector.ConnectorVpcLatticeEgressConfig",
|
|
19852
19876
|
"kind": "property",
|
|
@@ -87621,6 +87645,63 @@
|
|
|
87621
87645
|
"kind": "property",
|
|
87622
87646
|
"lexicon": "aws"
|
|
87623
87647
|
},
|
|
87648
|
+
"SecurityHubConnector": {
|
|
87649
|
+
"resourceType": "AWS::SecurityHub::Connector",
|
|
87650
|
+
"kind": "resource",
|
|
87651
|
+
"lexicon": "aws",
|
|
87652
|
+
"attrs": {
|
|
87653
|
+
"ConnectorArn": "ConnectorArn",
|
|
87654
|
+
"ConnectorId": "ConnectorId",
|
|
87655
|
+
"ConnectorStatus": "ConnectorStatus",
|
|
87656
|
+
"Message": "Message",
|
|
87657
|
+
"LastCheckedAt": "LastCheckedAt",
|
|
87658
|
+
"CreatedAt": "CreatedAt",
|
|
87659
|
+
"LastUpdatedAt": "LastUpdatedAt",
|
|
87660
|
+
"CreatedBy": "CreatedBy",
|
|
87661
|
+
"EnablementStatus": "EnablementStatus",
|
|
87662
|
+
"Issues": "Issues"
|
|
87663
|
+
},
|
|
87664
|
+
"propertyConstraints": {
|
|
87665
|
+
"ConnectorArn": {
|
|
87666
|
+
"pattern": "^arn:aws\\S*:securityhub:[a-z0-9-]+:[0-9]{12}:connector/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
|
87667
|
+
},
|
|
87668
|
+
"ConnectorId": {
|
|
87669
|
+
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
|
87670
|
+
}
|
|
87671
|
+
},
|
|
87672
|
+
"createOnly": [
|
|
87673
|
+
"Name",
|
|
87674
|
+
"Provider/Azure/AWSConfigConnectorArn"
|
|
87675
|
+
],
|
|
87676
|
+
"primaryIdentifier": [
|
|
87677
|
+
"ConnectorArn"
|
|
87678
|
+
],
|
|
87679
|
+
"tagging": {
|
|
87680
|
+
"taggable": true,
|
|
87681
|
+
"tagOnCreate": true,
|
|
87682
|
+
"tagUpdatable": true
|
|
87683
|
+
}
|
|
87684
|
+
},
|
|
87685
|
+
"SecurityHubConnector_AzureProviderConfiguration": {
|
|
87686
|
+
"resourceType": "AWS::SecurityHub::Connector.AzureProviderConfiguration",
|
|
87687
|
+
"kind": "property",
|
|
87688
|
+
"lexicon": "aws"
|
|
87689
|
+
},
|
|
87690
|
+
"SecurityHubConnector_AzureScopeConfiguration": {
|
|
87691
|
+
"resourceType": "AWS::SecurityHub::Connector.AzureScopeConfiguration",
|
|
87692
|
+
"kind": "property",
|
|
87693
|
+
"lexicon": "aws"
|
|
87694
|
+
},
|
|
87695
|
+
"SecurityHubConnector_HealthIssue": {
|
|
87696
|
+
"resourceType": "AWS::SecurityHub::Connector.HealthIssue",
|
|
87697
|
+
"kind": "property",
|
|
87698
|
+
"lexicon": "aws"
|
|
87699
|
+
},
|
|
87700
|
+
"SecurityHubConnector_Provider": {
|
|
87701
|
+
"resourceType": "AWS::SecurityHub::Connector.Provider",
|
|
87702
|
+
"kind": "property",
|
|
87703
|
+
"lexicon": "aws"
|
|
87704
|
+
},
|
|
87624
87705
|
"SecurityHubHub": {
|
|
87625
87706
|
"resourceType": "AWS::SecurityHub::Hub",
|
|
87626
87707
|
"kind": "resource",
|
|
@@ -97964,6 +98045,32 @@
|
|
|
97964
98045
|
"kind": "property",
|
|
97965
98046
|
"lexicon": "aws"
|
|
97966
98047
|
},
|
|
98048
|
+
"TransitGatewayPolicyTable": {
|
|
98049
|
+
"resourceType": "AWS::EC2::TransitGatewayPolicyTable",
|
|
98050
|
+
"kind": "resource",
|
|
98051
|
+
"lexicon": "aws",
|
|
98052
|
+
"attrs": {
|
|
98053
|
+
"TransitGatewayPolicyTableId": "TransitGatewayPolicyTableId",
|
|
98054
|
+
"State": "State",
|
|
98055
|
+
"CreationTime": "CreationTime"
|
|
98056
|
+
},
|
|
98057
|
+
"createOnly": [
|
|
98058
|
+
"TransitGatewayId"
|
|
98059
|
+
],
|
|
98060
|
+
"primaryIdentifier": [
|
|
98061
|
+
"TransitGatewayPolicyTableId"
|
|
98062
|
+
],
|
|
98063
|
+
"tagging": {
|
|
98064
|
+
"taggable": true,
|
|
98065
|
+
"tagOnCreate": true,
|
|
98066
|
+
"tagUpdatable": true
|
|
98067
|
+
}
|
|
98068
|
+
},
|
|
98069
|
+
"TransitGatewayPolicyTable_Tag": {
|
|
98070
|
+
"resourceType": "AWS::EC2::TransitGatewayPolicyTable.Tag",
|
|
98071
|
+
"kind": "property",
|
|
98072
|
+
"lexicon": "aws"
|
|
98073
|
+
},
|
|
97967
98074
|
"TransitGatewayRegistration": {
|
|
97968
98075
|
"resourceType": "AWS::NetworkManager::TransitGatewayRegistration",
|
|
97969
98076
|
"kind": "resource",
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { resolveTemplateAttrs } from "./live-attrs";
|
|
3
|
+
import { reconstructEdges } from "@intentius/chant/graph-refs";
|
|
4
|
+
import type { IRNode } from "@intentius/chant/graph-ir";
|
|
5
|
+
import { awsReferenceCatalog } from "./reference-catalog";
|
|
6
|
+
import type { ExportedTemplate } from "@intentius/chant/lexicon";
|
|
7
|
+
|
|
8
|
+
const tmpl = (resources: Array<{ logicalId: string; type: string; properties: Record<string, unknown> }>): ExportedTemplate =>
|
|
9
|
+
({ resources, parameters: [], outputs: {} }) as unknown as ExportedTemplate;
|
|
10
|
+
|
|
11
|
+
describe("resolveTemplateAttrs", () => {
|
|
12
|
+
it("resolves {Ref} and {Fn::GetAtt} intrinsics to bare logical ids", () => {
|
|
13
|
+
const attrs = resolveTemplateAttrs(
|
|
14
|
+
tmpl([
|
|
15
|
+
{ logicalId: "Subnet", type: "AWS::EC2::Subnet", properties: { VpcId: { Ref: "Vpc" }, CidrBlock: "10.0.1.0/24" } },
|
|
16
|
+
{ logicalId: "Listener", type: "AWS::ElasticLoadBalancingV2::Listener", properties: { LoadBalancerArn: { "Fn::GetAtt": ["Alb", "Arn"] } } },
|
|
17
|
+
]),
|
|
18
|
+
);
|
|
19
|
+
expect(attrs.Subnet).toEqual({ VpcId: "Vpc", CidrBlock: "10.0.1.0/24" });
|
|
20
|
+
expect(attrs.Listener).toEqual({ LoadBalancerArn: "Alb" });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("resolves intrinsics nested in arrays and objects", () => {
|
|
24
|
+
const attrs = resolveTemplateAttrs(
|
|
25
|
+
tmpl([
|
|
26
|
+
{ logicalId: "Inst", type: "AWS::EC2::Instance", properties: { SecurityGroupIds: [{ Ref: "SgA" }, { Ref: "SgB" }], Nested: { SubnetId: { Ref: "Sn" } } } },
|
|
27
|
+
]),
|
|
28
|
+
);
|
|
29
|
+
expect(attrs.Inst.SecurityGroupIds).toEqual(["SgA", "SgB"]);
|
|
30
|
+
expect(attrs.Inst.Nested).toEqual({ SubnetId: "Sn" });
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("live edges from a CloudFormation template (#784)", () => {
|
|
35
|
+
it("reconstructs containment + edges from resolved template attrs", () => {
|
|
36
|
+
const template = tmpl([
|
|
37
|
+
{ logicalId: "Vpc", type: "AWS::EC2::VPC", properties: { CidrBlock: "10.0.0.0/16" } },
|
|
38
|
+
{ logicalId: "Subnet", type: "AWS::EC2::Subnet", properties: { VpcId: { Ref: "Vpc" } } },
|
|
39
|
+
{ logicalId: "Sg", type: "AWS::EC2::SecurityGroup", properties: { VpcId: { Ref: "Vpc" } } },
|
|
40
|
+
{ logicalId: "Inst", type: "AWS::EC2::Instance", properties: { SubnetId: { Ref: "Subnet" }, SecurityGroupIds: [{ Ref: "Sg" }] } },
|
|
41
|
+
]);
|
|
42
|
+
const attrsById = resolveTemplateAttrs(template);
|
|
43
|
+
const nodes: IRNode[] = template.resources.map((r) => ({ id: r.logicalId, kind: r.type, lexicon: "aws", attrs: attrsById[r.logicalId] }));
|
|
44
|
+
|
|
45
|
+
const { edges, containment, dangling } = reconstructEdges(nodes, awsReferenceCatalog);
|
|
46
|
+
// containment: subnet and sg are inside the VPC; the instance is inside the subnet
|
|
47
|
+
expect(containment).toContainEqual({ child: "Subnet", parent: "Vpc", label: "in VPC" });
|
|
48
|
+
expect(containment).toContainEqual({ child: "Sg", parent: "Vpc", label: "in VPC" });
|
|
49
|
+
expect(containment).toContainEqual({ child: "Inst", parent: "Subnet", label: "in subnet" });
|
|
50
|
+
// edge: the instance references the security group
|
|
51
|
+
expect(edges).toContainEqual({ from: "Inst", to: "Sg", kind: "ref", viaAttr: "sg" });
|
|
52
|
+
expect(dangling).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ExportedTemplate } from "@intentius/chant/lexicon";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve a live CloudFormation template's per-resource properties into flat
|
|
5
|
+
* attributes for `chant graph --live` edge reconstruction (#784).
|
|
6
|
+
*
|
|
7
|
+
* `describeResources()` returns thin metadata (stack outputs), so the reference
|
|
8
|
+
* resolver (#778) had nothing to match on. `exportResources()` returns the
|
|
9
|
+
* deployed template, where resources reference each other by **logical id** via
|
|
10
|
+
* `{Ref: LogicalId}` / `{Fn::GetAtt: [LogicalId, …]}`. Logical id == the IR node
|
|
11
|
+
* id (both keyed by the CloudFormation logical name), so resolving those
|
|
12
|
+
* intrinsics to the bare logical-id string turns each reference into a value the
|
|
13
|
+
* resolver matches directly (it indexes every node by its own id).
|
|
14
|
+
*/
|
|
15
|
+
function resolveIntrinsics(v: unknown): unknown {
|
|
16
|
+
if (Array.isArray(v)) return v.map(resolveIntrinsics);
|
|
17
|
+
if (v && typeof v === "object") {
|
|
18
|
+
const o = v as Record<string, unknown>;
|
|
19
|
+
const keys = Object.keys(o);
|
|
20
|
+
if (keys.length === 1 && typeof o.Ref === "string") return o.Ref;
|
|
21
|
+
if (keys.length === 1 && o["Fn::GetAtt"] !== undefined) {
|
|
22
|
+
const g = o["Fn::GetAtt"];
|
|
23
|
+
if (Array.isArray(g) && typeof g[0] === "string") return g[0]; // ["LogicalId", "Attr"]
|
|
24
|
+
if (typeof g === "string") return g.split(".")[0]; // "LogicalId.Attr"
|
|
25
|
+
}
|
|
26
|
+
const out: Record<string, unknown> = {};
|
|
27
|
+
for (const [k, val] of Object.entries(o)) out[k] = resolveIntrinsics(val);
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
return v;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** logical id → resolved properties (references become bare logical-id strings). */
|
|
34
|
+
export function resolveTemplateAttrs(template: ExportedTemplate): Record<string, Record<string, unknown>> {
|
|
35
|
+
const out: Record<string, Record<string, unknown>> = {};
|
|
36
|
+
for (const r of template.resources) {
|
|
37
|
+
out[r.logicalId] = resolveIntrinsics(r.properties) as Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { writeFileSync, unlinkSync } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
awsApply,
|
|
5
|
+
awsDelete,
|
|
6
|
+
cfnUrl,
|
|
7
|
+
cfnForm,
|
|
8
|
+
capabilityParams,
|
|
9
|
+
xmlField,
|
|
10
|
+
stackStatus,
|
|
11
|
+
stackId,
|
|
12
|
+
isStackMissing,
|
|
13
|
+
isNoUpdates,
|
|
14
|
+
isSuccessStatus,
|
|
15
|
+
isFailureStatus,
|
|
16
|
+
isTerminalStatus,
|
|
17
|
+
type AwsHttp,
|
|
18
|
+
} from "./aws-apply";
|
|
19
|
+
|
|
20
|
+
const CREATE_OK = "<CreateStackResponse><CreateStackResult><StackId>arn:stack/s</StackId></CreateStackResult></CreateStackResponse>";
|
|
21
|
+
const UPDATE_OK = "<UpdateStackResponse><UpdateStackResult><StackId>arn:stack/s</StackId></UpdateStackResult></UpdateStackResponse>";
|
|
22
|
+
const MISSING = "<ErrorResponse><Error><Code>ValidationError</Code><Message>Stack with id s does not exist</Message></Error></ErrorResponse>";
|
|
23
|
+
const NO_UPDATES = "<ErrorResponse><Error><Message>No updates are to be performed.</Message></Error></ErrorResponse>";
|
|
24
|
+
const describe_ = (status: string) => `<DescribeStacksResponse><Stacks><member><StackId>arn:stack/s</StackId><StackStatus>${status}</StackStatus></member></Stacks></DescribeStacksResponse>`;
|
|
25
|
+
|
|
26
|
+
describe("CFN pure helpers (#awsApply)", () => {
|
|
27
|
+
test("cfnUrl: endpoint override vs real regional host", () => {
|
|
28
|
+
expect(cfnUrl("http://localhost:4566")).toBe("http://localhost:4566/");
|
|
29
|
+
expect(cfnUrl(undefined, "eu-west-1")).toBe("https://cloudformation.eu-west-1.amazonaws.com/");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("cfnForm stamps Action + Version", () => {
|
|
33
|
+
expect(cfnForm("CreateStack", { StackName: "s" })).toEqual({ Action: "CreateStack", Version: "2010-05-15", StackName: "s" });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("capabilityParams → Capabilities.member.N", () => {
|
|
37
|
+
expect(capabilityParams(["CAPABILITY_NAMED_IAM", "CAPABILITY_IAM"])).toEqual({
|
|
38
|
+
"Capabilities.member.1": "CAPABILITY_NAMED_IAM",
|
|
39
|
+
"Capabilities.member.2": "CAPABILITY_IAM",
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("xml parsing + status classification", () => {
|
|
44
|
+
expect(xmlField(describe_("CREATE_COMPLETE"), "StackStatus")).toBe("CREATE_COMPLETE");
|
|
45
|
+
expect(stackStatus(describe_("UPDATE_COMPLETE"))).toBe("UPDATE_COMPLETE");
|
|
46
|
+
expect(stackId(CREATE_OK)).toBe("arn:stack/s");
|
|
47
|
+
expect(isStackMissing(MISSING)).toBe(true);
|
|
48
|
+
expect(isNoUpdates(NO_UPDATES)).toBe(true);
|
|
49
|
+
expect(isSuccessStatus("CREATE_COMPLETE")).toBe(true);
|
|
50
|
+
expect(isSuccessStatus("UPDATE_COMPLETE_CLEANUP_IN_PROGRESS")).toBe(false); // transient, not settled
|
|
51
|
+
expect(isFailureStatus("ROLLBACK_COMPLETE")).toBe(true);
|
|
52
|
+
expect(isFailureStatus("CREATE_FAILED")).toBe(true);
|
|
53
|
+
expect(isTerminalStatus("CREATE_IN_PROGRESS")).toBe(false);
|
|
54
|
+
expect(isTerminalStatus("DELETE_COMPLETE")).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
function tmpl(): string {
|
|
59
|
+
const p = `/tmp/chant-cfn-${process.pid}-${Math.round(performance.now())}.json`;
|
|
60
|
+
writeFileSync(p, JSON.stringify({ Resources: { B: { Type: "AWS::S3::Bucket" } } }));
|
|
61
|
+
return p;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe("awsApply flow (#awsApply)", () => {
|
|
65
|
+
test("create path: absent stack → CreateStack → poll to CREATE_COMPLETE", async () => {
|
|
66
|
+
const calls: string[] = [];
|
|
67
|
+
let described = 0;
|
|
68
|
+
const http: AwsHttp = async (_url, form) => {
|
|
69
|
+
calls.push(form.Action);
|
|
70
|
+
if (form.Action === "DescribeStacks") return described++ === 0 ? { status: 400, text: MISSING } : { status: 200, text: describe_("CREATE_COMPLETE") };
|
|
71
|
+
return { status: 200, text: CREATE_OK };
|
|
72
|
+
};
|
|
73
|
+
const p = tmpl();
|
|
74
|
+
const res = await awsApply({ templatePath: p, stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, http);
|
|
75
|
+
unlinkSync(p);
|
|
76
|
+
expect(res).toEqual({ stackName: "s", status: "CREATE_COMPLETE", action: "created" });
|
|
77
|
+
expect(calls).toEqual(["DescribeStacks", "CreateStack", "DescribeStacks"]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("update path: existing stack → UpdateStack → UPDATE_COMPLETE", async () => {
|
|
81
|
+
let described = 0;
|
|
82
|
+
const http: AwsHttp = async (_url, form) => {
|
|
83
|
+
if (form.Action === "DescribeStacks") return { status: 200, text: describe_(described++ === 0 ? "CREATE_COMPLETE" : "UPDATE_COMPLETE") };
|
|
84
|
+
return { status: 200, text: UPDATE_OK };
|
|
85
|
+
};
|
|
86
|
+
const p = tmpl();
|
|
87
|
+
const res = await awsApply({ templatePath: p, stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, http);
|
|
88
|
+
unlinkSync(p);
|
|
89
|
+
expect(res.action).toBe("updated");
|
|
90
|
+
expect(res.status).toBe("UPDATE_COMPLETE");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("no-op update (real-AWS error) → unchanged, no poll", async () => {
|
|
94
|
+
const http: AwsHttp = async (_url, form) => {
|
|
95
|
+
if (form.Action === "DescribeStacks") return { status: 200, text: describe_("CREATE_COMPLETE") };
|
|
96
|
+
return { status: 400, text: NO_UPDATES }; // UpdateStack
|
|
97
|
+
};
|
|
98
|
+
const p = tmpl();
|
|
99
|
+
const res = await awsApply({ templatePath: p, stackName: "s", endpoint: "http://x" }, undefined, http);
|
|
100
|
+
unlinkSync(p);
|
|
101
|
+
expect(res).toEqual({ stackName: "s", status: "UPDATE_COMPLETE", action: "unchanged" });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("throws when the stack settles to a failure state", async () => {
|
|
105
|
+
let described = 0;
|
|
106
|
+
const http: AwsHttp = async (_url, form) => {
|
|
107
|
+
if (form.Action === "DescribeStacks") return described++ === 0 ? { status: 400, text: MISSING } : { status: 200, text: describe_("ROLLBACK_COMPLETE") };
|
|
108
|
+
return { status: 200, text: CREATE_OK };
|
|
109
|
+
};
|
|
110
|
+
const p = tmpl();
|
|
111
|
+
await expect(
|
|
112
|
+
awsApply({ templatePath: p, stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, http),
|
|
113
|
+
).rejects.toThrow(/created → ROLLBACK_COMPLETE/);
|
|
114
|
+
unlinkSync(p);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("surfaces a CreateStack API error", async () => {
|
|
118
|
+
const http: AwsHttp = async (_url, form) =>
|
|
119
|
+
form.Action === "DescribeStacks" ? { status: 400, text: MISSING } : { status: 400, text: "<Error><Message>bad template</Message></Error>" };
|
|
120
|
+
const p = tmpl();
|
|
121
|
+
await expect(
|
|
122
|
+
awsApply({ templatePath: p, stackName: "s", endpoint: "http://x" }, undefined, http),
|
|
123
|
+
).rejects.toThrow(/CreateStack failed \(400\): bad template/);
|
|
124
|
+
unlinkSync(p);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("awsDelete (#awsApply)", () => {
|
|
129
|
+
test("DeleteStack then polls until the stack is gone", async () => {
|
|
130
|
+
const calls: string[] = [];
|
|
131
|
+
let described = 0;
|
|
132
|
+
const http: AwsHttp = async (_url, form) => {
|
|
133
|
+
calls.push(form.Action);
|
|
134
|
+
if (form.Action === "DescribeStacks") return described++ === 0 ? { status: 200, text: describe_("DELETE_IN_PROGRESS") } : { status: 400, text: MISSING };
|
|
135
|
+
return { status: 200, text: "<DeleteStackResponse/>" };
|
|
136
|
+
};
|
|
137
|
+
const p = tmpl();
|
|
138
|
+
const res = await awsDelete({ templatePath: p, stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, http);
|
|
139
|
+
unlinkSync(p);
|
|
140
|
+
expect(res).toEqual({ stackName: "s", deleted: true });
|
|
141
|
+
expect(calls[0]).toBe("DeleteStack");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { safeHeartbeat, sleep } from "@intentius/chant/op";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_REGION = "us-east-1";
|
|
5
|
+
const CFN_API_VERSION = "2010-05-15";
|
|
6
|
+
const DEFAULT_CAPABILITIES = ["CAPABILITY_NAMED_IAM"];
|
|
7
|
+
|
|
8
|
+
export interface AwsApplyArgs {
|
|
9
|
+
/** Path to a built CloudFormation template (JSON/YAML). */
|
|
10
|
+
templatePath: string;
|
|
11
|
+
/** CloudFormation stack name — the deploy boundary. */
|
|
12
|
+
stackName: string;
|
|
13
|
+
/** CFN endpoint override (e.g. Floci `http://localhost:4566`). Default: real CloudFormation. */
|
|
14
|
+
endpoint?: string;
|
|
15
|
+
/** Region (real CFN host + `Version` context). Default: `us-east-1`. */
|
|
16
|
+
region?: string;
|
|
17
|
+
/** Capabilities to acknowledge. Default: `["CAPABILITY_NAMED_IAM"]`. */
|
|
18
|
+
capabilities?: string[];
|
|
19
|
+
/** Stack-settle timeout in ms. Default: `300000`. */
|
|
20
|
+
timeoutMs?: number;
|
|
21
|
+
/** Poll interval in ms. Default: `3000`. */
|
|
22
|
+
intervalMs?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Injectable CFN transport — a form POST returning the raw XML. Mirrors the az/gcp appliers so tests avoid the network. */
|
|
26
|
+
export type AwsHttp = (url: string, form: Record<string, string>, signal?: AbortSignal) => Promise<{ status: number; text: string }>;
|
|
27
|
+
|
|
28
|
+
const defaultHttp: AwsHttp = async (url, form, signal) => {
|
|
29
|
+
const res = await fetch(url, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
32
|
+
body: new URLSearchParams(form).toString(),
|
|
33
|
+
signal,
|
|
34
|
+
});
|
|
35
|
+
return { status: res.status, text: await res.text() };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// ── Pure helpers (CFN Query protocol) ─────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
/** The CloudFormation endpoint URL — the override, or the real regional host. */
|
|
41
|
+
export function cfnUrl(endpoint?: string, region = DEFAULT_REGION): string {
|
|
42
|
+
return `${(endpoint ?? `https://cloudformation.${region}.amazonaws.com`).replace(/\/$/, "")}/`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A CFN Query-protocol form body: `Action` + `Version` + params. */
|
|
46
|
+
export function cfnForm(action: string, params: Record<string, string>): Record<string, string> {
|
|
47
|
+
return { Action: action, Version: CFN_API_VERSION, ...params };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Capabilities as the CFN `Capabilities.member.N` list params. */
|
|
51
|
+
export function capabilityParams(capabilities: string[]): Record<string, string> {
|
|
52
|
+
const out: Record<string, string> = {};
|
|
53
|
+
capabilities.forEach((c, i) => (out[`Capabilities.member.${i + 1}`] = c));
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* First `<tag>…</tag>` text in a CFN XML response.
|
|
59
|
+
*
|
|
60
|
+
* Assumes flat scalar fields — the CloudFormation Query API returns simple
|
|
61
|
+
* `<StackStatus>…</StackStatus>` style leaves, so `[^<]*` is sufficient. It does
|
|
62
|
+
* NOT handle nested tags or XML entities in the value; if a field ever carries
|
|
63
|
+
* either, replace this with a real XML parser.
|
|
64
|
+
*/
|
|
65
|
+
export function xmlField(xml: string, tag: string): string | undefined {
|
|
66
|
+
return xml.match(new RegExp(`<${tag}>([^<]*)</${tag}>`))?.[1];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const stackStatus = (xml: string): string | undefined => xmlField(xml, "StackStatus");
|
|
70
|
+
export const stackId = (xml: string): string | undefined => xmlField(xml, "StackId");
|
|
71
|
+
export const cfnErrorMessage = (xml: string): string | undefined => xmlField(xml, "Message");
|
|
72
|
+
|
|
73
|
+
/** A DescribeStacks error for an absent stack (drives create-vs-update). */
|
|
74
|
+
export const isStackMissing = (xml: string): boolean => /does not exist/i.test(xml);
|
|
75
|
+
/** The real-AWS UpdateStack no-op error (Floci returns 200 instead). */
|
|
76
|
+
export const isNoUpdates = (xml: string): boolean => /No updates are to be performed/i.test(xml);
|
|
77
|
+
|
|
78
|
+
const SUCCESS_STATUS = new Set(["CREATE_COMPLETE", "UPDATE_COMPLETE", "IMPORT_COMPLETE"]);
|
|
79
|
+
export const isSuccessStatus = (status: string): boolean => SUCCESS_STATUS.has(status);
|
|
80
|
+
export const isFailureStatus = (status: string): boolean => /FAILED|ROLLBACK/.test(status);
|
|
81
|
+
/** A settled stack state — success, failure, or deleted. Transient `*_IN_PROGRESS` states aren't. */
|
|
82
|
+
export const isTerminalStatus = (status: string): boolean =>
|
|
83
|
+
isSuccessStatus(status) || isFailureStatus(status) || status === "DELETE_COMPLETE";
|
|
84
|
+
|
|
85
|
+
// ── Poll ──────────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
/** Poll DescribeStacks until the stack reaches a terminal state; returns that status. */
|
|
88
|
+
export async function waitForStackSettled(
|
|
89
|
+
url: string,
|
|
90
|
+
stackName: string,
|
|
91
|
+
http: AwsHttp,
|
|
92
|
+
opts: { timeoutMs: number; intervalMs: number },
|
|
93
|
+
signal?: AbortSignal,
|
|
94
|
+
): Promise<string> {
|
|
95
|
+
const deadline = Date.now() + opts.timeoutMs;
|
|
96
|
+
while (Date.now() < deadline) {
|
|
97
|
+
if (signal?.aborted) throw new Error("awsApply aborted");
|
|
98
|
+
safeHeartbeat({ step: "awsApply", stack: stackName });
|
|
99
|
+
const res = await http(url, cfnForm("DescribeStacks", { StackName: stackName }), signal);
|
|
100
|
+
const status = stackStatus(res.text);
|
|
101
|
+
if (status && isTerminalStatus(status)) return status;
|
|
102
|
+
await sleep(opts.intervalMs, signal);
|
|
103
|
+
}
|
|
104
|
+
throw new Error(`CloudFormation stack ${stackName} did not settle within ${opts.timeoutMs}ms`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Apply / delete ──────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The native AWS applier — deploy a built CloudFormation template by calling the
|
|
111
|
+
* CloudFormation API directly (create-or-update + poll to a settled stack),
|
|
112
|
+
* targeting a local Floci emulator or real AWS by endpoint override. The direct
|
|
113
|
+
* twin of `azApply`/`gcpApply`: it speaks the CloudFormation Query API over HTTP
|
|
114
|
+
* rather than shelling `aws cloudformation deploy` (that CLI path is still
|
|
115
|
+
* available via `nativeApply({ target: "cloudformation" })`). The stack is the
|
|
116
|
+
* ownership boundary, so deletes ride CloudFormation itself — no separate prune.
|
|
117
|
+
* `http` is injectable for tests.
|
|
118
|
+
*/
|
|
119
|
+
export async function awsApply(
|
|
120
|
+
args: AwsApplyArgs,
|
|
121
|
+
signal?: AbortSignal,
|
|
122
|
+
http: AwsHttp = defaultHttp,
|
|
123
|
+
): Promise<{ stackName: string; status: string; action: "created" | "updated" | "unchanged" }> {
|
|
124
|
+
const url = cfnUrl(args.endpoint, args.region);
|
|
125
|
+
const capabilities = args.capabilities ?? DEFAULT_CAPABILITIES;
|
|
126
|
+
const templateBody = readFileSync(args.templatePath, "utf8");
|
|
127
|
+
const timeoutMs = args.timeoutMs ?? 300_000;
|
|
128
|
+
const intervalMs = args.intervalMs ?? 3_000;
|
|
129
|
+
|
|
130
|
+
const desc = await http(url, cfnForm("DescribeStacks", { StackName: args.stackName }), signal);
|
|
131
|
+
if (desc.status >= 300 && !isStackMissing(desc.text)) {
|
|
132
|
+
throw new Error(`CloudFormation DescribeStacks failed (${desc.status}): ${cfnErrorMessage(desc.text) ?? desc.text}`);
|
|
133
|
+
}
|
|
134
|
+
const exists = desc.status < 300;
|
|
135
|
+
|
|
136
|
+
const params = { StackName: args.stackName, TemplateBody: templateBody, ...capabilityParams(capabilities) };
|
|
137
|
+
let action: "created" | "updated";
|
|
138
|
+
if (!exists) {
|
|
139
|
+
const res = await http(url, cfnForm("CreateStack", params), signal);
|
|
140
|
+
if (res.status >= 300) {
|
|
141
|
+
throw new Error(`CloudFormation CreateStack failed (${res.status}): ${cfnErrorMessage(res.text) ?? res.text}`);
|
|
142
|
+
}
|
|
143
|
+
action = "created";
|
|
144
|
+
} else {
|
|
145
|
+
const res = await http(url, cfnForm("UpdateStack", params), signal);
|
|
146
|
+
if (res.status >= 300) {
|
|
147
|
+
if (isNoUpdates(res.text)) {
|
|
148
|
+
console.log(`unchanged: ${args.stackName} (no updates)`);
|
|
149
|
+
return { stackName: args.stackName, status: "UPDATE_COMPLETE", action: "unchanged" };
|
|
150
|
+
}
|
|
151
|
+
throw new Error(`CloudFormation UpdateStack failed (${res.status}): ${cfnErrorMessage(res.text) ?? res.text}`);
|
|
152
|
+
}
|
|
153
|
+
action = "updated";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const status = await waitForStackSettled(url, args.stackName, http, { timeoutMs, intervalMs }, signal);
|
|
157
|
+
if (isFailureStatus(status)) {
|
|
158
|
+
throw new Error(`CloudFormation stack ${args.stackName} ${action} → ${status}`);
|
|
159
|
+
}
|
|
160
|
+
console.log(`${action}: ${args.stackName} (${status}) [${url}]`);
|
|
161
|
+
return { stackName: args.stackName, status, action };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The inverse of {@link awsApply} — DeleteStack, then poll until the stack is
|
|
166
|
+
* gone. Idempotent: an already-absent stack is a no-op. `http` is injectable.
|
|
167
|
+
*/
|
|
168
|
+
export async function awsDelete(
|
|
169
|
+
args: AwsApplyArgs,
|
|
170
|
+
signal?: AbortSignal,
|
|
171
|
+
http: AwsHttp = defaultHttp,
|
|
172
|
+
): Promise<{ stackName: string; deleted: boolean }> {
|
|
173
|
+
const url = cfnUrl(args.endpoint, args.region);
|
|
174
|
+
const timeoutMs = args.timeoutMs ?? 300_000;
|
|
175
|
+
const intervalMs = args.intervalMs ?? 3_000;
|
|
176
|
+
|
|
177
|
+
const res = await http(url, cfnForm("DeleteStack", { StackName: args.stackName }), signal);
|
|
178
|
+
if (res.status >= 300 && !isStackMissing(res.text)) {
|
|
179
|
+
throw new Error(`CloudFormation DeleteStack failed (${res.status}): ${cfnErrorMessage(res.text) ?? res.text}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const deadline = Date.now() + timeoutMs;
|
|
183
|
+
while (Date.now() < deadline) {
|
|
184
|
+
if (signal?.aborted) throw new Error("awsDelete aborted");
|
|
185
|
+
safeHeartbeat({ step: "awsDelete", stack: args.stackName });
|
|
186
|
+
const d = await http(url, cfnForm("DescribeStacks", { StackName: args.stackName }), signal);
|
|
187
|
+
if (d.status >= 300 && isStackMissing(d.text)) return { stackName: args.stackName, deleted: true };
|
|
188
|
+
const status = stackStatus(d.text);
|
|
189
|
+
if (status === "DELETE_COMPLETE") return { stackName: args.stackName, deleted: true };
|
|
190
|
+
if (status === "DELETE_FAILED") throw new Error(`CloudFormation stack ${args.stackName} delete → DELETE_FAILED`);
|
|
191
|
+
await sleep(intervalMs, signal);
|
|
192
|
+
}
|
|
193
|
+
throw new Error(`CloudFormation stack ${args.stackName} delete did not complete within ${timeoutMs}ms`);
|
|
194
|
+
}
|