@intentius/chant-lexicon-aws 0.45.0 → 0.46.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/components/cloud-executor.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/integrity.json +5 -3
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/cf-refs.d.ts +7 -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/manifest.json +1 -1
- package/dist/okf/index.md +2 -0
- package/dist/okf/rules/WAW059.md +25 -0
- package/dist/okf/rules/WAW060.md +17 -0
- package/dist/okf/types/Action.md +1 -0
- package/dist/okf/types/Bucket.md +1 -0
- package/dist/okf/types/GlobalTable.md +4 -0
- package/dist/okf/types/IamPolicy.md +2 -0
- package/dist/okf/types/InstanceProfile.md +4 -0
- package/dist/okf/types/ManagedPolicy.md +2 -0
- package/dist/okf/types/Map.md +1 -0
- package/dist/okf/types/Queue.md +1 -0
- package/dist/okf/types/Role.md +1 -0
- package/dist/okf/types/Table.md +1 -0
- package/dist/okf/types/Type.md +2 -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 +5 -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/rules/cf-refs.ts +22 -0
- package/dist/rules/waw059.ts +353 -0
- package/dist/rules/waw060.ts +91 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/teardown.d.ts +85 -0
- package/dist/teardown.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/components/cloud-executor.ts +10 -1
- package/src/lifecycle-integration.test.ts +4 -0
- package/src/lint/audit-catalog.ts +5 -0
- package/src/lint/post-synth/cf-refs.ts +22 -0
- package/src/lint/post-synth/index.ts +4 -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/op/activities/aws-apply.test.ts +111 -0
- package/src/op/activities/aws-apply.ts +100 -6
- package/src/op/activities/index.ts +5 -3
- package/src/ownership.test.ts +24 -1
- package/src/ownership.ts +37 -0
- package/src/plugin.ts +18 -0
- package/src/serializer-ownership.test.ts +18 -0
- package/src/serializer.ts +9 -1
- package/src/teardown.test.ts +258 -0
- package/src/teardown.ts +276 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { createPostSynthContext } from "@intentius/chant-test-utils";
|
|
3
|
+
import { waw060, checkPolicyUnattached } from "./waw060";
|
|
4
|
+
|
|
5
|
+
function makeCtx(template: object) {
|
|
6
|
+
return createPostSynthContext({ aws: template });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const ALLOW_S3_READ = {
|
|
10
|
+
Version: "2012-10-17",
|
|
11
|
+
Statement: [{ Effect: "Allow", Action: ["s3:GetObject"], Resource: "arn:aws:s3:::data/*" }],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function managedPolicy(extra?: Record<string, unknown>) {
|
|
15
|
+
return {
|
|
16
|
+
Type: "AWS::IAM::ManagedPolicy",
|
|
17
|
+
Properties: { PolicyDocument: ALLOW_S3_READ, ...extra },
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function inlinePolicy(extra?: Record<string, unknown>) {
|
|
22
|
+
return {
|
|
23
|
+
Type: "AWS::IAM::Policy",
|
|
24
|
+
Properties: { PolicyName: "read", PolicyDocument: ALLOW_S3_READ, ...extra },
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function role(extra?: Record<string, unknown>) {
|
|
29
|
+
return {
|
|
30
|
+
Type: "AWS::IAM::Role",
|
|
31
|
+
Properties: {
|
|
32
|
+
AssumeRolePolicyDocument: {
|
|
33
|
+
Version: "2012-10-17",
|
|
34
|
+
Statement: [{ Effect: "Allow", Principal: { Service: "lambda.amazonaws.com" }, Action: "sts:AssumeRole" }],
|
|
35
|
+
},
|
|
36
|
+
...extra,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("WAW060: IAM policy attached to no principal", () => {
|
|
42
|
+
test("check metadata", () => {
|
|
43
|
+
expect(waw060.id).toBe("WAW060");
|
|
44
|
+
expect(waw060.description).toContain("principal");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("flags an unattached IAM::Policy", () => {
|
|
48
|
+
const ctx = makeCtx({ Resources: { Orphan: inlinePolicy() } });
|
|
49
|
+
const diags = checkPolicyUnattached(ctx);
|
|
50
|
+
expect(diags).toHaveLength(1);
|
|
51
|
+
expect(diags[0].checkId).toBe("WAW060");
|
|
52
|
+
expect(diags[0].severity).toBe("warning");
|
|
53
|
+
expect(diags[0].entity).toBe("Orphan");
|
|
54
|
+
expect(diags[0].message).toContain("grants nothing");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("flags an unattached ManagedPolicy and one with empty principal lists", () => {
|
|
58
|
+
const ctx = makeCtx({
|
|
59
|
+
Resources: {
|
|
60
|
+
Orphan: managedPolicy(),
|
|
61
|
+
EmptyLists: managedPolicy({ Roles: [], Users: [], Groups: [] }),
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
const diags = checkPolicyUnattached(ctx);
|
|
65
|
+
expect(diags.map((d) => d.entity).sort()).toEqual(["EmptyLists", "Orphan"]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("quiet when a role's ManagedPolicyArns references the policy", () => {
|
|
69
|
+
const ctx = makeCtx({
|
|
70
|
+
Resources: {
|
|
71
|
+
ReadPolicy: managedPolicy(),
|
|
72
|
+
AppRole: role({ ManagedPolicyArns: [{ Ref: "ReadPolicy" }] }),
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
expect(checkPolicyUnattached(ctx)).toHaveLength(0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("quiet when the policy declares a Roles list", () => {
|
|
79
|
+
const ctx = makeCtx({
|
|
80
|
+
Resources: {
|
|
81
|
+
AppRole: role(),
|
|
82
|
+
ReadPolicy: managedPolicy({ Roles: [{ Ref: "AppRole" }] }),
|
|
83
|
+
InlineRead: inlinePolicy({ Roles: [{ Ref: "AppRole" }] }),
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
expect(checkPolicyUnattached(ctx)).toHaveLength(0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("AWS-managed ARN strings elsewhere do not attach the template's own policy", () => {
|
|
90
|
+
const ctx = makeCtx({
|
|
91
|
+
Resources: {
|
|
92
|
+
Orphan: managedPolicy(),
|
|
93
|
+
AppRole: role({ ManagedPolicyArns: ["arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"] }),
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
const diags = checkPolicyUnattached(ctx);
|
|
97
|
+
expect(diags).toHaveLength(1);
|
|
98
|
+
expect(diags[0].entity).toBe("Orphan");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("quiet on attachment-style resources and GetAtt edges", () => {
|
|
102
|
+
const ctx = makeCtx({
|
|
103
|
+
Resources: {
|
|
104
|
+
ReadPolicy: managedPolicy(),
|
|
105
|
+
Attachment: {
|
|
106
|
+
Type: "AWS::SSO::PermissionSet",
|
|
107
|
+
Properties: { ManagedPolicies: [{ "Fn::GetAtt": ["ReadPolicy", "PolicyArn"] }] },
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
expect(checkPolicyUnattached(ctx)).toHaveLength(0);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("an Output exporting the policy counts as a reference", () => {
|
|
115
|
+
const ctx = makeCtx({
|
|
116
|
+
Resources: { SharedPolicy: managedPolicy() },
|
|
117
|
+
Outputs: { SharedPolicyArn: { Value: { Ref: "SharedPolicy" }, Export: { Name: "shared-read" } } },
|
|
118
|
+
});
|
|
119
|
+
expect(checkPolicyUnattached(ctx)).toHaveLength(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("intrinsic principal lists are skipped, and non-policy IAM types are ignored", () => {
|
|
123
|
+
const ctx = makeCtx({
|
|
124
|
+
Resources: {
|
|
125
|
+
Conditional: managedPolicy({ Roles: { "Fn::If": ["UseRole", [{ Ref: "AppRole" }], []] } }),
|
|
126
|
+
AppRole: role(),
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
expect(checkPolicyUnattached(ctx)).toHaveLength(0);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAW060: IAM Policy Attached to No Principal
|
|
3
|
+
*
|
|
4
|
+
* Flags AWS::IAM::Policy / AWS::IAM::ManagedPolicy resources with empty or
|
|
5
|
+
* absent Roles, Users, and Groups that no other part of the template
|
|
6
|
+
* references. A policy attached to no principal grants nothing — the same
|
|
7
|
+
* detached-guardrail shape WAW057 catches for SCPs. Any Ref/GetAtt edge to
|
|
8
|
+
* the policy (a role's ManagedPolicyArns, an attachment-style resource, a
|
|
9
|
+
* stack Output exporting it for another stack to attach) keeps it quiet;
|
|
10
|
+
* plain AWS-managed ARN strings elsewhere are not references to the
|
|
11
|
+
* template's own policy.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
15
|
+
import { parseCFTemplate, findResourceRefs, isIntrinsic, type CFTemplate } from "./cf-refs";
|
|
16
|
+
|
|
17
|
+
const POLICY_TYPES = new Set(["AWS::IAM::Policy", "AWS::IAM::ManagedPolicy"]);
|
|
18
|
+
const PRINCIPAL_PROPS = ["Roles", "Users", "Groups"] as const;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Collect every logical id referenced via Ref/Fn::GetAtt from anywhere in the
|
|
22
|
+
* template except the policy resources themselves — other resources'
|
|
23
|
+
* properties and the Outputs section. An Output edge counts as attachment
|
|
24
|
+
* evidence: the policy may be exported for a consuming stack to attach.
|
|
25
|
+
*/
|
|
26
|
+
function collectExternalRefs(template: CFTemplate, excludeIds: Set<string>): Set<string> {
|
|
27
|
+
const refs = new Set<string>();
|
|
28
|
+
for (const [logicalId, resource] of Object.entries(template.Resources ?? {})) {
|
|
29
|
+
if (excludeIds.has(logicalId)) continue;
|
|
30
|
+
for (const ref of findResourceRefs(resource)) refs.add(ref);
|
|
31
|
+
}
|
|
32
|
+
for (const ref of findResourceRefs(template.Outputs)) refs.add(ref);
|
|
33
|
+
return refs;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function checkPolicyUnattached(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
37
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
38
|
+
|
|
39
|
+
for (const [_lexicon, output] of ctx.outputs) {
|
|
40
|
+
const template = parseCFTemplate(output);
|
|
41
|
+
if (!template?.Resources) continue;
|
|
42
|
+
|
|
43
|
+
const policyIds = new Set(
|
|
44
|
+
Object.entries(template.Resources)
|
|
45
|
+
.filter(([, resource]) => POLICY_TYPES.has(resource.Type))
|
|
46
|
+
.map(([logicalId]) => logicalId),
|
|
47
|
+
);
|
|
48
|
+
if (policyIds.size === 0) continue;
|
|
49
|
+
|
|
50
|
+
const externalRefs = collectExternalRefs(template, policyIds);
|
|
51
|
+
|
|
52
|
+
for (const logicalId of policyIds) {
|
|
53
|
+
const resource = template.Resources[logicalId];
|
|
54
|
+
const props = resource.Properties ?? {};
|
|
55
|
+
|
|
56
|
+
// Any declared principal list keeps the policy quiet. An intrinsic
|
|
57
|
+
// can't be statically evaluated, so it counts as attached.
|
|
58
|
+
let attached = false;
|
|
59
|
+
for (const prop of PRINCIPAL_PROPS) {
|
|
60
|
+
const value = props[prop];
|
|
61
|
+
if (isIntrinsic(value)) attached = true;
|
|
62
|
+
else if (Array.isArray(value) && value.length > 0) attached = true;
|
|
63
|
+
}
|
|
64
|
+
if (attached) continue;
|
|
65
|
+
|
|
66
|
+
// Reverse lookup: anything else in the template holding a Ref/GetAtt
|
|
67
|
+
// edge to this policy attaches (or exports) it.
|
|
68
|
+
if (externalRefs.has(logicalId)) continue;
|
|
69
|
+
|
|
70
|
+
const kind = resource.Type === "AWS::IAM::ManagedPolicy" ? "Managed policy" : "Policy";
|
|
71
|
+
diagnostics.push({
|
|
72
|
+
checkId: "WAW060",
|
|
73
|
+
severity: "warning",
|
|
74
|
+
message: `${kind} "${logicalId}" is attached to no principal — no Roles/Users/Groups and nothing in the template references it; an unattached policy grants nothing`,
|
|
75
|
+
entity: logicalId,
|
|
76
|
+
lexicon: "aws",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return diagnostics;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const waw060: PostSynthCheck = {
|
|
85
|
+
id: "WAW060",
|
|
86
|
+
description: "IAM policy attached to no principal — it grants nothing",
|
|
87
|
+
|
|
88
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
89
|
+
return checkPolicyUnattached(ctx);
|
|
90
|
+
},
|
|
91
|
+
};
|
|
@@ -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,7 @@ 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";
|
|
46
48
|
|
|
47
49
|
export { awsAgentCoreFetchTrace } from "../../agentcore/trace-fetch";
|
|
48
50
|
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
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -656,6 +656,9 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
656
656
|
// this path cannot read the ownership marker. Say `unknown` explicitly
|
|
657
657
|
// rather than leaving the field off and letting each consumer guess —
|
|
658
658
|
// the change set never escalates `unknown` to a delete.
|
|
659
|
+
// For the same reason `marker` (#1222) stays absent here: no tags, no
|
|
660
|
+
// stack/env identity to read, and absent means absent — never a guess.
|
|
661
|
+
// aws teardown is stack-level and reads the stack's own tags instead.
|
|
659
662
|
ownership: "unknown",
|
|
660
663
|
};
|
|
661
664
|
}
|
|
@@ -803,6 +806,21 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
803
806
|
return { stack: options.stack, present: true, status, healthy };
|
|
804
807
|
},
|
|
805
808
|
|
|
809
|
+
// Env teardown at STACK granularity (#1222): `describeResources` carries no
|
|
810
|
+
// tags, so per-resource marker selection is impossible here — the env's
|
|
811
|
+
// stacks are enumerated instead (`stacks[]`, else the env-named default) and
|
|
812
|
+
// ownership is verified on each stack's own DescribeStacks tags. Execution
|
|
813
|
+
// is DeleteStack via the applier's `awsDelete`. See ./teardown.ts.
|
|
814
|
+
async teardownOwned(options) {
|
|
815
|
+
const { teardownOwned } = await import("./teardown");
|
|
816
|
+
return teardownOwned(options);
|
|
817
|
+
},
|
|
818
|
+
|
|
819
|
+
async executeTeardown(options) {
|
|
820
|
+
const { executeTeardown } = await import("./teardown");
|
|
821
|
+
return executeTeardown(options);
|
|
822
|
+
},
|
|
823
|
+
|
|
806
824
|
async exportResources(options: {
|
|
807
825
|
environment: string;
|
|
808
826
|
stack?: string;
|
|
@@ -34,4 +34,22 @@ describe("awsSerializer ownership stamping (#119)", () => {
|
|
|
34
34
|
const tags = (template.Resources.MyBucket.Properties?.Tags ?? []) as Array<{ Key: string }>;
|
|
35
35
|
expect(tags.some((t) => t.Key.startsWith("chant:"))).toBe(false);
|
|
36
36
|
});
|
|
37
|
+
|
|
38
|
+
test("carries the marker at the template level too — Metadata[chant:ownership], the stack-tag source (#1222)", () => {
|
|
39
|
+
const entities = new Map<string, Declarable>([["MyBucket", new MockBucket({ BucketName: "b" })]]);
|
|
40
|
+
const out = awsSerializer.serialize(entities, [], { ownership: { stack: "billing", env: "prod" } });
|
|
41
|
+
const template = JSON.parse(out as string);
|
|
42
|
+
expect(template.Metadata["chant:ownership"]).toEqual({
|
|
43
|
+
"chant:managed-by": "chant",
|
|
44
|
+
"chant:stack": "billing",
|
|
45
|
+
"chant:env": "prod",
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("no ownership context → no template Metadata block", () => {
|
|
50
|
+
const entities = new Map<string, Declarable>([["MyBucket", new MockBucket({ BucketName: "b" })]]);
|
|
51
|
+
const out = awsSerializer.serialize(entities, []);
|
|
52
|
+
const template = JSON.parse(out as string);
|
|
53
|
+
expect(template.Metadata).toBeUndefined();
|
|
54
|
+
});
|
|
37
55
|
});
|