@intentius/chant-lexicon-aws 0.34.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/read-client.d.ts +108 -0
- package/dist/api/read-client.d.ts.map +1 -0
- package/dist/codegen/docs.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 +87 -26
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/dependencies.d.ts +3 -0
- package/dist/dependencies.d.ts.map +1 -1
- package/dist/generated/index.d.ts +1 -0
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +4 -4
- package/dist/manifest.json +1 -1
- package/dist/meta.json +30 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/properties.d.ts +23 -7
- package/dist/properties.d.ts.map +1 -1
- package/dist/types/index.d.ts +21 -0
- package/package.json +2 -2
- package/src/api/read-client.test.ts +203 -0
- package/src/api/read-client.ts +286 -0
- package/src/codegen/docs.ts +21 -4
- package/src/deep-observe.test.ts +357 -99
- package/src/deep-observe.ts +347 -131
- package/src/dependencies.ts +11 -6
- package/src/generated/index.d.ts +21 -0
- package/src/generated/index.ts +1 -0
- package/src/generated/lexicon-aws.json +30 -0
- package/src/index.ts +18 -1
- package/src/lifecycle-integration.test.ts +54 -45
- package/src/plugin.ts +66 -55
- package/src/properties.test.ts +38 -5
- package/src/properties.ts +47 -21
package/src/generated/index.d.ts
CHANGED
|
@@ -32891,6 +32891,27 @@ export declare class ServiceProfile {
|
|
|
32891
32891
|
readonly LoRaWAN_UlRatePolicy: string;
|
|
32892
32892
|
}
|
|
32893
32893
|
|
|
32894
|
+
export declare class ServiceSetting {
|
|
32895
|
+
constructor(props: {
|
|
32896
|
+
/** The ID of the service setting, such as /ssm/parameter-store/high-throughput-enabled. */
|
|
32897
|
+
SettingId: string;
|
|
32898
|
+
/** The value of the service setting. */
|
|
32899
|
+
SettingValue: string;
|
|
32900
|
+
/** The ARN of the service setting. */
|
|
32901
|
+
Arn?: string;
|
|
32902
|
+
/** The last time the service setting was modified. */
|
|
32903
|
+
LastModifiedDate?: string;
|
|
32904
|
+
/** The ARN of the last modified user. */
|
|
32905
|
+
LastModifiedUser?: string;
|
|
32906
|
+
/** The status of the service setting. The value can be Default, Customized or PendingUpdate. */
|
|
32907
|
+
Status?: string;
|
|
32908
|
+
}, attributes?: CFResourceAttributes);
|
|
32909
|
+
readonly Arn: string;
|
|
32910
|
+
readonly LastModifiedDate: string;
|
|
32911
|
+
readonly LastModifiedUser: string;
|
|
32912
|
+
readonly Status: string;
|
|
32913
|
+
}
|
|
32914
|
+
|
|
32894
32915
|
export declare class ServiceTemplate {
|
|
32895
32916
|
constructor(props: {
|
|
32896
32917
|
/** <p>The Amazon Resource Name (ARN) of the service template.</p> */
|
package/src/generated/index.ts
CHANGED
|
@@ -1356,6 +1356,7 @@ export const ServiceNetworkServiceAssociation = createResource("AWS::VpcLattice:
|
|
|
1356
1356
|
export const ServiceNetworkVpcAssociation = createResource("AWS::VpcLattice::ServiceNetworkVpcAssociation", "aws", {"Arn":"Arn","CreatedAt":"CreatedAt","Id":"Id","ServiceNetworkArn":"ServiceNetworkArn","ServiceNetworkId":"ServiceNetworkId","ServiceNetworkName":"ServiceNetworkName","Status":"Status","VpcId":"VpcId"});
|
|
1357
1357
|
export const ServicePrincipalName = createResource("AWS::PCAConnectorAD::ServicePrincipalName", "aws", {});
|
|
1358
1358
|
export const ServiceProfile = createResource("AWS::IoTWireless::ServiceProfile", "aws", {"Id":"Id","Arn":"Arn","LoRaWAN_UlRate":"LoRaWAN.UlRate","LoRaWAN_UlBucketSize":"LoRaWAN.UlBucketSize","LoRaWAN_UlRatePolicy":"LoRaWAN.UlRatePolicy","LoRaWAN_DlRate":"LoRaWAN.DlRate","LoRaWAN_DlBucketSize":"LoRaWAN.DlBucketSize","LoRaWAN_DlRatePolicy":"LoRaWAN.DlRatePolicy","LoRaWAN_DevStatusReqFreq":"LoRaWAN.DevStatusReqFreq","LoRaWAN_ReportDevStatusBattery":"LoRaWAN.ReportDevStatusBattery","LoRaWAN_ReportDevStatusMargin":"LoRaWAN.ReportDevStatusMargin","LoRaWAN_DrMin":"LoRaWAN.DrMin","LoRaWAN_DrMax":"LoRaWAN.DrMax","LoRaWAN_ChannelMask":"LoRaWAN.ChannelMask","LoRaWAN_HrAllowed":"LoRaWAN.HrAllowed","LoRaWAN_NwkGeoLoc":"LoRaWAN.NwkGeoLoc","LoRaWAN_TargetPer":"LoRaWAN.TargetPer","LoRaWAN_MinGwDiversity":"LoRaWAN.MinGwDiversity"});
|
|
1359
|
+
export const ServiceSetting = createResource("AWS::SSM::ServiceSetting", "aws", {"Status":"Status","LastModifiedDate":"LastModifiedDate","LastModifiedUser":"LastModifiedUser","Arn":"Arn"});
|
|
1359
1360
|
export const ServiceTemplate = createResource("AWS::Proton::ServiceTemplate", "aws", {"Arn":"Arn"});
|
|
1360
1361
|
export const SESConfigurationSet = createResource("AWS::SES::ConfigurationSet", "aws", {});
|
|
1361
1362
|
export const SESConfigurationSetEventDestination = createResource("AWS::SES::ConfigurationSetEventDestination", "aws", {"Id":"Id"});
|
|
@@ -91208,6 +91208,36 @@
|
|
|
91208
91208
|
"kind": "property",
|
|
91209
91209
|
"lexicon": "aws"
|
|
91210
91210
|
},
|
|
91211
|
+
"ServiceSetting": {
|
|
91212
|
+
"resourceType": "AWS::SSM::ServiceSetting",
|
|
91213
|
+
"kind": "resource",
|
|
91214
|
+
"lexicon": "aws",
|
|
91215
|
+
"attrs": {
|
|
91216
|
+
"Status": "Status",
|
|
91217
|
+
"LastModifiedDate": "LastModifiedDate",
|
|
91218
|
+
"LastModifiedUser": "LastModifiedUser",
|
|
91219
|
+
"Arn": "Arn"
|
|
91220
|
+
},
|
|
91221
|
+
"propertyConstraints": {
|
|
91222
|
+
"SettingId": {
|
|
91223
|
+
"minLength": 1,
|
|
91224
|
+
"maxLength": 1000
|
|
91225
|
+
},
|
|
91226
|
+
"SettingValue": {
|
|
91227
|
+
"minLength": 1,
|
|
91228
|
+
"maxLength": 4096
|
|
91229
|
+
},
|
|
91230
|
+
"LastModifiedDate": {
|
|
91231
|
+
"format": "date-time"
|
|
91232
|
+
}
|
|
91233
|
+
},
|
|
91234
|
+
"createOnly": [
|
|
91235
|
+
"SettingId"
|
|
91236
|
+
],
|
|
91237
|
+
"primaryIdentifier": [
|
|
91238
|
+
"Arn"
|
|
91239
|
+
]
|
|
91240
|
+
},
|
|
91211
91241
|
"ServiceSoftwareOptions": {
|
|
91212
91242
|
"resourceType": "AWS::OpenSearchService::Domain.ServiceSoftwareOptions",
|
|
91213
91243
|
"kind": "property",
|
package/src/index.ts
CHANGED
|
@@ -30,13 +30,30 @@ export { awsPlugin } from "./plugin";
|
|
|
30
30
|
export {
|
|
31
31
|
observeResourcesDeepAws,
|
|
32
32
|
awsDeepNormalizationHooks,
|
|
33
|
-
parseCloudControlResource,
|
|
34
33
|
hasOwnershipMarker,
|
|
35
34
|
DEEP_READABLE_TYPES,
|
|
36
35
|
AWS_READ_ONLY_NAMES,
|
|
37
36
|
AWS_SERVICE_DEFAULTS,
|
|
38
37
|
} from "./deep-observe";
|
|
39
38
|
|
|
39
|
+
// The native read transport (#1206) — the applier's own transport, pointed at
|
|
40
|
+
// CloudFormation Query and Cloud Control. `parseCloudControlResource` used to
|
|
41
|
+
// live in ./deep-observe and parsed AWS CLI stdout; the reader no longer speaks
|
|
42
|
+
// that wire format, so `parseResourceDescription` (which takes the API's own
|
|
43
|
+
// `ResourceDescription` object) replaces it.
|
|
44
|
+
export {
|
|
45
|
+
describeStackResources,
|
|
46
|
+
describeStackOutputs,
|
|
47
|
+
getResource,
|
|
48
|
+
listResources,
|
|
49
|
+
parseResourceDescription,
|
|
50
|
+
AwsReadError,
|
|
51
|
+
type AwsReadHttp,
|
|
52
|
+
type AwsReadClientOptions,
|
|
53
|
+
type CloudControlDescription,
|
|
54
|
+
type StackResource,
|
|
55
|
+
} from "./api/read-client";
|
|
56
|
+
|
|
40
57
|
// Intrinsics
|
|
41
58
|
export {
|
|
42
59
|
Sub,
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* cloud edge (the runtime adapter's spawn) mocked. Proves the seam between core
|
|
7
7
|
* and a real lexicon — not a `createMockPlugin` fixture.
|
|
8
8
|
*/
|
|
9
|
-
import { describe, test, expect, vi, beforeEach } from "vitest";
|
|
9
|
+
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
|
|
10
10
|
import { mkdtempSync, rmSync, readdirSync, readFileSync } from "node:fs";
|
|
11
11
|
import { tmpdir } from "node:os";
|
|
12
12
|
import { join } from "node:path";
|
|
@@ -40,8 +40,47 @@ const liveTemplate = {
|
|
|
40
40
|
|
|
41
41
|
const ok = (stdout: string) => ({ stdout, stderr: "", exitCode: 0 });
|
|
42
42
|
|
|
43
|
+
/* The stack reads moved off the CLI onto the CloudFormation Query protocol
|
|
44
|
+
* (#1206), so they are stubbed at `fetch` rather than at `spawn`. The paths
|
|
45
|
+
* still on the CLI — `describeStackStatus`, `exportResources`, and the
|
|
46
|
+
* per-kind property reads — keep using `spawnMock` below. */
|
|
47
|
+
|
|
48
|
+
const stackResourcesXml = (rows: Array<{ logicalId: string; type: string; physicalId: string }>) =>
|
|
49
|
+
`<DescribeStackResourcesResponse><StackResources>${rows
|
|
50
|
+
.map(
|
|
51
|
+
(r) =>
|
|
52
|
+
`<member><LogicalResourceId>${r.logicalId}</LogicalResourceId><ResourceType>${r.type}</ResourceType>` +
|
|
53
|
+
`<PhysicalResourceId>${r.physicalId}</PhysicalResourceId><ResourceStatus>CREATE_COMPLETE</ResourceStatus>` +
|
|
54
|
+
`<Timestamp>2026-01-01T00:00:00Z</Timestamp></member>`,
|
|
55
|
+
)
|
|
56
|
+
.join("")}</StackResources></DescribeStackResourcesResponse>`;
|
|
57
|
+
|
|
58
|
+
const stackOutputsXml = (outputs: Record<string, string> = {}) =>
|
|
59
|
+
`<DescribeStacksResponse><Outputs>${Object.entries(outputs)
|
|
60
|
+
.map(([k, v]) => `<member><OutputKey>${k}</OutputKey><OutputValue>${v}</OutputValue></member>`)
|
|
61
|
+
.join("")}</Outputs></DescribeStacksResponse>`;
|
|
62
|
+
|
|
63
|
+
const queryErrorXml = (code: string, message: string) =>
|
|
64
|
+
`<ErrorResponse><Error><Code>${code}</Code><Message>${message}</Message></Error></ErrorResponse>`;
|
|
65
|
+
|
|
66
|
+
/** Route a CloudFormation Query call by its `Action`, and answer as CFN would. */
|
|
67
|
+
const stubCfn = (route: (action: string) => { status?: number; text: string }): void => {
|
|
68
|
+
vi.spyOn(globalThis, "fetch").mockImplementation((async (_url: string, init: { body: string }) => {
|
|
69
|
+
const action = new URLSearchParams(init.body).get("Action") ?? "";
|
|
70
|
+
const res = route(action);
|
|
71
|
+
return { status: res.status ?? 200, text: () => Promise.resolve(res.text) };
|
|
72
|
+
}) as unknown as typeof fetch);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** The whole-stack refusal both the credentials and the missing-stack cases use. */
|
|
76
|
+
const stubCfnError = (code: string, message: string): void =>
|
|
77
|
+
stubCfn(() => ({ status: 400, text: queryErrorXml(code, message) }));
|
|
78
|
+
|
|
43
79
|
describe("aws lifecycle integration (#163)", () => {
|
|
44
80
|
beforeEach(() => spawnMock.mockReset());
|
|
81
|
+
// A `fetch` stub left standing would serve the next test's stack read, which
|
|
82
|
+
// is how a credentials case can come back looking healthy.
|
|
83
|
+
afterEach(() => vi.restoreAllMocks());
|
|
45
84
|
|
|
46
85
|
test("live-import driver: real exportResources → IR → generated source", async () => {
|
|
47
86
|
spawnMock.mockResolvedValue(ok(JSON.stringify({ TemplateBody: liveTemplate })));
|
|
@@ -65,27 +104,11 @@ describe("aws lifecycle integration (#163)", () => {
|
|
|
65
104
|
});
|
|
66
105
|
|
|
67
106
|
test("changeset path: real describeResources → buildChangeSet verdicts", async () => {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
JSON.stringify({
|
|
74
|
-
StackResources: [
|
|
75
|
-
{
|
|
76
|
-
LogicalResourceId: "MyBucket",
|
|
77
|
-
ResourceType: "AWS::S3::Bucket",
|
|
78
|
-
PhysicalResourceId: "my-bucket",
|
|
79
|
-
ResourceStatus: "CREATE_COMPLETE",
|
|
80
|
-
Timestamp: "2026-01-01T00:00:00Z",
|
|
81
|
-
},
|
|
82
|
-
],
|
|
83
|
-
}),
|
|
84
|
-
),
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
return Promise.resolve(ok(JSON.stringify({ Stacks: [{ Outputs: [] }] })));
|
|
88
|
-
});
|
|
107
|
+
stubCfn((action) =>
|
|
108
|
+
action === "DescribeStackResources"
|
|
109
|
+
? { text: stackResourcesXml([{ logicalId: "MyBucket", type: "AWS::S3::Bucket", physicalId: "my-bucket" }]) }
|
|
110
|
+
: { text: stackOutputsXml() },
|
|
111
|
+
);
|
|
89
112
|
|
|
90
113
|
const { resources: observedNow } = normalizeObservation(
|
|
91
114
|
await awsPlugin.describeResources!({
|
|
@@ -96,7 +119,7 @@ describe("aws lifecycle integration (#163)", () => {
|
|
|
96
119
|
}),
|
|
97
120
|
);
|
|
98
121
|
expect(observedNow.MyBucket?.type).toBe("AWS::S3::Bucket");
|
|
99
|
-
// Ownership verdicts are total (#1089):
|
|
122
|
+
// Ownership verdicts are total (#1089): DescribeStackResources carries no
|
|
100
123
|
// tags, so the verdict is an explicit `unknown`, not a missing field.
|
|
101
124
|
expect(observedNow.MyBucket?.ownership).toBe("unknown");
|
|
102
125
|
|
|
@@ -160,7 +183,7 @@ describe("aws lifecycle integration (#163)", () => {
|
|
|
160
183
|
* NOT-OBSERVED, and that survives describe → plan → component status.
|
|
161
184
|
*/
|
|
162
185
|
test("tri-state chain: a failed stack read stays unobserved through describe → plan → status (#1089)", async () => {
|
|
163
|
-
|
|
186
|
+
stubCfnError("AccessDenied", "Unable to locate credentials");
|
|
164
187
|
|
|
165
188
|
const observed = normalizeObservation(
|
|
166
189
|
await awsPlugin.describeResources!({
|
|
@@ -190,7 +213,7 @@ describe("aws lifecycle integration (#163)", () => {
|
|
|
190
213
|
});
|
|
191
214
|
|
|
192
215
|
test("a stack that does not exist is a real absence — every declared entity is a create", async () => {
|
|
193
|
-
|
|
216
|
+
stubCfnError("ValidationError", "Stack with id prod does not exist");
|
|
194
217
|
|
|
195
218
|
const observed = normalizeObservation(
|
|
196
219
|
await awsPlugin.describeResources!({
|
|
@@ -222,7 +245,7 @@ describeObservationConformance({
|
|
|
222
245
|
declared: ["MyBucket", "MyQueue"],
|
|
223
246
|
expectUnobserved: ["MyBucket", "MyQueue"],
|
|
224
247
|
run: () => {
|
|
225
|
-
|
|
248
|
+
stubCfnError("AccessDenied", "Unable to locate credentials");
|
|
226
249
|
return awsPlugin.describeResources!({
|
|
227
250
|
environment: "prod",
|
|
228
251
|
buildOutput: "",
|
|
@@ -236,7 +259,7 @@ describeObservationConformance({
|
|
|
236
259
|
declared: ["MyBucket"],
|
|
237
260
|
expectAbsent: ["MyBucket"],
|
|
238
261
|
run: () => {
|
|
239
|
-
|
|
262
|
+
stubCfnError("ValidationError", "Stack with id prod does not exist");
|
|
240
263
|
return awsPlugin.describeResources!({
|
|
241
264
|
environment: "prod",
|
|
242
265
|
buildOutput: "",
|
|
@@ -250,24 +273,10 @@ describeObservationConformance({
|
|
|
250
273
|
declared: ["MyBucket"],
|
|
251
274
|
expectPresent: ["MyBucket"],
|
|
252
275
|
run: () => {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
JSON.stringify({
|
|
258
|
-
StackResources: [
|
|
259
|
-
{
|
|
260
|
-
LogicalResourceId: "MyBucket",
|
|
261
|
-
ResourceType: "AWS::S3::Bucket",
|
|
262
|
-
PhysicalResourceId: "my-bucket",
|
|
263
|
-
ResourceStatus: "CREATE_COMPLETE",
|
|
264
|
-
Timestamp: "2026-01-01T00:00:00Z",
|
|
265
|
-
},
|
|
266
|
-
],
|
|
267
|
-
}),
|
|
268
|
-
)
|
|
269
|
-
: ok(JSON.stringify({ Stacks: [{ Outputs: [] }] })),
|
|
270
|
-
),
|
|
276
|
+
stubCfn((action) =>
|
|
277
|
+
action === "DescribeStackResources"
|
|
278
|
+
? { text: stackResourcesXml([{ logicalId: "MyBucket", type: "AWS::S3::Bucket", physicalId: "my-bucket" }]) }
|
|
279
|
+
: { text: stackOutputsXml() },
|
|
271
280
|
);
|
|
272
281
|
return awsPlugin.describeResources!({
|
|
273
282
|
environment: "prod",
|
package/src/plugin.ts
CHANGED
|
@@ -17,10 +17,17 @@ import { awsSerializer } from "./serializer";
|
|
|
17
17
|
import { FLOCI_EMULATOR } from "./op/activities/floci";
|
|
18
18
|
import { applyAwsEndpointArgv } from "./components/cloud-executor";
|
|
19
19
|
import { stackDoesNotExist } from "./stack-errors";
|
|
20
|
+
import {
|
|
21
|
+
AwsReadError,
|
|
22
|
+
describeStackOutputs,
|
|
23
|
+
describeStackResources,
|
|
24
|
+
type AwsReadClientOptions,
|
|
25
|
+
type StackResource,
|
|
26
|
+
} from "./api/read-client";
|
|
20
27
|
import { awsDeepNormalizationHooks, observeResourcesDeepAws } from "./deep-observe";
|
|
21
28
|
import { awsReferenceCatalog } from "./reference-catalog";
|
|
22
29
|
import { AMBIENT_KINDS } from "./ambient";
|
|
23
|
-
import { describeOwnProperties, stampRegion } from "./properties";
|
|
30
|
+
import { canDescribe, describeOwnProperties, stampRegion } from "./properties";
|
|
24
31
|
import { stampProviderDefaults } from "./defaults";
|
|
25
32
|
import { resolveTemplateAttrs } from "./live-attrs";
|
|
26
33
|
import { CFParser } from "./import/parser";
|
|
@@ -541,12 +548,14 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
541
548
|
region?: string;
|
|
542
549
|
owned?: boolean;
|
|
543
550
|
}): Promise<ObservationResult> {
|
|
544
|
-
const { getRuntime } = await import("@intentius/chant/runtime-adapter");
|
|
545
551
|
const { observation, unobservedAll } = await import("@intentius/chant/observation");
|
|
546
|
-
const rt = getRuntime();
|
|
547
552
|
const resources: Record<string, ResourceMetadata> = {};
|
|
548
|
-
//
|
|
549
|
-
|
|
553
|
+
// The applier's own transport, pointed at the read APIs (#1206). Multi-region
|
|
554
|
+
// estates target this stack's region, not the ambient one.
|
|
555
|
+
const client: AwsReadClientOptions = {
|
|
556
|
+
...(process.env.AWS_ENDPOINT_URL ? { endpoint: process.env.AWS_ENDPOINT_URL } : {}),
|
|
557
|
+
...(options.region ? { region: options.region } : {}),
|
|
558
|
+
};
|
|
550
559
|
|
|
551
560
|
if (options.owned) {
|
|
552
561
|
// describe-stack-resources does not return tags, so ownership cannot be
|
|
@@ -569,29 +578,26 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
569
578
|
// single-stack convention is the stack named after the environment (#932).
|
|
570
579
|
const stackName = options.stack ?? `${options.environment}`;
|
|
571
580
|
|
|
572
|
-
// Describe stack resources.
|
|
581
|
+
// Describe stack resources. The endpoint override rides the client, so a
|
|
573
582
|
// local emulator (Floci) is observed instead of real AWS (#926) — behold
|
|
574
583
|
// serve --local relies on this for the overlay.
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
"--output", "json",
|
|
580
|
-
], process.env.AWS_ENDPOINT_URL));
|
|
581
|
-
|
|
582
|
-
if (listResult.exitCode !== 0) {
|
|
584
|
+
let stackResources: StackResource[];
|
|
585
|
+
try {
|
|
586
|
+
stackResources = await describeStackResources(stackName, client);
|
|
587
|
+
} catch (err) {
|
|
583
588
|
// A stack that doesn't exist yet is the pre-first-apply state: nothing is
|
|
584
589
|
// deployed for this env, so there are no live resources (every declared
|
|
585
590
|
// resource is "pending") — not an error. That is a real absence, so the
|
|
586
591
|
// empty result is the honest one and `create` is the right proposal.
|
|
587
|
-
if (stackDoesNotExist(
|
|
592
|
+
if (err instanceof AwsReadError && stackDoesNotExist(err.message)) {
|
|
588
593
|
return observation(resources);
|
|
589
594
|
}
|
|
590
595
|
// Any other failure (credentials, throttling, a region that can't be
|
|
591
596
|
// reached) establishes nothing about what is deployed. Reporting every
|
|
592
597
|
// declared entity as NOT-OBSERVED (#1089) is what keeps a broken read
|
|
593
598
|
// from arriving downstream as "none of this exists".
|
|
594
|
-
const
|
|
599
|
+
const detail = err instanceof AwsReadError && err.code ? `${err.code}: ${err.message}` : String(err instanceof Error ? err.message : err);
|
|
600
|
+
const reason = /credential|token|expired|AccessDenied|not authorized|Unauthorized/i.test(detail)
|
|
595
601
|
? "no-credentials"
|
|
596
602
|
: "read-failed";
|
|
597
603
|
return observation(
|
|
@@ -599,45 +605,22 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
599
605
|
unobservedAll(
|
|
600
606
|
options.entityNames,
|
|
601
607
|
reason,
|
|
602
|
-
`
|
|
608
|
+
`DescribeStackResources failed for stack "${stackName}": ${detail}`,
|
|
603
609
|
),
|
|
604
610
|
);
|
|
605
611
|
}
|
|
606
612
|
|
|
607
|
-
const data = JSON.parse(listResult.stdout) as {
|
|
608
|
-
StackResources: Array<{
|
|
609
|
-
LogicalResourceId: string;
|
|
610
|
-
ResourceType: string;
|
|
611
|
-
PhysicalResourceId: string;
|
|
612
|
-
ResourceStatus: string;
|
|
613
|
-
Timestamp: string;
|
|
614
|
-
}>;
|
|
615
|
-
};
|
|
616
|
-
|
|
617
613
|
// Map logical names from build to stack resources
|
|
618
|
-
const stackResourceMap = new Map
|
|
619
|
-
for (const r of data.StackResources) {
|
|
620
|
-
stackResourceMap.set(r.LogicalResourceId, r);
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
// Get stack outputs
|
|
624
|
-
const describeResult = await rt.spawn(applyAwsEndpointArgv([
|
|
625
|
-
"aws", "cloudformation", "describe-stacks",
|
|
626
|
-
"--stack-name", stackName,
|
|
627
|
-
...regionArgs,
|
|
628
|
-
"--output", "json",
|
|
629
|
-
], process.env.AWS_ENDPOINT_URL));
|
|
614
|
+
const stackResourceMap = new Map(stackResources.map((r) => [r.logicalId, r]));
|
|
630
615
|
|
|
616
|
+
// Get stack outputs. A stack whose resources read fine but whose outputs do
|
|
617
|
+
// not is still a usable observation, so this failure is swallowed exactly as
|
|
618
|
+
// the non-zero exit code used to be.
|
|
631
619
|
let stackOutputs: Record<string, string> = {};
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
};
|
|
636
|
-
if (stacks.Stacks[0]?.Outputs) {
|
|
637
|
-
for (const o of stacks.Stacks[0].Outputs) {
|
|
638
|
-
stackOutputs[o.OutputKey] = o.OutputValue;
|
|
639
|
-
}
|
|
640
|
-
}
|
|
620
|
+
try {
|
|
621
|
+
stackOutputs = await describeStackOutputs(stackName, client);
|
|
622
|
+
} catch {
|
|
623
|
+
stackOutputs = {};
|
|
641
624
|
}
|
|
642
625
|
|
|
643
626
|
for (const entityName of options.entityNames) {
|
|
@@ -655,10 +638,10 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
655
638
|
}
|
|
656
639
|
|
|
657
640
|
resources[entityName] = {
|
|
658
|
-
type: stackResource.
|
|
659
|
-
physicalId: stackResource.
|
|
660
|
-
status: stackResource.
|
|
661
|
-
lastUpdated: stackResource.
|
|
641
|
+
type: stackResource.type,
|
|
642
|
+
physicalId: stackResource.physicalId ?? "",
|
|
643
|
+
status: stackResource.status ?? "",
|
|
644
|
+
lastUpdated: stackResource.timestamp ?? "",
|
|
662
645
|
// Total verdict (#1089): describe-stack-resources returns no tags, so
|
|
663
646
|
// this path cannot read the ownership marker. Say `unknown` explicitly
|
|
664
647
|
// rather than leaving the field off and letting each consumer guess —
|
|
@@ -671,9 +654,37 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
671
654
|
// Each resource's OWN properties, on top of the stack outputs above (#1279).
|
|
672
655
|
// Until this, a node's `attrs` were the stack's exports replicated onto
|
|
673
656
|
// every member, so no instance carried its own `VpcId`.
|
|
674
|
-
const
|
|
675
|
-
|
|
676
|
-
|
|
657
|
+
const own = await describeOwnProperties(resources, options.region);
|
|
658
|
+
const withProperties = stampProviderDefaults(stampRegion(own.resources, options.region));
|
|
659
|
+
|
|
660
|
+
// The own-property read is still a CLI shell-out while the stack reads are
|
|
661
|
+
// not (#1206), so the two halves can now fail independently. When the
|
|
662
|
+
// enrichment could not run at all, the resources it would have described are
|
|
663
|
+
// identified but not described — and `lifecycle diff` compares these
|
|
664
|
+
// attributes, so presenting them anyway reports every previously-recorded
|
|
665
|
+
// property as removed. That is a failed read arriving as drift, which is the
|
|
666
|
+
// shape #1089 exists to prevent, so say it is a hole instead.
|
|
667
|
+
const undescribed = own.transportFailed
|
|
668
|
+
? Object.keys(withProperties).filter((name) => canDescribe(withProperties[name].type))
|
|
669
|
+
: [];
|
|
670
|
+
if (undescribed.length > 0) {
|
|
671
|
+
const described: Record<string, ResourceMetadata> = {};
|
|
672
|
+
for (const [name, meta] of Object.entries(withProperties)) {
|
|
673
|
+
if (!undescribed.includes(name)) described[name] = meta;
|
|
674
|
+
}
|
|
675
|
+
// Attribute each hole to the call that failed for *its* kind, rather than
|
|
676
|
+
// to whichever call happened to fail first.
|
|
677
|
+
const holes: Record<string, { type: string; reason: "read-failed"; detail: string }> = {};
|
|
678
|
+
for (const name of undescribed) {
|
|
679
|
+
const type = withProperties[name].type;
|
|
680
|
+
holes[name] = {
|
|
681
|
+
type,
|
|
682
|
+
reason: "read-failed",
|
|
683
|
+
detail: `the stack was read, but this resource's own properties were not — ${own.failures.get(type) ?? "the describe call failed"}`,
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
return observation(described, holes);
|
|
687
|
+
}
|
|
677
688
|
|
|
678
689
|
// Every entity the stack answered for was answered for: an entity the
|
|
679
690
|
// template doesn't carry is genuinely not in this stack, which is an
|
package/src/properties.test.ts
CHANGED
|
@@ -29,7 +29,7 @@ describe("describeOwnProperties (#1279)", () => {
|
|
|
29
29
|
|
|
30
30
|
it("joins each resource's own properties back by physical id", async () => {
|
|
31
31
|
spawnMock.mockResolvedValue(ok(reservations(instance("i-1", "vpc-a"), instance("i-2", "vpc-b"))));
|
|
32
|
-
const merged = await describeOwnProperties(observed);
|
|
32
|
+
const { resources: merged } = await describeOwnProperties(observed);
|
|
33
33
|
expect(merged.web.attributes?.VpcId).toBe("vpc-a");
|
|
34
34
|
expect(merged.api.attributes?.VpcId).toBe("vpc-b");
|
|
35
35
|
});
|
|
@@ -50,7 +50,7 @@ describe("describeOwnProperties (#1279)", () => {
|
|
|
50
50
|
},
|
|
51
51
|
};
|
|
52
52
|
spawnMock.mockResolvedValue(ok(reservations(instance("i-1", "vpc-a"))));
|
|
53
|
-
const merged = await describeOwnProperties(withOutputs);
|
|
53
|
+
const { resources: merged } = await describeOwnProperties(withOutputs);
|
|
54
54
|
expect(merged.web.attributes?.expVpcId).toBe("vpc-exported");
|
|
55
55
|
expect(merged.web.attributes?.VpcId).toBe("vpc-a");
|
|
56
56
|
});
|
|
@@ -64,19 +64,52 @@ describe("describeOwnProperties (#1279)", () => {
|
|
|
64
64
|
.mockResolvedValueOnce(fail) // the batch, killed by i-2
|
|
65
65
|
.mockResolvedValueOnce(ok(reservations(instance("i-1", "vpc-a"))))
|
|
66
66
|
.mockResolvedValueOnce(fail); // i-2 really is gone
|
|
67
|
-
const merged = await describeOwnProperties(observed);
|
|
67
|
+
const { resources: merged } = await describeOwnProperties(observed);
|
|
68
68
|
expect(merged.web.attributes?.VpcId).toBe("vpc-a");
|
|
69
69
|
expect(merged.api.attributes).toBeUndefined();
|
|
70
70
|
});
|
|
71
71
|
|
|
72
72
|
it("leaves the observation untouched when the kind cannot be read at all", async () => {
|
|
73
73
|
spawnMock.mockResolvedValue(fail);
|
|
74
|
-
const merged = await describeOwnProperties({ web: observed.web });
|
|
74
|
+
const { resources: merged } = await describeOwnProperties({ web: observed.web });
|
|
75
75
|
expect(merged.web.attributes).toBeUndefined();
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
+
// #1206: these attributes are compared by `lifecycle diff`, so a read that
|
|
79
|
+
// fails after the resource is already identified reports every recorded
|
|
80
|
+
// property as removed. The caller can only avoid that if the failure is told.
|
|
81
|
+
it("reports a total failure, so a caller can call it a hole rather than thin data", async () => {
|
|
82
|
+
spawnMock.mockResolvedValue({ stdout: "", stderr: "spawn aws ENOENT", exitCode: 127 });
|
|
83
|
+
const result = await describeOwnProperties({ web: observed.web });
|
|
84
|
+
expect(result.transportFailed).toBe(true);
|
|
85
|
+
expect(result.failures.get("AWS::EC2::Instance")).toContain("ec2 describe-instances");
|
|
86
|
+
expect(result.failures.get("AWS::EC2::Instance")).toContain("ENOENT");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("a per-id retry that fails for every id is a failed kind, not an empty one", async () => {
|
|
90
|
+
// The batch dies, then so does every retry. Before #1206 the retry path
|
|
91
|
+
// replaced the null with `[]`, which read as "the account has nothing".
|
|
92
|
+
spawnMock.mockResolvedValue(fail);
|
|
93
|
+
const result = await describeOwnProperties(observed);
|
|
94
|
+
expect(result.transportFailed).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("a kind that answers keeps the pass best-effort, even when another fails", async () => {
|
|
98
|
+
// One kind readable, one not: still detail-loss, not a hole. Only a total
|
|
99
|
+
// failure changes the caller's verdict.
|
|
100
|
+
spawnMock.mockImplementation((argv?: string[]) =>
|
|
101
|
+
Promise.resolve(argv?.includes("describe-instances") ? ok(reservations(instance("i-1", "vpc-a"))) : fail),
|
|
102
|
+
);
|
|
103
|
+
const result = await describeOwnProperties({
|
|
104
|
+
web: observed.web,
|
|
105
|
+
net: { type: "AWS::EC2::Subnet", status: "OK", physicalId: "subnet-1" },
|
|
106
|
+
});
|
|
107
|
+
expect(result.transportFailed).toBe(false);
|
|
108
|
+
expect(result.resources.web.attributes?.VpcId).toBe("vpc-a");
|
|
109
|
+
});
|
|
110
|
+
|
|
78
111
|
it("does not call out for a kind it cannot describe", async () => {
|
|
79
|
-
const merged = await describeOwnProperties({
|
|
112
|
+
const { resources: merged } = await describeOwnProperties({
|
|
80
113
|
fn: { type: "AWS::Lambda::Function", status: "OK", physicalId: "fn-1" },
|
|
81
114
|
});
|
|
82
115
|
expect(spawnMock).not.toHaveBeenCalled();
|
package/src/properties.ts
CHANGED
|
@@ -86,20 +86,37 @@ export function canDescribe(kind: string): boolean {
|
|
|
86
86
|
return kind in DESCRIBE;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/** What an enrichment pass managed to do, so a caller can tell a miss from a no-op. */
|
|
90
|
+
export interface OwnPropertiesResult {
|
|
91
|
+
resources: Record<string, ResourceMetadata>;
|
|
92
|
+
/**
|
|
93
|
+
* True when enrichment was attempted and *every* kind's read failed — the
|
|
94
|
+
* transport is unavailable rather than the account being quiet. Callers must
|
|
95
|
+
* not present the result as a complete observation (#1089).
|
|
96
|
+
*/
|
|
97
|
+
transportFailed: boolean;
|
|
98
|
+
/** Why each failed kind failed, keyed by CloudFormation type, so a caller can attribute per resource. */
|
|
99
|
+
failures: Map<string, string>;
|
|
100
|
+
}
|
|
101
|
+
|
|
89
102
|
/**
|
|
90
103
|
* Merge each resource's own properties into an observation, in place of nothing.
|
|
91
104
|
*
|
|
92
|
-
* Best-effort per kind
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
105
|
+
* Best-effort per kind: an endpoint that cannot answer `describe-subnets` still
|
|
106
|
+
* yields instance properties. A miss costs detail — with one exception that used
|
|
107
|
+
* to be silent and is now reported.
|
|
108
|
+
*
|
|
109
|
+
* The exception (#1206): these attributes are compared by `lifecycle diff`, so a
|
|
110
|
+
* read that fails after the resource is already identified does not degrade to
|
|
111
|
+
* "less detail", it degrades to *drift* — the snapshot has `GroupId`, the live
|
|
112
|
+
* read has nothing, and the differ reports the property as removed. A partial
|
|
113
|
+
* failure still rides the best-effort path, but a total one is a hole the caller
|
|
114
|
+
* has to declare rather than a thinner set of facts.
|
|
98
115
|
*/
|
|
99
116
|
export async function describeOwnProperties(
|
|
100
117
|
resources: Record<string, ResourceMetadata>,
|
|
101
118
|
region?: string,
|
|
102
|
-
): Promise<
|
|
119
|
+
): Promise<OwnPropertiesResult> {
|
|
103
120
|
// Group the physical ids to look up by kind, so each kind is one call.
|
|
104
121
|
const wanted = new Map<string, Map<string, string[]>>();
|
|
105
122
|
for (const [name, meta] of Object.entries(resources)) {
|
|
@@ -108,22 +125,25 @@ export async function describeOwnProperties(
|
|
|
108
125
|
byId.set(meta.physicalId, [...(byId.get(meta.physicalId) ?? []), name]);
|
|
109
126
|
wanted.set(meta.type, byId);
|
|
110
127
|
}
|
|
111
|
-
if (wanted.size === 0) return resources;
|
|
128
|
+
if (wanted.size === 0) return { resources, transportFailed: false, failures: new Map() };
|
|
112
129
|
|
|
113
130
|
const { getRuntime } = await import("@intentius/chant/runtime-adapter");
|
|
114
131
|
const rt = getRuntime();
|
|
115
132
|
const regionArgs = region ? ["--region", region] : [];
|
|
116
133
|
const merged = { ...resources };
|
|
134
|
+
const failures = new Map<string, string>();
|
|
135
|
+
/** The last failure for a kind, phrased for a human — stderr when there is any, the exit status otherwise. */
|
|
136
|
+
let lastReason = "";
|
|
117
137
|
|
|
118
138
|
/** One describe for a set of ids. `null` when the call itself failed. */
|
|
119
139
|
const read = async (spec: (typeof DESCRIBE)[string], ids: string[]) => {
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
|
|
140
|
+
const argv = ["aws", ...spec.argv, spec.idFlag, ...ids, ...regionArgs, "--output", "json"];
|
|
141
|
+
const result = await rt.spawn(applyAwsEndpointArgv(argv, process.env.AWS_ENDPOINT_URL));
|
|
142
|
+
if (result.exitCode !== 0) {
|
|
143
|
+
const stderr = result.stderr.trim().split("\n")[0];
|
|
144
|
+
lastReason = `${spec.argv.join(" ")} failed${stderr ? `: ${stderr}` : ` (${result.exitCode})`}`;
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
127
147
|
return (JSON.parse(result.stdout)[spec.key] ?? []) as Array<Record<string, unknown>>;
|
|
128
148
|
};
|
|
129
149
|
|
|
@@ -137,10 +157,15 @@ export async function describeOwnProperties(
|
|
|
137
157
|
// down with it, and the result is indistinguishable from "the account has
|
|
138
158
|
// nothing to say". Retry one at a time so the damage stops at the bad id.
|
|
139
159
|
if (top === null && ids.length > 1) {
|
|
140
|
-
|
|
141
|
-
|
|
160
|
+
const perId = await Promise.all(ids.map((id) => read(spec, [id])));
|
|
161
|
+
// Every retry failing is the kind failing, not the kind being empty —
|
|
162
|
+
// the distinction the old `top = []` erased.
|
|
163
|
+
top = perId.every((r) => r === null) ? null : perId.flatMap((r) => r ?? []);
|
|
164
|
+
}
|
|
165
|
+
if (top === null) {
|
|
166
|
+
failures.set(kind, lastReason);
|
|
167
|
+
continue;
|
|
142
168
|
}
|
|
143
|
-
if (top === null) continue;
|
|
144
169
|
// `describe-instances` buries instances one level down under reservations;
|
|
145
170
|
// the others return the resources directly.
|
|
146
171
|
const rows = spec.nested
|
|
@@ -158,9 +183,10 @@ export async function describeOwnProperties(
|
|
|
158
183
|
};
|
|
159
184
|
}
|
|
160
185
|
}
|
|
161
|
-
} catch {
|
|
162
|
-
|
|
186
|
+
} catch (err) {
|
|
187
|
+
failures.set(kind, `${spec.argv.join(" ")} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
163
188
|
}
|
|
164
189
|
}
|
|
165
|
-
|
|
190
|
+
|
|
191
|
+
return { resources: merged, transportFailed: failures.size === wanted.size, failures };
|
|
166
192
|
}
|