Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -355,20 +355,20 @@ export interface KeyContextQuery extends ContextLookupRoleOptions {
}

/**
* Query input for lookup up Cloudformation resources using CC API
* Query input for lookup up CloudFormation resources using CC API
*/
export interface CcApiContextQuery extends ContextLookupRoleOptions {
/**
* The Cloudformation resource type.
* The CloudFormation resource type.
* See https://docs.aws.amazon.com/cloudcontrolapi/latest/userguide/supported-resources.html
*/
readonly typeName: string;

/**
* Identifier of the resource to look up using `GetResource`.
*
* Specifying exactIdentifier will return exactly one result, or throw an error.
*
* Specifying exactIdentifier will return exactly one result, or throw an error
* unless `ignoreErrorOnMissingContext` is set.
*
* @default - Either exactIdentifier or propertyMatch should be specified.
*/
Expand All @@ -377,7 +377,10 @@ export interface CcApiContextQuery extends ContextLookupRoleOptions {
/**
* Returns any resources matching these properties, using `ListResources`.
*
* Specifying propertyMatch will return 0 or more results.
* By default, specifying propertyMatch will successfully return 0 or more
* results. To throw an error if the number of results is unexpected (and
* prevent the query results from being committed to context), specify
* `expectedMatchCount`.
*
* ## Notes on property completeness
*
Expand Down Expand Up @@ -413,6 +416,23 @@ export interface CcApiContextQuery extends ContextLookupRoleOptions {
*/
readonly propertiesToReturn: string[];

/**
* Expected count of results if `propertyMatch` is specified.
*
* If the expected result count does not match the actual count,
* by default an error is produced and the result is not committed to cached
* context, and the user can correct the situation and try again without
* having to manually clear out the context key using `cdk context --remove`
*
* If the value of * `ignoreErrorOnMissingContext` is `true`, the value of
* `expectedMatchCount` is `at-least-one | exactly-one` and the number
* of found resources is 0, `dummyValue` is returned and committed to context
* instead.
*
* @default 'any'
*/
readonly expectedMatchCount?: 'any' | 'at-least-one' | 'at-most-one' | 'exactly-one';

/**
* The value to return if the resource was not found and `ignoreErrorOnMissingContext` is true.
*
Expand All @@ -432,8 +452,8 @@ export interface CcApiContextQuery extends ContextLookupRoleOptions {
*
* - In case of an `exactIdentifier` lookup, return the `dummyValue` if the resource with
* that identifier was not found.
* - In case of a `propertyMatch` lookup, this setting currently does not have any effect,
* as `propertyMatch` queries can legally return 0 resources.
* - In case of a `propertyMatch` lookup, return the `dummyValue` if `expectedMatchCount`
* is `at-least-one | exactly-one` and the number of resources found was 0.
*
* if `ignoreErrorOnMissingContext` is set, `dummyValue` should be set and be an array.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1024,19 +1024,19 @@
]
},
"CcApiContextQuery": {
"description": "Query input for lookup up Cloudformation resources using CC API",
"description": "Query input for lookup up CloudFormation resources using CC API",
"type": "object",
"properties": {
"typeName": {
"description": "The Cloudformation resource type.\nSee https://docs.aws.amazon.com/cloudcontrolapi/latest/userguide/supported-resources.html",
"description": "The CloudFormation resource type.\nSee https://docs.aws.amazon.com/cloudcontrolapi/latest/userguide/supported-resources.html",
"type": "string"
},
"exactIdentifier": {
"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.)",
"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.)",
"type": "string"
},
"propertyMatch": {
"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.)",
"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.)",
"$ref": "#/definitions/Record<string,unknown>"
},
"propertiesToReturn": {
Expand All @@ -1046,11 +1046,21 @@
"type": "string"
}
},
"expectedMatchCount": {
"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')",
"enum": [
"any",
"at-least-one",
"at-most-one",
"exactly-one"
],
"type": "string"
},
"dummyValue": {
"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)"
},
"ignoreErrorOnMissingContext": {
"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.",
"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.",
"default": false,
"type": "boolean"
},
Expand Down
4 changes: 2 additions & 2 deletions packages/@aws-cdk/cloud-assembly-schema/schema/version.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"schemaHash": "5683db246fac20b864d94d7bceef24ebda1a38c8c1f8ef0d5978534097dc9504",
"revision": 42
"schemaHash": "78936b0f9299bbe47497dd77f8065d71e65d8d739a0413ad7698ad03b22ef83e",
"revision": 43
}
10 changes: 9 additions & 1 deletion packages/aws-cdk/lib/context-providers/cc-api-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export class CcApiContextProviderPlugin implements ContextProviderPlugin {
resources = await this.getResource(cloudControl, args.typeName, args.exactIdentifier);
} else if (args.propertyMatch) {
// use listResource
resources = await this.listResources(cloudControl, args.typeName, args.propertyMatch);
resources = await this.listResources(cloudControl, args.typeName, args.propertyMatch, args.expectedMatchCount);
} else {
throw new ContextProviderError(`Provider protocol error: neither exactIdentifier nor propertyMatch is specified in ${JSON.stringify(args)}.`);
}
Expand Down Expand Up @@ -98,6 +98,7 @@ export class CcApiContextProviderPlugin implements ContextProviderPlugin {
cc: ICloudControlClient,
typeName: string,
propertyMatch: Record<string, unknown>,
expectedMatchCount?: CcApiContextQuery['expectedMatchCount'],
): Promise<FoundResource[]> {
try {
const result = await cc.listResources({
Expand All @@ -113,6 +114,13 @@ export class CcApiContextProviderPlugin implements ContextProviderPlugin {
});
});

if ((expectedMatchCount === 'at-least-one' || expectedMatchCount === 'exactly-one') && found.length === 0) {
throw new ZeroResourcesFoundError(`Could not find any resources matching ${JSON.stringify(propertyMatch)}`);
}
if ((expectedMatchCount === 'at-most-one' || expectedMatchCount === 'exactly-one') && found.length > 1) {
throw new ContextProviderError(`Found ${found.length} resources matching ${JSON.stringify(propertyMatch)}; please narrow the search criteria`);
}

return found;
} catch (err: any) {
if (!(err instanceof ContextProviderError) && !(err instanceof ZeroResourcesFoundError)) {
Expand Down
126 changes: 126 additions & 0 deletions packages/aws-cdk/test/context-providers/cc-api-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ import { mockCloudControlClient, MockSdkProvider, restoreSdkMocksToDefault } fro

let provider: CcApiContextProviderPlugin;

const INDIFFERENT_PROPERTYMATCH_PROPS = {
account: '123456789012',
region: 'us-east-1',
typeName: 'AWS::RDS::DBInstance',
propertyMatch: { },
propertiesToReturn: ['Index'],
};

beforeEach(() => {
provider = new CcApiContextProviderPlugin(new MockSdkProvider());
restoreSdkMocksToDefault();
Expand Down Expand Up @@ -180,6 +188,87 @@ test('looks up RDS instance using CC API listResources - error in CC API', async
).rejects.toThrow('error while listing AWS::RDS::DBInstance resources'); // THEN
});

test.each([
[undefined],
['any'],
['at-most-one'],
] as const)('return an empty array for empty result when expectedMatchCount is %s', async (expectedMatchCount) => {
// GIVEN
mockCloudControlClient.on(ListResourcesCommand).resolves({
ResourceDescriptions: [
{ Identifier: 'pl-xxxx', Properties: '{"PrefixListName":"name1","PrefixListId":"pl-xxxx","OwnerId":"123456789012"}' },
{ Identifier: 'pl-yyyy', Properties: '{"PrefixListName":"name1","PrefixListId":"pl-yyyy","OwnerId":"234567890123"}' },
{ Identifier: 'pl-zzzz', Properties: '{"PrefixListName":"name2","PrefixListId":"pl-zzzz","OwnerId":"123456789012"}' },
],
});

// WHEN
const results = await provider.getValue({
account: '123456789012',
region: 'us-east-1',
typeName: 'AWS::EC2::PrefixList',
propertyMatch: { PrefixListName: 'name3' },
propertiesToReturn: ['PrefixListId'],
expectedMatchCount,
});

// THEN
expect(results.length).toEqual(0);
});


test.each([
['at-least-one'],
['exactly-one']
] as const)('throws an error for empty result when expectedMatchCount is %s', async (expectedMatchCount) => {
// GIVEN
mockCloudControlClient.on(ListResourcesCommand).resolves({
ResourceDescriptions: [
{ Identifier: 'pl-xxxx', Properties: '{"PrefixListName":"name1","PrefixListId":"pl-xxxx","OwnerId":"123456789012"}' },
{ Identifier: 'pl-yyyy', Properties: '{"PrefixListName":"name1","PrefixListId":"pl-yyyy","OwnerId":"234567890123"}' },
{ Identifier: 'pl-zzzz', Properties: '{"PrefixListName":"name2","PrefixListId":"pl-zzzz","OwnerId":"123456789012"}' },
],
});

await expect(
// WHEN
provider.getValue({
account: '123456789012',
region: 'us-east-1',
typeName: 'AWS::EC2::PrefixList',
propertyMatch: { PrefixListName: 'name3' },
propertiesToReturn: ['PrefixListId'],
expectedMatchCount,
}),
).rejects.toThrow('Could not find any resources matching {"PrefixListName":"name3"}'); // THEN
});

test.each([
['at-most-one'],
['exactly-one']
] as const)('throws an error for multiple results when expectedMatchCount is %s', async (expectedMatchCount) => {
// GIVEN
mockCloudControlClient.on(ListResourcesCommand).resolves({
ResourceDescriptions: [
{ Identifier: 'pl-xxxx', Properties: '{"PrefixListName":"name1","PrefixListId":"pl-xxxx","OwnerId":"123456789012"}' },
{ Identifier: 'pl-yyyy', Properties: '{"PrefixListName":"name1","PrefixListId":"pl-yyyy","OwnerId":"234567890123"}' },
{ Identifier: 'pl-zzzz', Properties: '{"PrefixListName":"name2","PrefixListId":"pl-zzzz","OwnerId":"123456789012"}' },
],
});

await expect(
// WHEN
provider.getValue({
account: '123456789012',
region: 'us-east-1',
typeName: 'AWS::EC2::PrefixList',
propertyMatch: { PrefixListName: 'name1' },
propertiesToReturn: ['PrefixListId'],
expectedMatchCount,
}),
).rejects.toThrow('Found 2 resources matching {"PrefixListName":"name1"}'); // THEN
});

test('error by specifying both exactIdentifier and propertyMatch', async () => {
// GIVEN
mockCloudControlClient.on(GetResourceCommand).resolves({
Expand Down Expand Up @@ -425,6 +514,43 @@ describe('dummy value', () => {
}),
).rejects.toThrow('dummyValue must be an array of objects');
});

test.each(['at-least-one', 'exactly-one'] as const)('dummyValue is returned when list operation returns 0 values for expectedMatchCount %p', async (expectedMatchCount) => {
// GIVEN
mockCloudControlClient.on(ListResourcesCommand).resolves({
ResourceDescriptions: []
});

// WHEN/THEN
await expect(
provider.getValue({
...INDIFFERENT_PROPERTYMATCH_PROPS,
expectedMatchCount,
ignoreErrorOnMissingContext: true,
dummyValue: [{ Dummy: true }],
}),
).resolves.toEqual([{ Dummy: true }]);
});

test('ignoreErrorOnMissingContext does not suppress errors for at-most-one', async () => {
// GIVEN
mockCloudControlClient.on(ListResourcesCommand).resolves({
ResourceDescriptions: [
{ Properties: JSON.stringify({ Index: 1 }) },
{ Properties: JSON.stringify({ Index: 2 }) },
]
});

// WHEN/THEN
await expect(
provider.getValue({
...INDIFFERENT_PROPERTYMATCH_PROPS,
expectedMatchCount: 'at-most-one',
ignoreErrorOnMissingContext: true,
dummyValue: [{ Dummy: true }],
}),
).rejects.toThrow(/Found 2 resources matching/);
});
});
/* eslint-enable */

Expand Down