@intentius/chant-lexicon-aws 0.45.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/dist/api/read-client.d.ts +10 -2
  2. package/dist/api/read-client.d.ts.map +1 -1
  3. package/dist/components/cloud-executor.d.ts.map +1 -1
  4. package/dist/composites/agentcore-agent.d.ts +26 -1
  5. package/dist/composites/agentcore-agent.d.ts.map +1 -1
  6. package/dist/composites/bucket-deployment.d.ts +82 -0
  7. package/dist/composites/bucket-deployment.d.ts.map +1 -0
  8. package/dist/composites/dynamodb-table.d.ts +45 -0
  9. package/dist/composites/dynamodb-table.d.ts.map +1 -0
  10. package/dist/composites/ec2-instance-bundle.d.ts +61 -0
  11. package/dist/composites/ec2-instance-bundle.d.ts.map +1 -0
  12. package/dist/composites/ecr-repository.d.ts +49 -0
  13. package/dist/composites/ecr-repository.d.ts.map +1 -0
  14. package/dist/composites/fargate-service.d.ts +16 -0
  15. package/dist/composites/fargate-service.d.ts.map +1 -1
  16. package/dist/composites/index.d.ts +16 -2
  17. package/dist/composites/index.d.ts.map +1 -1
  18. package/dist/composites/lambda-function.d.ts +4 -4
  19. package/dist/composites/lambda-function.d.ts.map +1 -1
  20. package/dist/composites/monitoring-stack.d.ts +99 -0
  21. package/dist/composites/monitoring-stack.d.ts.map +1 -0
  22. package/dist/composites/nlb-service.d.ts +48 -0
  23. package/dist/composites/nlb-service.d.ts.map +1 -0
  24. package/dist/composites/step-functions-workflow.d.ts +36 -0
  25. package/dist/composites/step-functions-workflow.d.ts.map +1 -0
  26. package/dist/deep-observe.d.ts +45 -0
  27. package/dist/deep-observe.d.ts.map +1 -1
  28. package/dist/deep-topology.d.ts +104 -0
  29. package/dist/deep-topology.d.ts.map +1 -0
  30. package/dist/effect-receipt-row.d.ts +92 -0
  31. package/dist/effect-receipt-row.d.ts.map +1 -0
  32. package/dist/index.d.ts +6 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/integrity.json +9 -4
  35. package/dist/lint/audit-catalog.d.ts.map +1 -1
  36. package/dist/lint/post-synth/cf-refs.d.ts +24 -0
  37. package/dist/lint/post-synth/cf-refs.d.ts.map +1 -1
  38. package/dist/lint/post-synth/index.d.ts.map +1 -1
  39. package/dist/lint/post-synth/waw059.d.ts +36 -0
  40. package/dist/lint/post-synth/waw059.d.ts.map +1 -0
  41. package/dist/lint/post-synth/waw060.d.ts +16 -0
  42. package/dist/lint/post-synth/waw060.d.ts.map +1 -0
  43. package/dist/lint/post-synth/waw061.d.ts +19 -0
  44. package/dist/lint/post-synth/waw061.d.ts.map +1 -0
  45. package/dist/lint/post-synth/waw062.d.ts +19 -0
  46. package/dist/lint/post-synth/waw062.d.ts.map +1 -0
  47. package/dist/lint/post-synth/waw063.d.ts +35 -0
  48. package/dist/lint/post-synth/waw063.d.ts.map +1 -0
  49. package/dist/manifest.json +1 -1
  50. package/dist/okf/index.md +5 -0
  51. package/dist/okf/rules/WAW059.md +25 -0
  52. package/dist/okf/rules/WAW060.md +17 -0
  53. package/dist/okf/rules/WAW061.md +17 -0
  54. package/dist/okf/rules/WAW062.md +27 -0
  55. package/dist/okf/rules/WAW063.md +19 -0
  56. package/dist/okf/types/Action.md +2 -0
  57. package/dist/okf/types/AutoScalingGroup.md +1 -0
  58. package/dist/okf/types/Bucket.md +2 -0
  59. package/dist/okf/types/ECRRepository.md +1 -0
  60. package/dist/okf/types/Function.md +1 -0
  61. package/dist/okf/types/GlobalTable.md +4 -0
  62. package/dist/okf/types/IamPolicy.md +3 -0
  63. package/dist/okf/types/InstanceProfile.md +4 -0
  64. package/dist/okf/types/LaunchTemplate.md +1 -0
  65. package/dist/okf/types/LoadBalancer.md +1 -0
  66. package/dist/okf/types/ManagedPolicy.md +4 -0
  67. package/dist/okf/types/Map.md +2 -0
  68. package/dist/okf/types/Queue.md +1 -0
  69. package/dist/okf/types/Role.md +3 -0
  70. package/dist/okf/types/SecurityGroup.md +1 -0
  71. package/dist/okf/types/StateMachine.md +4 -0
  72. package/dist/okf/types/Subnet.md +4 -0
  73. package/dist/okf/types/Table.md +2 -0
  74. package/dist/okf/types/Type.md +5 -0
  75. package/dist/okf/types/Vpc.md +1 -0
  76. package/dist/op/activities/aws-apply.d.ts +48 -5
  77. package/dist/op/activities/aws-apply.d.ts.map +1 -1
  78. package/dist/op/activities/index.d.ts +11 -4
  79. package/dist/op/activities/index.d.ts.map +1 -1
  80. package/dist/ownership.d.ts +18 -0
  81. package/dist/ownership.d.ts.map +1 -1
  82. package/dist/plugin.d.ts.map +1 -1
  83. package/dist/receipt-store.d.ts +109 -0
  84. package/dist/receipt-store.d.ts.map +1 -0
  85. package/dist/rules/cf-refs.ts +62 -0
  86. package/dist/rules/waw059.ts +353 -0
  87. package/dist/rules/waw060.ts +91 -0
  88. package/dist/rules/waw061.ts +67 -0
  89. package/dist/rules/waw062.ts +0 -0
  90. package/dist/rules/waw063.ts +0 -0
  91. package/dist/serializer.d.ts.map +1 -1
  92. package/dist/skills/chant-aws-carve-terraform.md +26 -23
  93. package/dist/teardown.d.ts +85 -0
  94. package/dist/teardown.d.ts.map +1 -0
  95. package/package.json +3 -3
  96. package/src/api/read-client.test.ts +9 -0
  97. package/src/api/read-client.ts +15 -2
  98. package/src/components/cloud-executor.ts +10 -1
  99. package/src/composites/agentcore-agent.ts +35 -13
  100. package/src/composites/bucket-deployment.test.ts +84 -0
  101. package/src/composites/bucket-deployment.ts +153 -0
  102. package/src/composites/composites.test.ts +325 -0
  103. package/src/composites/dynamodb-table.test.ts +154 -0
  104. package/src/composites/dynamodb-table.ts +124 -0
  105. package/src/composites/ec2-instance-bundle.ts +134 -0
  106. package/src/composites/ecr-repository.test.ts +118 -0
  107. package/src/composites/ecr-repository.ts +117 -0
  108. package/src/composites/fargate-service.ts +19 -3
  109. package/src/composites/index.ts +21 -2
  110. package/src/composites/monitoring-stack.test.ts +130 -0
  111. package/src/composites/monitoring-stack.ts +164 -0
  112. package/src/composites/nlb-service.ts +117 -0
  113. package/src/composites/step-functions-workflow.ts +127 -0
  114. package/src/deep-observe.test.ts +201 -1
  115. package/src/deep-observe.ts +204 -0
  116. package/src/deep-topology.test.ts +392 -0
  117. package/src/deep-topology.ts +237 -0
  118. package/src/effect-receipt-row.test.ts +164 -0
  119. package/src/effect-receipt-row.ts +147 -0
  120. package/src/index.ts +28 -7
  121. package/src/lifecycle-integration.test.ts +4 -0
  122. package/src/lint/audit-catalog.ts +11 -0
  123. package/src/lint/post-synth/cf-refs.ts +62 -0
  124. package/src/lint/post-synth/index.ts +10 -0
  125. package/src/lint/post-synth/waw059.test.ts +309 -0
  126. package/src/lint/post-synth/waw059.ts +353 -0
  127. package/src/lint/post-synth/waw060.test.ts +131 -0
  128. package/src/lint/post-synth/waw060.ts +91 -0
  129. package/src/lint/post-synth/waw061.test.ts +99 -0
  130. package/src/lint/post-synth/waw061.ts +67 -0
  131. package/src/lint/post-synth/waw062.test.ts +109 -0
  132. package/src/lint/post-synth/waw062.ts +0 -0
  133. package/src/lint/post-synth/waw063.test.ts +220 -0
  134. package/src/lint/post-synth/waw063.ts +0 -0
  135. package/src/op/activities/aws-apply.test.ts +111 -0
  136. package/src/op/activities/aws-apply.ts +100 -6
  137. package/src/op/activities/index.ts +20 -3
  138. package/src/ownership.test.ts +24 -1
  139. package/src/ownership.ts +37 -0
  140. package/src/plugin.ts +59 -10
  141. package/src/receipt-store.test.ts +204 -0
  142. package/src/receipt-store.ts +315 -0
  143. package/src/serializer-ownership.test.ts +18 -0
  144. package/src/serializer.ts +114 -3
  145. package/src/skills/chant-aws-carve-terraform.md +26 -23
  146. package/src/teardown.test.ts +258 -0
  147. package/src/teardown.ts +276 -0
package/src/serializer.ts CHANGED
@@ -2,7 +2,19 @@ import type { Declarable, CoreParameter } from "@intentius/chant/declarable";
2
2
  import { isPropertyDeclarable, isResourceDeclarable } from "@intentius/chant/declarable";
3
3
  import type { Serializer, SerializerResult, SerializeContext } from "@intentius/chant/serializer";
4
4
  import { ownershipEntries, type OwnershipMarker } from "@intentius/chant/ownership";
5
- import { AWS_TAG_OWNERSHIP_KEYS } from "./ownership";
5
+ import {
6
+ isEffectReceipt,
7
+ receiptExpectation,
8
+ referenceInputPaths,
9
+ type EffectReceiptDeclaration,
10
+ } from "@intentius/chant/effect-receipt";
11
+ import { AWS_TAG_OWNERSHIP_KEYS, OWNERSHIP_METADATA_KEY } from "./ownership";
12
+ import {
13
+ AWS_EFFECT_RECEIPT_ENTITY_TYPE,
14
+ EFFECT_RECEIPTS_METADATA_KEY,
15
+ RECEIPT_UNRESOLVED_VALUE_NOTE,
16
+ receiptParameterName,
17
+ } from "./effect-receipt-row";
6
18
  import type { LexiconOutput } from "@intentius/chant/lexicon-output";
7
19
  import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
8
20
  import { isChildProject, type ChildProjectInstance } from "@intentius/chant/child-project";
@@ -26,6 +38,7 @@ function isCoreParameter(entity: Declarable): entity is CoreParameter {
26
38
  interface CFTemplate {
27
39
  AWSTemplateFormatVersion: "2010-09-09";
28
40
  Description?: string;
41
+ Metadata?: Record<string, unknown>;
29
42
  Transform?: string | string[];
30
43
  Parameters?: Record<string, CFParameter>;
31
44
  Resources: Record<string, CFResource>;
@@ -153,6 +166,80 @@ function toProperties(
153
166
  }
154
167
 
155
168
 
169
+ /** One rendered receipt row — a CFN-resource-shaped object that lives in the
170
+ * template's `Metadata`, never in `Resources` (#1832: the applier writes from
171
+ * `Resources`, and the `effect()` step is a receipt's sole writer). */
172
+ interface ReceiptRow {
173
+ Type: typeof AWS_EFFECT_RECEIPT_ENTITY_TYPE;
174
+ Properties: {
175
+ Name: string;
176
+ Type: "String";
177
+ Value: string;
178
+ Tags: Array<{ Key: string; Value: string }>;
179
+ };
180
+ }
181
+
182
+ /** The rendered `Value`: the synthesis-time expectation when the receipt is
183
+ * fully static, a placeholder note when reference inputs resolve later —
184
+ * never a digest hashed over placeholders (epic #1703, decision 5). */
185
+ function receiptRowValue(receipt: EffectReceiptDeclaration): string {
186
+ if (receipt.flavor === "hash" && referenceInputPaths(receipt).length > 0) {
187
+ return RECEIPT_UNRESOLVED_VALUE_NOTE;
188
+ }
189
+ return receiptExpectation(receipt);
190
+ }
191
+
192
+ /**
193
+ * Render the effect receipts (#1835) the build withheld from the apply-bound
194
+ * entity set (`SerializeContext.receipts`, #1832) as `AWS::SSM::Parameter`
195
+ * rows: plain `String`, named `/chant-receipts/<stack>/<env>/<effect>` from
196
+ * the resolved ownership marker (epic decision 4 — the same fields that stamp
197
+ * tags), carrying the ownership tags. Visibility only: the rows go under the
198
+ * template's `Metadata`, and the receipt store (./receipt-store.ts) is what
199
+ * actually writes the parameter — through the `effect()` step, on success,
200
+ * last.
201
+ *
202
+ * The env segment is explicit: a receipt with no resolved `ownership.env` is
203
+ * an error here, never a guessed path.
204
+ */
205
+ function renderReceiptRows(
206
+ receipts: ReadonlyMap<string, Declarable>,
207
+ ownership: OwnershipMarker | undefined,
208
+ ): Record<string, ReceiptRow> {
209
+ const rows: Record<string, ReceiptRow> = {};
210
+ const names = [...receipts.keys()].join(", ");
211
+ if (!ownership) {
212
+ throw new Error(
213
+ `aws receipts (${names}): no ownership marker resolved — the receipt path is ` +
214
+ `/chant-receipts/<stack>/<env>/<effect>, derived from the same ownership fields that ` +
215
+ `stamp markers (chant #1703, decision 4). Set ownership: { stack, env } in chant.config.ts.`,
216
+ );
217
+ }
218
+ if (!ownership.env) {
219
+ throw new Error(
220
+ `aws receipts (${names}): ownership resolved no env — the receipt path's <env> segment is ` +
221
+ `explicit (chant #1703, decision 4). Set ownership.env in chant.config.ts (a literal, or ` +
222
+ `{ param: "env" } with --param env=<name>).`,
223
+ );
224
+ }
225
+ const tags = Object.entries(ownershipEntries(AWS_TAG_OWNERSHIP_KEYS, ownership)).map(
226
+ ([Key, Value]) => ({ Key, Value }),
227
+ );
228
+ for (const [name, entity] of receipts) {
229
+ if (!isEffectReceipt(entity)) continue;
230
+ rows[name] = {
231
+ Type: AWS_EFFECT_RECEIPT_ENTITY_TYPE,
232
+ Properties: {
233
+ Name: receiptParameterName(ownership.stack, ownership.env, entity.effect),
234
+ Type: "String",
235
+ Value: receiptRowValue(entity),
236
+ Tags: tags,
237
+ },
238
+ };
239
+ }
240
+ return rows;
241
+ }
242
+
156
243
  /**
157
244
  * Serialize a set of entities into a CFTemplate object (without JSON.stringify).
158
245
  */
@@ -162,6 +249,7 @@ function serializeToTemplate(
162
249
  extraParameters?: Record<string, CFParameter>,
163
250
  extraOutputs?: Record<string, CFOutput>,
164
251
  ownership?: OwnershipMarker,
252
+ receiptRows?: Record<string, ReceiptRow>,
165
253
  ): CFTemplate {
166
254
  const template: CFTemplate = {
167
255
  AWSTemplateFormatVersion: "2010-09-09",
@@ -187,6 +275,21 @@ function serializeToTemplate(
187
275
  for (const [Key, Value] of Object.entries(ownershipEntries(AWS_TAG_OWNERSHIP_KEYS, ownership))) {
188
276
  defaultTagEntries.push({ Key, Value });
189
277
  }
278
+ // Also carry the marker at the template level (#1222): stack tags are a
279
+ // CreateStack/UpdateStack API parameter, not a template section, so the
280
+ // apply paths read this Metadata block and stamp it as the stack's own
281
+ // tags — what stack-level teardown verifies ownership on.
282
+ template.Metadata = {
283
+ [OWNERSHIP_METADATA_KEY]: ownershipEntries(AWS_TAG_OWNERSHIP_KEYS, ownership),
284
+ };
285
+ }
286
+ // Effect receipt rows (#1835) — visibility only, deliberately outside
287
+ // `Resources`: the applier's desired and prune sets both read `Resources`,
288
+ // and the `effect()` step is a receipt's sole writer (#1832, epic #1703
289
+ // decision 3). The observation leg (plugin.ts) reads the paths back from
290
+ // this block, so the identity is derived exactly once, here.
291
+ if (receiptRows && Object.keys(receiptRows).length > 0) {
292
+ template.Metadata = { ...(template.Metadata ?? {}), [EFFECT_RECEIPTS_METADATA_KEY]: receiptRows };
190
293
  }
191
294
  for (const [, entity] of entities) {
192
295
  if (isDefaultTags(entity)) {
@@ -404,6 +507,14 @@ export const awsSerializer: Serializer = {
404
507
  serialize(entities: Map<string, Declarable>, outputs?: LexiconOutput[], context?: SerializeContext): string | SerializerResult {
405
508
  const ownership = context?.ownership;
406
509
 
510
+ // Effect receipts (#1835): withheld from `entities` by the build (#1832),
511
+ // rendered here as Metadata rows — never as `Resources` an applier would
512
+ // write. Env-less ownership is an error, not a guessed path segment.
513
+ const receiptRows =
514
+ context?.receipts && context.receipts.size > 0
515
+ ? renderReceiptRows(context.receipts, ownership)
516
+ : undefined;
517
+
407
518
  // Check if any entities are child projects (nested stacks)
408
519
  const childProjects = new Map<string, ChildProjectInstance>();
409
520
  let hasChildProjects = false;
@@ -417,7 +528,7 @@ export const awsSerializer: Serializer = {
417
528
 
418
529
  // No nested stacks — use the simple path
419
530
  if (!hasChildProjects) {
420
- const template = serializeToTemplate(entities, outputs, undefined, undefined, ownership);
531
+ const template = serializeToTemplate(entities, outputs, undefined, undefined, ownership, receiptRows);
421
532
  return JSON.stringify(template, null, 2);
422
533
  }
423
534
 
@@ -457,7 +568,7 @@ export const awsSerializer: Serializer = {
457
568
  }
458
569
 
459
570
  // Serialize the parent template (ChildProjectInstance entities become CF::Stack resources)
460
- const parentTemplate = serializeToTemplate(entities, outputs, parentParams, undefined, ownership);
571
+ const parentTemplate = serializeToTemplate(entities, outputs, parentParams, undefined, ownership, receiptRows);
461
572
  const primary = JSON.stringify(parentTemplate, null, 2);
462
573
 
463
574
  return {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  skill: chant-aws-carve-terraform
3
- description: Demo carving a resource out of Terraform into native chant — advise, emit, bridge, apply — fully offline
3
+ description: Demo carving a resource out of Terraform into native chant — advise, emit, audit, bridge, apply — fully offline
4
4
  user-invocable: true
5
5
  ---
6
6
 
@@ -15,7 +15,7 @@ graduation.
15
15
  ## When to use
16
16
 
17
17
  - Someone asks "how does chant move things off Terraform?"
18
- - You want to show the advise → emit → bridge → apply loop end to end.
18
+ - You want to show the advise → emit → audit → bridge → apply loop end to end.
19
19
  - You are evaluating whether a Terraform estate is worth carving.
20
20
 
21
21
  ## Preconditions
@@ -27,7 +27,7 @@ graduation.
27
27
  ## The fastest path: run the bundled demo
28
28
 
29
29
  The `examples/terraform-carve-out` example ships a runnable estate + state and a
30
- script that runs all four steps with commentary:
30
+ script that runs all five steps with commentary:
31
31
 
32
32
  ```bash
33
33
  cd examples/terraform-carve-out
@@ -54,28 +54,30 @@ From `examples/terraform-carve-out`, with `TF=./terraform`:
54
54
  --state ./terraform/terraform.tfstate --output ./carveout
55
55
  ```
56
56
  Show `./carveout/src/assets.ts` — a real `new Bucket({ BucketName, Tags })` with
57
- CloudFormation-style properties mapped from the Terraform state attributes.
58
- Emit also scaffolds `./carveout` into a buildable chant project
59
- (`chant.config.ts`, `package.json`), so `npm install && npm run build` works
60
- there as-is. Explain: a Terraform-managed resource is not in any
61
- CloudFormation stack, so the correct source of its live shape is the state
62
- file, not a cloud read.
63
-
64
- 3. **Lint the inherited resource — optional, offer it.**
57
+ CloudFormation-style properties mapped from the Terraform state attributes,
58
+ and the `aws_s3_bucket_versioning` sub-resource folded into
59
+ `VersioningConfiguration`. Emit also scaffolds `./carveout` into a buildable
60
+ chant project (`chant.config.ts`, `package.json`) and persists a carve
61
+ manifest (`*.carve.json`) bridge and apply read the target from it, so
62
+ `--select` is only needed once. Explain: a Terraform-managed resource is not
63
+ in any CloudFormation stack, so the correct source of its live shape is the
64
+ state file, not a cloud read.
65
+
66
+ 3. **Audit the inherited resource.**
65
67
  ```bash
66
- chant lint ./carveout/src --lexicon aws
68
+ chant build ./carveout/src --lexicon aws
67
69
  ```
68
- After emit, offer to lint the carved source. chant audits the resource you
69
- inherited from Terraform against the AWS lexicon's rules. Findings have a
70
- severity: `error` (must fix before `chant build` will emit e.g. an S3
71
- bucket with no public-access block) and `warning`/`info` (advisory — e.g.
72
- DynamoDB point-in-time recovery). This is a feature of carving: chant
73
- immediately tells the person what is wrong with what they adopted. Whether to
74
- fix an advisory finding is their call; errors block the build until resolved.
70
+ The first build fails, deliberately: the post-synth audit refuses the
71
+ adopted bucket because Terraform managed it without a public-access block or
72
+ a TLS-only policy. This is a feature of carving: chant immediately tells the
73
+ person what is wrong with what they adopted. Show the fix (add
74
+ `PublicAccessBlockConfiguration` and a companion `S3BucketPolicy` with an
75
+ `aws:SecureTransport = false` Deny the tutorial page has the exact code),
76
+ then re-run the build and show the valid CloudFormation template.
75
77
 
76
78
  4. **Bridge — patch the surviving Terraform.**
77
79
  ```bash
78
- chant carve bridge --from ./terraform --select aws_s3_bucket.assets --output ./carveout
80
+ chant carve bridge --from ./terraform --output ./carveout
79
81
  ```
80
82
  Show the generated `data "aws_s3_bucket" "assets"` and the rewired survivor.
81
83
  Emphasize it is dry-run: nothing in `./terraform` changed. `--apply-rewrites`
@@ -83,11 +85,12 @@ From `examples/terraform-carve-out`, with `TF=./terraform`:
83
85
 
84
86
  5. **Apply — graduation plan.**
85
87
  ```bash
86
- chant carve apply --from ./terraform --select aws_s3_bucket.assets --env prod --stack assets
88
+ chant carve apply --from ./terraform --output ./carveout --env prod --stack assets
87
89
  ```
88
90
  Show the ownership marker (`chant:managed-by/stack/env`) and the finalized
89
- runbook. Stress this makes no cloud call the apply is the person's own
90
- lifecycle; chant just plans it.
91
+ runbook. `--write-source` additionally stamps the marker tags into the
92
+ emitted source. Stress this makes no cloud call — the apply is the person's
93
+ own lifecycle; chant just plans it.
91
94
 
92
95
  ## Key points to land
93
96
 
@@ -0,0 +1,258 @@
1
+ /**
2
+ * aws stack-level env teardown (#1222) — unit tests over fake Query-API
3
+ * transports, in the style of aws-apply.test.ts: no network, the fakes answer
4
+ * DescribeStacks/DeleteStack by parsing the form body.
5
+ */
6
+ import { describe, test, expect } from "vitest";
7
+ import { teardownOwned, executeTeardown, resolveTeardownStacks, STACK_TYPE } from "./teardown";
8
+ import type { AwsReadHttp } from "./api/read-client";
9
+ import type { AwsHttp } from "./op/activities/aws-apply";
10
+ import type { OwnershipMarker } from "@intentius/chant/ownership";
11
+
12
+ const MARKER: OwnershipMarker = { stack: "shop", env: "dev" };
13
+
14
+ const MISSING =
15
+ "<ErrorResponse><Error><Code>ValidationError</Code><Message>Stack with id x does not exist</Message></Error></ErrorResponse>";
16
+
17
+ /** A DescribeStacks response for one stack carrying the given tags. */
18
+ function stackXml(name: string, tags: Record<string, string>, status = "CREATE_COMPLETE"): string {
19
+ const members = Object.entries(tags)
20
+ .map(([k, v]) => `<member><Key>${k}</Key><Value>${v}</Value></member>`)
21
+ .join("");
22
+ return (
23
+ `<DescribeStacksResponse><DescribeStacksResult><Stacks><member>` +
24
+ `<StackId>arn:aws:cloudformation:us-east-1:0:stack/${name}/uuid</StackId>` +
25
+ `<StackName>${name}</StackName><StackStatus>${status}</StackStatus>` +
26
+ `<Tags>${members}</Tags>` +
27
+ `</member></Stacks></DescribeStacksResult></DescribeStacksResponse>`
28
+ );
29
+ }
30
+
31
+ const OWNED_TAGS = { "chant:managed-by": "chant", "chant:stack": "shop", "chant:env": "dev" };
32
+ const FOREIGN_ENV_TAGS = { "chant:managed-by": "chant", "chant:stack": "shop", "chant:env": "prod" };
33
+
34
+ /**
35
+ * A fake read transport: answers DescribeStacks per stack name from `stacks`;
36
+ * names not present answer "does not exist".
37
+ */
38
+ function fakeRead(stacks: Record<string, string>): AwsReadHttp {
39
+ return async (_url, init) => {
40
+ const form = new URLSearchParams(init.body);
41
+ expect(form.get("Action")).toBe("DescribeStacks");
42
+ const name = form.get("StackName") ?? "";
43
+ const xml = stacks[name];
44
+ return xml ? { status: 200, text: xml } : { status: 400, text: MISSING };
45
+ };
46
+ }
47
+
48
+ describe("resolveTeardownStacks — stacks[] else the env-named default (#932)", () => {
49
+ test("declared stacks win", () => {
50
+ expect(
51
+ resolveTeardownStacks({
52
+ environment: "dev",
53
+ marker: MARKER,
54
+ stacks: [{ name: "net" }, { name: "app", region: "eu-west-1" }],
55
+ }),
56
+ ).toEqual([{ name: "net" }, { name: "app", region: "eu-west-1" }]);
57
+ });
58
+
59
+ test("explicit stack option, else the stack named after the environment", () => {
60
+ expect(resolveTeardownStacks({ environment: "dev", marker: MARKER, stack: "s1" })).toEqual([{ name: "s1" }]);
61
+ expect(resolveTeardownStacks({ environment: "dev", marker: MARKER })).toEqual([{ name: "dev" }]);
62
+ });
63
+ });
64
+
65
+ describe("teardownOwned — marker-verified stack enumeration", () => {
66
+ test("a stack whose own tags carry the requested identity is a candidate", async () => {
67
+ const result = await teardownOwned(
68
+ { environment: "dev", marker: MARKER },
69
+ { read: { http: fakeRead({ dev: stackXml("dev", OWNED_TAGS) }) } },
70
+ );
71
+ expect(result.candidates).toEqual([
72
+ {
73
+ name: "dev",
74
+ type: STACK_TYPE,
75
+ physicalId: "arn:aws:cloudformation:us-east-1:0:stack/dev/uuid",
76
+ marker: { stack: "shop", env: "dev" },
77
+ },
78
+ ]);
79
+ expect(result.holes).toBeUndefined();
80
+ });
81
+
82
+ test("an untagged stack is a loud unverified-ownership hole, never a candidate", async () => {
83
+ const result = await teardownOwned(
84
+ { environment: "dev", marker: MARKER },
85
+ { read: { http: fakeRead({ dev: stackXml("dev", {}) }) } },
86
+ );
87
+ expect(result.candidates).toEqual([]);
88
+ expect(result.holes).toHaveLength(1);
89
+ expect(result.holes![0]).toMatchObject({ name: "dev", type: STACK_TYPE, reason: "filtered" });
90
+ expect(result.holes![0].detail).toMatch(/unverified-ownership/);
91
+ });
92
+
93
+ test("a stack verifiably carrying ANOTHER identity is out of scope — no candidate, no hole", async () => {
94
+ const result = await teardownOwned(
95
+ { environment: "dev", marker: MARKER },
96
+ { read: { http: fakeRead({ dev: stackXml("dev", FOREIGN_ENV_TAGS) }) } },
97
+ );
98
+ expect(result.candidates).toEqual([]);
99
+ expect(result.holes).toBeUndefined();
100
+ });
101
+
102
+ test("an absent stack is knowledge: nothing to tear down, no hole", async () => {
103
+ const result = await teardownOwned(
104
+ { environment: "dev", marker: MARKER },
105
+ { read: { http: fakeRead({}) } },
106
+ );
107
+ expect(result.candidates).toEqual([]);
108
+ expect(result.holes).toBeUndefined();
109
+ });
110
+
111
+ test("a failed DescribeStacks is a hole (#1089), never absence", async () => {
112
+ const http: AwsReadHttp = async () => ({ status: 500, text: "boom" });
113
+ const result = await teardownOwned({ environment: "dev", marker: MARKER }, { read: { http } });
114
+ expect(result.candidates).toEqual([]);
115
+ expect(result.holes).toHaveLength(1);
116
+ expect(result.holes![0]).toMatchObject({ name: "dev", type: STACK_TYPE, reason: "read-failed" });
117
+ });
118
+
119
+ test("multi-stack: every declared stack is checked, only verified ones are candidates", async () => {
120
+ const result = await teardownOwned(
121
+ {
122
+ environment: "dev",
123
+ marker: MARKER,
124
+ stacks: [{ name: "net" }, { name: "app" }, { name: "gone" }],
125
+ },
126
+ {
127
+ read: {
128
+ http: fakeRead({
129
+ net: stackXml("net", OWNED_TAGS),
130
+ app: stackXml("app", FOREIGN_ENV_TAGS),
131
+ }),
132
+ },
133
+ },
134
+ );
135
+ expect(result.candidates.map((c) => c.name)).toEqual(["net"]);
136
+ expect(result.holes).toBeUndefined();
137
+ });
138
+ });
139
+
140
+ describe("executeTeardown — re-verify, then DeleteStack to DELETE_COMPLETE", () => {
141
+ const CANDIDATE = { name: "dev", type: STACK_TYPE, marker: { stack: "shop", env: "dev" } };
142
+
143
+ /** A stateful fake pair: reads see the stack until DeleteStack lands. */
144
+ function fakeTarget(initialTags: Record<string, string> | undefined) {
145
+ const deletes: string[] = [];
146
+ let present = initialTags !== undefined;
147
+ const read: AwsReadHttp = async (_url, init) => {
148
+ const form = new URLSearchParams(init.body);
149
+ const name = form.get("StackName") ?? "";
150
+ if (!present) return { status: 400, text: MISSING };
151
+ return { status: 200, text: stackXml(name, initialTags ?? {}) };
152
+ };
153
+ const apply: AwsHttp = async (_url, form) => {
154
+ if (form.Action === "DeleteStack") {
155
+ deletes.push(form.StackName ?? "");
156
+ present = false;
157
+ return { status: 200, text: "<DeleteStackResponse/>" };
158
+ }
159
+ if (form.Action === "DescribeStacks") {
160
+ return present
161
+ ? { status: 200, text: stackXml(form.StackName ?? "", initialTags ?? {}) }
162
+ : { status: 400, text: MISSING };
163
+ }
164
+ throw new Error(`unexpected action ${form.Action}`);
165
+ };
166
+ return { read, apply, deletes };
167
+ }
168
+
169
+ test("a marker-verified stack is deleted and polled gone", async () => {
170
+ const target = fakeTarget(OWNED_TAGS);
171
+ const result = await executeTeardown(
172
+ { environment: "dev", marker: MARKER, candidates: [CANDIDATE] },
173
+ { read: { http: target.read }, applyHttp: target.apply, timeoutMs: 1000, intervalMs: 1 },
174
+ );
175
+ expect(result.outcomes).toEqual([{ name: "dev", type: STACK_TYPE, outcome: "deleted" }]);
176
+ expect(target.deletes).toEqual(["dev"]);
177
+ });
178
+
179
+ test("an identity that no longer matches is not-prunable — DeleteStack is never sent", async () => {
180
+ const target = fakeTarget(FOREIGN_ENV_TAGS);
181
+ const result = await executeTeardown(
182
+ { environment: "dev", marker: MARKER, candidates: [CANDIDATE] },
183
+ { read: { http: target.read }, applyHttp: target.apply, timeoutMs: 1000, intervalMs: 1 },
184
+ );
185
+ expect(result.outcomes[0]).toMatchObject({ name: "dev", outcome: "not-prunable" });
186
+ expect(result.outcomes[0].detail).toMatch(/unverified-ownership/);
187
+ expect(target.deletes).toEqual([]);
188
+ });
189
+
190
+ test("an untagged stack at execution time is not-prunable too", async () => {
191
+ const target = fakeTarget({});
192
+ const result = await executeTeardown(
193
+ { environment: "dev", marker: MARKER, candidates: [CANDIDATE] },
194
+ { read: { http: target.read }, applyHttp: target.apply, timeoutMs: 1000, intervalMs: 1 },
195
+ );
196
+ expect(result.outcomes[0]).toMatchObject({ name: "dev", outcome: "not-prunable" });
197
+ expect(target.deletes).toEqual([]);
198
+ });
199
+
200
+ test("an already-absent stack is deleted (teardown is idempotent)", async () => {
201
+ const target = fakeTarget(undefined);
202
+ const result = await executeTeardown(
203
+ { environment: "dev", marker: MARKER, candidates: [CANDIDATE] },
204
+ { read: { http: target.read }, applyHttp: target.apply, timeoutMs: 1000, intervalMs: 1 },
205
+ );
206
+ expect(result.outcomes).toEqual([
207
+ { name: "dev", type: STACK_TYPE, outcome: "deleted", detail: "already absent" },
208
+ ]);
209
+ expect(target.deletes).toEqual([]);
210
+ });
211
+
212
+ test("a failed DeleteStack is a failed outcome, never silence", async () => {
213
+ const target = fakeTarget(OWNED_TAGS);
214
+ const apply: AwsHttp = async (_url, form) => {
215
+ if (form.Action === "DeleteStack") return { status: 500, text: "<ErrorResponse><Error><Message>throttled</Message></Error></ErrorResponse>" };
216
+ return target.apply(_url, form);
217
+ };
218
+ const result = await executeTeardown(
219
+ { environment: "dev", marker: MARKER, candidates: [CANDIDATE] },
220
+ { read: { http: target.read }, applyHttp: apply, timeoutMs: 1000, intervalMs: 1 },
221
+ );
222
+ expect(result.outcomes[0]).toMatchObject({ name: "dev", outcome: "failed" });
223
+ expect(result.outcomes[0].detail).toMatch(/throttled/);
224
+ });
225
+
226
+ test("a candidate's region comes from the declared stacks", async () => {
227
+ // The URL is only regional when no ambient endpoint override is set.
228
+ const saved = {
229
+ all: process.env.AWS_ENDPOINT_URL,
230
+ cfn: process.env.AWS_ENDPOINT_URL_CLOUDFORMATION,
231
+ };
232
+ delete process.env.AWS_ENDPOINT_URL;
233
+ delete process.env.AWS_ENDPOINT_URL_CLOUDFORMATION;
234
+ const urls: string[] = [];
235
+ const target = fakeTarget(OWNED_TAGS);
236
+ const apply: AwsHttp = async (url, form) => {
237
+ urls.push(url);
238
+ return target.apply(url, form);
239
+ };
240
+ const read: AwsReadHttp = async (url, init) => {
241
+ urls.push(url);
242
+ return target.read(url, init);
243
+ };
244
+ await executeTeardown(
245
+ {
246
+ environment: "dev",
247
+ marker: MARKER,
248
+ candidates: [{ ...CANDIDATE, name: "app" }],
249
+ stacks: [{ name: "app", region: "eu-west-1" }],
250
+ },
251
+ { read: { http: read }, applyHttp: apply, timeoutMs: 1000, intervalMs: 1 },
252
+ );
253
+ if (saved.all !== undefined) process.env.AWS_ENDPOINT_URL = saved.all;
254
+ if (saved.cfn !== undefined) process.env.AWS_ENDPOINT_URL_CLOUDFORMATION = saved.cfn;
255
+ expect(urls.length).toBeGreaterThan(0);
256
+ expect(urls.every((u) => u.includes("eu-west-1"))).toBe(true);
257
+ });
258
+ });