@intentius/chant-lexicon-aws 0.44.14 → 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 (100) hide show
  1. package/dist/agentcore/trace-fetch.d.ts +4 -1
  2. package/dist/agentcore/trace-fetch.d.ts.map +1 -1
  3. package/dist/api/read-client.d.ts +31 -1
  4. package/dist/api/read-client.d.ts.map +1 -1
  5. package/dist/codegen/docs.d.ts.map +1 -1
  6. package/dist/components/capability-plugin.d.ts.map +1 -1
  7. package/dist/components/cloud-executor.d.ts +9 -0
  8. package/dist/components/cloud-executor.d.ts.map +1 -1
  9. package/dist/composites/agentcore-agent.d.ts +33 -19
  10. package/dist/composites/agentcore-agent.d.ts.map +1 -1
  11. package/dist/composites/index.d.ts +1 -1
  12. package/dist/composites/index.d.ts.map +1 -1
  13. package/dist/composites/lambda-function.d.ts +4 -4
  14. package/dist/composites/lambda-function.d.ts.map +1 -1
  15. package/dist/deep-observe.d.ts +21 -0
  16. package/dist/deep-observe.d.ts.map +1 -1
  17. package/dist/index.d.ts +2 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/integrity.json +5 -3
  20. package/dist/lint/audit-catalog.d.ts.map +1 -1
  21. package/dist/lint/post-synth/cf-refs.d.ts +7 -0
  22. package/dist/lint/post-synth/cf-refs.d.ts.map +1 -1
  23. package/dist/lint/post-synth/index.d.ts.map +1 -1
  24. package/dist/lint/post-synth/waw059.d.ts +36 -0
  25. package/dist/lint/post-synth/waw059.d.ts.map +1 -0
  26. package/dist/lint/post-synth/waw060.d.ts +16 -0
  27. package/dist/lint/post-synth/waw060.d.ts.map +1 -0
  28. package/dist/manifest.json +1 -1
  29. package/dist/okf/index.md +2 -0
  30. package/dist/okf/rules/WAW059.md +25 -0
  31. package/dist/okf/rules/WAW060.md +17 -0
  32. package/dist/okf/types/Action.md +1 -0
  33. package/dist/okf/types/Bucket.md +1 -0
  34. package/dist/okf/types/GlobalTable.md +4 -0
  35. package/dist/okf/types/IamPolicy.md +2 -0
  36. package/dist/okf/types/InstanceProfile.md +4 -0
  37. package/dist/okf/types/ManagedPolicy.md +2 -0
  38. package/dist/okf/types/Map.md +1 -0
  39. package/dist/okf/types/Queue.md +1 -0
  40. package/dist/okf/types/Role.md +1 -0
  41. package/dist/okf/types/Table.md +1 -0
  42. package/dist/okf/types/Type.md +2 -0
  43. package/dist/op/activities/aws-apply.d.ts +62 -9
  44. package/dist/op/activities/aws-apply.d.ts.map +1 -1
  45. package/dist/op/activities/index.d.ts +5 -4
  46. package/dist/op/activities/index.d.ts.map +1 -1
  47. package/dist/ownership.d.ts +18 -0
  48. package/dist/ownership.d.ts.map +1 -1
  49. package/dist/plugin.d.ts.map +1 -1
  50. package/dist/properties.d.ts +4 -3
  51. package/dist/properties.d.ts.map +1 -1
  52. package/dist/rules/cf-refs.ts +22 -0
  53. package/dist/rules/waw059.ts +353 -0
  54. package/dist/rules/waw060.ts +91 -0
  55. package/dist/serializer.d.ts.map +1 -1
  56. package/dist/spec/fetch.d.ts +12 -1
  57. package/dist/spec/fetch.d.ts.map +1 -1
  58. package/dist/teardown.d.ts +85 -0
  59. package/dist/teardown.d.ts.map +1 -0
  60. package/package.json +2 -2
  61. package/src/agentcore/trace-fetch.test.ts +17 -0
  62. package/src/agentcore/trace-fetch.ts +7 -2
  63. package/src/api/read-client.test.ts +87 -0
  64. package/src/api/read-client.ts +57 -1
  65. package/src/codegen/docs-links.test.ts +44 -30
  66. package/src/codegen/docs.ts +2 -1034
  67. package/src/components/capability-plugin.ts +5 -2
  68. package/src/components/cloud-executor.test.ts +29 -1
  69. package/src/components/cloud-executor.ts +33 -6
  70. package/src/composites/agentcore-agent.test.ts +32 -12
  71. package/src/composites/agentcore-agent.ts +43 -23
  72. package/src/composites/index.ts +1 -1
  73. package/src/composites/microvm-app.test.ts +2 -2
  74. package/src/deep-observe.test.ts +94 -0
  75. package/src/deep-observe.ts +58 -2
  76. package/src/import/roundtrip-fixtures.test.ts +1 -1
  77. package/src/index.ts +3 -1
  78. package/src/lifecycle-integration.test.ts +93 -0
  79. package/src/lint/audit-catalog.ts +5 -0
  80. package/src/lint/post-synth/cf-refs.ts +22 -0
  81. package/src/lint/post-synth/index.ts +4 -0
  82. package/src/lint/post-synth/waw059.test.ts +309 -0
  83. package/src/lint/post-synth/waw059.ts +353 -0
  84. package/src/lint/post-synth/waw060.test.ts +131 -0
  85. package/src/lint/post-synth/waw060.ts +91 -0
  86. package/src/op/activities/aws-apply.test.ts +151 -4
  87. package/src/op/activities/aws-apply.ts +123 -13
  88. package/src/op/activities/index.ts +5 -3
  89. package/src/ownership.test.ts +24 -1
  90. package/src/ownership.ts +37 -0
  91. package/src/plugin.ts +52 -35
  92. package/src/properties.test.ts +5 -5
  93. package/src/properties.ts +6 -5
  94. package/src/serializer-ownership.test.ts +18 -0
  95. package/src/serializer.test.ts +73 -33
  96. package/src/serializer.ts +9 -1
  97. package/src/spec/fetch.test.ts +40 -0
  98. package/src/spec/fetch.ts +24 -3
  99. package/src/teardown.test.ts +258 -0
  100. package/src/teardown.ts +276 -0
@@ -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,
@@ -25,8 +26,16 @@ const describe_ = (status: string) => `<DescribeStacksResponse><Stacks><member><
25
26
 
26
27
  describe("CFN pure helpers (#awsApply)", () => {
27
28
  test("cfnUrl: endpoint override vs real regional host", () => {
28
- expect(cfnUrl("http://localhost:4566")).toBe("http://localhost:4566/");
29
- expect(cfnUrl(undefined, "eu-west-1")).toBe("https://cloudformation.eu-west-1.amazonaws.com/");
29
+ expect(cfnUrl("http://localhost:4566", undefined, {})).toBe("http://localhost:4566/");
30
+ expect(cfnUrl(undefined, "eu-west-1", {})).toBe("https://cloudformation.eu-west-1.amazonaws.com/");
31
+ });
32
+
33
+ test("cfnUrl: AWS_ENDPOINT_URL[_CLOUDFORMATION] is an override too, the same rule as the read client (#1694)", () => {
34
+ expect(cfnUrl(undefined, "eu-west-1", { AWS_ENDPOINT_URL: "http://localhost:4566" })).toBe("http://localhost:4566/");
35
+ expect(
36
+ cfnUrl(undefined, "eu-west-1", { AWS_ENDPOINT_URL: "http://all:1", AWS_ENDPOINT_URL_CLOUDFORMATION: "http://cfn:2" }),
37
+ ).toBe("http://cfn:2/");
38
+ expect(cfnUrl("http://opt:3", "eu-west-1", { AWS_ENDPOINT_URL: "http://all:1" })).toBe("http://opt:3/");
30
39
  });
31
40
 
32
41
  test("cfnForm stamps Action + Version", () => {
@@ -55,9 +64,9 @@ describe("CFN pure helpers (#awsApply)", () => {
55
64
  });
56
65
  });
57
66
 
58
- function tmpl(): string {
67
+ function tmpl(extra: Record<string, unknown> = {}): string {
59
68
  const p = `/tmp/chant-cfn-${process.pid}-${Math.round(performance.now())}.json`;
60
- writeFileSync(p, JSON.stringify({ Resources: { B: { Type: "AWS::S3::Bucket" } } }));
69
+ writeFileSync(p, JSON.stringify({ ...extra, Resources: { B: { Type: "AWS::S3::Bucket" } } }));
61
70
  return p;
62
71
  }
63
72
 
@@ -77,6 +86,82 @@ describe("awsApply flow (#awsApply)", () => {
77
86
  expect(calls).toEqual(["DescribeStacks", "CreateStack", "DescribeStacks"]);
78
87
  });
79
88
 
89
+ test("capabilities follow the template: NAMED_IAM alone, plus AUTO_EXPAND for a Transform (#980)", async () => {
90
+ const sent: Record<string, string>[] = [];
91
+ let described = 0;
92
+ const http: AwsHttp = async (_url, form) => {
93
+ if (form.Action === "DescribeStacks") return described++ % 2 === 0 ? { status: 400, text: MISSING } : { status: 200, text: describe_("CREATE_COMPLETE") };
94
+ sent.push(form);
95
+ return { status: 200, text: CREATE_OK };
96
+ };
97
+ const plain = tmpl();
98
+ await awsApply({ templatePath: plain, stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, http);
99
+ unlinkSync(plain);
100
+ expect(sent[0]["Capabilities.member.1"]).toBe("CAPABILITY_NAMED_IAM");
101
+ expect(sent[0]["Capabilities.member.2"]).toBeUndefined();
102
+
103
+ const macro = tmpl({ Transform: "AWS::SecretsManager-2020-07-23" });
104
+ await awsApply({ templatePath: macro, stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, http);
105
+ unlinkSync(macro);
106
+ expect(sent[1]["Capabilities.member.1"]).toBe("CAPABILITY_NAMED_IAM");
107
+ expect(sent[1]["Capabilities.member.2"]).toBe("CAPABILITY_AUTO_EXPAND");
108
+
109
+ // An explicit list still wins.
110
+ const explicit = tmpl({ Transform: "AWS::Serverless-2016-10-31" });
111
+ await awsApply({ templatePath: explicit, stackName: "s", endpoint: "http://x", intervalMs: 1, capabilities: ["CAPABILITY_IAM"] }, undefined, http);
112
+ unlinkSync(explicit);
113
+ expect(sent[2]["Capabilities.member.1"]).toBe("CAPABILITY_IAM");
114
+ expect(sent[2]["Capabilities.member.2"]).toBeUndefined();
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
+
80
165
  test("update path: existing stack → UpdateStack → UPDATE_COMPLETE", async () => {
81
166
  let described = 0;
82
167
  const http: AwsHttp = async (_url, form) => {
@@ -141,3 +226,65 @@ describe("awsDelete (#awsApply)", () => {
141
226
  expect(calls[0]).toBe("DeleteStack");
142
227
  });
143
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
+ });
@@ -1,20 +1,29 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { safeHeartbeat, sleep } from "@intentius/chant/op";
3
+ import { awsDeployCapabilitiesForBody } from "../../components/cloud-executor.js";
4
+ import { resolveEndpointOverride } from "../../api/read-client.js";
5
+ import { ownershipStackTagsForBody } from "../../ownership.js";
3
6
 
4
7
  const DEFAULT_REGION = "us-east-1";
5
8
  const CFN_API_VERSION = "2010-05-15";
6
- const DEFAULT_CAPABILITIES = ["CAPABILITY_NAMED_IAM"];
7
9
 
8
10
  export interface AwsApplyArgs {
9
11
  /** Path to a built CloudFormation template (JSON/YAML). */
10
12
  templatePath: string;
11
13
  /** CloudFormation stack name — the deploy boundary. */
12
14
  stackName: string;
13
- /** CFN endpoint override (e.g. Floci `http://localhost:4566`). Default: real CloudFormation. */
15
+ /**
16
+ * CFN endpoint override (e.g. Floci `http://localhost:4566`). Omitted,
17
+ * `AWS_ENDPOINT_URL_CLOUDFORMATION` then `AWS_ENDPOINT_URL` answer — the same
18
+ * rule the read client applies (#1694). With neither: real CloudFormation.
19
+ */
14
20
  endpoint?: string;
15
21
  /** Region (real CFN host + `Version` context). Default: `us-east-1`. */
16
22
  region?: string;
17
- /** Capabilities to acknowledge. Default: `["CAPABILITY_NAMED_IAM"]`. */
23
+ /**
24
+ * Capabilities to acknowledge. Default: `CAPABILITY_NAMED_IAM`, plus
25
+ * `CAPABILITY_AUTO_EXPAND` when the template has a top-level `Transform` (#980).
26
+ */
18
27
  capabilities?: string[];
19
28
  /** Stack-settle timeout in ms. Default: `300000`. */
20
29
  timeoutMs?: number;
@@ -37,9 +46,17 @@ const defaultHttp: AwsHttp = async (url, form, signal) => {
37
46
 
38
47
  // ── Pure helpers (CFN Query protocol) ─────────────────────────────────────────
39
48
 
40
- /** The CloudFormation endpoint URL — the override, or the real regional host. */
41
- export function cfnUrl(endpoint?: string, region = DEFAULT_REGION): string {
42
- return `${(endpoint ?? `https://cloudformation.${region}.amazonaws.com`).replace(/\/$/, "")}/`;
49
+ /**
50
+ * The CloudFormation endpoint URL — the override, or the real regional host.
51
+ * `env` is what the ambient-variable fallback reads; injectable for tests.
52
+ */
53
+ export function cfnUrl(
54
+ endpoint?: string,
55
+ region = DEFAULT_REGION,
56
+ env: Record<string, string | undefined> = process.env,
57
+ ): string {
58
+ const override = resolveEndpointOverride("cloudformation", endpoint, env);
59
+ return `${(override ?? `https://cloudformation.${region}.amazonaws.com`).replace(/\/$/, "")}/`;
43
60
  }
44
61
 
45
62
  /** A CFN Query-protocol form body: `Action` + `Version` + params. */
@@ -54,6 +71,22 @@ export function capabilityParams(capabilities: string[]): Record<string, string>
54
71
  return out;
55
72
  }
56
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
+
57
90
  /**
58
91
  * First `<tag>…</tag>` text in a CFN XML response.
59
92
  *
@@ -111,10 +144,10 @@ export async function waitForStackSettled(
111
144
  * CloudFormation API directly (create-or-update + poll to a settled stack),
112
145
  * targeting a local Floci emulator or real AWS by endpoint override. The direct
113
146
  * twin of `azApply`/`gcpApply`: it speaks the CloudFormation Query API over HTTP
114
- * rather than shelling `aws cloudformation deploy` (that CLI path is still
115
- * available via `nativeApply({ target: "cloudformation" })`). The stack is the
116
- * ownership boundary, so deletes ride CloudFormation itself — no separate prune.
117
- * `http` is injectable for tests.
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.
118
151
  */
119
152
  export async function awsApply(
120
153
  args: AwsApplyArgs,
@@ -122,8 +155,8 @@ export async function awsApply(
122
155
  http: AwsHttp = defaultHttp,
123
156
  ): Promise<{ stackName: string; status: string; action: "created" | "updated" | "unchanged" }> {
124
157
  const url = cfnUrl(args.endpoint, args.region);
125
- const capabilities = args.capabilities ?? DEFAULT_CAPABILITIES;
126
158
  const templateBody = readFileSync(args.templatePath, "utf8");
159
+ const capabilities = args.capabilities ?? awsDeployCapabilitiesForBody(templateBody);
127
160
  const timeoutMs = args.timeoutMs ?? 300_000;
128
161
  const intervalMs = args.intervalMs ?? 3_000;
129
162
 
@@ -133,7 +166,17 @@ export async function awsApply(
133
166
  }
134
167
  const exists = desc.status < 300;
135
168
 
136
- 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
+ };
137
180
  let action: "created" | "updated";
138
181
  if (!exists) {
139
182
  const res = await http(url, cfnForm("CreateStack", params), signal);
@@ -161,12 +204,18 @@ export async function awsApply(
161
204
  return { stackName: args.stackName, status, action };
162
205
  }
163
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
+
164
213
  /**
165
214
  * The inverse of {@link awsApply} — DeleteStack, then poll until the stack is
166
215
  * gone. Idempotent: an already-absent stack is a no-op. `http` is injectable.
167
216
  */
168
217
  export async function awsDelete(
169
- args: AwsApplyArgs,
218
+ args: AwsDeleteArgs,
170
219
  signal?: AbortSignal,
171
220
  http: AwsHttp = defaultHttp,
172
221
  ): Promise<{ stackName: string; deleted: boolean }> {
@@ -192,3 +241,64 @@ export async function awsDelete(
192
241
  }
193
242
  throw new Error(`CloudFormation stack ${args.stackName} delete did not complete within ${timeoutMs}ms`);
194
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
@@ -47,8 +47,14 @@ export { stackDoesNotExist } from "./stack-errors";
47
47
  * Provides serializer, lint rules, template detection,
48
48
  * import parsing, and code generation for AWS CloudFormation.
49
49
  */
50
- /** #1265 — the ownership notice is about the environment, so it is said once. */
51
- let warnedOwnership = false;
50
+ /**
51
+ * describe-stack-resources returns no tags, so the ownership filter cannot be
52
+ * applied on this read path. Returned as a run-level note rather than printed
53
+ * (#1265): core says it once per run, after the answer, however many stacks
54
+ * were read. The text is the contract consumers grep for.
55
+ */
56
+ const OWNERSHIP_UNAVAILABLE_NOTE =
57
+ "ownership filter unavailable on describeResources (no tags from describe-stack-resources) — returning all, each with an explicit `unknown` verdict; use `chant import --from <env> --owned` for ownership-filtered export";
52
58
 
53
59
  export const awsPlugin: LexiconPlugin = {
54
60
  name: "aws",
@@ -558,25 +564,17 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
558
564
  // The applier's own transport, pointed at the read APIs (#1206). Multi-region
559
565
  // estates target this stack's region, not the ambient one.
560
566
  const client: AwsReadClientOptions = {
561
- ...(process.env.AWS_ENDPOINT_URL ? { endpoint: process.env.AWS_ENDPOINT_URL } : {}),
562
567
  ...(options.region ? { region: options.region } : {}),
563
568
  };
564
569
 
565
- if (options.owned) {
566
- // describe-stack-resources does not return tags, so ownership cannot be
567
- // determined here. Degrade to detect-only rather than silently filtering.
568
- //
569
- // Once per process, not once per stack (#1265). It is a property of the
570
- // environment, not of each stack, and a four-stack project printed four
571
- // identical copies ahead of every answer enough that an agent piping
572
- // `graph --format ir` with `2>&1` had to skip lines to find the JSON.
573
- warnedOwnership ||
574
- // eslint-disable-next-line no-console
575
- console.warn(
576
- "[aws] ownership filter unavailable on describeResources (no tags from describe-stack-resources) — returning all, each with an explicit `unknown` verdict; use `chant import --from <env> --owned` for ownership-filtered export",
577
- );
578
- warnedOwnership = true;
579
- }
570
+ // describe-stack-resources does not return tags, so ownership cannot be
571
+ // determined here. Degrade to detect-only rather than silently filtering,
572
+ // and say so as a note on the observation. It is a property of the
573
+ // environment, not of each stack: a four-stack project once printed four
574
+ // identical copies ahead of every answer (#1265) enough that an agent
575
+ // piping `graph --format ir` with `2>&1` had to skip lines to find the
576
+ // JSON. Core dedupes the note across stacks and prints it with the footer.
577
+ const notes = options.owned ? [OWNERSHIP_UNAVAILABLE_NOTE] : undefined;
580
578
 
581
579
  // Derive stack name. A multi-stack project passes the explicit CloudFormation
582
580
  // stack this observation targets (see `stacks` in ChantConfig); otherwise the
@@ -600,7 +598,7 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
600
598
  if (err instanceof AwsReadError && stackDoesNotExist(err.message)) {
601
599
  const { observeByIdentity } = await import("./identity-observe");
602
600
  const identity = await observeByIdentity(options.entityNames, options.entities, resources, client);
603
- return observation({ ...resources, ...identity.resources }, undefined, identity.queried);
601
+ return observation({ ...resources, ...identity.resources }, undefined, identity.queried, notes);
604
602
  }
605
603
  // Any other failure (credentials, throttling, a region that can't be
606
604
  // reached) establishes nothing about what is deployed. Reporting every
@@ -617,6 +615,8 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
617
615
  reason,
618
616
  `DescribeStackResources failed for stack "${stackName}": ${detail}`,
619
617
  ),
618
+ undefined,
619
+ notes,
620
620
  );
621
621
  }
622
622
 
@@ -633,20 +633,20 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
633
633
  stackOutputs = {};
634
634
  }
635
635
 
636
+ // The outputs are the stack's, not any member's (#1279). They used to be
637
+ // copied onto every resource's `attributes`, so a VPC carried the stack's
638
+ // `expWebIp` and no `CidrBlock`. They ride the envelope once, keyed by the
639
+ // stack, scrubbed of anything that looks secret.
640
+ const exports: Record<string, unknown> = {};
641
+ for (const [key, value] of Object.entries(stackOutputs)) {
642
+ exports[key] = /password|secret|token|key/i.test(key) ? "[REDACTED]" : value;
643
+ }
644
+ const stackExports = Object.keys(exports).length > 0 ? { [stackName]: exports } : undefined;
645
+
636
646
  for (const entityName of options.entityNames) {
637
647
  const stackResource = stackResourceMap.get(entityName);
638
648
  if (!stackResource) continue;
639
649
 
640
- const attributes: Record<string, unknown> = {};
641
- // Include stack outputs as attributes (scrub sensitive ones)
642
- for (const [key, value] of Object.entries(stackOutputs)) {
643
- if (/password|secret|token|key/i.test(key)) {
644
- attributes[key] = "[REDACTED]";
645
- } else {
646
- attributes[key] = value;
647
- }
648
- }
649
-
650
650
  resources[entityName] = {
651
651
  type: stackResource.type,
652
652
  physicalId: stackResource.physicalId ?? "",
@@ -656,8 +656,10 @@ 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
- attributes: Object.keys(attributes).length > 0 ? attributes : undefined,
661
663
  };
662
664
  }
663
665
 
@@ -669,9 +671,9 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
669
671
  const { observeByIdentity } = await import("./identity-observe");
670
672
  const identity = await observeByIdentity(options.entityNames, options.entities, resources, client);
671
673
 
672
- // Each resource's OWN properties, on top of the stack outputs above (#1279).
673
- // Until this, a node's `attrs` were the stack's exports replicated onto
674
- // every member, so no instance carried its own `VpcId`.
674
+ // Each resource's OWN properties (#1279). Until this, a node's `attrs` were
675
+ // the stack's exports replicated onto every member, so no instance carried
676
+ // its own `VpcId`.
675
677
  const own = await describeOwnProperties(resources, options.region);
676
678
  const withProperties = stampProviderDefaults(stampRegion(own.resources, options.region));
677
679
 
@@ -701,13 +703,13 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
701
703
  detail: `the stack was read, but this resource's own properties were not — ${own.failures.get(type) ?? "the describe call failed"}`,
702
704
  };
703
705
  }
704
- return observation({ ...described, ...identity.resources }, holes, identity.queried);
706
+ return observation({ ...described, ...identity.resources }, holes, identity.queried, notes, stackExports);
705
707
  }
706
708
 
707
709
  // Every entity the stack answered for was answered for: an entity the
708
710
  // template doesn't carry is genuinely not in this stack, which is an
709
711
  // absence, not a hole — unless the identity fallback saw it live (#1647).
710
- return observation({ ...withProperties, ...identity.resources }, undefined, identity.queried);
712
+ return observation({ ...withProperties, ...identity.resources }, undefined, identity.queried, notes, stackExports);
711
713
  },
712
714
 
713
715
  /**
@@ -804,6 +806,21 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
804
806
  return { stack: options.stack, present: true, status, healthy };
805
807
  },
806
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
+
807
824
  async exportResources(options: {
808
825
  environment: string;
809
826
  stack?: string;