packages/@aws-cdk/toolkit-lib/lib/context-providers/cc-api-provider.ts (114 lines of code) (raw):
import type { CcApiContextQuery } from '@aws-cdk/cloud-assembly-schema';
import type { ResourceDescription } from '@aws-sdk/client-cloudcontrol';
import { ResourceNotFoundException } from '@aws-sdk/client-cloudcontrol';
import type { ICloudControlClient, SdkProvider } from '../api/aws-auth/private';
import { initContextProviderSdk } from '../api/aws-auth/private';
import type { ContextProviderPlugin } from '../api/plugin';
import { ContextProviderError } from '../api/toolkit-error';
import { findJsonValue, getResultObj } from '../util';
export class CcApiContextProviderPlugin implements ContextProviderPlugin {
constructor(private readonly aws: SdkProvider) {
}
/**
* This returns a data object with the value from CloudControl API result.
*
* See the documentation in the Cloud Assembly Schema for the semantics of
* each query parameter.
*/
public async getValue(args: CcApiContextQuery) {
// Validate input
if (args.exactIdentifier && args.propertyMatch) {
throw new ContextProviderError(`Provider protocol error: specify either exactIdentifier or propertyMatch, but not both (got ${JSON.stringify(args)})`);
}
if (args.ignoreErrorOnMissingContext && args.dummyValue === undefined) {
throw new ContextProviderError(`Provider protocol error: if ignoreErrorOnMissingContext is set, a dummyValue must be supplied (got ${JSON.stringify(args)})`);
}
if (args.dummyValue !== undefined && (!Array.isArray(args.dummyValue) || !args.dummyValue.every(isObject))) {
throw new ContextProviderError(`Provider protocol error: dummyValue must be an array of objects (got ${JSON.stringify(args.dummyValue)})`);
}
// Do the lookup
const cloudControl = (await initContextProviderSdk(this.aws, args)).cloudControl();
try {
let resources: FoundResource[];
if (args.exactIdentifier) {
// use getResource to get the exact indentifier
resources = await this.getResource(cloudControl, args.typeName, args.exactIdentifier);
} else if (args.propertyMatch) {
// use listResource
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)}.`);
}
return resources.map((r) => getResultObj(r.properties, r.identifier, args.propertiesToReturn));
} catch (err) {
if (err instanceof ZeroResourcesFoundError && args.ignoreErrorOnMissingContext) {
// We've already type-checked dummyValue.
return args.dummyValue;
}
throw err;
}
}
/**
* Calls getResource from CC API to get the resource.
* See https://docs.aws.amazon.com/cli/latest/reference/cloudcontrol/get-resource.html
*
* Will always return exactly one resource, or fail.
*/
private async getResource(
cc: ICloudControlClient,
typeName: string,
exactIdentifier: string,
): Promise<FoundResource[]> {
try {
const result = await cc.getResource({
TypeName: typeName,
Identifier: exactIdentifier,
});
if (!result.ResourceDescription) {
throw new ContextProviderError('Unexpected CloudControl API behavior: returned empty response');
}
return [foundResourceFromCcApi(result.ResourceDescription)];
} catch (err: any) {
if (err instanceof ResourceNotFoundException || (err as any).name === 'ResourceNotFoundException') {
throw new ZeroResourcesFoundError(`No resource of type ${typeName} with identifier: ${exactIdentifier}`);
}
if (!(err instanceof ContextProviderError)) {
throw new ContextProviderError(`Encountered CC API error while getting ${typeName} resource ${exactIdentifier}: ${err.message}`);
}
throw err;
}
}
/**
* Calls listResources from CC API to get the resources and apply args.propertyMatch to find the resources.
* See https://docs.aws.amazon.com/cli/latest/reference/cloudcontrol/list-resources.html
*
* Will return 0 or more resources.
*
* Does not currently paginate through more than one result page.
*/
private async listResources(
cc: ICloudControlClient,
typeName: string,
propertyMatch: Record<string, unknown>,
expectedMatchCount?: CcApiContextQuery['expectedMatchCount'],
): Promise<FoundResource[]> {
try {
const result = await cc.listResources({
TypeName: typeName,
});
const found = (result.ResourceDescriptions ?? [])
.map(foundResourceFromCcApi)
.filter((r) => {
return Object.entries(propertyMatch).every(([propPath, expected]) => {
const actual = findJsonValue(r.properties, propPath);
return propertyMatchesFilter(actual, expected);
});
});
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)) {
throw new ContextProviderError(`Encountered CC API error while listing ${typeName} resources matching ${JSON.stringify(propertyMatch)}: ${err.message}`);
}
throw err;
}
}
}
/**
* Convert a CC API response object into a nicer object (parse the JSON)
*/
function foundResourceFromCcApi(desc: ResourceDescription): FoundResource {
return {
identifier: desc.Identifier ?? '*MISSING*',
properties: JSON.parse(desc.Properties ?? '{}'),
};
}
/**
* Whether the given property value matches the given filter
*
* For now we just check for strict equality, but we can implement pattern matching and fuzzy matching here later
*/
function propertyMatchesFilter(actual: unknown, expected: unknown) {
return expected === actual;
}
function isObject(x: unknown): x is {[key: string]: unknown} {
return typeof x === 'object' && x !== null && !Array.isArray(x);
}
/**
* A parsed version of the return value from CCAPI
*/
interface FoundResource {
readonly identifier: string;
readonly properties: Record<string, unknown>;
}
/**
* A specific lookup failure indicating 0 resources found that can be recovered
*/
class ZeroResourcesFoundError extends Error {
}