@intentius/chant-lexicon-aws 0.44.14 → 0.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agentcore/trace-fetch.d.ts +4 -1
- package/dist/agentcore/trace-fetch.d.ts.map +1 -1
- package/dist/api/read-client.d.ts +31 -1
- package/dist/api/read-client.d.ts.map +1 -1
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/components/capability-plugin.d.ts.map +1 -1
- package/dist/components/cloud-executor.d.ts +9 -0
- package/dist/components/cloud-executor.d.ts.map +1 -1
- package/dist/composites/agentcore-agent.d.ts +33 -19
- package/dist/composites/agentcore-agent.d.ts.map +1 -1
- package/dist/composites/index.d.ts +1 -1
- 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/deep-observe.d.ts +21 -0
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.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 +62 -9
- 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/properties.d.ts +4 -3
- package/dist/properties.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/spec/fetch.d.ts +12 -1
- package/dist/spec/fetch.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/agentcore/trace-fetch.test.ts +17 -0
- package/src/agentcore/trace-fetch.ts +7 -2
- package/src/api/read-client.test.ts +87 -0
- package/src/api/read-client.ts +57 -1
- package/src/codegen/docs-links.test.ts +44 -30
- package/src/codegen/docs.ts +2 -1034
- package/src/components/capability-plugin.ts +5 -2
- package/src/components/cloud-executor.test.ts +29 -1
- package/src/components/cloud-executor.ts +33 -6
- package/src/composites/agentcore-agent.test.ts +32 -12
- package/src/composites/agentcore-agent.ts +43 -23
- package/src/composites/index.ts +1 -1
- package/src/composites/microvm-app.test.ts +2 -2
- package/src/deep-observe.test.ts +94 -0
- package/src/deep-observe.ts +58 -2
- package/src/import/roundtrip-fixtures.test.ts +1 -1
- package/src/index.ts +3 -1
- package/src/lifecycle-integration.test.ts +93 -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 +151 -4
- package/src/op/activities/aws-apply.ts +123 -13
- 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 +52 -35
- package/src/properties.test.ts +5 -5
- package/src/properties.ts +6 -5
- package/src/serializer-ownership.test.ts +18 -0
- package/src/serializer.test.ts +73 -33
- package/src/serializer.ts +9 -1
- package/src/spec/fetch.test.ts +40 -0
- package/src/spec/fetch.ts +24 -3
- package/src/teardown.test.ts +258 -0
- package/src/teardown.ts +276 -0
|
@@ -3,6 +3,7 @@ import { writeFileSync, unlinkSync } from "node:fs";
|
|
|
3
3
|
import {
|
|
4
4
|
awsApply,
|
|
5
5
|
awsDelete,
|
|
6
|
+
rollbackStack,
|
|
6
7
|
cfnUrl,
|
|
7
8
|
cfnForm,
|
|
8
9
|
capabilityParams,
|
|
@@ -25,8 +26,16 @@ const describe_ = (status: string) => `<DescribeStacksResponse><Stacks><member><
|
|
|
25
26
|
|
|
26
27
|
describe("CFN pure helpers (#awsApply)", () => {
|
|
27
28
|
test("cfnUrl: endpoint override vs real regional host", () => {
|
|
28
|
-
expect(cfnUrl("http://localhost:4566")).toBe("http://localhost:4566/");
|
|
29
|
-
expect(cfnUrl(undefined, "eu-west-1")).toBe("https://cloudformation.eu-west-1.amazonaws.com/");
|
|
29
|
+
expect(cfnUrl("http://localhost:4566", undefined, {})).toBe("http://localhost:4566/");
|
|
30
|
+
expect(cfnUrl(undefined, "eu-west-1", {})).toBe("https://cloudformation.eu-west-1.amazonaws.com/");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("cfnUrl: AWS_ENDPOINT_URL[_CLOUDFORMATION] is an override too, the same rule as the read client (#1694)", () => {
|
|
34
|
+
expect(cfnUrl(undefined, "eu-west-1", { AWS_ENDPOINT_URL: "http://localhost:4566" })).toBe("http://localhost:4566/");
|
|
35
|
+
expect(
|
|
36
|
+
cfnUrl(undefined, "eu-west-1", { AWS_ENDPOINT_URL: "http://all:1", AWS_ENDPOINT_URL_CLOUDFORMATION: "http://cfn:2" }),
|
|
37
|
+
).toBe("http://cfn:2/");
|
|
38
|
+
expect(cfnUrl("http://opt:3", "eu-west-1", { AWS_ENDPOINT_URL: "http://all:1" })).toBe("http://opt:3/");
|
|
30
39
|
});
|
|
31
40
|
|
|
32
41
|
test("cfnForm stamps Action + Version", () => {
|
|
@@ -55,9 +64,9 @@ describe("CFN pure helpers (#awsApply)", () => {
|
|
|
55
64
|
});
|
|
56
65
|
});
|
|
57
66
|
|
|
58
|
-
function tmpl(): string {
|
|
67
|
+
function tmpl(extra: Record<string, unknown> = {}): string {
|
|
59
68
|
const p = `/tmp/chant-cfn-${process.pid}-${Math.round(performance.now())}.json`;
|
|
60
|
-
writeFileSync(p, JSON.stringify({ Resources: { B: { Type: "AWS::S3::Bucket" } } }));
|
|
69
|
+
writeFileSync(p, JSON.stringify({ ...extra, Resources: { B: { Type: "AWS::S3::Bucket" } } }));
|
|
61
70
|
return p;
|
|
62
71
|
}
|
|
63
72
|
|
|
@@ -77,6 +86,82 @@ describe("awsApply flow (#awsApply)", () => {
|
|
|
77
86
|
expect(calls).toEqual(["DescribeStacks", "CreateStack", "DescribeStacks"]);
|
|
78
87
|
});
|
|
79
88
|
|
|
89
|
+
test("capabilities follow the template: NAMED_IAM alone, plus AUTO_EXPAND for a Transform (#980)", async () => {
|
|
90
|
+
const sent: Record<string, string>[] = [];
|
|
91
|
+
let described = 0;
|
|
92
|
+
const http: AwsHttp = async (_url, form) => {
|
|
93
|
+
if (form.Action === "DescribeStacks") return described++ % 2 === 0 ? { status: 400, text: MISSING } : { status: 200, text: describe_("CREATE_COMPLETE") };
|
|
94
|
+
sent.push(form);
|
|
95
|
+
return { status: 200, text: CREATE_OK };
|
|
96
|
+
};
|
|
97
|
+
const plain = tmpl();
|
|
98
|
+
await awsApply({ templatePath: plain, stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, http);
|
|
99
|
+
unlinkSync(plain);
|
|
100
|
+
expect(sent[0]["Capabilities.member.1"]).toBe("CAPABILITY_NAMED_IAM");
|
|
101
|
+
expect(sent[0]["Capabilities.member.2"]).toBeUndefined();
|
|
102
|
+
|
|
103
|
+
const macro = tmpl({ Transform: "AWS::SecretsManager-2020-07-23" });
|
|
104
|
+
await awsApply({ templatePath: macro, stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, http);
|
|
105
|
+
unlinkSync(macro);
|
|
106
|
+
expect(sent[1]["Capabilities.member.1"]).toBe("CAPABILITY_NAMED_IAM");
|
|
107
|
+
expect(sent[1]["Capabilities.member.2"]).toBe("CAPABILITY_AUTO_EXPAND");
|
|
108
|
+
|
|
109
|
+
// An explicit list still wins.
|
|
110
|
+
const explicit = tmpl({ Transform: "AWS::Serverless-2016-10-31" });
|
|
111
|
+
await awsApply({ templatePath: explicit, stackName: "s", endpoint: "http://x", intervalMs: 1, capabilities: ["CAPABILITY_IAM"] }, undefined, http);
|
|
112
|
+
unlinkSync(explicit);
|
|
113
|
+
expect(sent[2]["Capabilities.member.1"]).toBe("CAPABILITY_IAM");
|
|
114
|
+
expect(sent[2]["Capabilities.member.2"]).toBeUndefined();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("the template's ownership Metadata becomes the STACK's own tags on create AND update (#1222)", async () => {
|
|
118
|
+
const sent: Array<Record<string, string>> = [];
|
|
119
|
+
let described = 0;
|
|
120
|
+
const createHttp: AwsHttp = async (_url, form) => {
|
|
121
|
+
if (form.Action === "DescribeStacks") return described++ === 0 ? { status: 400, text: MISSING } : { status: 200, text: describe_("CREATE_COMPLETE") };
|
|
122
|
+
sent.push(form);
|
|
123
|
+
return { status: 200, text: CREATE_OK };
|
|
124
|
+
};
|
|
125
|
+
const p = tmpl({
|
|
126
|
+
Metadata: { "chant:ownership": { "chant:managed-by": "chant", "chant:stack": "shop", "chant:env": "dev" } },
|
|
127
|
+
});
|
|
128
|
+
await awsApply({ templatePath: p, stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, createHttp);
|
|
129
|
+
|
|
130
|
+
expect(sent[0].Action).toBe("CreateStack");
|
|
131
|
+
expect(sent[0]["Tags.member.1.Key"]).toBe("chant:env");
|
|
132
|
+
expect(sent[0]["Tags.member.1.Value"]).toBe("dev");
|
|
133
|
+
expect(sent[0]["Tags.member.2.Key"]).toBe("chant:managed-by");
|
|
134
|
+
expect(sent[0]["Tags.member.2.Value"]).toBe("chant");
|
|
135
|
+
expect(sent[0]["Tags.member.3.Key"]).toBe("chant:stack");
|
|
136
|
+
expect(sent[0]["Tags.member.3.Value"]).toBe("shop");
|
|
137
|
+
|
|
138
|
+
// Update path re-stamps the same tags.
|
|
139
|
+
let updDescribed = 0;
|
|
140
|
+
const updateHttp: AwsHttp = async (_url, form) => {
|
|
141
|
+
if (form.Action === "DescribeStacks") return { status: 200, text: describe_(updDescribed++ === 0 ? "CREATE_COMPLETE" : "UPDATE_COMPLETE") };
|
|
142
|
+
sent.push(form);
|
|
143
|
+
return { status: 200, text: UPDATE_OK };
|
|
144
|
+
};
|
|
145
|
+
await awsApply({ templatePath: p, stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, updateHttp);
|
|
146
|
+
unlinkSync(p);
|
|
147
|
+
expect(sent[1].Action).toBe("UpdateStack");
|
|
148
|
+
expect(sent[1]["Tags.member.2.Key"]).toBe("chant:managed-by");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("a template without the ownership Metadata sends no Tags parameter at all", async () => {
|
|
152
|
+
const sent: Array<Record<string, string>> = [];
|
|
153
|
+
let described = 0;
|
|
154
|
+
const http: AwsHttp = async (_url, form) => {
|
|
155
|
+
if (form.Action === "DescribeStacks") return described++ === 0 ? { status: 400, text: MISSING } : { status: 200, text: describe_("CREATE_COMPLETE") };
|
|
156
|
+
sent.push(form);
|
|
157
|
+
return { status: 200, text: CREATE_OK };
|
|
158
|
+
};
|
|
159
|
+
const p = tmpl();
|
|
160
|
+
await awsApply({ templatePath: p, stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, http);
|
|
161
|
+
unlinkSync(p);
|
|
162
|
+
expect(Object.keys(sent[0]).some((k) => k.startsWith("Tags."))).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
|
|
80
165
|
test("update path: existing stack → UpdateStack → UPDATE_COMPLETE", async () => {
|
|
81
166
|
let described = 0;
|
|
82
167
|
const http: AwsHttp = async (_url, form) => {
|
|
@@ -141,3 +226,65 @@ describe("awsDelete (#awsApply)", () => {
|
|
|
141
226
|
expect(calls[0]).toBe("DeleteStack");
|
|
142
227
|
});
|
|
143
228
|
});
|
|
229
|
+
|
|
230
|
+
describe("rollbackStack (#1449)", () => {
|
|
231
|
+
test("RollbackStack then polls to UPDATE_ROLLBACK_COMPLETE", async () => {
|
|
232
|
+
const calls: string[] = [];
|
|
233
|
+
const http: AwsHttp = async (_url, form) => {
|
|
234
|
+
calls.push(form.Action);
|
|
235
|
+
if (form.Action === "DescribeStacks") return { status: 200, text: describe_("UPDATE_ROLLBACK_COMPLETE") };
|
|
236
|
+
return { status: 200, text: "<RollbackStackResponse/>" };
|
|
237
|
+
};
|
|
238
|
+
const res = await rollbackStack({ stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, http);
|
|
239
|
+
expect(res).toEqual({ stackName: "s", rolledBack: true, status: "UPDATE_ROLLBACK_COMPLETE" });
|
|
240
|
+
expect(calls[0]).toBe("RollbackStack");
|
|
241
|
+
expect(calls[0]).not.toBe("DescribeStacks"); // no probe first — the action itself answers
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("posts the stack name on the Query API form", async () => {
|
|
245
|
+
const forms: Record<string, string>[] = [];
|
|
246
|
+
const http: AwsHttp = async (_url, form) => {
|
|
247
|
+
forms.push(form);
|
|
248
|
+
if (form.Action === "DescribeStacks") return { status: 200, text: describe_("ROLLBACK_COMPLETE") };
|
|
249
|
+
return { status: 200, text: "<RollbackStackResponse/>" };
|
|
250
|
+
};
|
|
251
|
+
await rollbackStack({ stackName: "prod", endpoint: "http://x", intervalMs: 1 }, undefined, http);
|
|
252
|
+
expect(forms[0]).toMatchObject({ Action: "RollbackStack", StackName: "prod" });
|
|
253
|
+
expect(forms[0].Version).toBeDefined();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("an absent stack is nothing to roll back — rolledBack: false, no throw", async () => {
|
|
257
|
+
const http: AwsHttp = async () => ({ status: 400, text: MISSING });
|
|
258
|
+
const res = await rollbackStack({ stackName: "s", endpoint: "http://x" }, undefined, http);
|
|
259
|
+
expect(res).toEqual({ stackName: "s", rolledBack: false });
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("a target without RollbackStack (Floci's UnknownAction, #947) degrades, not crashes", async () => {
|
|
263
|
+
const http: AwsHttp = async () => ({
|
|
264
|
+
status: 400,
|
|
265
|
+
text: "<ErrorResponse><Error><Code>UnknownAction</Code><Message>Action RollbackStack is not supported.</Message></Error></ErrorResponse>",
|
|
266
|
+
});
|
|
267
|
+
const res = await rollbackStack({ stackName: "s", endpoint: "http://x" }, undefined, http);
|
|
268
|
+
expect(res).toEqual({ stackName: "s", rolledBack: false });
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("any other API error throws — a compensation must not fail silently", async () => {
|
|
272
|
+
const http: AwsHttp = async () => ({
|
|
273
|
+
status: 400,
|
|
274
|
+
text: "<ErrorResponse><Error><Message>Rollback requires a stack in UPDATE_FAILED state</Message></Error></ErrorResponse>",
|
|
275
|
+
});
|
|
276
|
+
await expect(rollbackStack({ stackName: "s", endpoint: "http://x" }, undefined, http)).rejects.toThrow(
|
|
277
|
+
/RollbackStack failed \(400\): Rollback requires/,
|
|
278
|
+
);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("throws when the stack settles anywhere but *ROLLBACK_COMPLETE", async () => {
|
|
282
|
+
const http: AwsHttp = async (_url, form) => {
|
|
283
|
+
if (form.Action === "DescribeStacks") return { status: 200, text: describe_("UPDATE_ROLLBACK_FAILED") };
|
|
284
|
+
return { status: 200, text: "<RollbackStackResponse/>" };
|
|
285
|
+
};
|
|
286
|
+
await expect(rollbackStack({ stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, http)).rejects.toThrow(
|
|
287
|
+
/rollback → UPDATE_ROLLBACK_FAILED/,
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
@@ -1,20 +1,29 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { safeHeartbeat, sleep } from "@intentius/chant/op";
|
|
3
|
+
import { awsDeployCapabilitiesForBody } from "../../components/cloud-executor.js";
|
|
4
|
+
import { resolveEndpointOverride } from "../../api/read-client.js";
|
|
5
|
+
import { ownershipStackTagsForBody } from "../../ownership.js";
|
|
3
6
|
|
|
4
7
|
const DEFAULT_REGION = "us-east-1";
|
|
5
8
|
const CFN_API_VERSION = "2010-05-15";
|
|
6
|
-
const DEFAULT_CAPABILITIES = ["CAPABILITY_NAMED_IAM"];
|
|
7
9
|
|
|
8
10
|
export interface AwsApplyArgs {
|
|
9
11
|
/** Path to a built CloudFormation template (JSON/YAML). */
|
|
10
12
|
templatePath: string;
|
|
11
13
|
/** CloudFormation stack name — the deploy boundary. */
|
|
12
14
|
stackName: string;
|
|
13
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* CFN endpoint override (e.g. Floci `http://localhost:4566`). Omitted,
|
|
17
|
+
* `AWS_ENDPOINT_URL_CLOUDFORMATION` then `AWS_ENDPOINT_URL` answer — the same
|
|
18
|
+
* rule the read client applies (#1694). With neither: real CloudFormation.
|
|
19
|
+
*/
|
|
14
20
|
endpoint?: string;
|
|
15
21
|
/** Region (real CFN host + `Version` context). Default: `us-east-1`. */
|
|
16
22
|
region?: string;
|
|
17
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* Capabilities to acknowledge. Default: `CAPABILITY_NAMED_IAM`, plus
|
|
25
|
+
* `CAPABILITY_AUTO_EXPAND` when the template has a top-level `Transform` (#980).
|
|
26
|
+
*/
|
|
18
27
|
capabilities?: string[];
|
|
19
28
|
/** Stack-settle timeout in ms. Default: `300000`. */
|
|
20
29
|
timeoutMs?: number;
|
|
@@ -37,9 +46,17 @@ const defaultHttp: AwsHttp = async (url, form, signal) => {
|
|
|
37
46
|
|
|
38
47
|
// ── Pure helpers (CFN Query protocol) ─────────────────────────────────────────
|
|
39
48
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
49
|
+
/**
|
|
50
|
+
* The CloudFormation endpoint URL — the override, or the real regional host.
|
|
51
|
+
* `env` is what the ambient-variable fallback reads; injectable for tests.
|
|
52
|
+
*/
|
|
53
|
+
export function cfnUrl(
|
|
54
|
+
endpoint?: string,
|
|
55
|
+
region = DEFAULT_REGION,
|
|
56
|
+
env: Record<string, string | undefined> = process.env,
|
|
57
|
+
): string {
|
|
58
|
+
const override = resolveEndpointOverride("cloudformation", endpoint, env);
|
|
59
|
+
return `${(override ?? `https://cloudformation.${region}.amazonaws.com`).replace(/\/$/, "")}/`;
|
|
43
60
|
}
|
|
44
61
|
|
|
45
62
|
/** A CFN Query-protocol form body: `Action` + `Version` + params. */
|
|
@@ -54,6 +71,22 @@ export function capabilityParams(capabilities: string[]): Record<string, string>
|
|
|
54
71
|
return out;
|
|
55
72
|
}
|
|
56
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Stack tags as the CFN `Tags.member.N.Key/Value` list params (#1222). Sorted
|
|
76
|
+
* so the request is deterministic. Empty in, empty out — a template without an
|
|
77
|
+
* ownership marker adds no `Tags` parameter at all.
|
|
78
|
+
*/
|
|
79
|
+
export function tagParams(tags: Record<string, string>): Record<string, string> {
|
|
80
|
+
const out: Record<string, string> = {};
|
|
81
|
+
Object.keys(tags)
|
|
82
|
+
.sort()
|
|
83
|
+
.forEach((key, i) => {
|
|
84
|
+
out[`Tags.member.${i + 1}.Key`] = key;
|
|
85
|
+
out[`Tags.member.${i + 1}.Value`] = tags[key];
|
|
86
|
+
});
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
57
90
|
/**
|
|
58
91
|
* First `<tag>…</tag>` text in a CFN XML response.
|
|
59
92
|
*
|
|
@@ -111,10 +144,10 @@ export async function waitForStackSettled(
|
|
|
111
144
|
* CloudFormation API directly (create-or-update + poll to a settled stack),
|
|
112
145
|
* targeting a local Floci emulator or real AWS by endpoint override. The direct
|
|
113
146
|
* twin of `azApply`/`gcpApply`: it speaks the CloudFormation Query API over HTTP
|
|
114
|
-
* rather than shelling `aws cloudformation deploy`
|
|
115
|
-
*
|
|
116
|
-
* ownership boundary, so deletes ride CloudFormation
|
|
117
|
-
* `http` is injectable for tests.
|
|
147
|
+
* rather than shelling `aws cloudformation deploy` — and since chant #1449 it is
|
|
148
|
+
* also what `nativeApply({ target: "cloudformation" })` runs, so no CLI path
|
|
149
|
+
* remains. The stack is the ownership boundary, so deletes ride CloudFormation
|
|
150
|
+
* itself — no separate prune. `http` is injectable for tests.
|
|
118
151
|
*/
|
|
119
152
|
export async function awsApply(
|
|
120
153
|
args: AwsApplyArgs,
|
|
@@ -122,8 +155,8 @@ export async function awsApply(
|
|
|
122
155
|
http: AwsHttp = defaultHttp,
|
|
123
156
|
): Promise<{ stackName: string; status: string; action: "created" | "updated" | "unchanged" }> {
|
|
124
157
|
const url = cfnUrl(args.endpoint, args.region);
|
|
125
|
-
const capabilities = args.capabilities ?? DEFAULT_CAPABILITIES;
|
|
126
158
|
const templateBody = readFileSync(args.templatePath, "utf8");
|
|
159
|
+
const capabilities = args.capabilities ?? awsDeployCapabilitiesForBody(templateBody);
|
|
127
160
|
const timeoutMs = args.timeoutMs ?? 300_000;
|
|
128
161
|
const intervalMs = args.intervalMs ?? 3_000;
|
|
129
162
|
|
|
@@ -133,7 +166,17 @@ export async function awsApply(
|
|
|
133
166
|
}
|
|
134
167
|
const exists = desc.status < 300;
|
|
135
168
|
|
|
136
|
-
|
|
169
|
+
// Stamp the template's ownership marker as the STACK's own tags (#1222):
|
|
170
|
+
// stack-level teardown verifies ownership on DescribeStacks tags, and this
|
|
171
|
+
// is the write that makes every future stack teardown-eligible. A template
|
|
172
|
+
// carrying no marker adds nothing.
|
|
173
|
+
const stackTags = ownershipStackTagsForBody(templateBody);
|
|
174
|
+
const params = {
|
|
175
|
+
StackName: args.stackName,
|
|
176
|
+
TemplateBody: templateBody,
|
|
177
|
+
...capabilityParams(capabilities),
|
|
178
|
+
...tagParams(stackTags),
|
|
179
|
+
};
|
|
137
180
|
let action: "created" | "updated";
|
|
138
181
|
if (!exists) {
|
|
139
182
|
const res = await http(url, cfnForm("CreateStack", params), signal);
|
|
@@ -161,12 +204,18 @@ export async function awsApply(
|
|
|
161
204
|
return { stackName: args.stackName, status, action };
|
|
162
205
|
}
|
|
163
206
|
|
|
207
|
+
/** {@link awsDelete}'s arguments: {@link AwsApplyArgs} minus the template — a
|
|
208
|
+
* delete needs no body, so teardown (#1222) can call it with a stack name
|
|
209
|
+
* alone. Op builders that thread `templatePath` through keep working; it is
|
|
210
|
+
* simply unused here. */
|
|
211
|
+
export type AwsDeleteArgs = Omit<AwsApplyArgs, "templatePath"> & { templatePath?: string };
|
|
212
|
+
|
|
164
213
|
/**
|
|
165
214
|
* The inverse of {@link awsApply} — DeleteStack, then poll until the stack is
|
|
166
215
|
* gone. Idempotent: an already-absent stack is a no-op. `http` is injectable.
|
|
167
216
|
*/
|
|
168
217
|
export async function awsDelete(
|
|
169
|
-
args:
|
|
218
|
+
args: AwsDeleteArgs,
|
|
170
219
|
signal?: AbortSignal,
|
|
171
220
|
http: AwsHttp = defaultHttp,
|
|
172
221
|
): Promise<{ stackName: string; deleted: boolean }> {
|
|
@@ -192,3 +241,64 @@ export async function awsDelete(
|
|
|
192
241
|
}
|
|
193
242
|
throw new Error(`CloudFormation stack ${args.stackName} delete did not complete within ${timeoutMs}ms`);
|
|
194
243
|
}
|
|
244
|
+
|
|
245
|
+
export interface RollbackStackArgs {
|
|
246
|
+
/** CloudFormation stack name to roll back. */
|
|
247
|
+
stackName: string;
|
|
248
|
+
/** CFN endpoint override — same resolution rule as {@link AwsApplyArgs.endpoint} (#1694). */
|
|
249
|
+
endpoint?: string;
|
|
250
|
+
/** Region (real CFN host). Default: `us-east-1`. */
|
|
251
|
+
region?: string;
|
|
252
|
+
/** Stack-settle timeout in ms. Default: `300000`. */
|
|
253
|
+
timeoutMs?: number;
|
|
254
|
+
/** Poll interval in ms. Default: `3000`. */
|
|
255
|
+
intervalMs?: number;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The saga compensation for {@link awsApply} — CloudFormation `RollbackStack`
|
|
260
|
+
* via the same Query-API client, then poll until the stack settles. Returns the
|
|
261
|
+
* stack to its last known stable state after a failed update (#1449 — this
|
|
262
|
+
* replaces the Temporal lexicon exec-ing `aws cloudformation rollback-stack`).
|
|
263
|
+
*
|
|
264
|
+
* Degrades rather than crashes in two cases where there is nothing to do:
|
|
265
|
+
* an absent stack (nothing applied, nothing to revert) and a target that does
|
|
266
|
+
* not implement the action — Floci answers `UnknownAction` (#947). Both return
|
|
267
|
+
* `rolledBack: false` with a logged warning; every other API error throws,
|
|
268
|
+
* because a compensation that silently fails leaves partial state looking
|
|
269
|
+
* reverted when it isn't. `http` is injectable for tests.
|
|
270
|
+
*/
|
|
271
|
+
export async function rollbackStack(
|
|
272
|
+
args: RollbackStackArgs,
|
|
273
|
+
signal?: AbortSignal,
|
|
274
|
+
http: AwsHttp = defaultHttp,
|
|
275
|
+
): Promise<{ stackName: string; rolledBack: boolean; status?: string }> {
|
|
276
|
+
const url = cfnUrl(args.endpoint, args.region);
|
|
277
|
+
const timeoutMs = args.timeoutMs ?? 300_000;
|
|
278
|
+
const intervalMs = args.intervalMs ?? 3_000;
|
|
279
|
+
|
|
280
|
+
const res = await http(url, cfnForm("RollbackStack", { StackName: args.stackName }), signal);
|
|
281
|
+
if (res.status >= 300) {
|
|
282
|
+
if (isStackMissing(res.text)) {
|
|
283
|
+
console.warn(`rollbackStack: stack ${args.stackName} does not exist — nothing to roll back`);
|
|
284
|
+
return { stackName: args.stackName, rolledBack: false };
|
|
285
|
+
}
|
|
286
|
+
// Local emulators (Floci) don't implement RollbackStack → `UnknownAction` (#947).
|
|
287
|
+
if (/UnknownAction|not supported/i.test(res.text)) {
|
|
288
|
+
console.warn(
|
|
289
|
+
`rollbackStack: the target doesn't support RollbackStack (a local emulator such as Floci) — skipping automated rollback of ${args.stackName}`,
|
|
290
|
+
);
|
|
291
|
+
return { stackName: args.stackName, rolledBack: false };
|
|
292
|
+
}
|
|
293
|
+
throw new Error(`CloudFormation RollbackStack failed (${res.status}): ${cfnErrorMessage(res.text) ?? res.text}`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const status = await waitForStackSettled(url, args.stackName, http, { timeoutMs, intervalMs }, signal);
|
|
297
|
+
// A settled rollback ends in `ROLLBACK_COMPLETE`/`UPDATE_ROLLBACK_COMPLETE` —
|
|
298
|
+
// classified a failure by the deploy-path matcher, but the success state here.
|
|
299
|
+
if (!/ROLLBACK_COMPLETE$/.test(status)) {
|
|
300
|
+
throw new Error(`CloudFormation stack ${args.stackName} rollback → ${status}`);
|
|
301
|
+
}
|
|
302
|
+
console.log(`rolled back: ${args.stackName} (${status}) [${url}]`);
|
|
303
|
+
return { stackName: args.stackName, rolledBack: true, status };
|
|
304
|
+
}
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* AWS Op activities — resolved by the core activity registry when a project's
|
|
3
3
|
* `chant.config.ts` lists the `aws` lexicon. Contributes the local Floci AWS
|
|
4
4
|
* emulator lifecycle (`flociUp`/`flociDown`) and the native CloudFormation
|
|
5
|
-
* applier (`awsApply`
|
|
6
|
-
* shelling `aws` — the
|
|
5
|
+
* applier (`awsApply`, with `awsDelete` and the `rollbackStack` compensation),
|
|
6
|
+
* which calls the CloudFormation API directly rather than shelling `aws` — the
|
|
7
|
+
* direct twin of `azApply`/`gcpApply`.
|
|
7
8
|
*
|
|
8
9
|
* The registry keys every exported *function* here by its name, so only the
|
|
9
10
|
* activities themselves belong in this barrel. `awsAgentCoreFetchTrace`'s
|
|
@@ -28,6 +29,7 @@ export type { FlociUpArgs, FlociDownArgs } from "./floci";
|
|
|
28
29
|
export {
|
|
29
30
|
awsApply,
|
|
30
31
|
awsDelete,
|
|
32
|
+
rollbackStack,
|
|
31
33
|
waitForStackSettled,
|
|
32
34
|
cfnUrl,
|
|
33
35
|
cfnForm,
|
|
@@ -42,7 +44,7 @@ export {
|
|
|
42
44
|
isFailureStatus,
|
|
43
45
|
isTerminalStatus,
|
|
44
46
|
} from "./aws-apply";
|
|
45
|
-
export type { AwsApplyArgs, AwsHttp } from "./aws-apply";
|
|
47
|
+
export type { AwsApplyArgs, RollbackStackArgs, AwsHttp } from "./aws-apply";
|
|
46
48
|
|
|
47
49
|
export { awsAgentCoreFetchTrace } from "../../agentcore/trace-fetch";
|
|
48
50
|
export type {
|
package/src/ownership.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
2
|
import { ownershipEntries } from "@intentius/chant/ownership";
|
|
3
|
-
import { AWS_TAG_OWNERSHIP_KEYS } from "./ownership";
|
|
3
|
+
import { AWS_TAG_OWNERSHIP_KEYS, OWNERSHIP_METADATA_KEY, ownershipStackTagsForBody } from "./ownership";
|
|
4
4
|
|
|
5
5
|
describe("AWS_TAG_OWNERSHIP_KEYS", () => {
|
|
6
6
|
test("uses AWS colon-form tag keys", () => {
|
|
@@ -16,3 +16,26 @@ describe("AWS_TAG_OWNERSHIP_KEYS", () => {
|
|
|
16
16
|
expect(e).toEqual({ "chant:managed-by": "chant", "chant:stack": "billing", "chant:env": "prod" });
|
|
17
17
|
});
|
|
18
18
|
});
|
|
19
|
+
|
|
20
|
+
describe("ownershipStackTagsForBody (#1222)", () => {
|
|
21
|
+
test("reads the flat tag map under Metadata[chant:ownership]", () => {
|
|
22
|
+
const body = JSON.stringify({
|
|
23
|
+
Metadata: { [OWNERSHIP_METADATA_KEY]: { "chant:managed-by": "chant", "chant:stack": "shop", "chant:env": "dev" } },
|
|
24
|
+
Resources: {},
|
|
25
|
+
});
|
|
26
|
+
expect(ownershipStackTagsForBody(body)).toEqual({
|
|
27
|
+
"chant:managed-by": "chant",
|
|
28
|
+
"chant:stack": "shop",
|
|
29
|
+
"chant:env": "dev",
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("total on bad input: non-JSON, no Metadata, no marker, non-string values", () => {
|
|
34
|
+
expect(ownershipStackTagsForBody("Resources:\n B:\n")).toEqual({});
|
|
35
|
+
expect(ownershipStackTagsForBody(JSON.stringify({ Resources: {} }))).toEqual({});
|
|
36
|
+
expect(ownershipStackTagsForBody(JSON.stringify({ Metadata: {}, Resources: {} }))).toEqual({});
|
|
37
|
+
expect(
|
|
38
|
+
ownershipStackTagsForBody(JSON.stringify({ Metadata: { [OWNERSHIP_METADATA_KEY]: { a: 1, b: "x" } } })),
|
|
39
|
+
).toEqual({ b: "x" });
|
|
40
|
+
});
|
|
41
|
+
});
|
package/src/ownership.ts
CHANGED
|
@@ -13,3 +13,40 @@ export const AWS_TAG_OWNERSHIP_KEYS: ChannelKeys = {
|
|
|
13
13
|
stack: "chant:stack",
|
|
14
14
|
env: "chant:env",
|
|
15
15
|
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Template-level `Metadata` key the serializer stamps the ownership marker
|
|
19
|
+
* under (#1222). Stack-level teardown verifies ownership on the *stack's own*
|
|
20
|
+
* tags, and CloudFormation stack tags are an API parameter, not a template
|
|
21
|
+
* section — so the template carries the marker here and the apply paths
|
|
22
|
+
* (`awsApply`, the `cfn-deploy` change set) turn it into stack tags on
|
|
23
|
+
* create/update. One source: the same build that stamps resource tags decides
|
|
24
|
+
* the stack tags.
|
|
25
|
+
*/
|
|
26
|
+
export const OWNERSHIP_METADATA_KEY = "chant:ownership";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The stack tags a template body asks for: the flat tag map under
|
|
30
|
+
* `Metadata["chant:ownership"]`, or nothing. Total on bad input — a body that
|
|
31
|
+
* is not JSON (a YAML template, a handwritten one) or carries no marker
|
|
32
|
+
* returns `{}`, and the stack simply stays untagged, which teardown reports
|
|
33
|
+
* as unverified rather than deleting.
|
|
34
|
+
*/
|
|
35
|
+
export function ownershipStackTagsForBody(body: string): Record<string, string> {
|
|
36
|
+
let template: unknown;
|
|
37
|
+
try {
|
|
38
|
+
template = JSON.parse(body);
|
|
39
|
+
} catch {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
if (typeof template !== "object" || template === null) return {};
|
|
43
|
+
const metadata = (template as { Metadata?: unknown }).Metadata;
|
|
44
|
+
if (typeof metadata !== "object" || metadata === null) return {};
|
|
45
|
+
const marker = (metadata as Record<string, unknown>)[OWNERSHIP_METADATA_KEY];
|
|
46
|
+
if (typeof marker !== "object" || marker === null) return {};
|
|
47
|
+
const tags: Record<string, string> = {};
|
|
48
|
+
for (const [key, value] of Object.entries(marker as Record<string, unknown>)) {
|
|
49
|
+
if (typeof value === "string") tags[key] = value;
|
|
50
|
+
}
|
|
51
|
+
return tags;
|
|
52
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -47,8 +47,14 @@ export { stackDoesNotExist } from "./stack-errors";
|
|
|
47
47
|
* Provides serializer, lint rules, template detection,
|
|
48
48
|
* import parsing, and code generation for AWS CloudFormation.
|
|
49
49
|
*/
|
|
50
|
-
/**
|
|
51
|
-
|
|
50
|
+
/**
|
|
51
|
+
* describe-stack-resources returns no tags, so the ownership filter cannot be
|
|
52
|
+
* applied on this read path. Returned as a run-level note rather than printed
|
|
53
|
+
* (#1265): core says it once per run, after the answer, however many stacks
|
|
54
|
+
* were read. The text is the contract consumers grep for.
|
|
55
|
+
*/
|
|
56
|
+
const OWNERSHIP_UNAVAILABLE_NOTE =
|
|
57
|
+
"ownership filter unavailable on describeResources (no tags from describe-stack-resources) — returning all, each with an explicit `unknown` verdict; use `chant import --from <env> --owned` for ownership-filtered export";
|
|
52
58
|
|
|
53
59
|
export const awsPlugin: LexiconPlugin = {
|
|
54
60
|
name: "aws",
|
|
@@ -558,25 +564,17 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
558
564
|
// The applier's own transport, pointed at the read APIs (#1206). Multi-region
|
|
559
565
|
// estates target this stack's region, not the ambient one.
|
|
560
566
|
const client: AwsReadClientOptions = {
|
|
561
|
-
...(process.env.AWS_ENDPOINT_URL ? { endpoint: process.env.AWS_ENDPOINT_URL } : {}),
|
|
562
567
|
...(options.region ? { region: options.region } : {}),
|
|
563
568
|
};
|
|
564
569
|
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
warnedOwnership ||
|
|
574
|
-
// eslint-disable-next-line no-console
|
|
575
|
-
console.warn(
|
|
576
|
-
"[aws] ownership filter unavailable on describeResources (no tags from describe-stack-resources) — returning all, each with an explicit `unknown` verdict; use `chant import --from <env> --owned` for ownership-filtered export",
|
|
577
|
-
);
|
|
578
|
-
warnedOwnership = true;
|
|
579
|
-
}
|
|
570
|
+
// describe-stack-resources does not return tags, so ownership cannot be
|
|
571
|
+
// determined here. Degrade to detect-only rather than silently filtering,
|
|
572
|
+
// and say so as a note on the observation. It is a property of the
|
|
573
|
+
// environment, not of each stack: a four-stack project once printed four
|
|
574
|
+
// identical copies ahead of every answer (#1265) — enough that an agent
|
|
575
|
+
// piping `graph --format ir` with `2>&1` had to skip lines to find the
|
|
576
|
+
// JSON. Core dedupes the note across stacks and prints it with the footer.
|
|
577
|
+
const notes = options.owned ? [OWNERSHIP_UNAVAILABLE_NOTE] : undefined;
|
|
580
578
|
|
|
581
579
|
// Derive stack name. A multi-stack project passes the explicit CloudFormation
|
|
582
580
|
// stack this observation targets (see `stacks` in ChantConfig); otherwise the
|
|
@@ -600,7 +598,7 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
600
598
|
if (err instanceof AwsReadError && stackDoesNotExist(err.message)) {
|
|
601
599
|
const { observeByIdentity } = await import("./identity-observe");
|
|
602
600
|
const identity = await observeByIdentity(options.entityNames, options.entities, resources, client);
|
|
603
|
-
return observation({ ...resources, ...identity.resources }, undefined, identity.queried);
|
|
601
|
+
return observation({ ...resources, ...identity.resources }, undefined, identity.queried, notes);
|
|
604
602
|
}
|
|
605
603
|
// Any other failure (credentials, throttling, a region that can't be
|
|
606
604
|
// reached) establishes nothing about what is deployed. Reporting every
|
|
@@ -617,6 +615,8 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
617
615
|
reason,
|
|
618
616
|
`DescribeStackResources failed for stack "${stackName}": ${detail}`,
|
|
619
617
|
),
|
|
618
|
+
undefined,
|
|
619
|
+
notes,
|
|
620
620
|
);
|
|
621
621
|
}
|
|
622
622
|
|
|
@@ -633,20 +633,20 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
633
633
|
stackOutputs = {};
|
|
634
634
|
}
|
|
635
635
|
|
|
636
|
+
// The outputs are the stack's, not any member's (#1279). They used to be
|
|
637
|
+
// copied onto every resource's `attributes`, so a VPC carried the stack's
|
|
638
|
+
// `expWebIp` and no `CidrBlock`. They ride the envelope once, keyed by the
|
|
639
|
+
// stack, scrubbed of anything that looks secret.
|
|
640
|
+
const exports: Record<string, unknown> = {};
|
|
641
|
+
for (const [key, value] of Object.entries(stackOutputs)) {
|
|
642
|
+
exports[key] = /password|secret|token|key/i.test(key) ? "[REDACTED]" : value;
|
|
643
|
+
}
|
|
644
|
+
const stackExports = Object.keys(exports).length > 0 ? { [stackName]: exports } : undefined;
|
|
645
|
+
|
|
636
646
|
for (const entityName of options.entityNames) {
|
|
637
647
|
const stackResource = stackResourceMap.get(entityName);
|
|
638
648
|
if (!stackResource) continue;
|
|
639
649
|
|
|
640
|
-
const attributes: Record<string, unknown> = {};
|
|
641
|
-
// Include stack outputs as attributes (scrub sensitive ones)
|
|
642
|
-
for (const [key, value] of Object.entries(stackOutputs)) {
|
|
643
|
-
if (/password|secret|token|key/i.test(key)) {
|
|
644
|
-
attributes[key] = "[REDACTED]";
|
|
645
|
-
} else {
|
|
646
|
-
attributes[key] = value;
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
|
|
650
650
|
resources[entityName] = {
|
|
651
651
|
type: stackResource.type,
|
|
652
652
|
physicalId: stackResource.physicalId ?? "",
|
|
@@ -656,8 +656,10 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
656
656
|
// this path cannot read the ownership marker. Say `unknown` explicitly
|
|
657
657
|
// rather than leaving the field off and letting each consumer guess —
|
|
658
658
|
// the change set never escalates `unknown` to a delete.
|
|
659
|
+
// For the same reason `marker` (#1222) stays absent here: no tags, no
|
|
660
|
+
// stack/env identity to read, and absent means absent — never a guess.
|
|
661
|
+
// aws teardown is stack-level and reads the stack's own tags instead.
|
|
659
662
|
ownership: "unknown",
|
|
660
|
-
attributes: Object.keys(attributes).length > 0 ? attributes : undefined,
|
|
661
663
|
};
|
|
662
664
|
}
|
|
663
665
|
|
|
@@ -669,9 +671,9 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
669
671
|
const { observeByIdentity } = await import("./identity-observe");
|
|
670
672
|
const identity = await observeByIdentity(options.entityNames, options.entities, resources, client);
|
|
671
673
|
|
|
672
|
-
// Each resource's OWN properties
|
|
673
|
-
//
|
|
674
|
-
//
|
|
674
|
+
// Each resource's OWN properties (#1279). Until this, a node's `attrs` were
|
|
675
|
+
// the stack's exports replicated onto every member, so no instance carried
|
|
676
|
+
// its own `VpcId`.
|
|
675
677
|
const own = await describeOwnProperties(resources, options.region);
|
|
676
678
|
const withProperties = stampProviderDefaults(stampRegion(own.resources, options.region));
|
|
677
679
|
|
|
@@ -701,13 +703,13 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
701
703
|
detail: `the stack was read, but this resource's own properties were not — ${own.failures.get(type) ?? "the describe call failed"}`,
|
|
702
704
|
};
|
|
703
705
|
}
|
|
704
|
-
return observation({ ...described, ...identity.resources }, holes, identity.queried);
|
|
706
|
+
return observation({ ...described, ...identity.resources }, holes, identity.queried, notes, stackExports);
|
|
705
707
|
}
|
|
706
708
|
|
|
707
709
|
// Every entity the stack answered for was answered for: an entity the
|
|
708
710
|
// template doesn't carry is genuinely not in this stack, which is an
|
|
709
711
|
// absence, not a hole — unless the identity fallback saw it live (#1647).
|
|
710
|
-
return observation({ ...withProperties, ...identity.resources }, undefined, identity.queried);
|
|
712
|
+
return observation({ ...withProperties, ...identity.resources }, undefined, identity.queried, notes, stackExports);
|
|
711
713
|
},
|
|
712
714
|
|
|
713
715
|
/**
|
|
@@ -804,6 +806,21 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
804
806
|
return { stack: options.stack, present: true, status, healthy };
|
|
805
807
|
},
|
|
806
808
|
|
|
809
|
+
// Env teardown at STACK granularity (#1222): `describeResources` carries no
|
|
810
|
+
// tags, so per-resource marker selection is impossible here — the env's
|
|
811
|
+
// stacks are enumerated instead (`stacks[]`, else the env-named default) and
|
|
812
|
+
// ownership is verified on each stack's own DescribeStacks tags. Execution
|
|
813
|
+
// is DeleteStack via the applier's `awsDelete`. See ./teardown.ts.
|
|
814
|
+
async teardownOwned(options) {
|
|
815
|
+
const { teardownOwned } = await import("./teardown");
|
|
816
|
+
return teardownOwned(options);
|
|
817
|
+
},
|
|
818
|
+
|
|
819
|
+
async executeTeardown(options) {
|
|
820
|
+
const { executeTeardown } = await import("./teardown");
|
|
821
|
+
return executeTeardown(options);
|
|
822
|
+
},
|
|
823
|
+
|
|
807
824
|
async exportResources(options: {
|
|
808
825
|
environment: string;
|
|
809
826
|
stack?: string;
|