@intentius/chant-lexicon-aws 0.45.0 → 0.49.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/api/read-client.d.ts +10 -2
- package/dist/api/read-client.d.ts.map +1 -1
- package/dist/components/cloud-executor.d.ts.map +1 -1
- package/dist/composites/agentcore-agent.d.ts +26 -1
- package/dist/composites/agentcore-agent.d.ts.map +1 -1
- package/dist/composites/bucket-deployment.d.ts +82 -0
- package/dist/composites/bucket-deployment.d.ts.map +1 -0
- package/dist/composites/dynamodb-table.d.ts +45 -0
- package/dist/composites/dynamodb-table.d.ts.map +1 -0
- package/dist/composites/ec2-instance-bundle.d.ts +61 -0
- package/dist/composites/ec2-instance-bundle.d.ts.map +1 -0
- package/dist/composites/ecr-repository.d.ts +49 -0
- package/dist/composites/ecr-repository.d.ts.map +1 -0
- package/dist/composites/fargate-service.d.ts +16 -0
- package/dist/composites/fargate-service.d.ts.map +1 -1
- package/dist/composites/index.d.ts +16 -2
- package/dist/composites/index.d.ts.map +1 -1
- package/dist/composites/lambda-function.d.ts +4 -4
- package/dist/composites/lambda-function.d.ts.map +1 -1
- package/dist/composites/monitoring-stack.d.ts +99 -0
- package/dist/composites/monitoring-stack.d.ts.map +1 -0
- package/dist/composites/nlb-service.d.ts +48 -0
- package/dist/composites/nlb-service.d.ts.map +1 -0
- package/dist/composites/step-functions-workflow.d.ts +36 -0
- package/dist/composites/step-functions-workflow.d.ts.map +1 -0
- package/dist/deep-observe.d.ts +45 -0
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/deep-topology.d.ts +104 -0
- package/dist/deep-topology.d.ts.map +1 -0
- package/dist/effect-receipt-row.d.ts +92 -0
- package/dist/effect-receipt-row.d.ts.map +1 -0
- package/dist/index.d.ts +6 -2
- 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/cf-refs.d.ts +24 -0
- package/dist/lint/post-synth/cf-refs.d.ts.map +1 -1
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/waw059.d.ts +36 -0
- package/dist/lint/post-synth/waw059.d.ts.map +1 -0
- package/dist/lint/post-synth/waw060.d.ts +16 -0
- package/dist/lint/post-synth/waw060.d.ts.map +1 -0
- package/dist/lint/post-synth/waw061.d.ts +19 -0
- package/dist/lint/post-synth/waw061.d.ts.map +1 -0
- package/dist/lint/post-synth/waw062.d.ts +19 -0
- package/dist/lint/post-synth/waw062.d.ts.map +1 -0
- package/dist/lint/post-synth/waw063.d.ts +35 -0
- package/dist/lint/post-synth/waw063.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +5 -0
- package/dist/okf/rules/WAW059.md +25 -0
- package/dist/okf/rules/WAW060.md +17 -0
- package/dist/okf/rules/WAW061.md +17 -0
- package/dist/okf/rules/WAW062.md +27 -0
- package/dist/okf/rules/WAW063.md +19 -0
- package/dist/okf/types/Action.md +2 -0
- package/dist/okf/types/AutoScalingGroup.md +1 -0
- package/dist/okf/types/Bucket.md +2 -0
- package/dist/okf/types/ECRRepository.md +1 -0
- package/dist/okf/types/Function.md +1 -0
- package/dist/okf/types/GlobalTable.md +4 -0
- package/dist/okf/types/IamPolicy.md +3 -0
- package/dist/okf/types/InstanceProfile.md +4 -0
- package/dist/okf/types/LaunchTemplate.md +1 -0
- package/dist/okf/types/LoadBalancer.md +1 -0
- package/dist/okf/types/ManagedPolicy.md +4 -0
- package/dist/okf/types/Map.md +2 -0
- package/dist/okf/types/Queue.md +1 -0
- package/dist/okf/types/Role.md +3 -0
- package/dist/okf/types/SecurityGroup.md +1 -0
- package/dist/okf/types/StateMachine.md +4 -0
- package/dist/okf/types/Subnet.md +4 -0
- package/dist/okf/types/Table.md +2 -0
- package/dist/okf/types/Type.md +5 -0
- package/dist/okf/types/Vpc.md +1 -0
- package/dist/op/activities/aws-apply.d.ts +48 -5
- package/dist/op/activities/aws-apply.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +11 -4
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/ownership.d.ts +18 -0
- package/dist/ownership.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/receipt-store.d.ts +109 -0
- package/dist/receipt-store.d.ts.map +1 -0
- package/dist/rules/cf-refs.ts +62 -0
- package/dist/rules/waw059.ts +353 -0
- package/dist/rules/waw060.ts +91 -0
- package/dist/rules/waw061.ts +67 -0
- package/dist/rules/waw062.ts +0 -0
- package/dist/rules/waw063.ts +0 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/skills/chant-aws-carve-terraform.md +26 -23
- package/dist/teardown.d.ts +85 -0
- package/dist/teardown.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/api/read-client.test.ts +9 -0
- package/src/api/read-client.ts +15 -2
- package/src/components/cloud-executor.ts +10 -1
- package/src/composites/agentcore-agent.ts +35 -13
- package/src/composites/bucket-deployment.test.ts +84 -0
- package/src/composites/bucket-deployment.ts +153 -0
- package/src/composites/composites.test.ts +325 -0
- package/src/composites/dynamodb-table.test.ts +154 -0
- package/src/composites/dynamodb-table.ts +124 -0
- package/src/composites/ec2-instance-bundle.ts +134 -0
- package/src/composites/ecr-repository.test.ts +118 -0
- package/src/composites/ecr-repository.ts +117 -0
- package/src/composites/fargate-service.ts +19 -3
- package/src/composites/index.ts +21 -2
- package/src/composites/monitoring-stack.test.ts +130 -0
- package/src/composites/monitoring-stack.ts +164 -0
- package/src/composites/nlb-service.ts +117 -0
- package/src/composites/step-functions-workflow.ts +127 -0
- package/src/deep-observe.test.ts +201 -1
- package/src/deep-observe.ts +204 -0
- package/src/deep-topology.test.ts +392 -0
- package/src/deep-topology.ts +237 -0
- package/src/effect-receipt-row.test.ts +164 -0
- package/src/effect-receipt-row.ts +147 -0
- package/src/index.ts +28 -7
- package/src/lifecycle-integration.test.ts +4 -0
- package/src/lint/audit-catalog.ts +11 -0
- package/src/lint/post-synth/cf-refs.ts +62 -0
- package/src/lint/post-synth/index.ts +10 -0
- package/src/lint/post-synth/waw059.test.ts +309 -0
- package/src/lint/post-synth/waw059.ts +353 -0
- package/src/lint/post-synth/waw060.test.ts +131 -0
- package/src/lint/post-synth/waw060.ts +91 -0
- package/src/lint/post-synth/waw061.test.ts +99 -0
- package/src/lint/post-synth/waw061.ts +67 -0
- package/src/lint/post-synth/waw062.test.ts +109 -0
- package/src/lint/post-synth/waw062.ts +0 -0
- package/src/lint/post-synth/waw063.test.ts +220 -0
- package/src/lint/post-synth/waw063.ts +0 -0
- package/src/op/activities/aws-apply.test.ts +111 -0
- package/src/op/activities/aws-apply.ts +100 -6
- package/src/op/activities/index.ts +20 -3
- package/src/ownership.test.ts +24 -1
- package/src/ownership.ts +37 -0
- package/src/plugin.ts +59 -10
- package/src/receipt-store.test.ts +204 -0
- package/src/receipt-store.ts +315 -0
- package/src/serializer-ownership.test.ts +18 -0
- package/src/serializer.ts +114 -3
- package/src/skills/chant-aws-carve-terraform.md +26 -23
- package/src/teardown.test.ts +258 -0
- package/src/teardown.ts +276 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { createPostSynthContext } from "@intentius/chant-test-utils";
|
|
3
|
+
import { waw063, checkDenyAllowContradiction } from "./waw063";
|
|
4
|
+
|
|
5
|
+
function makeCtx(template: object) {
|
|
6
|
+
return createPostSynthContext({ aws: template });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("WAW063: IAM Policy Denies An Action It Also Allows", () => {
|
|
10
|
+
test("check metadata", () => {
|
|
11
|
+
expect(waw063.id).toBe("WAW063");
|
|
12
|
+
expect(waw063.description).toContain("Deny");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("guardrail policy denies exactly what a workflow policy allows on the same role → error", () => {
|
|
16
|
+
// Mirrors the issue's own example: a role used by a StepFunctions workflow
|
|
17
|
+
// needs ec2:ModifyInstanceAttribute; a separately-attached guardrail policy
|
|
18
|
+
// denies it. Deploy succeeds; the workflow 403s at runtime.
|
|
19
|
+
const ctx = makeCtx({
|
|
20
|
+
Resources: {
|
|
21
|
+
WorkflowRole: { Type: "AWS::IAM::Role", Properties: { AssumeRolePolicyDocument: { Statement: [] } } },
|
|
22
|
+
WorkflowPolicy: {
|
|
23
|
+
Type: "AWS::IAM::Policy",
|
|
24
|
+
Properties: {
|
|
25
|
+
PolicyName: "workflow-needs",
|
|
26
|
+
Roles: [{ Ref: "WorkflowRole" }],
|
|
27
|
+
PolicyDocument: {
|
|
28
|
+
Statement: [{ Effect: "Allow", Action: "ec2:ModifyInstanceAttribute", Resource: "*" }],
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
SecurityGuardrailPolicy: {
|
|
33
|
+
Type: "AWS::IAM::Policy",
|
|
34
|
+
Properties: {
|
|
35
|
+
PolicyName: "guardrail",
|
|
36
|
+
Roles: [{ Ref: "WorkflowRole" }],
|
|
37
|
+
PolicyDocument: {
|
|
38
|
+
Statement: [{ Effect: "Deny", Action: "ec2:ModifyInstanceAttribute", Resource: "*" }],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
const diags = checkDenyAllowContradiction(ctx);
|
|
45
|
+
expect(diags).toHaveLength(1);
|
|
46
|
+
expect(diags[0].checkId).toBe("WAW063");
|
|
47
|
+
expect(diags[0].severity).toBe("error");
|
|
48
|
+
expect(diags[0].entity).toBe("WorkflowRole");
|
|
49
|
+
expect(diags[0].message).toContain("ec2:ModifyInstanceAttribute");
|
|
50
|
+
expect(diags[0].message).toContain("SecurityGuardrailPolicy");
|
|
51
|
+
expect(diags[0].message).toContain("WorkflowPolicy");
|
|
52
|
+
expect(diags[0].lexicon).toBe("aws");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("wildcarded Deny nullifies a literal Allow on the same role's inline policy → error", () => {
|
|
56
|
+
const ctx = makeCtx({
|
|
57
|
+
Resources: {
|
|
58
|
+
AppRole: {
|
|
59
|
+
Type: "AWS::IAM::Role",
|
|
60
|
+
Properties: {
|
|
61
|
+
AssumeRolePolicyDocument: { Statement: [] },
|
|
62
|
+
Policies: [
|
|
63
|
+
{
|
|
64
|
+
PolicyName: "inline",
|
|
65
|
+
PolicyDocument: {
|
|
66
|
+
Statement: [
|
|
67
|
+
{ Effect: "Allow", Action: "s3:GetObject", Resource: "*" },
|
|
68
|
+
{ Effect: "Deny", Action: "s3:*", Resource: "*" },
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
expect(checkDenyAllowContradiction(ctx)).toHaveLength(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("broad Allow with a narrow safety Deny (intentional guardrail) → no diagnostic", () => {
|
|
81
|
+
const ctx = makeCtx({
|
|
82
|
+
Resources: {
|
|
83
|
+
AppRole: {
|
|
84
|
+
Type: "AWS::IAM::Role",
|
|
85
|
+
Properties: {
|
|
86
|
+
AssumeRolePolicyDocument: { Statement: [] },
|
|
87
|
+
Policies: [
|
|
88
|
+
{
|
|
89
|
+
PolicyName: "inline",
|
|
90
|
+
PolicyDocument: {
|
|
91
|
+
Statement: [
|
|
92
|
+
{ Effect: "Allow", Action: "ec2:*", Resource: "*" },
|
|
93
|
+
{ Effect: "Deny", Action: "ec2:TerminateInstances", Resource: "*" },
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
expect(checkDenyAllowContradiction(ctx)).toHaveLength(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("Deny on a different role than the Allow → no diagnostic", () => {
|
|
106
|
+
const ctx = makeCtx({
|
|
107
|
+
Resources: {
|
|
108
|
+
RoleA: {
|
|
109
|
+
Type: "AWS::IAM::Role",
|
|
110
|
+
Properties: {
|
|
111
|
+
AssumeRolePolicyDocument: { Statement: [] },
|
|
112
|
+
Policies: [{ PolicyName: "p", PolicyDocument: { Statement: [{ Effect: "Allow", Action: "ec2:ModifyInstanceAttribute", Resource: "*" }] } }],
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
RoleB: {
|
|
116
|
+
Type: "AWS::IAM::Role",
|
|
117
|
+
Properties: {
|
|
118
|
+
AssumeRolePolicyDocument: { Statement: [] },
|
|
119
|
+
Policies: [{ PolicyName: "p", PolicyDocument: { Statement: [{ Effect: "Deny", Action: "ec2:ModifyInstanceAttribute", Resource: "*" }] } }],
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
expect(checkDenyAllowContradiction(ctx)).toHaveLength(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("Deny scoped to a disjoint literal resource → no diagnostic", () => {
|
|
128
|
+
const ctx = makeCtx({
|
|
129
|
+
Resources: {
|
|
130
|
+
AppRole: {
|
|
131
|
+
Type: "AWS::IAM::Role",
|
|
132
|
+
Properties: {
|
|
133
|
+
AssumeRolePolicyDocument: { Statement: [] },
|
|
134
|
+
Policies: [
|
|
135
|
+
{
|
|
136
|
+
PolicyName: "inline",
|
|
137
|
+
PolicyDocument: {
|
|
138
|
+
Statement: [
|
|
139
|
+
{ Effect: "Allow", Action: "s3:GetObject", Resource: "arn:aws:s3:::bucket-a/*" },
|
|
140
|
+
{ Effect: "Deny", Action: "s3:GetObject", Resource: "arn:aws:s3:::bucket-b/*" },
|
|
141
|
+
],
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
expect(checkDenyAllowContradiction(ctx)).toHaveLength(0);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("conditional Deny is skipped (unprovable statically) → no diagnostic", () => {
|
|
153
|
+
const ctx = makeCtx({
|
|
154
|
+
Resources: {
|
|
155
|
+
AppRole: {
|
|
156
|
+
Type: "AWS::IAM::Role",
|
|
157
|
+
Properties: {
|
|
158
|
+
AssumeRolePolicyDocument: { Statement: [] },
|
|
159
|
+
Policies: [
|
|
160
|
+
{
|
|
161
|
+
PolicyName: "inline",
|
|
162
|
+
PolicyDocument: {
|
|
163
|
+
Statement: [
|
|
164
|
+
{ Effect: "Allow", Action: "s3:GetObject", Resource: "*" },
|
|
165
|
+
{
|
|
166
|
+
Effect: "Deny",
|
|
167
|
+
Action: "s3:GetObject",
|
|
168
|
+
Resource: "*",
|
|
169
|
+
Condition: { StringNotEquals: { "aws:SourceVpc": "vpc-123" } },
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
expect(checkDenyAllowContradiction(ctx)).toHaveLength(0);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("Deny from a managed policy attached via ManagedPolicyArns collides with an inline Allow → error", () => {
|
|
183
|
+
const ctx = makeCtx({
|
|
184
|
+
Resources: {
|
|
185
|
+
AppRole: {
|
|
186
|
+
Type: "AWS::IAM::Role",
|
|
187
|
+
Properties: {
|
|
188
|
+
AssumeRolePolicyDocument: { Statement: [] },
|
|
189
|
+
ManagedPolicyArns: [{ Ref: "GuardrailManagedPolicy" }],
|
|
190
|
+
Policies: [{ PolicyName: "inline", PolicyDocument: { Statement: [{ Effect: "Allow", Action: "ec2:ModifyInstanceAttribute", Resource: "*" }] } }],
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
GuardrailManagedPolicy: {
|
|
194
|
+
Type: "AWS::IAM::ManagedPolicy",
|
|
195
|
+
Properties: {
|
|
196
|
+
PolicyDocument: { Statement: [{ Effect: "Deny", Action: "ec2:ModifyInstanceAttribute", Resource: "*" }] },
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
const diags = checkDenyAllowContradiction(ctx);
|
|
202
|
+
expect(diags).toHaveLength(1);
|
|
203
|
+
expect(diags[0].message).toContain("GuardrailManagedPolicy");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("role with only Allow statements → no diagnostic", () => {
|
|
207
|
+
const ctx = makeCtx({
|
|
208
|
+
Resources: {
|
|
209
|
+
AppRole: {
|
|
210
|
+
Type: "AWS::IAM::Role",
|
|
211
|
+
Properties: {
|
|
212
|
+
AssumeRolePolicyDocument: { Statement: [] },
|
|
213
|
+
Policies: [{ PolicyName: "p", PolicyDocument: { Statement: [{ Effect: "Allow", Action: "s3:GetObject", Resource: "*" }] } }],
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
expect(checkDenyAllowContradiction(ctx)).toHaveLength(0);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
Binary file
|
|
@@ -3,6 +3,7 @@ import { writeFileSync, unlinkSync } from "node:fs";
|
|
|
3
3
|
import {
|
|
4
4
|
awsApply,
|
|
5
5
|
awsDelete,
|
|
6
|
+
rollbackStack,
|
|
6
7
|
cfnUrl,
|
|
7
8
|
cfnForm,
|
|
8
9
|
capabilityParams,
|
|
@@ -113,6 +114,54 @@ describe("awsApply flow (#awsApply)", () => {
|
|
|
113
114
|
expect(sent[2]["Capabilities.member.2"]).toBeUndefined();
|
|
114
115
|
});
|
|
115
116
|
|
|
117
|
+
test("the template's ownership Metadata becomes the STACK's own tags on create AND update (#1222)", async () => {
|
|
118
|
+
const sent: Array<Record<string, string>> = [];
|
|
119
|
+
let described = 0;
|
|
120
|
+
const createHttp: AwsHttp = async (_url, form) => {
|
|
121
|
+
if (form.Action === "DescribeStacks") return described++ === 0 ? { status: 400, text: MISSING } : { status: 200, text: describe_("CREATE_COMPLETE") };
|
|
122
|
+
sent.push(form);
|
|
123
|
+
return { status: 200, text: CREATE_OK };
|
|
124
|
+
};
|
|
125
|
+
const p = tmpl({
|
|
126
|
+
Metadata: { "chant:ownership": { "chant:managed-by": "chant", "chant:stack": "shop", "chant:env": "dev" } },
|
|
127
|
+
});
|
|
128
|
+
await awsApply({ templatePath: p, stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, createHttp);
|
|
129
|
+
|
|
130
|
+
expect(sent[0].Action).toBe("CreateStack");
|
|
131
|
+
expect(sent[0]["Tags.member.1.Key"]).toBe("chant:env");
|
|
132
|
+
expect(sent[0]["Tags.member.1.Value"]).toBe("dev");
|
|
133
|
+
expect(sent[0]["Tags.member.2.Key"]).toBe("chant:managed-by");
|
|
134
|
+
expect(sent[0]["Tags.member.2.Value"]).toBe("chant");
|
|
135
|
+
expect(sent[0]["Tags.member.3.Key"]).toBe("chant:stack");
|
|
136
|
+
expect(sent[0]["Tags.member.3.Value"]).toBe("shop");
|
|
137
|
+
|
|
138
|
+
// Update path re-stamps the same tags.
|
|
139
|
+
let updDescribed = 0;
|
|
140
|
+
const updateHttp: AwsHttp = async (_url, form) => {
|
|
141
|
+
if (form.Action === "DescribeStacks") return { status: 200, text: describe_(updDescribed++ === 0 ? "CREATE_COMPLETE" : "UPDATE_COMPLETE") };
|
|
142
|
+
sent.push(form);
|
|
143
|
+
return { status: 200, text: UPDATE_OK };
|
|
144
|
+
};
|
|
145
|
+
await awsApply({ templatePath: p, stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, updateHttp);
|
|
146
|
+
unlinkSync(p);
|
|
147
|
+
expect(sent[1].Action).toBe("UpdateStack");
|
|
148
|
+
expect(sent[1]["Tags.member.2.Key"]).toBe("chant:managed-by");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("a template without the ownership Metadata sends no Tags parameter at all", async () => {
|
|
152
|
+
const sent: Array<Record<string, string>> = [];
|
|
153
|
+
let described = 0;
|
|
154
|
+
const http: AwsHttp = async (_url, form) => {
|
|
155
|
+
if (form.Action === "DescribeStacks") return described++ === 0 ? { status: 400, text: MISSING } : { status: 200, text: describe_("CREATE_COMPLETE") };
|
|
156
|
+
sent.push(form);
|
|
157
|
+
return { status: 200, text: CREATE_OK };
|
|
158
|
+
};
|
|
159
|
+
const p = tmpl();
|
|
160
|
+
await awsApply({ templatePath: p, stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, http);
|
|
161
|
+
unlinkSync(p);
|
|
162
|
+
expect(Object.keys(sent[0]).some((k) => k.startsWith("Tags."))).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
|
|
116
165
|
test("update path: existing stack → UpdateStack → UPDATE_COMPLETE", async () => {
|
|
117
166
|
let described = 0;
|
|
118
167
|
const http: AwsHttp = async (_url, form) => {
|
|
@@ -177,3 +226,65 @@ describe("awsDelete (#awsApply)", () => {
|
|
|
177
226
|
expect(calls[0]).toBe("DeleteStack");
|
|
178
227
|
});
|
|
179
228
|
});
|
|
229
|
+
|
|
230
|
+
describe("rollbackStack (#1449)", () => {
|
|
231
|
+
test("RollbackStack then polls to UPDATE_ROLLBACK_COMPLETE", async () => {
|
|
232
|
+
const calls: string[] = [];
|
|
233
|
+
const http: AwsHttp = async (_url, form) => {
|
|
234
|
+
calls.push(form.Action);
|
|
235
|
+
if (form.Action === "DescribeStacks") return { status: 200, text: describe_("UPDATE_ROLLBACK_COMPLETE") };
|
|
236
|
+
return { status: 200, text: "<RollbackStackResponse/>" };
|
|
237
|
+
};
|
|
238
|
+
const res = await rollbackStack({ stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, http);
|
|
239
|
+
expect(res).toEqual({ stackName: "s", rolledBack: true, status: "UPDATE_ROLLBACK_COMPLETE" });
|
|
240
|
+
expect(calls[0]).toBe("RollbackStack");
|
|
241
|
+
expect(calls[0]).not.toBe("DescribeStacks"); // no probe first — the action itself answers
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("posts the stack name on the Query API form", async () => {
|
|
245
|
+
const forms: Record<string, string>[] = [];
|
|
246
|
+
const http: AwsHttp = async (_url, form) => {
|
|
247
|
+
forms.push(form);
|
|
248
|
+
if (form.Action === "DescribeStacks") return { status: 200, text: describe_("ROLLBACK_COMPLETE") };
|
|
249
|
+
return { status: 200, text: "<RollbackStackResponse/>" };
|
|
250
|
+
};
|
|
251
|
+
await rollbackStack({ stackName: "prod", endpoint: "http://x", intervalMs: 1 }, undefined, http);
|
|
252
|
+
expect(forms[0]).toMatchObject({ Action: "RollbackStack", StackName: "prod" });
|
|
253
|
+
expect(forms[0].Version).toBeDefined();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("an absent stack is nothing to roll back — rolledBack: false, no throw", async () => {
|
|
257
|
+
const http: AwsHttp = async () => ({ status: 400, text: MISSING });
|
|
258
|
+
const res = await rollbackStack({ stackName: "s", endpoint: "http://x" }, undefined, http);
|
|
259
|
+
expect(res).toEqual({ stackName: "s", rolledBack: false });
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("a target without RollbackStack (Floci's UnknownAction, #947) degrades, not crashes", async () => {
|
|
263
|
+
const http: AwsHttp = async () => ({
|
|
264
|
+
status: 400,
|
|
265
|
+
text: "<ErrorResponse><Error><Code>UnknownAction</Code><Message>Action RollbackStack is not supported.</Message></Error></ErrorResponse>",
|
|
266
|
+
});
|
|
267
|
+
const res = await rollbackStack({ stackName: "s", endpoint: "http://x" }, undefined, http);
|
|
268
|
+
expect(res).toEqual({ stackName: "s", rolledBack: false });
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("any other API error throws — a compensation must not fail silently", async () => {
|
|
272
|
+
const http: AwsHttp = async () => ({
|
|
273
|
+
status: 400,
|
|
274
|
+
text: "<ErrorResponse><Error><Message>Rollback requires a stack in UPDATE_FAILED state</Message></Error></ErrorResponse>",
|
|
275
|
+
});
|
|
276
|
+
await expect(rollbackStack({ stackName: "s", endpoint: "http://x" }, undefined, http)).rejects.toThrow(
|
|
277
|
+
/RollbackStack failed \(400\): Rollback requires/,
|
|
278
|
+
);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("throws when the stack settles anywhere but *ROLLBACK_COMPLETE", async () => {
|
|
282
|
+
const http: AwsHttp = async (_url, form) => {
|
|
283
|
+
if (form.Action === "DescribeStacks") return { status: 200, text: describe_("UPDATE_ROLLBACK_FAILED") };
|
|
284
|
+
return { status: 200, text: "<RollbackStackResponse/>" };
|
|
285
|
+
};
|
|
286
|
+
await expect(rollbackStack({ stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, http)).rejects.toThrow(
|
|
287
|
+
/rollback → UPDATE_ROLLBACK_FAILED/,
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { safeHeartbeat, sleep } from "@intentius/chant/op";
|
|
3
3
|
import { awsDeployCapabilitiesForBody } from "../../components/cloud-executor.js";
|
|
4
4
|
import { resolveEndpointOverride } from "../../api/read-client.js";
|
|
5
|
+
import { ownershipStackTagsForBody } from "../../ownership.js";
|
|
5
6
|
|
|
6
7
|
const DEFAULT_REGION = "us-east-1";
|
|
7
8
|
const CFN_API_VERSION = "2010-05-15";
|
|
@@ -70,6 +71,22 @@ export function capabilityParams(capabilities: string[]): Record<string, string>
|
|
|
70
71
|
return out;
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Stack tags as the CFN `Tags.member.N.Key/Value` list params (#1222). Sorted
|
|
76
|
+
* so the request is deterministic. Empty in, empty out — a template without an
|
|
77
|
+
* ownership marker adds no `Tags` parameter at all.
|
|
78
|
+
*/
|
|
79
|
+
export function tagParams(tags: Record<string, string>): Record<string, string> {
|
|
80
|
+
const out: Record<string, string> = {};
|
|
81
|
+
Object.keys(tags)
|
|
82
|
+
.sort()
|
|
83
|
+
.forEach((key, i) => {
|
|
84
|
+
out[`Tags.member.${i + 1}.Key`] = key;
|
|
85
|
+
out[`Tags.member.${i + 1}.Value`] = tags[key];
|
|
86
|
+
});
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
73
90
|
/**
|
|
74
91
|
* First `<tag>…</tag>` text in a CFN XML response.
|
|
75
92
|
*
|
|
@@ -127,10 +144,10 @@ export async function waitForStackSettled(
|
|
|
127
144
|
* CloudFormation API directly (create-or-update + poll to a settled stack),
|
|
128
145
|
* targeting a local Floci emulator or real AWS by endpoint override. The direct
|
|
129
146
|
* twin of `azApply`/`gcpApply`: it speaks the CloudFormation Query API over HTTP
|
|
130
|
-
* rather than shelling `aws cloudformation deploy`
|
|
131
|
-
*
|
|
132
|
-
* ownership boundary, so deletes ride CloudFormation
|
|
133
|
-
* `http` is injectable for tests.
|
|
147
|
+
* rather than shelling `aws cloudformation deploy` — and since chant #1449 it is
|
|
148
|
+
* also what `nativeApply({ target: "cloudformation" })` runs, so no CLI path
|
|
149
|
+
* remains. The stack is the ownership boundary, so deletes ride CloudFormation
|
|
150
|
+
* itself — no separate prune. `http` is injectable for tests.
|
|
134
151
|
*/
|
|
135
152
|
export async function awsApply(
|
|
136
153
|
args: AwsApplyArgs,
|
|
@@ -149,7 +166,17 @@ export async function awsApply(
|
|
|
149
166
|
}
|
|
150
167
|
const exists = desc.status < 300;
|
|
151
168
|
|
|
152
|
-
|
|
169
|
+
// Stamp the template's ownership marker as the STACK's own tags (#1222):
|
|
170
|
+
// stack-level teardown verifies ownership on DescribeStacks tags, and this
|
|
171
|
+
// is the write that makes every future stack teardown-eligible. A template
|
|
172
|
+
// carrying no marker adds nothing.
|
|
173
|
+
const stackTags = ownershipStackTagsForBody(templateBody);
|
|
174
|
+
const params = {
|
|
175
|
+
StackName: args.stackName,
|
|
176
|
+
TemplateBody: templateBody,
|
|
177
|
+
...capabilityParams(capabilities),
|
|
178
|
+
...tagParams(stackTags),
|
|
179
|
+
};
|
|
153
180
|
let action: "created" | "updated";
|
|
154
181
|
if (!exists) {
|
|
155
182
|
const res = await http(url, cfnForm("CreateStack", params), signal);
|
|
@@ -177,12 +204,18 @@ export async function awsApply(
|
|
|
177
204
|
return { stackName: args.stackName, status, action };
|
|
178
205
|
}
|
|
179
206
|
|
|
207
|
+
/** {@link awsDelete}'s arguments: {@link AwsApplyArgs} minus the template — a
|
|
208
|
+
* delete needs no body, so teardown (#1222) can call it with a stack name
|
|
209
|
+
* alone. Op builders that thread `templatePath` through keep working; it is
|
|
210
|
+
* simply unused here. */
|
|
211
|
+
export type AwsDeleteArgs = Omit<AwsApplyArgs, "templatePath"> & { templatePath?: string };
|
|
212
|
+
|
|
180
213
|
/**
|
|
181
214
|
* The inverse of {@link awsApply} — DeleteStack, then poll until the stack is
|
|
182
215
|
* gone. Idempotent: an already-absent stack is a no-op. `http` is injectable.
|
|
183
216
|
*/
|
|
184
217
|
export async function awsDelete(
|
|
185
|
-
args:
|
|
218
|
+
args: AwsDeleteArgs,
|
|
186
219
|
signal?: AbortSignal,
|
|
187
220
|
http: AwsHttp = defaultHttp,
|
|
188
221
|
): Promise<{ stackName: string; deleted: boolean }> {
|
|
@@ -208,3 +241,64 @@ export async function awsDelete(
|
|
|
208
241
|
}
|
|
209
242
|
throw new Error(`CloudFormation stack ${args.stackName} delete did not complete within ${timeoutMs}ms`);
|
|
210
243
|
}
|
|
244
|
+
|
|
245
|
+
export interface RollbackStackArgs {
|
|
246
|
+
/** CloudFormation stack name to roll back. */
|
|
247
|
+
stackName: string;
|
|
248
|
+
/** CFN endpoint override — same resolution rule as {@link AwsApplyArgs.endpoint} (#1694). */
|
|
249
|
+
endpoint?: string;
|
|
250
|
+
/** Region (real CFN host). Default: `us-east-1`. */
|
|
251
|
+
region?: string;
|
|
252
|
+
/** Stack-settle timeout in ms. Default: `300000`. */
|
|
253
|
+
timeoutMs?: number;
|
|
254
|
+
/** Poll interval in ms. Default: `3000`. */
|
|
255
|
+
intervalMs?: number;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The saga compensation for {@link awsApply} — CloudFormation `RollbackStack`
|
|
260
|
+
* via the same Query-API client, then poll until the stack settles. Returns the
|
|
261
|
+
* stack to its last known stable state after a failed update (#1449 — this
|
|
262
|
+
* replaces the Temporal lexicon exec-ing `aws cloudformation rollback-stack`).
|
|
263
|
+
*
|
|
264
|
+
* Degrades rather than crashes in two cases where there is nothing to do:
|
|
265
|
+
* an absent stack (nothing applied, nothing to revert) and a target that does
|
|
266
|
+
* not implement the action — Floci answers `UnknownAction` (#947). Both return
|
|
267
|
+
* `rolledBack: false` with a logged warning; every other API error throws,
|
|
268
|
+
* because a compensation that silently fails leaves partial state looking
|
|
269
|
+
* reverted when it isn't. `http` is injectable for tests.
|
|
270
|
+
*/
|
|
271
|
+
export async function rollbackStack(
|
|
272
|
+
args: RollbackStackArgs,
|
|
273
|
+
signal?: AbortSignal,
|
|
274
|
+
http: AwsHttp = defaultHttp,
|
|
275
|
+
): Promise<{ stackName: string; rolledBack: boolean; status?: string }> {
|
|
276
|
+
const url = cfnUrl(args.endpoint, args.region);
|
|
277
|
+
const timeoutMs = args.timeoutMs ?? 300_000;
|
|
278
|
+
const intervalMs = args.intervalMs ?? 3_000;
|
|
279
|
+
|
|
280
|
+
const res = await http(url, cfnForm("RollbackStack", { StackName: args.stackName }), signal);
|
|
281
|
+
if (res.status >= 300) {
|
|
282
|
+
if (isStackMissing(res.text)) {
|
|
283
|
+
console.warn(`rollbackStack: stack ${args.stackName} does not exist — nothing to roll back`);
|
|
284
|
+
return { stackName: args.stackName, rolledBack: false };
|
|
285
|
+
}
|
|
286
|
+
// Local emulators (Floci) don't implement RollbackStack → `UnknownAction` (#947).
|
|
287
|
+
if (/UnknownAction|not supported/i.test(res.text)) {
|
|
288
|
+
console.warn(
|
|
289
|
+
`rollbackStack: the target doesn't support RollbackStack (a local emulator such as Floci) — skipping automated rollback of ${args.stackName}`,
|
|
290
|
+
);
|
|
291
|
+
return { stackName: args.stackName, rolledBack: false };
|
|
292
|
+
}
|
|
293
|
+
throw new Error(`CloudFormation RollbackStack failed (${res.status}): ${cfnErrorMessage(res.text) ?? res.text}`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const status = await waitForStackSettled(url, args.stackName, http, { timeoutMs, intervalMs }, signal);
|
|
297
|
+
// A settled rollback ends in `ROLLBACK_COMPLETE`/`UPDATE_ROLLBACK_COMPLETE` —
|
|
298
|
+
// classified a failure by the deploy-path matcher, but the success state here.
|
|
299
|
+
if (!/ROLLBACK_COMPLETE$/.test(status)) {
|
|
300
|
+
throw new Error(`CloudFormation stack ${args.stackName} rollback → ${status}`);
|
|
301
|
+
}
|
|
302
|
+
console.log(`rolled back: ${args.stackName} (${status}) [${url}]`);
|
|
303
|
+
return { stackName: args.stackName, rolledBack: true, status };
|
|
304
|
+
}
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* AWS Op activities — resolved by the core activity registry when a project's
|
|
3
3
|
* `chant.config.ts` lists the `aws` lexicon. Contributes the local Floci AWS
|
|
4
4
|
* emulator lifecycle (`flociUp`/`flociDown`) and the native CloudFormation
|
|
5
|
-
* applier (`awsApply`
|
|
6
|
-
* shelling `aws` — the
|
|
5
|
+
* applier (`awsApply`, with `awsDelete` and the `rollbackStack` compensation),
|
|
6
|
+
* which calls the CloudFormation API directly rather than shelling `aws` — the
|
|
7
|
+
* direct twin of `azApply`/`gcpApply`.
|
|
7
8
|
*
|
|
8
9
|
* The registry keys every exported *function* here by its name, so only the
|
|
9
10
|
* activities themselves belong in this barrel. `awsAgentCoreFetchTrace`'s
|
|
@@ -28,6 +29,7 @@ export type { FlociUpArgs, FlociDownArgs } from "./floci";
|
|
|
28
29
|
export {
|
|
29
30
|
awsApply,
|
|
30
31
|
awsDelete,
|
|
32
|
+
rollbackStack,
|
|
31
33
|
waitForStackSettled,
|
|
32
34
|
cfnUrl,
|
|
33
35
|
cfnForm,
|
|
@@ -42,7 +44,22 @@ export {
|
|
|
42
44
|
isFailureStatus,
|
|
43
45
|
isTerminalStatus,
|
|
44
46
|
} from "./aws-apply";
|
|
45
|
-
export type { AwsApplyArgs, AwsHttp } from "./aws-apply";
|
|
47
|
+
export type { AwsApplyArgs, RollbackStackArgs, AwsHttp } from "./aws-apply";
|
|
48
|
+
|
|
49
|
+
// Effect-receipt activities (#1835) — core's receipt seam (#1834) bound to
|
|
50
|
+
// the SSM store, the way the k8s lexicon binds `ensureSecret` (#1830). The
|
|
51
|
+
// registry keys exported functions by name, so the three bound activities are
|
|
52
|
+
// re-exported individually: `receiptRead`/`receiptWrite` serve the `effect()`
|
|
53
|
+
// step's read-compare-run-write, `receiptStaleness` serves WatchOp's
|
|
54
|
+
// read-only phase. The store resolves its path identity and endpoint lazily
|
|
55
|
+
// at first use, so this module stays cheap to load.
|
|
56
|
+
import { receiptActivities } from "@intentius/chant/op/receipt-store";
|
|
57
|
+
import { awsReceiptStore } from "../../receipt-store";
|
|
58
|
+
|
|
59
|
+
const boundReceiptActivities = receiptActivities(awsReceiptStore());
|
|
60
|
+
export const receiptRead = boundReceiptActivities.receiptRead;
|
|
61
|
+
export const receiptWrite = boundReceiptActivities.receiptWrite;
|
|
62
|
+
export const receiptStaleness = boundReceiptActivities.receiptStaleness;
|
|
46
63
|
|
|
47
64
|
export { awsAgentCoreFetchTrace } from "../../agentcore/trace-fetch";
|
|
48
65
|
export type {
|
package/src/ownership.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
2
|
import { ownershipEntries } from "@intentius/chant/ownership";
|
|
3
|
-
import { AWS_TAG_OWNERSHIP_KEYS } from "./ownership";
|
|
3
|
+
import { AWS_TAG_OWNERSHIP_KEYS, OWNERSHIP_METADATA_KEY, ownershipStackTagsForBody } from "./ownership";
|
|
4
4
|
|
|
5
5
|
describe("AWS_TAG_OWNERSHIP_KEYS", () => {
|
|
6
6
|
test("uses AWS colon-form tag keys", () => {
|
|
@@ -16,3 +16,26 @@ describe("AWS_TAG_OWNERSHIP_KEYS", () => {
|
|
|
16
16
|
expect(e).toEqual({ "chant:managed-by": "chant", "chant:stack": "billing", "chant:env": "prod" });
|
|
17
17
|
});
|
|
18
18
|
});
|
|
19
|
+
|
|
20
|
+
describe("ownershipStackTagsForBody (#1222)", () => {
|
|
21
|
+
test("reads the flat tag map under Metadata[chant:ownership]", () => {
|
|
22
|
+
const body = JSON.stringify({
|
|
23
|
+
Metadata: { [OWNERSHIP_METADATA_KEY]: { "chant:managed-by": "chant", "chant:stack": "shop", "chant:env": "dev" } },
|
|
24
|
+
Resources: {},
|
|
25
|
+
});
|
|
26
|
+
expect(ownershipStackTagsForBody(body)).toEqual({
|
|
27
|
+
"chant:managed-by": "chant",
|
|
28
|
+
"chant:stack": "shop",
|
|
29
|
+
"chant:env": "dev",
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("total on bad input: non-JSON, no Metadata, no marker, non-string values", () => {
|
|
34
|
+
expect(ownershipStackTagsForBody("Resources:\n B:\n")).toEqual({});
|
|
35
|
+
expect(ownershipStackTagsForBody(JSON.stringify({ Resources: {} }))).toEqual({});
|
|
36
|
+
expect(ownershipStackTagsForBody(JSON.stringify({ Metadata: {}, Resources: {} }))).toEqual({});
|
|
37
|
+
expect(
|
|
38
|
+
ownershipStackTagsForBody(JSON.stringify({ Metadata: { [OWNERSHIP_METADATA_KEY]: { a: 1, b: "x" } } })),
|
|
39
|
+
).toEqual({ b: "x" });
|
|
40
|
+
});
|
|
41
|
+
});
|
package/src/ownership.ts
CHANGED
|
@@ -13,3 +13,40 @@ export const AWS_TAG_OWNERSHIP_KEYS: ChannelKeys = {
|
|
|
13
13
|
stack: "chant:stack",
|
|
14
14
|
env: "chant:env",
|
|
15
15
|
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Template-level `Metadata` key the serializer stamps the ownership marker
|
|
19
|
+
* under (#1222). Stack-level teardown verifies ownership on the *stack's own*
|
|
20
|
+
* tags, and CloudFormation stack tags are an API parameter, not a template
|
|
21
|
+
* section — so the template carries the marker here and the apply paths
|
|
22
|
+
* (`awsApply`, the `cfn-deploy` change set) turn it into stack tags on
|
|
23
|
+
* create/update. One source: the same build that stamps resource tags decides
|
|
24
|
+
* the stack tags.
|
|
25
|
+
*/
|
|
26
|
+
export const OWNERSHIP_METADATA_KEY = "chant:ownership";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The stack tags a template body asks for: the flat tag map under
|
|
30
|
+
* `Metadata["chant:ownership"]`, or nothing. Total on bad input — a body that
|
|
31
|
+
* is not JSON (a YAML template, a handwritten one) or carries no marker
|
|
32
|
+
* returns `{}`, and the stack simply stays untagged, which teardown reports
|
|
33
|
+
* as unverified rather than deleting.
|
|
34
|
+
*/
|
|
35
|
+
export function ownershipStackTagsForBody(body: string): Record<string, string> {
|
|
36
|
+
let template: unknown;
|
|
37
|
+
try {
|
|
38
|
+
template = JSON.parse(body);
|
|
39
|
+
} catch {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
if (typeof template !== "object" || template === null) return {};
|
|
43
|
+
const metadata = (template as { Metadata?: unknown }).Metadata;
|
|
44
|
+
if (typeof metadata !== "object" || metadata === null) return {};
|
|
45
|
+
const marker = (metadata as Record<string, unknown>)[OWNERSHIP_METADATA_KEY];
|
|
46
|
+
if (typeof marker !== "object" || marker === null) return {};
|
|
47
|
+
const tags: Record<string, string> = {};
|
|
48
|
+
for (const [key, value] of Object.entries(marker as Record<string, unknown>)) {
|
|
49
|
+
if (typeof value === "string") tags[key] = value;
|
|
50
|
+
}
|
|
51
|
+
return tags;
|
|
52
|
+
}
|