@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.
Files changed (60) hide show
  1. package/dist/components/cloud-executor.d.ts.map +1 -1
  2. package/dist/composites/lambda-function.d.ts +4 -4
  3. package/dist/composites/lambda-function.d.ts.map +1 -1
  4. package/dist/integrity.json +5 -3
  5. package/dist/lint/audit-catalog.d.ts.map +1 -1
  6. package/dist/lint/post-synth/cf-refs.d.ts +7 -0
  7. package/dist/lint/post-synth/cf-refs.d.ts.map +1 -1
  8. package/dist/lint/post-synth/index.d.ts.map +1 -1
  9. package/dist/lint/post-synth/waw059.d.ts +36 -0
  10. package/dist/lint/post-synth/waw059.d.ts.map +1 -0
  11. package/dist/lint/post-synth/waw060.d.ts +16 -0
  12. package/dist/lint/post-synth/waw060.d.ts.map +1 -0
  13. package/dist/manifest.json +1 -1
  14. package/dist/okf/index.md +2 -0
  15. package/dist/okf/rules/WAW059.md +25 -0
  16. package/dist/okf/rules/WAW060.md +17 -0
  17. package/dist/okf/types/Action.md +1 -0
  18. package/dist/okf/types/Bucket.md +1 -0
  19. package/dist/okf/types/GlobalTable.md +4 -0
  20. package/dist/okf/types/IamPolicy.md +2 -0
  21. package/dist/okf/types/InstanceProfile.md +4 -0
  22. package/dist/okf/types/ManagedPolicy.md +2 -0
  23. package/dist/okf/types/Map.md +1 -0
  24. package/dist/okf/types/Queue.md +1 -0
  25. package/dist/okf/types/Role.md +1 -0
  26. package/dist/okf/types/Table.md +1 -0
  27. package/dist/okf/types/Type.md +2 -0
  28. package/dist/op/activities/aws-apply.d.ts +48 -5
  29. package/dist/op/activities/aws-apply.d.ts.map +1 -1
  30. package/dist/op/activities/index.d.ts +5 -4
  31. package/dist/op/activities/index.d.ts.map +1 -1
  32. package/dist/ownership.d.ts +18 -0
  33. package/dist/ownership.d.ts.map +1 -1
  34. package/dist/plugin.d.ts.map +1 -1
  35. package/dist/rules/cf-refs.ts +22 -0
  36. package/dist/rules/waw059.ts +353 -0
  37. package/dist/rules/waw060.ts +91 -0
  38. package/dist/serializer.d.ts.map +1 -1
  39. package/dist/teardown.d.ts +85 -0
  40. package/dist/teardown.d.ts.map +1 -0
  41. package/package.json +2 -2
  42. package/src/components/cloud-executor.ts +10 -1
  43. package/src/lifecycle-integration.test.ts +4 -0
  44. package/src/lint/audit-catalog.ts +5 -0
  45. package/src/lint/post-synth/cf-refs.ts +22 -0
  46. package/src/lint/post-synth/index.ts +4 -0
  47. package/src/lint/post-synth/waw059.test.ts +309 -0
  48. package/src/lint/post-synth/waw059.ts +353 -0
  49. package/src/lint/post-synth/waw060.test.ts +131 -0
  50. package/src/lint/post-synth/waw060.ts +91 -0
  51. package/src/op/activities/aws-apply.test.ts +111 -0
  52. package/src/op/activities/aws-apply.ts +100 -6
  53. package/src/op/activities/index.ts +5 -3
  54. package/src/ownership.test.ts +24 -1
  55. package/src/ownership.ts +37 -0
  56. package/src/plugin.ts +18 -0
  57. package/src/serializer-ownership.test.ts +18 -0
  58. package/src/serializer.ts +9 -1
  59. package/src/teardown.test.ts +258 -0
  60. 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` (that CLI path is still
131
- * available via `nativeApply({ target: "cloudformation" })`). The stack is the
132
- * ownership boundary, so deletes ride CloudFormation itself — no separate prune.
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
- const params = { StackName: args.stackName, TemplateBody: templateBody, ...capabilityParams(capabilities) };
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: AwsApplyArgs,
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`), which calls the CloudFormation API directly rather than
6
- * shelling `aws` — the direct twin of `azApply`/`gcpApply`.
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 {
@@ -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
  });