UNPKG

boostr

Version:
491 lines 20.5 kB
import pick from 'lodash/pick.js'; import sortBy from 'lodash/sortBy.js'; import isEqual from 'lodash/isEqual.js'; import takeRight from 'lodash/takeRight.js'; import trimEnd from 'lodash/trimEnd.js'; import { sleep } from '@layr/utilities'; import { BaseResource } from '../base.js'; import { requireGlobalNPMPackage } from '../../npm.js'; const AWS_SDK_PACKAGE_VERSION = '2.1290.0'; const DEFAULT_ROUTE_53_TTL = 300; export class AWSBaseResource extends BaseResource { constructor(config, options = {}) { super(config, options); } normalizeConfig(config) { const { region, profile, accessKeyId, secretAccessKey, ...otherAttributes } = config; if (!region) { this.throwError(`A 'region' property is required in the configuration`); } return { ...super.normalizeConfig(otherAttributes), region, profile, accessKeyId, secretAccessKey }; } async initialize() { await super.initialize(); this._AWS = await requireGlobalNPMPackage('aws-sdk', AWS_SDK_PACKAGE_VERSION, { serviceName: this.getServiceName() }); } getIAMClient() { if (this._iamClient === undefined) { this._iamClient = new this._AWS.IAM({ ...this.buildAWSConfig(), apiVersion: '2010-05-08' }); } return this._iamClient; } getLambdaClient() { if (this._lambdaClient === undefined) { this._lambdaClient = new this._AWS.Lambda({ ...this.buildAWSConfig(), apiVersion: '2015-03-31' }); } return this._lambdaClient; } getEventBridgeClient() { if (this._eventBridgeClient === undefined) { this._eventBridgeClient = new this._AWS.EventBridge({ ...this.buildAWSConfig(), apiVersion: '2015-10-07' }); } return this._eventBridgeClient; } getS3Client() { if (this._s3Client === undefined) { this._s3Client = new this._AWS.S3({ ...this.buildAWSConfig(), apiVersion: '2006-03-01' }); } return this._s3Client; } getAPIGatewayV2Client() { if (this._apiGatewayV2Client === undefined) { this._apiGatewayV2Client = new this._AWS.ApiGatewayV2({ ...this.buildAWSConfig(), apiVersion: '2018-11-29' }); } return this._apiGatewayV2Client; } getCloudFrontClient() { if (this._cloudFrontClient === undefined) { this._cloudFrontClient = new this._AWS.CloudFront({ ...this.buildAWSConfig(), apiVersion: '2019-03-26' }); } return this._cloudFrontClient; } async getRoute53HostedZone({ throwIfMissing = true } = {}) { if (this._route53HostedZone === undefined) { const hostedZone = await this.findRoute53HostedZone(this.getConfig().domainName); if (hostedZone !== undefined) { this._route53HostedZone = { id: hostedZone.Id }; } } if (this._route53HostedZone === undefined && throwIfMissing) { this.throwError(`Couldn't get the Route 53 hosted zone. Please make sure your domain name is hosted by Route 53.`); } return this._route53HostedZone; } async ensureRoute53CNAME({ name, value }) { const route53 = this.getRoute53Client(); this.logMessage(`Checking the Route53 CNAME...`); name = trimEnd(name, '.'); value = trimEnd(value, '.'); const hostedZone = (await this.getRoute53HostedZone()); const recordSet = await this.findRoute53RecordSet({ hostedZoneId: hostedZone.id, name, type: 'CNAME' }); let isMissingOrDifferent = false; if (recordSet === undefined) { this.logMessage(`Creating the Route53 CNAME...`); isMissingOrDifferent = true; } else if (recordSet.ResourceRecords?.[0]?.Value !== value) { this.logMessage(`Updating the Route53 CNAME...`); isMissingOrDifferent = true; } if (isMissingOrDifferent) { const { ChangeInfo: { Id: changeId } } = await route53 .changeResourceRecordSets({ HostedZoneId: hostedZone.id, ChangeBatch: { Changes: [ { Action: 'UPSERT', ResourceRecordSet: { Name: name + '.', Type: 'CNAME', ResourceRecords: [{ Value: value }], TTL: DEFAULT_ROUTE_53_TTL } } ] } }) .promise(); await this.waitForRoute53RecordSetChange(changeId); } return true; } async ensureRoute53Alias({ name, targetDomainName, targetHostedZoneId }) { const route53 = this.getRoute53Client(); name = trimEnd(name, '.'); targetDomainName = trimEnd(targetDomainName, '.'); this.logMessage(`Checking the Route53 Alias...`); const hostedZone = (await this.getRoute53HostedZone()); const recordSet = await this.findRoute53RecordSet({ hostedZoneId: hostedZone.id, name, type: 'A' }); let isMissingOrDifferent = false; if (recordSet === undefined) { this.logMessage(`Creating the Route53 Alias...`); isMissingOrDifferent = true; } else if (recordSet.AliasTarget?.DNSName !== targetDomainName + '.') { this.logMessage(`Updating the Route53 Alias...`); isMissingOrDifferent = true; } if (isMissingOrDifferent) { const { ChangeInfo: { Id: changeId } } = await route53 .changeResourceRecordSets({ HostedZoneId: hostedZone.id, ChangeBatch: { Changes: [ { Action: 'UPSERT', ResourceRecordSet: { Name: name + '.', Type: 'A', AliasTarget: { DNSName: targetDomainName + '.', HostedZoneId: targetHostedZoneId, EvaluateTargetHealth: false } } } ] } }) .promise(); await this.waitForRoute53RecordSetChange(changeId); } return true; } async findRoute53HostedZone(domainName) { const route53 = this.getRoute53Client(); this.logMessage(`Searching for the Route 53 hosted zone...`); const dnsName = takeRight(domainName.split('.'), 2).join('.'); const result = await route53.listHostedZonesByName({ DNSName: dnsName }).promise(); let bestHostedZone; for (const hostedZone of result.HostedZones) { if (domainName + '.' === hostedZone.Name || (domainName + '.').endsWith('.' + hostedZone.Name)) { if (bestHostedZone === undefined || hostedZone.Name.length > bestHostedZone.Name.length) { bestHostedZone = hostedZone; } } } if (bestHostedZone !== undefined) { return bestHostedZone; } if (result.IsTruncated) { this.throwError(`Whoa, you have a lot of Route 53 hosted zones! Unfortunately, this tool cannot list them all.`); } return undefined; } async findRoute53RecordSet({ hostedZoneId, name, type }) { const route53 = this.getRoute53Client(); this.logMessage(`Searching for the Route 53 record set...`); name += '.'; const result = await route53 .listResourceRecordSets({ HostedZoneId: hostedZoneId, StartRecordName: name, StartRecordType: type }) .promise(); const recordSet = result.ResourceRecordSets.find((recordSet) => recordSet.Name === name && recordSet.Type === type); if (recordSet) { return recordSet; } if (result.IsTruncated) { this.throwError(`Whoa, you have a lot of Route 53 record sets! Unfortunately, this tool cannot list them all.`); } return undefined; } async waitForRoute53RecordSetChange(changeId) { const route53 = this.getRoute53Client(); this.logMessage(`Waiting for the Route 53 record set change to complete...`); let totalSleepTime = 0; const maxSleepTime = 3 * 60 * 1000; // 3 minutes const sleepTime = 5000; // 5 seconds do { await sleep(sleepTime); totalSleepTime += sleepTime; const result = await route53.getChange({ Id: changeId }).promise(); if (result.ChangeInfo.Status !== 'PENDING') { return; } } while (totalSleepTime <= maxSleepTime); this.throwError(`Route 53 record set change uncompleted after ${totalSleepTime / 1000} seconds`); } getRoute53Client() { if (this._route53Client === undefined) { this._route53Client = new this._AWS.Route53({ ...this.buildAWSConfig(), apiVersion: '2013-04-01' }); } return this._route53Client; } // === ACM === async ensureACMCertificate({ region } = {}) { this.logMessage(`Checking the ACM Certificate...`); let certificate = await this.getACMCertificate({ region, throwIfMissing: false }); if (certificate === undefined) { certificate = await this.createACMCertificate({ region }); } return certificate; } async getACMCertificate({ region, throwIfMissing = true } = {}) { if (this._acmCertificate === undefined) { const certificate = await this.findACMCertificate(this.getConfig().domainName, { region }); if (certificate !== undefined) { const arn = certificate.CertificateArn; if (certificate.Status === 'PENDING_VALIDATION') { await this.waitForACMCertificateValidation(arn, { region }); } this._acmCertificate = { arn }; } } if (this._acmCertificate === undefined && throwIfMissing) { this.throwError(`Couldn't get the ACM Certificate`); } return this._acmCertificate; } async createACMCertificate({ region } = {}) { const acm = this.getACMClient({ region }); this.logMessage(`Creating the ACM Certificate...`); const result = await acm .requestCertificate({ DomainName: this.getConfig().domainName, ValidationMethod: 'DNS', Tags: [{ Key: 'managed-by', Value: this.constructor.managerIdentifiers[0] }] }) .promise(); const arn = result.CertificateArn; const validationCNAME = await this.getACMCertificateValidationCNAME(arn, { region }); await this.ensureRoute53CNAME(validationCNAME); await this.waitForACMCertificateValidation(arn, { region }); this._acmCertificate = { arn }; return this._acmCertificate; } async findACMCertificate(domainName, { region }) { const acm = this.getACMClient({ region }); let rootDomainName; const parts = domainName.split('.'); if (parts.length > 2) { rootDomainName = parts.slice(1).join('.'); } const result = await acm .listCertificates({ CertificateStatuses: ['ISSUED', 'PENDING_VALIDATION'], Includes: { // We need the following because 'RSA_4096' is not included by default keyTypes: [ 'RSA_2048', 'RSA_1024', 'RSA_4096', 'EC_prime256v1', 'EC_secp384r1', 'EC_secp521r1' ] }, MaxItems: 1000 }) .promise(); const certificates = result.CertificateSummaryList.filter((certificate) => { if (certificate.DomainName === domainName) { return true; } if (rootDomainName !== undefined) { if (certificate.DomainName === '*.' + rootDomainName) { return true; } } return false; }); let bestCertificates = []; const bestCertificatesMatchedName = new Map(); for (let certificate of certificates) { const result = await acm .describeCertificate({ CertificateArn: certificate.CertificateArn }) .promise(); const certificateDetail = result.Certificate; let matchedName; for (const name of certificateDetail.SubjectAlternativeNames) { if (name === domainName || (rootDomainName !== undefined && name === '*.' + rootDomainName)) { if (matchedName === undefined || matchedName.length < name.length) { matchedName = name; } } } if (matchedName !== undefined) { bestCertificatesMatchedName.set(certificateDetail, matchedName); bestCertificates.push(certificateDetail); } } bestCertificates = sortBy(bestCertificates, (certificate) => -bestCertificatesMatchedName.get(certificate).length); for (const certificate of bestCertificates) { if (certificate.Status === 'ISSUED') { return certificate; } } for (const certificate of bestCertificates) { if (certificate.Status === 'PENDING_VALIDATION') { const result = await acm .listTagsForCertificate({ CertificateArn: certificate.CertificateArn }) .promise(); if (result.Tags.some((tag) => isEqual(tag, { Key: 'managed-by', Value: this.constructor.managerIdentifiers[0] }))) { return certificate; } } } if (result.NextToken) { this.throwError(`Whoa, you have a lot of ACM Certificates! Unfortunately, this tool cannot list them all.`); } return undefined; } async getACMCertificateValidationCNAME(arn, { region }) { const acm = this.getACMClient({ region }); this.logMessage(`Getting the ACM Certificate DNS Validation record...`); let totalSleepTime = 0; const maxSleepTime = 60 * 1000; // 1 minute const sleepTime = 5 * 1000; // 5 seconds do { await sleep(sleepTime); totalSleepTime += sleepTime; const { Certificate: certificate } = await acm .describeCertificate({ CertificateArn: arn }) .promise(); const record = certificate?.DomainValidationOptions?.[0].ResourceRecord; if (record?.Type === 'CNAME') { return { name: record.Name, value: record.Value }; } } while (totalSleepTime <= maxSleepTime); this.throwError(`Couldn't get the ACM Certificate DNS Validation record after ${totalSleepTime / 1000} seconds`); } async waitForACMCertificateValidation(arn, { region }) { const acm = this.getACMClient({ region }); this.logMessage(`Waiting for the ACM Certificate validation...`); let totalSleepTime = 0; const maxSleepTime = 60 * 60 * 1000; // 1 hour const sleepTime = 10000; // 10 seconds do { await sleep(sleepTime); totalSleepTime += sleepTime; const result = await acm .describeCertificate({ CertificateArn: arn }) .promise(); if (result.Certificate?.Status === 'ISSUED') { return; } } while (totalSleepTime <= maxSleepTime); this.throwError(`ACM Certificate has not been validated after ${totalSleepTime / 1000} seconds`); } getACMClient({ region } = {}) { if (this._acmClients === undefined) { this._acmClients = {}; } const regionKey = region !== undefined ? region : '$config'; if (this._acmClients[regionKey] === undefined) { this._acmClients[regionKey] = new this._AWS.ACM({ ...this.buildAWSConfig({ region }), apiVersion: '2015-12-08' }); } return this._acmClients[regionKey]; } // === AWS Config === buildAWSConfig({ region } = {}) { const config = this.getConfig(); let credentials = {}; if (config.profile !== undefined) { const profileCredentials = new this._AWS.SharedIniFileCredentials({ profile: config.profile }); credentials = pick(profileCredentials, ['accessKeyId', 'secretAccessKey']); } if (config.accessKeyId !== undefined) { credentials.accessKeyId = config.accessKeyId; } if (config.secretAccessKey !== undefined) { credentials.secretAccessKey = config.secretAccessKey; } return { ...credentials, region: region ?? config.region, signatureVersion: 'v4' }; } } const S3_REGIONS = { 'us-east-1': { websiteEndpoint: 's3-website-us-east-1.amazonaws.com' }, 'us-east-2': { websiteEndpoint: 's3-website.us-east-2.amazonaws.com' }, 'us-west-1': { websiteEndpoint: 's3-website-us-west-1.amazonaws.com' }, 'us-west-2': { websiteEndpoint: 's3-website-us-west-2.amazonaws.com' }, 'ca-central-1': { websiteEndpoint: 's3-website.ca-central-1.amazonaws.com' }, 'sa-east-1': { websiteEndpoint: 's3-website-sa-east-1.amazonaws.com' }, 'ap-east-1': { websiteEndpoint: 's3-website.ap-east-1.amazonaws.com' }, 'ap-south-1': { websiteEndpoint: 's3-website.ap-south-1.amazonaws.com' }, 'ap-northeast-1': { websiteEndpoint: 's3-website-ap-northeast-1.amazonaws.com' }, 'ap-northeast-2': { websiteEndpoint: 's3-website.ap-northeast-2.amazonaws.com' }, 'ap-northeast-3': { websiteEndpoint: 's3-website.ap-northeast-3.amazonaws.com' }, 'ap-southeast-1': { websiteEndpoint: 's3-website-ap-southeast-1.amazonaws.com' }, 'ap-southeast-2': { websiteEndpoint: 's3-website-ap-southeast-2.amazonaws.com' }, 'cn-northwest-1': { websiteEndpoint: 's3-website.cn-northwest-1.amazonaws.com.cn' }, 'eu-central-1': { websiteEndpoint: 's3-website.eu-central-1.amazonaws.com' }, 'eu-west-1': { websiteEndpoint: 's3-website-eu-west-1.amazonaws.com' }, 'eu-west-2': { websiteEndpoint: 's3-website.eu-west-2.amazonaws.com' }, 'eu-west-3': { websiteEndpoint: 's3-website.eu-west-3.amazonaws.com' }, 'eu-north-1': { websiteEndpoint: 's3-website.eu-north-1.amazonaws.com' }, 'af-south-1': { websiteEndpoint: 's3-website.af-south-1.amazonaws.com' } }; export function getS3Endpoint(bucketName) { return `${bucketName}.s3.amazonaws.com`; } export function getS3WebsiteEndpoint(regionName = 'us-east-1') { const region = S3_REGIONS[regionName]; if (region === undefined) { throw new Error(`Sorry, the AWS S3 region '${regionName}' is not supported yet`); } return region.websiteEndpoint; } export function getS3WebsiteDomainName(bucketName, regionName) { return `${bucketName}.${getS3WebsiteEndpoint(regionName)}`; } export function formatS3URL({ bucket, key }) { return `https://${getS3Endpoint(bucket)}/${key}`; } export function parseS3URL(url) { const matches = url.match(/^https:\/\/(.+)\.s3\.amazonaws\.com\/(.+)$/i); if (matches === null) { throw new Error(`The S3 URL '${url}' is invalid`); } return { bucket: matches[1], key: matches[2] }; } //# sourceMappingURL=base.js.map