Skip to content

Commit 5ce3aef

Browse files
authored
refactor(toolkit): move context providers (#337)
Moves the context providers to the temporary helper package. Tests will be moved later to keep the diff size manageable. Code is still tested as the tests are still running from `aws-cdk` package. --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license
1 parent d68d000 commit 5ce3aef

25 files changed

+1340
-1329
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { AmiContextQuery } from '@aws-cdk/cloud-assembly-schema';
2+
import type { IContextProviderMessages } from '.';
3+
import { type SdkProvider, initContextProviderSdk } from '../api/aws-auth';
4+
import type { ContextProviderPlugin } from '../api/plugin';
5+
import { ContextProviderError } from '../api/toolkit-error';
6+
7+
/**
8+
* Plugin to search AMIs for the current account
9+
*/
10+
export class AmiContextProviderPlugin implements ContextProviderPlugin {
11+
constructor(private readonly aws: SdkProvider, private readonly io: IContextProviderMessages) {
12+
}
13+
14+
public async getValue(args: AmiContextQuery) {
15+
const region = args.region;
16+
const account = args.account;
17+
18+
// Normally we'd do this only as 'debug', but searching AMIs typically takes dozens
19+
// of seconds, so be little more verbose about it so users know what is going on.
20+
await this.io.info(`Searching for AMI in ${account}:${region}`);
21+
await this.io.debug(`AMI search parameters: ${JSON.stringify(args)}`);
22+
23+
const ec2 = (await initContextProviderSdk(this.aws, args)).ec2();
24+
const response = await ec2.describeImages({
25+
Owners: args.owners,
26+
Filters: Object.entries(args.filters).map(([key, values]) => ({
27+
Name: key,
28+
Values: values,
29+
})),
30+
});
31+
32+
const images = [...(response.Images || [])].filter((i) => i.ImageId !== undefined);
33+
34+
if (images.length === 0) {
35+
throw new ContextProviderError('No AMI found that matched the search criteria');
36+
}
37+
38+
// Return the most recent one
39+
// Note: Date.parse() is not going to respect the timezone of the string,
40+
// but since we only care about the relative values that is okay.
41+
images.sort(descending((i) => Date.parse(i.CreationDate || '1970')));
42+
43+
await this.io.debug(`Selected image '${images[0].ImageId}' created at '${images[0].CreationDate}'`);
44+
return images[0].ImageId!;
45+
}
46+
}
47+
48+
/**
49+
* Make a comparator that sorts in descending order given a sort key extractor
50+
*/
51+
function descending<A>(valueOf: (x: A) => number) {
52+
return (a: A, b: A) => {
53+
return valueOf(b) - valueOf(a);
54+
};
55+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { AvailabilityZonesContextQuery } from '@aws-cdk/cloud-assembly-schema';
2+
import type { AvailabilityZone } from '@aws-sdk/client-ec2';
3+
import type { IContextProviderMessages } from '.';
4+
import { type SdkProvider, initContextProviderSdk } from '../api/aws-auth';
5+
import type { ContextProviderPlugin } from '../api/plugin';
6+
7+
/**
8+
* Plugin to retrieve the Availability Zones for the current account
9+
*/
10+
export class AZContextProviderPlugin implements ContextProviderPlugin {
11+
constructor(private readonly aws: SdkProvider, private readonly io: IContextProviderMessages) {
12+
}
13+
14+
public async getValue(args: AvailabilityZonesContextQuery) {
15+
const region = args.region;
16+
const account = args.account;
17+
await this.io.debug(`Reading AZs for ${account}:${region}`);
18+
const ec2 = (await initContextProviderSdk(this.aws, args)).ec2();
19+
const response = await ec2.describeAvailabilityZones({});
20+
if (!response.AvailabilityZones) {
21+
return [];
22+
}
23+
const azs = response.AvailabilityZones.filter((zone: AvailabilityZone) => zone.State === 'available').map(
24+
(zone: AvailabilityZone) => zone.ZoneName,
25+
);
26+
return azs;
27+
}
28+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import type { CcApiContextQuery } from '@aws-cdk/cloud-assembly-schema';
2+
import type { ResourceDescription } from '@aws-sdk/client-cloudcontrol';
3+
import { ResourceNotFoundException } from '@aws-sdk/client-cloudcontrol';
4+
import type { ICloudControlClient, SdkProvider } from '../api/aws-auth';
5+
import { initContextProviderSdk } from '../api/aws-auth';
6+
import type { ContextProviderPlugin } from '../api/plugin';
7+
import { ContextProviderError } from '../api/toolkit-error';
8+
import { findJsonValue, getResultObj } from '../util';
9+
10+
export class CcApiContextProviderPlugin implements ContextProviderPlugin {
11+
constructor(private readonly aws: SdkProvider) {
12+
}
13+
14+
/**
15+
* This returns a data object with the value from CloudControl API result.
16+
*
17+
* See the documentation in the Cloud Assembly Schema for the semantics of
18+
* each query parameter.
19+
*/
20+
public async getValue(args: CcApiContextQuery) {
21+
// Validate input
22+
if (args.exactIdentifier && args.propertyMatch) {
23+
throw new ContextProviderError(`Provider protocol error: specify either exactIdentifier or propertyMatch, but not both (got ${JSON.stringify(args)})`);
24+
}
25+
if (args.ignoreErrorOnMissingContext && args.dummyValue === undefined) {
26+
throw new ContextProviderError(`Provider protocol error: if ignoreErrorOnMissingContext is set, a dummyValue must be supplied (got ${JSON.stringify(args)})`);
27+
}
28+
if (args.dummyValue !== undefined && (!Array.isArray(args.dummyValue) || !args.dummyValue.every(isObject))) {
29+
throw new ContextProviderError(`Provider protocol error: dummyValue must be an array of objects (got ${JSON.stringify(args.dummyValue)})`);
30+
}
31+
32+
// Do the lookup
33+
const cloudControl = (await initContextProviderSdk(this.aws, args)).cloudControl();
34+
35+
try {
36+
let resources: FoundResource[];
37+
if (args.exactIdentifier) {
38+
// use getResource to get the exact indentifier
39+
resources = await this.getResource(cloudControl, args.typeName, args.exactIdentifier);
40+
} else if (args.propertyMatch) {
41+
// use listResource
42+
resources = await this.listResources(cloudControl, args.typeName, args.propertyMatch, args.expectedMatchCount);
43+
} else {
44+
throw new ContextProviderError(`Provider protocol error: neither exactIdentifier nor propertyMatch is specified in ${JSON.stringify(args)}.`);
45+
}
46+
47+
return resources.map((r) => getResultObj(r.properties, r.identifier, args.propertiesToReturn));
48+
} catch (err) {
49+
if (err instanceof ZeroResourcesFoundError && args.ignoreErrorOnMissingContext) {
50+
// We've already type-checked dummyValue.
51+
return args.dummyValue;
52+
}
53+
throw err;
54+
}
55+
}
56+
57+
/**
58+
* Calls getResource from CC API to get the resource.
59+
* See https://docs.aws.amazon.com/cli/latest/reference/cloudcontrol/get-resource.html
60+
*
61+
* Will always return exactly one resource, or fail.
62+
*/
63+
private async getResource(
64+
cc: ICloudControlClient,
65+
typeName: string,
66+
exactIdentifier: string,
67+
): Promise<FoundResource[]> {
68+
try {
69+
const result = await cc.getResource({
70+
TypeName: typeName,
71+
Identifier: exactIdentifier,
72+
});
73+
if (!result.ResourceDescription) {
74+
throw new ContextProviderError('Unexpected CloudControl API behavior: returned empty response');
75+
}
76+
77+
return [foundResourceFromCcApi(result.ResourceDescription)];
78+
} catch (err: any) {
79+
if (err instanceof ResourceNotFoundException || (err as any).name === 'ResourceNotFoundException') {
80+
throw new ZeroResourcesFoundError(`No resource of type ${typeName} with identifier: ${exactIdentifier}`);
81+
}
82+
if (!(err instanceof ContextProviderError)) {
83+
throw new ContextProviderError(`Encountered CC API error while getting ${typeName} resource ${exactIdentifier}: ${err.message}`);
84+
}
85+
throw err;
86+
}
87+
}
88+
89+
/**
90+
* Calls listResources from CC API to get the resources and apply args.propertyMatch to find the resources.
91+
* See https://docs.aws.amazon.com/cli/latest/reference/cloudcontrol/list-resources.html
92+
*
93+
* Will return 0 or more resources.
94+
*
95+
* Does not currently paginate through more than one result page.
96+
*/
97+
private async listResources(
98+
cc: ICloudControlClient,
99+
typeName: string,
100+
propertyMatch: Record<string, unknown>,
101+
expectedMatchCount?: CcApiContextQuery['expectedMatchCount'],
102+
): Promise<FoundResource[]> {
103+
try {
104+
const result = await cc.listResources({
105+
TypeName: typeName,
106+
107+
});
108+
const found = (result.ResourceDescriptions ?? [])
109+
.map(foundResourceFromCcApi)
110+
.filter((r) => {
111+
return Object.entries(propertyMatch).every(([propPath, expected]) => {
112+
const actual = findJsonValue(r.properties, propPath);
113+
return propertyMatchesFilter(actual, expected);
114+
});
115+
});
116+
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+
124+
return found;
125+
} catch (err: any) {
126+
if (!(err instanceof ContextProviderError) && !(err instanceof ZeroResourcesFoundError)) {
127+
throw new ContextProviderError(`Encountered CC API error while listing ${typeName} resources matching ${JSON.stringify(propertyMatch)}: ${err.message}`);
128+
}
129+
throw err;
130+
}
131+
}
132+
}
133+
134+
/**
135+
* Convert a CC API response object into a nicer object (parse the JSON)
136+
*/
137+
function foundResourceFromCcApi(desc: ResourceDescription): FoundResource {
138+
return {
139+
identifier: desc.Identifier ?? '*MISSING*',
140+
properties: JSON.parse(desc.Properties ?? '{}'),
141+
};
142+
}
143+
144+
/**
145+
* Whether the given property value matches the given filter
146+
*
147+
* For now we just check for strict equality, but we can implement pattern matching and fuzzy matching here later
148+
*/
149+
function propertyMatchesFilter(actual: unknown, expected: unknown) {
150+
return expected === actual;
151+
}
152+
153+
function isObject(x: unknown): x is {[key: string]: unknown} {
154+
return typeof x === 'object' && x !== null && !Array.isArray(x);
155+
}
156+
157+
/**
158+
* A parsed version of the return value from CCAPI
159+
*/
160+
interface FoundResource {
161+
readonly identifier: string;
162+
readonly properties: Record<string, unknown>;
163+
}
164+
165+
/**
166+
* A specific lookup failure indicating 0 resources found that can be recovered
167+
*/
168+
class ZeroResourcesFoundError extends Error {
169+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { EndpointServiceAvailabilityZonesContextQuery } from '@aws-cdk/cloud-assembly-schema';
2+
import type { IContextProviderMessages } from '.';
3+
import { type SdkProvider, initContextProviderSdk } from '../api/aws-auth';
4+
import type { ContextProviderPlugin } from '../api/plugin';
5+
6+
/**
7+
* Plugin to retrieve the Availability Zones for an endpoint service
8+
*/
9+
export class EndpointServiceAZContextProviderPlugin implements ContextProviderPlugin {
10+
constructor(private readonly aws: SdkProvider, private readonly io: IContextProviderMessages) {
11+
}
12+
13+
public async getValue(args: EndpointServiceAvailabilityZonesContextQuery) {
14+
const region = args.region;
15+
const account = args.account;
16+
const serviceName = args.serviceName;
17+
await this.io.debug(`Reading AZs for ${account}:${region}:${serviceName}`);
18+
const ec2 = (await initContextProviderSdk(this.aws, args)).ec2();
19+
const response = await ec2.describeVpcEndpointServices({
20+
ServiceNames: [serviceName],
21+
});
22+
23+
// expect a service in the response
24+
if (!response.ServiceDetails || response.ServiceDetails.length === 0) {
25+
await this.io.debug(`Could not retrieve service details for ${account}:${region}:${serviceName}`);
26+
return [];
27+
}
28+
const azs = response.ServiceDetails[0].AvailabilityZones;
29+
await this.io.debug(`Endpoint service ${account}:${region}:${serviceName} is available in availability zones ${azs}`);
30+
return azs;
31+
}
32+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { HostedZoneContextQuery } from '@aws-cdk/cloud-assembly-schema';
2+
import type { HostedZone } from '@aws-sdk/client-route-53';
3+
import type { IContextProviderMessages } from '.';
4+
import type { IRoute53Client, SdkProvider } from '../api/aws-auth';
5+
import { initContextProviderSdk } from '../api/aws-auth';
6+
import type { ContextProviderPlugin } from '../api/plugin';
7+
import { ContextProviderError } from '../api/toolkit-error';
8+
9+
export class HostedZoneContextProviderPlugin implements ContextProviderPlugin {
10+
constructor(private readonly aws: SdkProvider, private readonly io: IContextProviderMessages) {
11+
}
12+
13+
public async getValue(args: HostedZoneContextQuery): Promise<object> {
14+
const account = args.account;
15+
const region = args.region;
16+
if (!this.isHostedZoneQuery(args)) {
17+
throw new ContextProviderError(`HostedZoneProvider requires domainName property to be set in ${args}`);
18+
}
19+
const domainName = args.domainName;
20+
await this.io.debug(`Reading hosted zone ${account}:${region}:${domainName}`);
21+
const r53 = (await initContextProviderSdk(this.aws, args)).route53();
22+
const response = await r53.listHostedZonesByName({ DNSName: domainName });
23+
if (!response.HostedZones) {
24+
throw new ContextProviderError(`Hosted Zone not found in account ${account}, region ${region}: ${domainName}`);
25+
}
26+
const candidateZones = await this.filterZones(r53, response.HostedZones, args);
27+
if (candidateZones.length !== 1) {
28+
const filteProps = `dns:${domainName}, privateZone:${args.privateZone}, vpcId:${args.vpcId}`;
29+
throw new ContextProviderError(`Found zones: ${JSON.stringify(candidateZones)} for ${filteProps}, but wanted exactly 1 zone`);
30+
}
31+
32+
return {
33+
Id: candidateZones[0].Id,
34+
Name: candidateZones[0].Name,
35+
};
36+
}
37+
38+
private async filterZones(
39+
r53: IRoute53Client,
40+
zones: HostedZone[],
41+
props: HostedZoneContextQuery,
42+
): Promise<HostedZone[]> {
43+
let candidates: HostedZone[] = [];
44+
const domainName = props.domainName.endsWith('.') ? props.domainName : `${props.domainName}.`;
45+
await this.io.debug(`Found the following zones ${JSON.stringify(zones)}`);
46+
candidates = zones.filter((zone) => zone.Name === domainName);
47+
await this.io.debug(`Found the following matched name zones ${JSON.stringify(candidates)}`);
48+
if (props.privateZone) {
49+
candidates = candidates.filter((zone) => zone.Config && zone.Config.PrivateZone);
50+
} else {
51+
candidates = candidates.filter((zone) => !zone.Config || !zone.Config.PrivateZone);
52+
}
53+
if (props.vpcId) {
54+
const vpcZones: HostedZone[] = [];
55+
for (const zone of candidates) {
56+
const data = await r53.getHostedZone({ Id: zone.Id });
57+
if (!data.VPCs) {
58+
await this.io.debug(`Expected VPC for private zone but no VPC found ${zone.Id}`);
59+
continue;
60+
}
61+
if (data.VPCs.map((vpc) => vpc.VPCId).includes(props.vpcId)) {
62+
vpcZones.push(zone);
63+
}
64+
}
65+
return vpcZones;
66+
}
67+
return candidates;
68+
}
69+
70+
private isHostedZoneQuery(props: HostedZoneContextQuery | any): props is HostedZoneContextQuery {
71+
return (props as HostedZoneContextQuery).domainName !== undefined;
72+
}
73+
}

0 commit comments

Comments
 (0)