@intentius/chant-lexicon-aws 0.45.0 → 0.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/cloud-executor.d.ts.map +1 -1
- package/dist/composites/lambda-function.d.ts +4 -4
- package/dist/composites/lambda-function.d.ts.map +1 -1
- package/dist/integrity.json +5 -3
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/cf-refs.d.ts +7 -0
- package/dist/lint/post-synth/cf-refs.d.ts.map +1 -1
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/waw059.d.ts +36 -0
- package/dist/lint/post-synth/waw059.d.ts.map +1 -0
- package/dist/lint/post-synth/waw060.d.ts +16 -0
- package/dist/lint/post-synth/waw060.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +2 -0
- package/dist/okf/rules/WAW059.md +25 -0
- package/dist/okf/rules/WAW060.md +17 -0
- package/dist/okf/types/Action.md +1 -0
- package/dist/okf/types/Bucket.md +1 -0
- package/dist/okf/types/GlobalTable.md +4 -0
- package/dist/okf/types/IamPolicy.md +2 -0
- package/dist/okf/types/InstanceProfile.md +4 -0
- package/dist/okf/types/ManagedPolicy.md +2 -0
- package/dist/okf/types/Map.md +1 -0
- package/dist/okf/types/Queue.md +1 -0
- package/dist/okf/types/Role.md +1 -0
- package/dist/okf/types/Table.md +1 -0
- package/dist/okf/types/Type.md +2 -0
- package/dist/op/activities/aws-apply.d.ts +48 -5
- package/dist/op/activities/aws-apply.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +5 -4
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/ownership.d.ts +18 -0
- package/dist/ownership.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rules/cf-refs.ts +22 -0
- package/dist/rules/waw059.ts +353 -0
- package/dist/rules/waw060.ts +91 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/teardown.d.ts +85 -0
- package/dist/teardown.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/components/cloud-executor.ts +10 -1
- package/src/lifecycle-integration.test.ts +4 -0
- package/src/lint/audit-catalog.ts +5 -0
- package/src/lint/post-synth/cf-refs.ts +22 -0
- package/src/lint/post-synth/index.ts +4 -0
- package/src/lint/post-synth/waw059.test.ts +309 -0
- package/src/lint/post-synth/waw059.ts +353 -0
- package/src/lint/post-synth/waw060.test.ts +131 -0
- package/src/lint/post-synth/waw060.ts +91 -0
- package/src/op/activities/aws-apply.test.ts +111 -0
- package/src/op/activities/aws-apply.ts +100 -6
- package/src/op/activities/index.ts +5 -3
- package/src/ownership.test.ts +24 -1
- package/src/ownership.ts +37 -0
- package/src/plugin.ts +18 -0
- package/src/serializer-ownership.test.ts +18 -0
- package/src/serializer.ts +9 -1
- package/src/teardown.test.ts +258 -0
- package/src/teardown.ts +276 -0
package/src/serializer.ts
CHANGED
|
@@ -2,7 +2,7 @@ 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 { AWS_TAG_OWNERSHIP_KEYS, OWNERSHIP_METADATA_KEY } from "./ownership";
|
|
6
6
|
import type { LexiconOutput } from "@intentius/chant/lexicon-output";
|
|
7
7
|
import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
|
|
8
8
|
import { isChildProject, type ChildProjectInstance } from "@intentius/chant/child-project";
|
|
@@ -26,6 +26,7 @@ function isCoreParameter(entity: Declarable): entity is CoreParameter {
|
|
|
26
26
|
interface CFTemplate {
|
|
27
27
|
AWSTemplateFormatVersion: "2010-09-09";
|
|
28
28
|
Description?: string;
|
|
29
|
+
Metadata?: Record<string, unknown>;
|
|
29
30
|
Transform?: string | string[];
|
|
30
31
|
Parameters?: Record<string, CFParameter>;
|
|
31
32
|
Resources: Record<string, CFResource>;
|
|
@@ -187,6 +188,13 @@ function serializeToTemplate(
|
|
|
187
188
|
for (const [Key, Value] of Object.entries(ownershipEntries(AWS_TAG_OWNERSHIP_KEYS, ownership))) {
|
|
188
189
|
defaultTagEntries.push({ Key, Value });
|
|
189
190
|
}
|
|
191
|
+
// Also carry the marker at the template level (#1222): stack tags are a
|
|
192
|
+
// CreateStack/UpdateStack API parameter, not a template section, so the
|
|
193
|
+
// apply paths read this Metadata block and stamp it as the stack's own
|
|
194
|
+
// tags — what stack-level teardown verifies ownership on.
|
|
195
|
+
template.Metadata = {
|
|
196
|
+
[OWNERSHIP_METADATA_KEY]: ownershipEntries(AWS_TAG_OWNERSHIP_KEYS, ownership),
|
|
197
|
+
};
|
|
190
198
|
}
|
|
191
199
|
for (const [, entity] of entities) {
|
|
192
200
|
if (isDefaultTags(entity)) {
|
|
@@ -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
|
+
});
|
package/src/teardown.ts
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Env teardown for the aws lexicon (chant #1222) — both halves of the
|
|
3
|
+
* `teardownOwned` / `executeTeardown` capability pair, at STACK granularity.
|
|
4
|
+
*
|
|
5
|
+
* Per-resource selection is impossible here by construction: the thin read
|
|
6
|
+
* (`describeResources`) is sourced from `describe-stack-resources`, which
|
|
7
|
+
* returns no tags, so no resource-level marker can be read on the teardown
|
|
8
|
+
* path. The stack is aws's ownership boundary anyway — the applier deploys
|
|
9
|
+
* whole stacks and deletes ride CloudFormation — so teardown enumerates the
|
|
10
|
+
* environment's stacks and verifies the marker on each STACK's own tags
|
|
11
|
+
* (`DescribeStacks` → `Tags`), stamped there by the apply paths from the
|
|
12
|
+
* template's `Metadata["chant:ownership"]` block.
|
|
13
|
+
*
|
|
14
|
+
* Which stacks are the environment's: the project's declared `stacks[]` when
|
|
15
|
+
* it is a multi-stack project, else the single-stack convention — the explicit
|
|
16
|
+
* `stack` option, else the stack named after the environment (the same rule
|
|
17
|
+
* `describeResources` and `exportResources` apply, see chant #932).
|
|
18
|
+
*
|
|
19
|
+
* Verification is tag-reading, never name-trusting. A resolved stack whose
|
|
20
|
+
* tags carry exactly the requested identity (managed-by + stack + env) is a
|
|
21
|
+
* candidate of type `AWS::CloudFormation::Stack`. A stack carrying a DIFFERENT
|
|
22
|
+
* marker identity verifiably belongs to another project or env — out of scope,
|
|
23
|
+
* silently, the way another env's resources are. A stack carrying NO marker at
|
|
24
|
+
* all is unknowable — a legacy chant stack from before stack tagging, or a
|
|
25
|
+
* foreign stack that happens to hold the env's name — and is reported as a
|
|
26
|
+
* hole (`filtered` / unverified-ownership), never deleted. An absent stack is
|
|
27
|
+
* knowledge, not a hole: nothing to tear down.
|
|
28
|
+
*
|
|
29
|
+
* Execution re-reads each candidate stack's tags immediately before deleting
|
|
30
|
+
* (the enumeration is a moment old at best, and a delete is not undoable),
|
|
31
|
+
* then rides the existing Query-API delete — `awsDelete`, DeleteStack polled
|
|
32
|
+
* to DELETE_COMPLETE. An identity that no longer matches is `not-prunable:
|
|
33
|
+
* unverified-ownership`; an already-absent stack is `deleted` (teardown is
|
|
34
|
+
* idempotent).
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import type {
|
|
38
|
+
TeardownCandidate,
|
|
39
|
+
TeardownEnumeration,
|
|
40
|
+
TeardownExecution,
|
|
41
|
+
TeardownHole,
|
|
42
|
+
TeardownOutcome,
|
|
43
|
+
} from "@intentius/chant/lexicon";
|
|
44
|
+
import { readOwnership, type OwnershipMarker } from "@intentius/chant/ownership";
|
|
45
|
+
import { AWS_TAG_OWNERSHIP_KEYS } from "./ownership";
|
|
46
|
+
import {
|
|
47
|
+
cfnQuery,
|
|
48
|
+
xmlLeaves,
|
|
49
|
+
xmlMembers,
|
|
50
|
+
AwsReadError,
|
|
51
|
+
type AwsReadClientOptions,
|
|
52
|
+
} from "./api/read-client";
|
|
53
|
+
import { awsDelete, type AwsHttp } from "./op/activities/aws-apply";
|
|
54
|
+
|
|
55
|
+
/** The one candidate type this lexicon's teardown produces: whole stacks. */
|
|
56
|
+
export const STACK_TYPE = "AWS::CloudFormation::Stack";
|
|
57
|
+
|
|
58
|
+
export interface AwsTeardownOptions {
|
|
59
|
+
environment: string;
|
|
60
|
+
/** The identity to select on: this project's ownership stack + the env being torn down. */
|
|
61
|
+
marker: OwnershipMarker;
|
|
62
|
+
/** Explicit deployed stack name (single-stack override). */
|
|
63
|
+
stack?: string;
|
|
64
|
+
/** Region that stack is deployed in. */
|
|
65
|
+
region?: string;
|
|
66
|
+
/** A multi-stack project's declared stacks (chant.config `stacks[]`). */
|
|
67
|
+
stacks?: Array<{ name: string; region?: string }>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Injectable transports/timeouts, so tests double the Query API (no network). */
|
|
71
|
+
export interface AwsTeardownDeps {
|
|
72
|
+
/** Options for the read client's `DescribeStacks` calls (http injection, endpoint). */
|
|
73
|
+
read?: AwsReadClientOptions;
|
|
74
|
+
/** The applier transport `awsDelete` polls DeleteStack through. */
|
|
75
|
+
applyHttp?: AwsHttp;
|
|
76
|
+
/** DeleteStack settle timeout in ms (default `awsDelete`'s 300000). */
|
|
77
|
+
timeoutMs?: number;
|
|
78
|
+
/** DeleteStack poll interval in ms (default `awsDelete`'s 3000). */
|
|
79
|
+
intervalMs?: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The stacks an env teardown resolves to: `stacks[]`, else the single-stack convention. */
|
|
83
|
+
export function resolveTeardownStacks(
|
|
84
|
+
options: AwsTeardownOptions,
|
|
85
|
+
): Array<{ name: string; region?: string }> {
|
|
86
|
+
if (options.stacks && options.stacks.length > 0) return options.stacks;
|
|
87
|
+
return [
|
|
88
|
+
{
|
|
89
|
+
name: options.stack ?? options.environment,
|
|
90
|
+
...(options.region ? { region: options.region } : {}),
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** One live stack, as DescribeStacks answers for it. */
|
|
96
|
+
interface LiveStack {
|
|
97
|
+
stackId?: string;
|
|
98
|
+
status?: string;
|
|
99
|
+
/** The stack's own tags, as a flat map. */
|
|
100
|
+
tags: Record<string, string>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A DescribeStacks miss — CloudFormation's "does not exist" ValidationError. */
|
|
104
|
+
function isStackMissingError(err: unknown): boolean {
|
|
105
|
+
return err instanceof AwsReadError && /does not exist/i.test(err.message);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** `DescribeStacks` for one stack; `undefined` when the stack does not exist. */
|
|
109
|
+
async function describeStack(
|
|
110
|
+
name: string,
|
|
111
|
+
region: string | undefined,
|
|
112
|
+
read: AwsReadClientOptions,
|
|
113
|
+
): Promise<LiveStack | undefined> {
|
|
114
|
+
let xml: string;
|
|
115
|
+
try {
|
|
116
|
+
xml = await cfnQuery("DescribeStacks", { StackName: name }, {
|
|
117
|
+
...read,
|
|
118
|
+
...(region ? { region } : {}),
|
|
119
|
+
});
|
|
120
|
+
} catch (err) {
|
|
121
|
+
if (isStackMissingError(err)) return undefined;
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
// One StackName queried → one stack member; `xmlLeaves` keeps the first
|
|
125
|
+
// occurrence of each scalar, which is that stack's StackId/StackStatus.
|
|
126
|
+
const leaves = xmlLeaves(xml);
|
|
127
|
+
const tags: Record<string, string> = {};
|
|
128
|
+
for (const m of xmlMembers(xml, "Tags")) {
|
|
129
|
+
if (m.Key) tags[m.Key] = m.Value ?? "";
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
...(leaves.StackId ? { stackId: leaves.StackId } : {}),
|
|
133
|
+
...(leaves.StackStatus ? { status: leaves.StackStatus } : {}),
|
|
134
|
+
tags,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** True when a live stack's own tags carry exactly the requested identity. */
|
|
139
|
+
function matchesMarker(tags: Record<string, string>, marker: OwnershipMarker): boolean {
|
|
140
|
+
const read = readOwnership(tags, AWS_TAG_OWNERSHIP_KEYS);
|
|
141
|
+
return read !== undefined && read.stack === marker.stack && read.env === marker.env;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Enumerate the environment's marker-verified stacks — the aws half of
|
|
146
|
+
* `chant lifecycle teardown <env>` planning. Read-only.
|
|
147
|
+
*/
|
|
148
|
+
export async function teardownOwned(
|
|
149
|
+
options: AwsTeardownOptions,
|
|
150
|
+
deps: AwsTeardownDeps = {},
|
|
151
|
+
): Promise<TeardownEnumeration> {
|
|
152
|
+
const read = deps.read ?? {};
|
|
153
|
+
const candidates: TeardownCandidate[] = [];
|
|
154
|
+
const holes: TeardownHole[] = [];
|
|
155
|
+
|
|
156
|
+
for (const ref of resolveTeardownStacks(options)) {
|
|
157
|
+
let live: LiveStack | undefined;
|
|
158
|
+
try {
|
|
159
|
+
live = await describeStack(ref.name, ref.region, read);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
holes.push({
|
|
162
|
+
name: ref.name,
|
|
163
|
+
type: STACK_TYPE,
|
|
164
|
+
reason: "read-failed",
|
|
165
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
166
|
+
});
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
// Absent is knowledge: the env-resolved stack does not exist, so there is
|
|
170
|
+
// nothing to tear down and nothing unknown about it.
|
|
171
|
+
if (live === undefined) continue;
|
|
172
|
+
|
|
173
|
+
const identity = readOwnership(live.tags, AWS_TAG_OWNERSHIP_KEYS);
|
|
174
|
+
if (identity !== undefined && identity.stack === options.marker.stack && identity.env === options.marker.env) {
|
|
175
|
+
candidates.push({
|
|
176
|
+
name: ref.name,
|
|
177
|
+
type: STACK_TYPE,
|
|
178
|
+
...(live.stackId ? { physicalId: live.stackId } : {}),
|
|
179
|
+
marker: identity,
|
|
180
|
+
});
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (identity !== undefined) {
|
|
184
|
+
// A marker for a DIFFERENT identity is verifiably someone else's
|
|
185
|
+
// (another project's stack, another env's deployment of a shared stack
|
|
186
|
+
// name) — out of scope, the way any foreign-env resource is.
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
// No marker at all: a legacy chant stack deployed before stack tagging, or
|
|
190
|
+
// a foreign stack under the env's name — unknowable either way. Loud, and
|
|
191
|
+
// never deleted (#1089): unverified ownership reads as "do not touch",
|
|
192
|
+
// not as "clean".
|
|
193
|
+
holes.push({
|
|
194
|
+
name: ref.name,
|
|
195
|
+
type: STACK_TYPE,
|
|
196
|
+
reason: "filtered",
|
|
197
|
+
detail:
|
|
198
|
+
`unverified-ownership: the stack exists but its tags carry no chant ownership marker ` +
|
|
199
|
+
`(expected ${AWS_TAG_OWNERSHIP_KEYS.managedBy} + ${AWS_TAG_OWNERSHIP_KEYS.stack}=${options.marker.stack}` +
|
|
200
|
+
`${options.marker.env ? ` + ${AWS_TAG_OWNERSHIP_KEYS.env}=${options.marker.env}` : ""}) — ` +
|
|
201
|
+
`a chant stack deployed before stack tagging, or a foreign stack; re-deploy to stamp it, it will not be deleted`,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return { candidates, ...(holes.length > 0 ? { holes } : {}) };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Delete the handed-over stacks: re-verify each stack's own tags, then
|
|
210
|
+
* DeleteStack via `awsDelete`, polled to DELETE_COMPLETE. One outcome per
|
|
211
|
+
* candidate, always.
|
|
212
|
+
*/
|
|
213
|
+
export async function executeTeardown(
|
|
214
|
+
options: AwsTeardownOptions & { candidates: TeardownCandidate[] },
|
|
215
|
+
deps: AwsTeardownDeps = {},
|
|
216
|
+
): Promise<TeardownExecution> {
|
|
217
|
+
const read = deps.read ?? {};
|
|
218
|
+
const regionByStack = new Map<string, string | undefined>(
|
|
219
|
+
resolveTeardownStacks(options).map((s) => [s.name, s.region]),
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
const outcomes: TeardownOutcome[] = [];
|
|
223
|
+
for (const candidate of options.candidates) {
|
|
224
|
+
outcomes.push(await deleteStackCandidate(candidate, regionByStack.get(candidate.name), options.marker, read, deps));
|
|
225
|
+
}
|
|
226
|
+
return { outcomes };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function deleteStackCandidate(
|
|
230
|
+
candidate: TeardownCandidate,
|
|
231
|
+
region: string | undefined,
|
|
232
|
+
marker: OwnershipMarker,
|
|
233
|
+
read: AwsReadClientOptions,
|
|
234
|
+
deps: AwsTeardownDeps,
|
|
235
|
+
): Promise<TeardownOutcome> {
|
|
236
|
+
const base = {
|
|
237
|
+
name: candidate.name,
|
|
238
|
+
type: STACK_TYPE,
|
|
239
|
+
...(candidate.physicalId ? { physicalId: candidate.physicalId } : {}),
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// Re-read and re-verify the stack's own tags right before deleting: the
|
|
243
|
+
// enumeration is a moment old at best, and a DeleteStack is not undoable.
|
|
244
|
+
let live: LiveStack | undefined;
|
|
245
|
+
try {
|
|
246
|
+
live = await describeStack(candidate.name, region, read);
|
|
247
|
+
} catch (err) {
|
|
248
|
+
return { ...base, outcome: "failed", detail: err instanceof Error ? err.message : String(err) };
|
|
249
|
+
}
|
|
250
|
+
if (live === undefined || live.status === "DELETE_COMPLETE") {
|
|
251
|
+
return { ...base, outcome: "deleted", detail: "already absent" };
|
|
252
|
+
}
|
|
253
|
+
if (!matchesMarker(live.tags, marker)) {
|
|
254
|
+
return {
|
|
255
|
+
...base,
|
|
256
|
+
outcome: "not-prunable",
|
|
257
|
+
detail: "unverified-ownership: the live stack's tags no longer carry the requested marker identity",
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
await awsDelete(
|
|
263
|
+
{
|
|
264
|
+
stackName: candidate.name,
|
|
265
|
+
...(region ? { region } : {}),
|
|
266
|
+
...(deps.timeoutMs !== undefined ? { timeoutMs: deps.timeoutMs } : {}),
|
|
267
|
+
...(deps.intervalMs !== undefined ? { intervalMs: deps.intervalMs } : {}),
|
|
268
|
+
},
|
|
269
|
+
undefined,
|
|
270
|
+
deps.applyHttp,
|
|
271
|
+
);
|
|
272
|
+
} catch (err) {
|
|
273
|
+
return { ...base, outcome: "failed", detail: err instanceof Error ? err.message : String(err) };
|
|
274
|
+
}
|
|
275
|
+
return { ...base, outcome: "deleted" };
|
|
276
|
+
}
|