UNPKG

boostr

Version:
577 lines 24 kB
import { statSync, createReadStream } from 'fs'; import { join } from 'path'; import walkSync from 'walk-sync'; import isEqual from 'lodash/isEqual.js'; import hasha from 'hasha'; import minimatch from 'minimatch'; import bytes from 'bytes'; import mime from 'mime'; import { sleep } from '@layr/utilities'; import { AWSBaseResource, getS3WebsiteDomainName } from './base.js'; const DEFAULT_INDEX_PAGE = 'index.html'; const DEFAULT_IMMUTABLE_FILES = ['**/*.immutable.*']; const DEFAULT_CLOUDFRONT_PRICE_CLASS = 'PriceClass_100'; const CONFIG_FILE_S3_KEY = '.boostr.json'; const IMMUTABLE_FILES_MAX_AGE = 3153600000; // 100 years const CLOUDFRONT_CACHING_MIN_TTL = 0; const CLOUDFRONT_CACHING_DEFAULT_TTL = 86400; // 1 day const CLOUDFRONT_CACHING_MAX_TTL = 3153600000; // 100 years const CLOUDFRONT_ERROR_CACHING_MIN_TTL = 86400; // 1 day const CLOUDFRONT_DISTRIBUTION_CUSTOM_ERROR_RESPONSES = { Quantity: 1, Items: [ { ErrorCode: 404, ResponseCode: '200', ResponsePagePath: `/${DEFAULT_INDEX_PAGE}`, ErrorCachingMinTTL: CLOUDFRONT_ERROR_CACHING_MIN_TTL } ] }; const CLOUDFRONT_HOSTED_ZONE_ID = 'Z2FDTNDATAQYW2'; export class AWSWebsiteResource extends AWSBaseResource { constructor(config, options = {}) { super(config, options); } normalizeConfig(config) { const { directory, indexPage = DEFAULT_INDEX_PAGE, immutableFiles = DEFAULT_IMMUTABLE_FILES, cloudFront: { priceClass = DEFAULT_CLOUDFRONT_PRICE_CLASS } = {}, ...otherAttributes } = config; if (!directory) { this.throwError(`A 'directory' property is required in the configuration`); } return { ...super.normalizeConfig(otherAttributes), directory, indexPage, immutableFiles, cloudFront: { priceClass } }; } async deploy() { const config = this.getConfig(); this.logMessage(`Starting the deployment of a website to AWS...`); await this.getRoute53HostedZone(); await this.createOrUpdateS3Bucket(); const changes = await this.synchronizeFiles(); const hasBeenCreated = await this.createOrUpdateCloudFrontDistribution(); if (!hasBeenCreated && changes.length > 0) { await this.runCloudFrontInvalidation(changes); } await this.createOrUpdateCloudFrontDomainName(); this.logMessage(`Deployment completed`); this.logMessage(`The website should be available at https://${config.domainName}`); } // === S3 === async createOrUpdateS3Bucket() { const config = this.getConfig(); const s3 = this.getS3Client(); this.logMessage(`Checking the S3 bucket...`); const tags = await this.getS3BucketTags(); if (tags === undefined) { // The bucket doesn't exist yet this.logMessage(`Creating the S3 bucket...`); const params = { Bucket: this.getS3BucketName(), ACL: 'public-read' }; if (config.region !== 'us-east-1') { params.CreateBucketConfiguration = { LocationConstraint: config.region }; } await s3.createBucket(params).promise(); await s3 .putBucketTagging({ Bucket: this.getS3BucketName(), Tagging: { TagSet: [{ Key: 'managed-by', Value: this.constructor.managerIdentifiers[0] }] } }) .promise(); await s3 .putBucketWebsite({ Bucket: this.getS3BucketName(), WebsiteConfiguration: { IndexDocument: { Suffix: config.indexPage } } }) .promise(); await s3.waitFor('bucketExists', { Bucket: this.getS3BucketName() }).promise(); await sleep(5000); // 5 secs } else { // The bucket already exists if (!tags.some(({ Key, Value }) => Key === 'managed-by' && this.constructor.managerIdentifiers.includes(Value))) { this.throwError(`Cannot use a S3 bucket that was not originally created by this tool (bucket name: '${this.getS3BucketName()}')`); } const locationConstraint = (await s3.getBucketLocation({ Bucket: this.getS3BucketName() }).promise()) .LocationConstraint || 'us-east-1'; if (locationConstraint !== config.region) { this.throwError(`Sorry, it is not currently possible to change the region of a S3 bucket. Please remove the bucket '${this.getS3BucketName()}' manually or set 'region' to '${locationConstraint}'.`); } const websiteConfiguration = await s3 .getBucketWebsite({ Bucket: this.getS3BucketName() }) .promise(); if (websiteConfiguration.IndexDocument?.Suffix !== config.indexPage) { this.logMessage(`Updating the S3 bucket website configuration...`); websiteConfiguration.IndexDocument = { Suffix: config.indexPage }; await s3 .putBucketWebsite({ Bucket: this.getS3BucketName(), WebsiteConfiguration: websiteConfiguration }) .promise(); } } } async getS3BucketTags() { const s3 = this.getS3Client(); try { return (await s3.getBucketTagging({ Bucket: this.getS3BucketName() }).promise()).TagSet; } catch (err) { if (err.code === 'NoSuchTagSet') { return []; } if (err.code === 'NoSuchBucket') { return undefined; } if (err.code === 'AccessDenied') { this.throwError(`Access denied to the S3 bucket '${this.getS3BucketName()}'`); } throw err; } } async synchronizeFiles() { const config = this.getConfig(); const s3 = this.getS3Client(); this.logMessage(`Synchronizing the files...`); const files = walkSync(config.directory, { directories: false, ignore: ['**/.*'] }); const previousConfig = await this.loadConfigFromS3(); const s3Files = await this.listS3Files(); const changes = new Array(); let addedFiles = 0; let updatedFiles = 0; let removedFiles = 0; for (const file of files) { const absoluteFile = join(config.directory, file); const md5 = hasha.fromFileSync(absoluteFile, { algorithm: 'md5' }); const size = statSync(absoluteFile).size; const isImmutable = matchFilePatterns(file, config.immutableFiles); let s3File; const index = s3Files.findIndex(({ path }) => path === file); if (index !== -1) { s3File = s3Files[index]; s3Files.splice(index, 1); } if (s3File !== undefined && s3File.size === size && s3File.md5 === md5) { const wasImmutable = matchFilePatterns(file, previousConfig.immutableFiles); if (isImmutable === wasImmutable) { continue; // No changes } } this.logMessage(`Uploading '${file}' (${bytes(size)}) to S3...`); const params = { Bucket: this.getS3BucketName(), Key: file, ACL: 'public-read', Body: createReadStream(absoluteFile), ContentType: mime.getType(file) ?? 'application/octet-stream', ContentMD5: Buffer.from(md5, 'hex').toString('base64') }; if (isImmutable) { params.CacheControl = `max-age=${IMMUTABLE_FILES_MAX_AGE}`; } await s3.putObject(params).promise(); if (s3File === undefined) { addedFiles++; } else { updatedFiles++; } changes.push(file); } for (const s3File of s3Files) { this.logMessage(`Removing '${s3File.path}' from S3...`); await s3.deleteObject({ Bucket: this.getS3BucketName(), Key: s3File.path }).promise(); removedFiles++; changes.push(s3File.path); } if (!isEqual(config, previousConfig)) { await this.saveConfigToS3(); } let info = ''; const buildInfo = (operation, fileCount) => { if (fileCount === 0) { return; } if (info !== '') { info += ', '; } info += `${fileCount} file`; if (fileCount > 1) { info += 's'; } info += ` ${operation}`; }; buildInfo('added', addedFiles); buildInfo('updated', updatedFiles); buildInfo('removed', removedFiles); if (info === '') { info = 'no changes'; } this.logMessage(`Synchronization completed (${info})`); return changes; } async listS3Files() { const s3 = this.getS3Client(); this.logMessage(`Listing the existing files in S3...`); const files = []; let nextContinuationToken; do { const result = await s3 .listObjectsV2({ Bucket: this.getS3BucketName(), ContinuationToken: nextContinuationToken }) .promise(); for (const item of result.Contents) { const path = item.Key; const size = item.Size; const md5 = item.ETag.slice(1, -1); if (path[0] !== '.') { files.push({ path, size, md5 }); } } nextContinuationToken = result.NextContinuationToken; } while (nextContinuationToken); return files; } async loadConfigFromS3() { const s3 = this.getS3Client(); try { const result = await s3 .getObject({ Bucket: this.getS3BucketName(), Key: CONFIG_FILE_S3_KEY }) .promise(); return JSON.parse(result.Body); } catch (err) { if (err.code === 'NoSuchKey') { return {}; } throw err; } } async saveConfigToS3() { const config = this.getConfig(); const s3 = this.getS3Client(); const body = JSON.stringify(config); const md5 = hasha(body, { algorithm: 'md5' }); const contentMD5 = Buffer.from(md5, 'hex').toString('base64'); await s3 .putObject({ Bucket: this.getS3BucketName(), Key: CONFIG_FILE_S3_KEY, Body: body, ContentType: 'application/json', ContentMD5: contentMD5 }) .promise(); } getS3BucketName() { return this.getConfig().domainName; } // === CloudFront === async createOrUpdateCloudFrontDistribution() { let hasBeenCreated; let status = await this.checkCloudFrontDistribution(); if (status === 'NOT_FOUND') { await this.createCloudFrontDistribution(); hasBeenCreated = true; status = 'DEPLOYING'; } else if (status === 'NEEDS_UPDATE') { await this.updateCloudFrontDistribution(); status = 'DEPLOYING'; } if (status === 'DEPLOYING') { await this.waitForCloudFrontDistributionDeployment(); } return hasBeenCreated; } async checkCloudFrontDistribution() { this.logMessage(`Checking the CloudFront distribution...`); const distribution = await this.getCloudFrontDistribution(); if (distribution === undefined) { return 'NOT_FOUND'; } await this.checkCloudFrontDistributionTags(); if (!distribution.Enabled) { this.throwError(`The CloudFront distribution is disabled (ARN: '${distribution.ARN}')`); } if (await this.checkIfCloudFrontDistributionNeedsUpdate()) { return 'NEEDS_UPDATE'; } if (distribution.Status !== 'Deployed') { return 'DEPLOYING'; } return 'OKAY'; } async getCloudFrontDistribution() { if (this._cloudFrontDistribution === undefined) { const config = this.getConfig(); const cloudFront = this.getCloudFrontClient(); this.logMessage(`Searching for an existing CloudFront distribution...`); const result = await cloudFront.listDistributions().promise(); for (const distribution of result.DistributionList.Items) { if (distribution.Aliases.Items.includes(config.domainName)) { this._cloudFrontDistribution = distribution; break; } } if (this._cloudFrontDistribution === undefined && result.DistributionList.IsTruncated) { this.throwError(`Whoa, you have a lot of CloudFront distributions! Unfortunately, this tool cannot list them all.`); } } return this._cloudFrontDistribution; } async createCloudFrontDistribution() { const config = this.getConfig(); const cloudFront = this.getCloudFrontClient(); const certificate = await this.ensureACMCertificate({ region: 'us-east-1' }); this.logMessage(`Creating the CloudFront distribution...`); const params = { DistributionConfigWithTags: { DistributionConfig: { CallerReference: String(Date.now()), Aliases: { Quantity: 1, Items: [config.domainName] }, DefaultRootObject: config.indexPage, Origins: this.generateCloudFrontDistributionOrigins(), DefaultCacheBehavior: this.generateCloudFrontDistributionDefaultCacheBehavior(), CacheBehaviors: { Quantity: 0, Items: [] }, CustomErrorResponses: CLOUDFRONT_DISTRIBUTION_CUSTOM_ERROR_RESPONSES, Comment: '', Logging: { Enabled: false, IncludeCookies: false, Bucket: '', Prefix: '' }, PriceClass: config.cloudFront.priceClass, Enabled: true, ViewerCertificate: { ACMCertificateArn: certificate.arn, SSLSupportMethod: 'sni-only', MinimumProtocolVersion: 'TLSv1', Certificate: certificate.arn, CertificateSource: 'acm' }, Restrictions: { GeoRestriction: { RestrictionType: 'none', Quantity: 0, Items: [] } }, WebACLId: '', HttpVersion: 'http2', IsIPV6Enabled: true }, Tags: { Items: [{ Key: 'managed-by', Value: this.constructor.managerIdentifiers[0] }] } } }; const { Distribution: distribution } = await cloudFront .createDistributionWithTags(params) .promise(); this._cloudFrontDistribution = distribution; return this._cloudFrontDistribution; } async checkIfCloudFrontDistributionNeedsUpdate() { const config = this.getConfig(); const distribution = (await this.getCloudFrontDistribution()); // if (!isEqual(distribution.Origins, this.generateCloudFrontDistributionOrigins())) { // return true; // } // if ( // !isEqual( // distribution.DefaultCacheBehavior, // this.generateCloudFrontDistributionDefaultCacheBehavior() // ) // ) { // return true; // } // if ( // !isEqual(distribution.CustomErrorResponses, CLOUDFRONT_DISTRIBUTION_CUSTOM_ERROR_RESPONSES) // ) { // return true; // } if (distribution.PriceClass !== config.cloudFront.priceClass) { return true; } return false; } async updateCloudFrontDistribution() { const config = this.getConfig(); const cloudFront = this.getCloudFrontClient(); this.logMessage(`Updating the CloudFront distribution...`); const distribution = (await this.getCloudFrontDistribution()); const { DistributionConfig: distConfig, ETag: eTag } = await cloudFront .getDistributionConfig({ Id: distribution.Id }) .promise(); distConfig.Origins = this.generateCloudFrontDistributionOrigins(); distConfig.DefaultCacheBehavior = this.generateCloudFrontDistributionDefaultCacheBehavior(); distConfig.CustomErrorResponses = CLOUDFRONT_DISTRIBUTION_CUSTOM_ERROR_RESPONSES; distConfig.PriceClass = config.cloudFront.priceClass; await cloudFront .updateDistribution({ Id: distribution.Id, IfMatch: eTag, DistributionConfig: distConfig }) .promise(); } generateCloudFrontDistributionOrigins() { const config = this.getConfig(); return { Quantity: 1, Items: [ { Id: config.domainName, DomainName: getS3WebsiteDomainName(this.getS3BucketName(), config.region), OriginPath: '', CustomHeaders: { Quantity: 0, Items: [] }, CustomOriginConfig: { HTTPPort: 80, HTTPSPort: 443, OriginProtocolPolicy: 'http-only', OriginSslProtocols: { Quantity: 3, Items: ['TLSv1', 'TLSv1.1', 'TLSv1.2'] }, OriginReadTimeout: 30, OriginKeepaliveTimeout: 30 }, ConnectionAttempts: 3, ConnectionTimeout: 10, OriginShield: { Enabled: false } } ] }; } generateCloudFrontDistributionDefaultCacheBehavior() { const config = this.getConfig(); return { TargetOriginId: config.domainName, ForwardedValues: { QueryString: false, Cookies: { Forward: 'none' }, Headers: { Quantity: 0, Items: [] }, QueryStringCacheKeys: { Quantity: 0, Items: [] } }, TrustedSigners: { Enabled: false, Quantity: 0, Items: [] }, TrustedKeyGroups: { Enabled: false, Quantity: 0, Items: [] }, ViewerProtocolPolicy: 'redirect-to-https', AllowedMethods: { Quantity: 2, Items: ['HEAD', 'GET'], CachedMethods: { Quantity: 2, Items: ['HEAD', 'GET'] } }, SmoothStreaming: false, MinTTL: CLOUDFRONT_CACHING_MIN_TTL, DefaultTTL: CLOUDFRONT_CACHING_DEFAULT_TTL, MaxTTL: CLOUDFRONT_CACHING_MAX_TTL, Compress: true, LambdaFunctionAssociations: { Quantity: 0, Items: [] }, FieldLevelEncryptionId: '' }; } async checkCloudFrontDistributionTags() { const cloudFront = this.getCloudFrontClient(); const distribution = (await this.getCloudFrontDistribution()); const result = await cloudFront.listTagsForResource({ Resource: distribution.ARN }).promise(); // isEqual(tag, {Key: 'managed-by', Value: MANAGER_IDENTIFIER}) if (!result.Tags.Items.some(({ Key, Value }) => Key === 'managed-by' && this.constructor.managerIdentifiers.includes(Value))) { this.throwError(`Cannot use a CloudFront distribution that was not originally created by this tool (ARN: '${distribution.ARN}')`); } } async runCloudFrontInvalidation(changes) { const config = this.getConfig(); const cloudFront = this.getCloudFrontClient(); if (changes.length === 0) { return; } this.logMessage(`Running the CloudFront invalidation...`); const distribution = (await this.getCloudFrontDistribution()); const paths = new Array(); for (const change of changes) { paths.push('/' + change); if (change.endsWith('/' + config.indexPage)) { // 'section/index.html' => /section/ paths.push('/' + change.slice(0, -config.indexPage.length)); } } const { Invalidation: invalidation } = await cloudFront .createInvalidation({ DistributionId: distribution.Id, InvalidationBatch: { CallerReference: String(Date.now()), Paths: { Quantity: paths.length, Items: paths } } }) .promise(); await this.waitForCloudFrontInvalidation(invalidation.Id); } async waitForCloudFrontDistributionDeployment() { const cloudFront = this.getCloudFrontClient(); this.logMessage(`Waiting for the CloudFront deployment (please be patient, it can take up to 30 minutes)...`); const distribution = (await this.getCloudFrontDistribution()); let totalSleepTime = 0; const maxSleepTime = 60 * 60 * 1000; // 1 hour const sleepTime = 30 * 1000; // 30 seconds do { await sleep(sleepTime); totalSleepTime += sleepTime; const result = await cloudFront.getDistribution({ Id: distribution.Id }).promise(); if (result.Distribution?.Status === 'Deployed') { return; } } while (totalSleepTime <= maxSleepTime); this.throwError(`CloudFront deployment uncompleted after ${totalSleepTime / 1000} seconds`); } async waitForCloudFrontInvalidation(invalidationId) { const cloudFront = this.getCloudFrontClient(); this.logMessage(`Waiting for the CloudFront invalidation...`); const distribution = (await this.getCloudFrontDistribution()); let totalSleepTime = 0; const maxSleepTime = 10 * 60 * 1000; // 10 minutes const sleepTime = 10000; // 10 seconds do { await sleep(sleepTime); totalSleepTime += sleepTime; const result = await cloudFront .getInvalidation({ DistributionId: distribution.Id, Id: invalidationId }) .promise(); if (result.Invalidation?.Status === 'Completed') { return; } } while (totalSleepTime <= maxSleepTime); this.throwError(`CloudFront invalidation uncompleted after ${totalSleepTime / 1000} seconds`); } async createOrUpdateCloudFrontDomainName() { const config = this.getConfig(); this.logMessage(`Checking the CloudFront domain name...`); const distribution = (await this.getCloudFrontDistribution()); await this.ensureRoute53Alias({ name: config.domainName, targetDomainName: distribution.DomainName, targetHostedZoneId: CLOUDFRONT_HOSTED_ZONE_ID }); } } AWSWebsiteResource.managerIdentifiers = [...AWSBaseResource.managerIdentifiers, 'aws-s3-hosted-website-v1']; function matchFilePatterns(file, patterns = []) { for (const pattern of patterns) { if (minimatch(file, pattern)) { return true; } } return false; } //# sourceMappingURL=website.js.map