Skip to content

Commit a9e3f74

Browse files
committed
refactor(toolkit): move context providers
1 parent c2bf1d0 commit a9e3f74

25 files changed

+1340
-1329
lines changed
Lines changed: 55 additions & 0 deletions
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+
}
Lines changed: 28 additions & 0 deletions
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+
}
Lines changed: 169 additions & 0 deletions
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+
}
Lines changed: 32 additions & 0 deletions
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+
}
Lines changed: 73 additions & 0 deletions
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)