Skip to content

Commit a1d5af9

Browse files
Tietewrix0rrr
andauthored
feat(cli): CcApi context provider can be configured to fail if listing does not find a specific count of resources (#251)
Fixes #257 As described in the issue, `CcApiContextProviderPlugin` should have the way to ensure the result has exact one resource. This PR adds the `expectedMatchCount` option to restrict the length of results in `listResources()`. Unit tests are added and modified ensure the changes. --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license --------- Co-authored-by: Rico Huijbers <[email protected]>
1 parent f816a1b commit a1d5af9

File tree

5 files changed

+179
-15
lines changed

5 files changed

+179
-15
lines changed

packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/context-queries.ts

+27-7
Original file line numberDiff line numberDiff line change
@@ -355,20 +355,20 @@ export interface KeyContextQuery extends ContextLookupRoleOptions {
355355
}
356356

357357
/**
358-
* Query input for lookup up Cloudformation resources using CC API
358+
* Query input for lookup up CloudFormation resources using CC API
359359
*/
360360
export interface CcApiContextQuery extends ContextLookupRoleOptions {
361361
/**
362-
* The Cloudformation resource type.
362+
* The CloudFormation resource type.
363363
* See https://docs.aws.amazon.com/cloudcontrolapi/latest/userguide/supported-resources.html
364364
*/
365365
readonly typeName: string;
366366

367367
/**
368368
* Identifier of the resource to look up using `GetResource`.
369369
*
370-
* Specifying exactIdentifier will return exactly one result, or throw an error.
371-
*
370+
* Specifying exactIdentifier will return exactly one result, or throw an error
371+
* unless `ignoreErrorOnMissingContext` is set.
372372
*
373373
* @default - Either exactIdentifier or propertyMatch should be specified.
374374
*/
@@ -377,7 +377,10 @@ export interface CcApiContextQuery extends ContextLookupRoleOptions {
377377
/**
378378
* Returns any resources matching these properties, using `ListResources`.
379379
*
380-
* Specifying propertyMatch will return 0 or more results.
380+
* By default, specifying propertyMatch will successfully return 0 or more
381+
* results. To throw an error if the number of results is unexpected (and
382+
* prevent the query results from being committed to context), specify
383+
* `expectedMatchCount`.
381384
*
382385
* ## Notes on property completeness
383386
*
@@ -413,6 +416,23 @@ export interface CcApiContextQuery extends ContextLookupRoleOptions {
413416
*/
414417
readonly propertiesToReturn: string[];
415418

419+
/**
420+
* Expected count of results if `propertyMatch` is specified.
421+
*
422+
* If the expected result count does not match the actual count,
423+
* by default an error is produced and the result is not committed to cached
424+
* context, and the user can correct the situation and try again without
425+
* having to manually clear out the context key using `cdk context --remove`
426+
*
427+
* If the value of * `ignoreErrorOnMissingContext` is `true`, the value of
428+
* `expectedMatchCount` is `at-least-one | exactly-one` and the number
429+
* of found resources is 0, `dummyValue` is returned and committed to context
430+
* instead.
431+
*
432+
* @default 'any'
433+
*/
434+
readonly expectedMatchCount?: 'any' | 'at-least-one' | 'at-most-one' | 'exactly-one';
435+
416436
/**
417437
* The value to return if the resource was not found and `ignoreErrorOnMissingContext` is true.
418438
*
@@ -432,8 +452,8 @@ export interface CcApiContextQuery extends ContextLookupRoleOptions {
432452
*
433453
* - In case of an `exactIdentifier` lookup, return the `dummyValue` if the resource with
434454
* that identifier was not found.
435-
* - In case of a `propertyMatch` lookup, this setting currently does not have any effect,
436-
* as `propertyMatch` queries can legally return 0 resources.
455+
* - In case of a `propertyMatch` lookup, return the `dummyValue` if `expectedMatchCount`
456+
* is `at-least-one | exactly-one` and the number of resources found was 0.
437457
*
438458
* if `ignoreErrorOnMissingContext` is set, `dummyValue` should be set and be an array.
439459
*

packages/@aws-cdk/cloud-assembly-schema/schema/cloud-assembly.schema.json

+15-5
Original file line numberDiff line numberDiff line change
@@ -1024,19 +1024,19 @@
10241024
]
10251025
},
10261026
"CcApiContextQuery": {
1027-
"description": "Query input for lookup up Cloudformation resources using CC API",
1027+
"description": "Query input for lookup up CloudFormation resources using CC API",
10281028
"type": "object",
10291029
"properties": {
10301030
"typeName": {
1031-
"description": "The Cloudformation resource type.\nSee https://docs.aws.amazon.com/cloudcontrolapi/latest/userguide/supported-resources.html",
1031+
"description": "The CloudFormation resource type.\nSee https://docs.aws.amazon.com/cloudcontrolapi/latest/userguide/supported-resources.html",
10321032
"type": "string"
10331033
},
10341034
"exactIdentifier": {
1035-
"description": "Identifier of the resource to look up using `GetResource`.\n\nSpecifying exactIdentifier will return exactly one result, or throw an error. (Default - Either exactIdentifier or propertyMatch should be specified.)",
1035+
"description": "Identifier of the resource to look up using `GetResource`.\n\nSpecifying exactIdentifier will return exactly one result, or throw an error\nunless `ignoreErrorOnMissingContext` is set. (Default - Either exactIdentifier or propertyMatch should be specified.)",
10361036
"type": "string"
10371037
},
10381038
"propertyMatch": {
1039-
"description": "Returns any resources matching these properties, using `ListResources`.\n\nSpecifying propertyMatch will return 0 or more results.\n\n## Notes on property completeness\n\nCloudControl API's `ListResources` may return fewer properties than\n`GetResource` would, depending on the resource implementation.\n\nThe resources that `propertyMatch` matches against will *only ever* be the\nproperties returned by the `ListResources` call. (Default - Either exactIdentifier or propertyMatch should be specified.)",
1039+
"description": "Returns any resources matching these properties, using `ListResources`.\n\nBy default, specifying propertyMatch will successfully return 0 or more\nresults. To throw an error if the number of results is unexpected (and\nprevent the query results from being committed to context), specify\n`expectedMatchCount`.\n\n## Notes on property completeness\n\nCloudControl API's `ListResources` may return fewer properties than\n`GetResource` would, depending on the resource implementation.\n\nThe resources that `propertyMatch` matches against will *only ever* be the\nproperties returned by the `ListResources` call. (Default - Either exactIdentifier or propertyMatch should be specified.)",
10401040
"$ref": "#/definitions/Record<string,unknown>"
10411041
},
10421042
"propertiesToReturn": {
@@ -1046,11 +1046,21 @@
10461046
"type": "string"
10471047
}
10481048
},
1049+
"expectedMatchCount": {
1050+
"description": "Expected count of results if `propertyMatch` is specified.\n\nIf the expected result count does not match the actual count,\nby default an error is produced and the result is not committed to cached\ncontext, and the user can correct the situation and try again without\nhaving to manually clear out the context key using `cdk context --remove`\n\nIf the value of * `ignoreErrorOnMissingContext` is `true`, the value of\n`expectedMatchCount` is `at-least-one | exactly-one` and the number\nof found resources is 0, `dummyValue` is returned and committed to context\ninstead. (Default 'any')",
1051+
"enum": [
1052+
"any",
1053+
"at-least-one",
1054+
"at-most-one",
1055+
"exactly-one"
1056+
],
1057+
"type": "string"
1058+
},
10491059
"dummyValue": {
10501060
"description": "The value to return if the resource was not found and `ignoreErrorOnMissingContext` is true.\n\nIf supplied, `dummyValue` should be an array of objects.\n\n`dummyValue` does not have to have elements, and it may have objects with\ndifferent properties than the properties in `propertiesToReturn`, but it\nwill be easiest for downstream code if the `dummyValue` conforms to\nthe expected response shape. (Default - No dummy value available)"
10511061
},
10521062
"ignoreErrorOnMissingContext": {
1053-
"description": "Ignore an error and return the `dummyValue` instead if the resource was not found.\n\n- In case of an `exactIdentifier` lookup, return the `dummyValue` if the resource with\n that identifier was not found.\n- In case of a `propertyMatch` lookup, this setting currently does not have any effect,\n as `propertyMatch` queries can legally return 0 resources.\n\nif `ignoreErrorOnMissingContext` is set, `dummyValue` should be set and be an array.",
1063+
"description": "Ignore an error and return the `dummyValue` instead if the resource was not found.\n\n- In case of an `exactIdentifier` lookup, return the `dummyValue` if the resource with\n that identifier was not found.\n- In case of a `propertyMatch` lookup, return the `dummyValue` if `expectedMatchCount`\n is `at-least-one | exactly-one` and the number of resources found was 0.\n\nif `ignoreErrorOnMissingContext` is set, `dummyValue` should be set and be an array.",
10541064
"default": false,
10551065
"type": "boolean"
10561066
},
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
2-
"schemaHash": "5683db246fac20b864d94d7bceef24ebda1a38c8c1f8ef0d5978534097dc9504",
3-
"revision": 42
2+
"schemaHash": "78936b0f9299bbe47497dd77f8065d71e65d8d739a0413ad7698ad03b22ef83e",
3+
"revision": 43
44
}

packages/aws-cdk/lib/context-providers/cc-api-provider.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export class CcApiContextProviderPlugin implements ContextProviderPlugin {
3939
resources = await this.getResource(cloudControl, args.typeName, args.exactIdentifier);
4040
} else if (args.propertyMatch) {
4141
// use listResource
42-
resources = await this.listResources(cloudControl, args.typeName, args.propertyMatch);
42+
resources = await this.listResources(cloudControl, args.typeName, args.propertyMatch, args.expectedMatchCount);
4343
} else {
4444
throw new ContextProviderError(`Provider protocol error: neither exactIdentifier nor propertyMatch is specified in ${JSON.stringify(args)}.`);
4545
}
@@ -98,6 +98,7 @@ export class CcApiContextProviderPlugin implements ContextProviderPlugin {
9898
cc: ICloudControlClient,
9999
typeName: string,
100100
propertyMatch: Record<string, unknown>,
101+
expectedMatchCount?: CcApiContextQuery['expectedMatchCount'],
101102
): Promise<FoundResource[]> {
102103
try {
103104
const result = await cc.listResources({
@@ -113,6 +114,13 @@ export class CcApiContextProviderPlugin implements ContextProviderPlugin {
113114
});
114115
});
115116

117+
if ((expectedMatchCount === 'at-least-one' || expectedMatchCount === 'exactly-one') && found.length === 0) {
118+
throw new ZeroResourcesFoundError(`Could not find any resources matching ${JSON.stringify(propertyMatch)}`);
119+
}
120+
if ((expectedMatchCount === 'at-most-one' || expectedMatchCount === 'exactly-one') && found.length > 1) {
121+
throw new ContextProviderError(`Found ${found.length} resources matching ${JSON.stringify(propertyMatch)}; please narrow the search criteria`);
122+
}
123+
116124
return found;
117125
} catch (err: any) {
118126
if (!(err instanceof ContextProviderError) && !(err instanceof ZeroResourcesFoundError)) {

packages/aws-cdk/test/context-providers/cc-api-provider.test.ts

+126
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ import { mockCloudControlClient, MockSdkProvider, restoreSdkMocksToDefault } fro
44

55
let provider: CcApiContextProviderPlugin;
66

7+
const INDIFFERENT_PROPERTYMATCH_PROPS = {
8+
account: '123456789012',
9+
region: 'us-east-1',
10+
typeName: 'AWS::RDS::DBInstance',
11+
propertyMatch: { },
12+
propertiesToReturn: ['Index'],
13+
};
14+
715
beforeEach(() => {
816
provider = new CcApiContextProviderPlugin(new MockSdkProvider());
917
restoreSdkMocksToDefault();
@@ -180,6 +188,87 @@ test('looks up RDS instance using CC API listResources - error in CC API', async
180188
).rejects.toThrow('error while listing AWS::RDS::DBInstance resources'); // THEN
181189
});
182190

191+
test.each([
192+
[undefined],
193+
['any'],
194+
['at-most-one'],
195+
] as const)('return an empty array for empty result when expectedMatchCount is %s', async (expectedMatchCount) => {
196+
// GIVEN
197+
mockCloudControlClient.on(ListResourcesCommand).resolves({
198+
ResourceDescriptions: [
199+
{ Identifier: 'pl-xxxx', Properties: '{"PrefixListName":"name1","PrefixListId":"pl-xxxx","OwnerId":"123456789012"}' },
200+
{ Identifier: 'pl-yyyy', Properties: '{"PrefixListName":"name1","PrefixListId":"pl-yyyy","OwnerId":"234567890123"}' },
201+
{ Identifier: 'pl-zzzz', Properties: '{"PrefixListName":"name2","PrefixListId":"pl-zzzz","OwnerId":"123456789012"}' },
202+
],
203+
});
204+
205+
// WHEN
206+
const results = await provider.getValue({
207+
account: '123456789012',
208+
region: 'us-east-1',
209+
typeName: 'AWS::EC2::PrefixList',
210+
propertyMatch: { PrefixListName: 'name3' },
211+
propertiesToReturn: ['PrefixListId'],
212+
expectedMatchCount,
213+
});
214+
215+
// THEN
216+
expect(results.length).toEqual(0);
217+
});
218+
219+
220+
test.each([
221+
['at-least-one'],
222+
['exactly-one']
223+
] as const)('throws an error for empty result when expectedMatchCount is %s', async (expectedMatchCount) => {
224+
// GIVEN
225+
mockCloudControlClient.on(ListResourcesCommand).resolves({
226+
ResourceDescriptions: [
227+
{ Identifier: 'pl-xxxx', Properties: '{"PrefixListName":"name1","PrefixListId":"pl-xxxx","OwnerId":"123456789012"}' },
228+
{ Identifier: 'pl-yyyy', Properties: '{"PrefixListName":"name1","PrefixListId":"pl-yyyy","OwnerId":"234567890123"}' },
229+
{ Identifier: 'pl-zzzz', Properties: '{"PrefixListName":"name2","PrefixListId":"pl-zzzz","OwnerId":"123456789012"}' },
230+
],
231+
});
232+
233+
await expect(
234+
// WHEN
235+
provider.getValue({
236+
account: '123456789012',
237+
region: 'us-east-1',
238+
typeName: 'AWS::EC2::PrefixList',
239+
propertyMatch: { PrefixListName: 'name3' },
240+
propertiesToReturn: ['PrefixListId'],
241+
expectedMatchCount,
242+
}),
243+
).rejects.toThrow('Could not find any resources matching {"PrefixListName":"name3"}'); // THEN
244+
});
245+
246+
test.each([
247+
['at-most-one'],
248+
['exactly-one']
249+
] as const)('throws an error for multiple results when expectedMatchCount is %s', async (expectedMatchCount) => {
250+
// GIVEN
251+
mockCloudControlClient.on(ListResourcesCommand).resolves({
252+
ResourceDescriptions: [
253+
{ Identifier: 'pl-xxxx', Properties: '{"PrefixListName":"name1","PrefixListId":"pl-xxxx","OwnerId":"123456789012"}' },
254+
{ Identifier: 'pl-yyyy', Properties: '{"PrefixListName":"name1","PrefixListId":"pl-yyyy","OwnerId":"234567890123"}' },
255+
{ Identifier: 'pl-zzzz', Properties: '{"PrefixListName":"name2","PrefixListId":"pl-zzzz","OwnerId":"123456789012"}' },
256+
],
257+
});
258+
259+
await expect(
260+
// WHEN
261+
provider.getValue({
262+
account: '123456789012',
263+
region: 'us-east-1',
264+
typeName: 'AWS::EC2::PrefixList',
265+
propertyMatch: { PrefixListName: 'name1' },
266+
propertiesToReturn: ['PrefixListId'],
267+
expectedMatchCount,
268+
}),
269+
).rejects.toThrow('Found 2 resources matching {"PrefixListName":"name1"}'); // THEN
270+
});
271+
183272
test('error by specifying both exactIdentifier and propertyMatch', async () => {
184273
// GIVEN
185274
mockCloudControlClient.on(GetResourceCommand).resolves({
@@ -425,6 +514,43 @@ describe('dummy value', () => {
425514
}),
426515
).rejects.toThrow('dummyValue must be an array of objects');
427516
});
517+
518+
test.each(['at-least-one', 'exactly-one'] as const)('dummyValue is returned when list operation returns 0 values for expectedMatchCount %p', async (expectedMatchCount) => {
519+
// GIVEN
520+
mockCloudControlClient.on(ListResourcesCommand).resolves({
521+
ResourceDescriptions: []
522+
});
523+
524+
// WHEN/THEN
525+
await expect(
526+
provider.getValue({
527+
...INDIFFERENT_PROPERTYMATCH_PROPS,
528+
expectedMatchCount,
529+
ignoreErrorOnMissingContext: true,
530+
dummyValue: [{ Dummy: true }],
531+
}),
532+
).resolves.toEqual([{ Dummy: true }]);
533+
});
534+
535+
test('ignoreErrorOnMissingContext does not suppress errors for at-most-one', async () => {
536+
// GIVEN
537+
mockCloudControlClient.on(ListResourcesCommand).resolves({
538+
ResourceDescriptions: [
539+
{ Properties: JSON.stringify({ Index: 1 }) },
540+
{ Properties: JSON.stringify({ Index: 2 }) },
541+
]
542+
});
543+
544+
// WHEN/THEN
545+
await expect(
546+
provider.getValue({
547+
...INDIFFERENT_PROPERTYMATCH_PROPS,
548+
expectedMatchCount: 'at-most-one',
549+
ignoreErrorOnMissingContext: true,
550+
dummyValue: [{ Dummy: true }],
551+
}),
552+
).rejects.toThrow(/Found 2 resources matching/);
553+
});
428554
});
429555
/* eslint-enable */
430556

0 commit comments

Comments
 (0)