@intentius/chant-lexicon-aws 0.44.13 → 0.45.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/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 +2 -2
- package/dist/manifest.json +1 -1
- package/dist/op/activities/aws-apply.d.ts +14 -4
- package/dist/op/activities/aws-apply.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/spec/fetch.d.ts +12 -1
- package/dist/spec/fetch.d.ts.map +1 -1
- 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 +23 -5
- 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 +89 -0
- package/src/op/activities/aws-apply.test.ts +40 -4
- package/src/op/activities/aws-apply.ts +23 -7
- package/src/plugin.ts +34 -35
- package/src/properties.test.ts +5 -5
- package/src/properties.ts +6 -5
- package/src/serializer.test.ts +73 -33
- package/src/spec/fetch.test.ts +40 -0
- package/src/spec/fetch.ts +24 -3
|
@@ -28,8 +28,11 @@ const { awsPlugin } = await import("./plugin");
|
|
|
28
28
|
const { liveImportFromPlugins } = await import("@intentius/chant/cli/commands/import");
|
|
29
29
|
const { buildChangeSet } = await import("@intentius/chant/lifecycle/change-set");
|
|
30
30
|
const { normalizeObservation } = await import("@intentius/chant/observation");
|
|
31
|
+
const { buildLiveGraphIr } = await import("@intentius/chant/graph-ir");
|
|
31
32
|
const { liveEvidenceFromChangeSet, reconcileStatus } = await import("@intentius/chant/lifecycle/status");
|
|
32
33
|
const { describeObservationConformance } = await import("@intentius/chant-test-utils");
|
|
34
|
+
const { observeResources } = await import("@intentius/chant/lifecycle/observe");
|
|
35
|
+
const { DECLARABLE_MARKER } = await import("@intentius/chant/declarable");
|
|
33
36
|
|
|
34
37
|
const liveTemplate = {
|
|
35
38
|
AWSTemplateFormatVersion: "2010-09-09",
|
|
@@ -143,6 +146,49 @@ describe("aws lifecycle integration (#163)", () => {
|
|
|
143
146
|
expect(cs2.entries.find((e) => e.name === "MyBucket")!.action).toBe("noop");
|
|
144
147
|
});
|
|
145
148
|
|
|
149
|
+
// #1279 — a node's attributes are the resource's own. The stack's outputs
|
|
150
|
+
// used to be copied onto every member, so a VPC carried `expWebIp` and no
|
|
151
|
+
// `CidrBlock`; they now ride the envelope once, keyed by the stack.
|
|
152
|
+
test("an observed VPC carries its own properties; the stack carries the exports (#1279)", async () => {
|
|
153
|
+
stubCfn((action) =>
|
|
154
|
+
action === "DescribeStackResources"
|
|
155
|
+
? { text: stackResourcesXml([{ logicalId: "vpc", type: "AWS::EC2::VPC", physicalId: "vpc-a5a41663" }]) }
|
|
156
|
+
: { text: stackOutputsXml({ expVpcId: "vpc-a5a41663", expWebIp: "54.1.2.3", expDbPassword: "hunter2" }) },
|
|
157
|
+
);
|
|
158
|
+
spawnMock.mockResolvedValue(
|
|
159
|
+
ok(JSON.stringify({ Vpcs: [{ VpcId: "vpc-a5a41663", CidrBlock: "10.0.0.0/16", IsDefault: false }] })),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const observed = normalizeObservation(
|
|
163
|
+
await awsPlugin.describeResources!({
|
|
164
|
+
environment: "prod",
|
|
165
|
+
buildOutput: "",
|
|
166
|
+
entityNames: ["vpc"],
|
|
167
|
+
entities: new Map(),
|
|
168
|
+
}),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const attrs = observed.resources.vpc?.attributes ?? {};
|
|
172
|
+
expect(attrs.CidrBlock).toBe("10.0.0.0/16");
|
|
173
|
+
expect(attrs.IsDefault).toBe(false);
|
|
174
|
+
expect(Object.keys(attrs).filter((k) => k.startsWith("exp"))).toEqual([]);
|
|
175
|
+
|
|
176
|
+
expect(observed.stackExports).toEqual({
|
|
177
|
+
prod: { expVpcId: "vpc-a5a41663", expWebIp: "54.1.2.3", expDbPassword: "[REDACTED]" },
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// And through the graph: the exports land on the IR's `exports`, not on the node.
|
|
181
|
+
const ir = buildLiveGraphIr([{ lexicon: "aws", resources: observed.resources, stackExports: observed.stackExports }]);
|
|
182
|
+
const node = ir.nodes.find((n) => n.id === "vpc")!;
|
|
183
|
+
expect(node.attrs.CidrBlock).toBe("10.0.0.0/16");
|
|
184
|
+
expect(node.attrs.expVpcId).toBeUndefined();
|
|
185
|
+
expect(ir.exports).toEqual([
|
|
186
|
+
{ name: "expDbPassword", value: "[REDACTED]", stack: "prod" },
|
|
187
|
+
{ name: "expVpcId", value: "vpc-a5a41663", stack: "prod" },
|
|
188
|
+
{ name: "expWebIp", value: "54.1.2.3", stack: "prod" },
|
|
189
|
+
]);
|
|
190
|
+
});
|
|
191
|
+
|
|
146
192
|
// #1647 — the carve state, end to end: terraform applied the bucket, carve
|
|
147
193
|
// emitted a carveout declaring it by BucketName, and no CFN stack has ever
|
|
148
194
|
// heard of it. The stack read alone said confirmed-absent (missing → a plan
|
|
@@ -303,6 +349,49 @@ describe("aws lifecycle integration (#163)", () => {
|
|
|
303
349
|
});
|
|
304
350
|
expect(cs.entries[0].action).toBe("create");
|
|
305
351
|
});
|
|
352
|
+
|
|
353
|
+
// #1265 — the ownership notice printed once per `describeResources` call, so
|
|
354
|
+
// a four-stack project got four identical copies ahead of every answer. It
|
|
355
|
+
// is a property of the read path, not of a stack: the plugin now returns it
|
|
356
|
+
// as a note on the observation and core says it once per run.
|
|
357
|
+
test("owned read on a four-stack project: one ownership note, nothing printed", async () => {
|
|
358
|
+
stubCfn((action) =>
|
|
359
|
+
action === "DescribeStackResources"
|
|
360
|
+
? { text: stackResourcesXml([{ logicalId: "MyBucket", type: "AWS::S3::Bucket", physicalId: "my-bucket" }]) }
|
|
361
|
+
: { text: stackOutputsXml() },
|
|
362
|
+
);
|
|
363
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
364
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
365
|
+
const entities = new Map([
|
|
366
|
+
["MyBucket", { [DECLARABLE_MARKER]: true as const, lexicon: "aws", entityType: "AWS::S3::Bucket", props: {} }],
|
|
367
|
+
]);
|
|
368
|
+
const result = await observeResources(
|
|
369
|
+
"prod",
|
|
370
|
+
[awsPlugin],
|
|
371
|
+
{ errors: [], warnings: [], entities, outputs: new Map([["aws", ""]]) } as never,
|
|
372
|
+
{ owned: true, stacks: ["prod-net", "prod-data", "prod-app", "prod-edge"] },
|
|
373
|
+
);
|
|
374
|
+
const ownership = result.notes.filter((n) => n.includes("ownership filter unavailable"));
|
|
375
|
+
expect(ownership).toHaveLength(1);
|
|
376
|
+
expect(ownership[0]).toBe(
|
|
377
|
+
"[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",
|
|
378
|
+
);
|
|
379
|
+
expect(result.warnings.join("\n")).not.toContain("ownership filter unavailable");
|
|
380
|
+
expect(warn).not.toHaveBeenCalled();
|
|
381
|
+
expect(error).not.toHaveBeenCalled();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("an unowned read carries no ownership note", async () => {
|
|
385
|
+
stubCfn((action) =>
|
|
386
|
+
action === "DescribeStackResources"
|
|
387
|
+
? { text: stackResourcesXml([{ logicalId: "MyBucket", type: "AWS::S3::Bucket", physicalId: "my-bucket" }]) }
|
|
388
|
+
: { text: stackOutputsXml() },
|
|
389
|
+
);
|
|
390
|
+
const observed = normalizeObservation(
|
|
391
|
+
await awsPlugin.describeResources!({ environment: "prod", buildOutput: "", entityNames: ["MyBucket"], entities: new Map() }),
|
|
392
|
+
);
|
|
393
|
+
expect(observed.notes).toEqual([]);
|
|
394
|
+
});
|
|
306
395
|
});
|
|
307
396
|
|
|
308
397
|
// The shared conformance suite (#1089).
|
|
@@ -25,8 +25,16 @@ const describe_ = (status: string) => `<DescribeStacksResponse><Stacks><member><
|
|
|
25
25
|
|
|
26
26
|
describe("CFN pure helpers (#awsApply)", () => {
|
|
27
27
|
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/");
|
|
28
|
+
expect(cfnUrl("http://localhost:4566", undefined, {})).toBe("http://localhost:4566/");
|
|
29
|
+
expect(cfnUrl(undefined, "eu-west-1", {})).toBe("https://cloudformation.eu-west-1.amazonaws.com/");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("cfnUrl: AWS_ENDPOINT_URL[_CLOUDFORMATION] is an override too, the same rule as the read client (#1694)", () => {
|
|
33
|
+
expect(cfnUrl(undefined, "eu-west-1", { AWS_ENDPOINT_URL: "http://localhost:4566" })).toBe("http://localhost:4566/");
|
|
34
|
+
expect(
|
|
35
|
+
cfnUrl(undefined, "eu-west-1", { AWS_ENDPOINT_URL: "http://all:1", AWS_ENDPOINT_URL_CLOUDFORMATION: "http://cfn:2" }),
|
|
36
|
+
).toBe("http://cfn:2/");
|
|
37
|
+
expect(cfnUrl("http://opt:3", "eu-west-1", { AWS_ENDPOINT_URL: "http://all:1" })).toBe("http://opt:3/");
|
|
30
38
|
});
|
|
31
39
|
|
|
32
40
|
test("cfnForm stamps Action + Version", () => {
|
|
@@ -55,9 +63,9 @@ describe("CFN pure helpers (#awsApply)", () => {
|
|
|
55
63
|
});
|
|
56
64
|
});
|
|
57
65
|
|
|
58
|
-
function tmpl(): string {
|
|
66
|
+
function tmpl(extra: Record<string, unknown> = {}): string {
|
|
59
67
|
const p = `/tmp/chant-cfn-${process.pid}-${Math.round(performance.now())}.json`;
|
|
60
|
-
writeFileSync(p, JSON.stringify({ Resources: { B: { Type: "AWS::S3::Bucket" } } }));
|
|
68
|
+
writeFileSync(p, JSON.stringify({ ...extra, Resources: { B: { Type: "AWS::S3::Bucket" } } }));
|
|
61
69
|
return p;
|
|
62
70
|
}
|
|
63
71
|
|
|
@@ -77,6 +85,34 @@ describe("awsApply flow (#awsApply)", () => {
|
|
|
77
85
|
expect(calls).toEqual(["DescribeStacks", "CreateStack", "DescribeStacks"]);
|
|
78
86
|
});
|
|
79
87
|
|
|
88
|
+
test("capabilities follow the template: NAMED_IAM alone, plus AUTO_EXPAND for a Transform (#980)", async () => {
|
|
89
|
+
const sent: Record<string, string>[] = [];
|
|
90
|
+
let described = 0;
|
|
91
|
+
const http: AwsHttp = async (_url, form) => {
|
|
92
|
+
if (form.Action === "DescribeStacks") return described++ % 2 === 0 ? { status: 400, text: MISSING } : { status: 200, text: describe_("CREATE_COMPLETE") };
|
|
93
|
+
sent.push(form);
|
|
94
|
+
return { status: 200, text: CREATE_OK };
|
|
95
|
+
};
|
|
96
|
+
const plain = tmpl();
|
|
97
|
+
await awsApply({ templatePath: plain, stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, http);
|
|
98
|
+
unlinkSync(plain);
|
|
99
|
+
expect(sent[0]["Capabilities.member.1"]).toBe("CAPABILITY_NAMED_IAM");
|
|
100
|
+
expect(sent[0]["Capabilities.member.2"]).toBeUndefined();
|
|
101
|
+
|
|
102
|
+
const macro = tmpl({ Transform: "AWS::SecretsManager-2020-07-23" });
|
|
103
|
+
await awsApply({ templatePath: macro, stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, http);
|
|
104
|
+
unlinkSync(macro);
|
|
105
|
+
expect(sent[1]["Capabilities.member.1"]).toBe("CAPABILITY_NAMED_IAM");
|
|
106
|
+
expect(sent[1]["Capabilities.member.2"]).toBe("CAPABILITY_AUTO_EXPAND");
|
|
107
|
+
|
|
108
|
+
// An explicit list still wins.
|
|
109
|
+
const explicit = tmpl({ Transform: "AWS::Serverless-2016-10-31" });
|
|
110
|
+
await awsApply({ templatePath: explicit, stackName: "s", endpoint: "http://x", intervalMs: 1, capabilities: ["CAPABILITY_IAM"] }, undefined, http);
|
|
111
|
+
unlinkSync(explicit);
|
|
112
|
+
expect(sent[2]["Capabilities.member.1"]).toBe("CAPABILITY_IAM");
|
|
113
|
+
expect(sent[2]["Capabilities.member.2"]).toBeUndefined();
|
|
114
|
+
});
|
|
115
|
+
|
|
80
116
|
test("update path: existing stack → UpdateStack → UPDATE_COMPLETE", async () => {
|
|
81
117
|
let described = 0;
|
|
82
118
|
const http: AwsHttp = async (_url, form) => {
|
|
@@ -1,20 +1,28 @@
|
|
|
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";
|
|
3
5
|
|
|
4
6
|
const DEFAULT_REGION = "us-east-1";
|
|
5
7
|
const CFN_API_VERSION = "2010-05-15";
|
|
6
|
-
const DEFAULT_CAPABILITIES = ["CAPABILITY_NAMED_IAM"];
|
|
7
8
|
|
|
8
9
|
export interface AwsApplyArgs {
|
|
9
10
|
/** Path to a built CloudFormation template (JSON/YAML). */
|
|
10
11
|
templatePath: string;
|
|
11
12
|
/** CloudFormation stack name — the deploy boundary. */
|
|
12
13
|
stackName: string;
|
|
13
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* CFN endpoint override (e.g. Floci `http://localhost:4566`). Omitted,
|
|
16
|
+
* `AWS_ENDPOINT_URL_CLOUDFORMATION` then `AWS_ENDPOINT_URL` answer — the same
|
|
17
|
+
* rule the read client applies (#1694). With neither: real CloudFormation.
|
|
18
|
+
*/
|
|
14
19
|
endpoint?: string;
|
|
15
20
|
/** Region (real CFN host + `Version` context). Default: `us-east-1`. */
|
|
16
21
|
region?: string;
|
|
17
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Capabilities to acknowledge. Default: `CAPABILITY_NAMED_IAM`, plus
|
|
24
|
+
* `CAPABILITY_AUTO_EXPAND` when the template has a top-level `Transform` (#980).
|
|
25
|
+
*/
|
|
18
26
|
capabilities?: string[];
|
|
19
27
|
/** Stack-settle timeout in ms. Default: `300000`. */
|
|
20
28
|
timeoutMs?: number;
|
|
@@ -37,9 +45,17 @@ const defaultHttp: AwsHttp = async (url, form, signal) => {
|
|
|
37
45
|
|
|
38
46
|
// ── Pure helpers (CFN Query protocol) ─────────────────────────────────────────
|
|
39
47
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
48
|
+
/**
|
|
49
|
+
* The CloudFormation endpoint URL — the override, or the real regional host.
|
|
50
|
+
* `env` is what the ambient-variable fallback reads; injectable for tests.
|
|
51
|
+
*/
|
|
52
|
+
export function cfnUrl(
|
|
53
|
+
endpoint?: string,
|
|
54
|
+
region = DEFAULT_REGION,
|
|
55
|
+
env: Record<string, string | undefined> = process.env,
|
|
56
|
+
): string {
|
|
57
|
+
const override = resolveEndpointOverride("cloudformation", endpoint, env);
|
|
58
|
+
return `${(override ?? `https://cloudformation.${region}.amazonaws.com`).replace(/\/$/, "")}/`;
|
|
43
59
|
}
|
|
44
60
|
|
|
45
61
|
/** A CFN Query-protocol form body: `Action` + `Version` + params. */
|
|
@@ -122,8 +138,8 @@ export async function awsApply(
|
|
|
122
138
|
http: AwsHttp = defaultHttp,
|
|
123
139
|
): Promise<{ stackName: string; status: string; action: "created" | "updated" | "unchanged" }> {
|
|
124
140
|
const url = cfnUrl(args.endpoint, args.region);
|
|
125
|
-
const capabilities = args.capabilities ?? DEFAULT_CAPABILITIES;
|
|
126
141
|
const templateBody = readFileSync(args.templatePath, "utf8");
|
|
142
|
+
const capabilities = args.capabilities ?? awsDeployCapabilitiesForBody(templateBody);
|
|
127
143
|
const timeoutMs = args.timeoutMs ?? 300_000;
|
|
128
144
|
const intervalMs = args.intervalMs ?? 3_000;
|
|
129
145
|
|
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 ?? "",
|
|
@@ -657,7 +657,6 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
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
659
|
ownership: "unknown",
|
|
660
|
-
attributes: Object.keys(attributes).length > 0 ? attributes : undefined,
|
|
661
660
|
};
|
|
662
661
|
}
|
|
663
662
|
|
|
@@ -669,9 +668,9 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
669
668
|
const { observeByIdentity } = await import("./identity-observe");
|
|
670
669
|
const identity = await observeByIdentity(options.entityNames, options.entities, resources, client);
|
|
671
670
|
|
|
672
|
-
// Each resource's OWN properties
|
|
673
|
-
//
|
|
674
|
-
//
|
|
671
|
+
// Each resource's OWN properties (#1279). Until this, a node's `attrs` were
|
|
672
|
+
// the stack's exports replicated onto every member, so no instance carried
|
|
673
|
+
// its own `VpcId`.
|
|
675
674
|
const own = await describeOwnProperties(resources, options.region);
|
|
676
675
|
const withProperties = stampProviderDefaults(stampRegion(own.resources, options.region));
|
|
677
676
|
|
|
@@ -701,13 +700,13 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
701
700
|
detail: `the stack was read, but this resource's own properties were not — ${own.failures.get(type) ?? "the describe call failed"}`,
|
|
702
701
|
};
|
|
703
702
|
}
|
|
704
|
-
return observation({ ...described, ...identity.resources }, holes, identity.queried);
|
|
703
|
+
return observation({ ...described, ...identity.resources }, holes, identity.queried, notes, stackExports);
|
|
705
704
|
}
|
|
706
705
|
|
|
707
706
|
// Every entity the stack answered for was answered for: an entity the
|
|
708
707
|
// template doesn't carry is genuinely not in this stack, which is an
|
|
709
708
|
// absence, not a hole — unless the identity fallback saw it live (#1647).
|
|
710
|
-
return observation({ ...withProperties, ...identity.resources }, undefined, identity.queried);
|
|
709
|
+
return observation({ ...withProperties, ...identity.resources }, undefined, identity.queried, notes, stackExports);
|
|
711
710
|
},
|
|
712
711
|
|
|
713
712
|
/**
|
package/src/properties.test.ts
CHANGED
|
@@ -40,18 +40,18 @@ describe("describeOwnProperties (#1279)", () => {
|
|
|
40
40
|
expect(spawnMock).toHaveBeenCalledTimes(1);
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
-
it("keeps
|
|
44
|
-
const
|
|
43
|
+
it("keeps what the entry already carried, and lets the resource's own property win the name", async () => {
|
|
44
|
+
const withPrior = {
|
|
45
45
|
web: {
|
|
46
46
|
type: "AWS::EC2::Instance",
|
|
47
47
|
status: "OK",
|
|
48
48
|
physicalId: "i-1",
|
|
49
|
-
attributes: {
|
|
49
|
+
attributes: { region: "us-east-1", VpcId: "stale" },
|
|
50
50
|
},
|
|
51
51
|
};
|
|
52
52
|
spawnMock.mockResolvedValue(ok(reservations(instance("i-1", "vpc-a"))));
|
|
53
|
-
const { resources: merged } = await describeOwnProperties(
|
|
54
|
-
expect(merged.web.attributes?.
|
|
53
|
+
const { resources: merged } = await describeOwnProperties(withPrior);
|
|
54
|
+
expect(merged.web.attributes?.region).toBe("us-east-1");
|
|
55
55
|
expect(merged.web.attributes?.VpcId).toBe("vpc-a");
|
|
56
56
|
});
|
|
57
57
|
|
package/src/properties.ts
CHANGED
|
@@ -16,9 +16,10 @@
|
|
|
16
16
|
* A deep read (Cloud Control) answers this properly but is a per-resource call
|
|
17
17
|
* and is not available on every endpoint. This is the cheap middle: one describe
|
|
18
18
|
* per kind for the whole observation, joined back by physical id. Stack outputs
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* no longer ride the resource at all — they are the stack's, and travel on the
|
|
20
|
+
* observation envelope's `stackExports`. Whatever a resource already carries is
|
|
21
|
+
* kept underneath its own properties, which win a name collision because they
|
|
22
|
+
* are the resource's.
|
|
22
23
|
*/
|
|
23
24
|
|
|
24
25
|
import { applyAwsEndpointArgv } from "./components/cloud-executor";
|
|
@@ -177,8 +178,8 @@ export async function describeOwnProperties(
|
|
|
177
178
|
for (const name of byId.get(id) ?? []) {
|
|
178
179
|
merged[name] = {
|
|
179
180
|
...merged[name],
|
|
180
|
-
// Own properties last: a resource's `VpcId` outranks
|
|
181
|
-
//
|
|
181
|
+
// Own properties last: a resource's `VpcId` outranks anything the
|
|
182
|
+
// entry already carried under the same name.
|
|
182
183
|
attributes: { ...(merged[name].attributes ?? {}), ...row },
|
|
183
184
|
};
|
|
184
185
|
}
|