@intentius/chant-lexicon-aws 0.45.0 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/read-client.d.ts +10 -2
- package/dist/api/read-client.d.ts.map +1 -1
- package/dist/components/cloud-executor.d.ts.map +1 -1
- package/dist/composites/agentcore-agent.d.ts +26 -1
- package/dist/composites/agentcore-agent.d.ts.map +1 -1
- package/dist/composites/bucket-deployment.d.ts +82 -0
- package/dist/composites/bucket-deployment.d.ts.map +1 -0
- package/dist/composites/dynamodb-table.d.ts +45 -0
- package/dist/composites/dynamodb-table.d.ts.map +1 -0
- package/dist/composites/ec2-instance-bundle.d.ts +61 -0
- package/dist/composites/ec2-instance-bundle.d.ts.map +1 -0
- package/dist/composites/ecr-repository.d.ts +49 -0
- package/dist/composites/ecr-repository.d.ts.map +1 -0
- package/dist/composites/fargate-service.d.ts +16 -0
- package/dist/composites/fargate-service.d.ts.map +1 -1
- package/dist/composites/index.d.ts +16 -2
- package/dist/composites/index.d.ts.map +1 -1
- package/dist/composites/lambda-function.d.ts +4 -4
- package/dist/composites/lambda-function.d.ts.map +1 -1
- package/dist/composites/monitoring-stack.d.ts +99 -0
- package/dist/composites/monitoring-stack.d.ts.map +1 -0
- package/dist/composites/nlb-service.d.ts +48 -0
- package/dist/composites/nlb-service.d.ts.map +1 -0
- package/dist/composites/step-functions-workflow.d.ts +36 -0
- package/dist/composites/step-functions-workflow.d.ts.map +1 -0
- package/dist/deep-observe.d.ts +45 -0
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/deep-topology.d.ts +104 -0
- package/dist/deep-topology.d.ts.map +1 -0
- package/dist/effect-receipt-row.d.ts +92 -0
- package/dist/effect-receipt-row.d.ts.map +1 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +9 -4
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/cf-refs.d.ts +24 -0
- package/dist/lint/post-synth/cf-refs.d.ts.map +1 -1
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/waw059.d.ts +36 -0
- package/dist/lint/post-synth/waw059.d.ts.map +1 -0
- package/dist/lint/post-synth/waw060.d.ts +16 -0
- package/dist/lint/post-synth/waw060.d.ts.map +1 -0
- package/dist/lint/post-synth/waw061.d.ts +19 -0
- package/dist/lint/post-synth/waw061.d.ts.map +1 -0
- package/dist/lint/post-synth/waw062.d.ts +19 -0
- package/dist/lint/post-synth/waw062.d.ts.map +1 -0
- package/dist/lint/post-synth/waw063.d.ts +35 -0
- package/dist/lint/post-synth/waw063.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +5 -0
- package/dist/okf/rules/WAW059.md +25 -0
- package/dist/okf/rules/WAW060.md +17 -0
- package/dist/okf/rules/WAW061.md +17 -0
- package/dist/okf/rules/WAW062.md +27 -0
- package/dist/okf/rules/WAW063.md +19 -0
- package/dist/okf/types/Action.md +2 -0
- package/dist/okf/types/AutoScalingGroup.md +1 -0
- package/dist/okf/types/Bucket.md +2 -0
- package/dist/okf/types/ECRRepository.md +1 -0
- package/dist/okf/types/Function.md +1 -0
- package/dist/okf/types/GlobalTable.md +4 -0
- package/dist/okf/types/IamPolicy.md +3 -0
- package/dist/okf/types/InstanceProfile.md +4 -0
- package/dist/okf/types/LaunchTemplate.md +1 -0
- package/dist/okf/types/LoadBalancer.md +1 -0
- package/dist/okf/types/ManagedPolicy.md +4 -0
- package/dist/okf/types/Map.md +2 -0
- package/dist/okf/types/Queue.md +1 -0
- package/dist/okf/types/Role.md +3 -0
- package/dist/okf/types/SecurityGroup.md +1 -0
- package/dist/okf/types/StateMachine.md +4 -0
- package/dist/okf/types/Subnet.md +4 -0
- package/dist/okf/types/Table.md +2 -0
- package/dist/okf/types/Type.md +5 -0
- package/dist/okf/types/Vpc.md +1 -0
- package/dist/op/activities/aws-apply.d.ts +48 -5
- package/dist/op/activities/aws-apply.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +11 -4
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/ownership.d.ts +18 -0
- package/dist/ownership.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/receipt-store.d.ts +109 -0
- package/dist/receipt-store.d.ts.map +1 -0
- package/dist/rules/cf-refs.ts +62 -0
- package/dist/rules/waw059.ts +353 -0
- package/dist/rules/waw060.ts +91 -0
- package/dist/rules/waw061.ts +67 -0
- package/dist/rules/waw062.ts +0 -0
- package/dist/rules/waw063.ts +0 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/skills/chant-aws-carve-terraform.md +26 -23
- package/dist/teardown.d.ts +85 -0
- package/dist/teardown.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/api/read-client.test.ts +9 -0
- package/src/api/read-client.ts +15 -2
- package/src/components/cloud-executor.ts +10 -1
- package/src/composites/agentcore-agent.ts +35 -13
- package/src/composites/bucket-deployment.test.ts +84 -0
- package/src/composites/bucket-deployment.ts +153 -0
- package/src/composites/composites.test.ts +325 -0
- package/src/composites/dynamodb-table.test.ts +154 -0
- package/src/composites/dynamodb-table.ts +124 -0
- package/src/composites/ec2-instance-bundle.ts +134 -0
- package/src/composites/ecr-repository.test.ts +118 -0
- package/src/composites/ecr-repository.ts +117 -0
- package/src/composites/fargate-service.ts +19 -3
- package/src/composites/index.ts +21 -2
- package/src/composites/monitoring-stack.test.ts +130 -0
- package/src/composites/monitoring-stack.ts +164 -0
- package/src/composites/nlb-service.ts +117 -0
- package/src/composites/step-functions-workflow.ts +127 -0
- package/src/deep-observe.test.ts +201 -1
- package/src/deep-observe.ts +204 -0
- package/src/deep-topology.test.ts +392 -0
- package/src/deep-topology.ts +237 -0
- package/src/effect-receipt-row.test.ts +164 -0
- package/src/effect-receipt-row.ts +147 -0
- package/src/index.ts +28 -7
- package/src/lifecycle-integration.test.ts +4 -0
- package/src/lint/audit-catalog.ts +11 -0
- package/src/lint/post-synth/cf-refs.ts +62 -0
- package/src/lint/post-synth/index.ts +10 -0
- package/src/lint/post-synth/waw059.test.ts +309 -0
- package/src/lint/post-synth/waw059.ts +353 -0
- package/src/lint/post-synth/waw060.test.ts +131 -0
- package/src/lint/post-synth/waw060.ts +91 -0
- package/src/lint/post-synth/waw061.test.ts +99 -0
- package/src/lint/post-synth/waw061.ts +67 -0
- package/src/lint/post-synth/waw062.test.ts +109 -0
- package/src/lint/post-synth/waw062.ts +0 -0
- package/src/lint/post-synth/waw063.test.ts +220 -0
- package/src/lint/post-synth/waw063.ts +0 -0
- package/src/op/activities/aws-apply.test.ts +111 -0
- package/src/op/activities/aws-apply.ts +100 -6
- package/src/op/activities/index.ts +20 -3
- package/src/ownership.test.ts +24 -1
- package/src/ownership.ts +37 -0
- package/src/plugin.ts +59 -10
- package/src/receipt-store.test.ts +204 -0
- package/src/receipt-store.ts +315 -0
- package/src/serializer-ownership.test.ts +18 -0
- package/src/serializer.ts +114 -3
- package/src/skills/chant-aws-carve-terraform.md +26 -23
- package/src/teardown.test.ts +258 -0
- package/src/teardown.ts +276 -0
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 {
|
|
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
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
68
|
+
chant build ./carveout/src --lexicon aws
|
|
67
69
|
```
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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 --
|
|
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 --
|
|
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.
|
|
90
|
-
|
|
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
|
+
});
|