From 854185e10cb979125f1854fcfc31487cb529318b Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Wed, 1 Dec 2021 20:53:30 -0500 Subject: [PATCH] microapps-publish - Optional app overwrite (#154) * Optional App Overwrite * Overwrite param Deployer requests * Update docs for overwrite param * Actually pass overwrite flag to Deployer * Set defaultFile again on overwrite * Add noCache flag to microapps-publish --- packages/microapps-deployer-lib/src/index.ts | 22 +++ .../src/controllers/VersionController.ts | 160 ++++++++++-------- .../src/commands/nextjs-docker-auto.ts | 59 +++++-- .../src/commands/nextjs-version-restore.ts | 7 +- .../src/commands/nextjs-version.ts | 7 +- .../src/commands/preflight.ts | 32 ++-- .../src/commands/publish-static.ts | 72 +++++--- .../microapps-publish/src/commands/publish.ts | 59 +++++-- .../microapps-publish/src/lib/DeployClient.ts | 20 ++- 9 files changed, 278 insertions(+), 160 deletions(-) diff --git a/packages/microapps-deployer-lib/src/index.ts b/packages/microapps-deployer-lib/src/index.ts index de923a7d..26055881 100644 --- a/packages/microapps-deployer-lib/src/index.ts +++ b/packages/microapps-deployer-lib/src/index.ts @@ -4,13 +4,35 @@ export interface IRequestBase { export interface ICreateApplicationRequest extends IRequestBase { readonly type: 'createApp'; + + /** + * Name of the application + */ readonly appName: string; + + /** + * Display name of the application + */ readonly displayName: string; } export interface IDeployVersionRequestBase extends IRequestBase { + /** + * Name of the application + */ readonly appName: string; + + /** + * SemVer being published + */ readonly semVer: string; + + /** + * Allow overwrite of existing version + * + * @default false; + */ + readonly overwrite?: boolean; } export interface IDeployVersionPreflightRequest extends IDeployVersionRequestBase { diff --git a/packages/microapps-deployer/src/controllers/VersionController.ts b/packages/microapps-deployer/src/controllers/VersionController.ts index af994d67..d549e98a 100644 --- a/packages/microapps-deployer/src/controllers/VersionController.ts +++ b/packages/microapps-deployer/src/controllers/VersionController.ts @@ -41,7 +41,7 @@ export default class VersionController { config: IConfig; }): Promise { const { dbManager, request, config } = opts; - const { appName, semVer, needS3Creds = true } = request; + const { appName, semVer, needS3Creds = true, overwrite = false } = request; // Check if the version exists const record = await Version.LoadVersion({ @@ -49,69 +49,80 @@ export default class VersionController { key: { AppName: appName, SemVer: semVer }, }); if (record !== undefined && record.Status !== 'pending') { - // - // Version exists and has moved beyond pending status - // No need to create S3 upload credentials - // NOTE: This may change in the future if we allow - // mutability of versions (at own risk) - // - Log.Instance.info('App/Version already exists', { appName, semVer }); - return { statusCode: 200 }; - } else { - // - // Version does not exist - // Create S3 temp credentials for the static assets upload - // - - Log.Instance.info('App/Version does not exist', { appName, semVer }); - - // Get S3 creds if requested - if (needS3Creds) { - // Generate a temp policy for staging bucket app prefix - const iamPolicyDoc = new iamCDK.PolicyDocument({ - statements: [ - new iamCDK.PolicyStatement({ - effect: iamCDK.Effect.ALLOW, - actions: ['s3:PutObject', 's3:GetObject', 's3:AbortMultipartUpload'], - resources: [`arn:aws:s3:::${config.filestore.stagingBucket}/*`], - }), - new iamCDK.PolicyStatement({ - effect: iamCDK.Effect.ALLOW, - actions: ['s3:ListBucket'], - resources: [`arn:aws:s3:::${config.filestore.stagingBucket}`], - }), - ], + if (!overwrite) { + // + // Version exists and has moved beyond pending status + // No need to create S3 upload credentials + // NOTE: This may change in the future if we allow + // mutability of versions (at own risk) + // + Log.Instance.info('Error: App/Version already exists', { + appName: request.appName, + semVer: request.semVer, }); - Log.Instance.debug('Temp IAM Policy', { policy: JSON.stringify(iamPolicyDoc.toJSON()) }); + return { statusCode: 200 }; + } else { + Log.Instance.info('Warning: App/Version already exists', { + appName: request.appName, + semVer: request.semVer, + }); + } + } - // Assume the upload role with limited S3 permissions - const stsResult = await stsClient.send( - new sts.AssumeRoleCommand({ - RoleArn: `arn:aws:iam::${config.awsAccountID}:role/${config.uploadRoleName}`, - DurationSeconds: 60 * 60, - RoleSessionName: VersionController.SHA1Hash(VersionController.GetBucketPrefix(request)), - Policy: JSON.stringify(iamPolicyDoc.toJSON()), + // + // Version does not exist + // Create S3 temp credentials for the static assets upload + // + + Log.Instance.info('App/Version does not exist', { appName, semVer }); + + // Get S3 creds if requested + if (needS3Creds) { + // Generate a temp policy for staging bucket app prefix + const iamPolicyDoc = new iamCDK.PolicyDocument({ + statements: [ + new iamCDK.PolicyStatement({ + effect: iamCDK.Effect.ALLOW, + actions: ['s3:PutObject', 's3:GetObject', 's3:AbortMultipartUpload'], + resources: [`arn:aws:s3:::${config.filestore.stagingBucket}/*`], }), - ); + new iamCDK.PolicyStatement({ + effect: iamCDK.Effect.ALLOW, + actions: ['s3:ListBucket'], + resources: [`arn:aws:s3:::${config.filestore.stagingBucket}`], + }), + ], + }); - return { - statusCode: 404, - s3UploadUrl: `s3://${config.filestore.stagingBucket}/${VersionController.GetBucketPrefix( - request, - )}`, - - awsCredentials: { - accessKeyId: stsResult.Credentials?.AccessKeyId as string, - secretAccessKey: stsResult.Credentials?.SecretAccessKey as string, - sessionToken: stsResult.Credentials?.SessionToken as string, - }, - }; - } else { - return { - statusCode: 404, - }; - } + Log.Instance.debug('Temp IAM Policy', { policy: JSON.stringify(iamPolicyDoc.toJSON()) }); + + // Assume the upload role with limited S3 permissions + const stsResult = await stsClient.send( + new sts.AssumeRoleCommand({ + RoleArn: `arn:aws:iam::${config.awsAccountID}:role/${config.uploadRoleName}`, + DurationSeconds: 60 * 60, + RoleSessionName: VersionController.SHA1Hash(VersionController.GetBucketPrefix(request)), + Policy: JSON.stringify(iamPolicyDoc.toJSON()), + }), + ); + + return { + statusCode: 404, + s3UploadUrl: `s3://${config.filestore.stagingBucket}/${VersionController.GetBucketPrefix( + request, + )}`, + + awsCredentials: { + accessKeyId: stsResult.Credentials?.AccessKeyId as string, + secretAccessKey: stsResult.Credentials?.SecretAccessKey as string, + sessionToken: stsResult.Credentials?.SessionToken as string, + }, + }; + } else { + return { + statusCode: 404, + }; } } @@ -126,6 +137,8 @@ export default class VersionController { config: IConfig; }): Promise { const { dbManager, request, config } = opts; + const { overwrite = false } = request; + Log.Instance.debug('Got Body:', request); const destinationPrefix = VersionController.GetBucketPrefix(request); @@ -136,11 +149,19 @@ export default class VersionController { key: { AppName: request.appName, SemVer: request.semVer }, }); if (record !== undefined && record.Status === 'routed') { - Log.Instance.info('App/Version already exists', { - appName: request.appName, - semVer: request.semVer, - }); - return { statusCode: 409 }; + if (!overwrite) { + Log.Instance.info('Error: App/Version already exists', { + appName: request.appName, + semVer: request.semVer, + }); + + return { statusCode: 409 }; + } else { + Log.Instance.info('Warning: App/Version already exists', { + appName: request.appName, + semVer: request.semVer, + }); + } } const { appType = 'lambda' } = request; @@ -161,7 +182,7 @@ export default class VersionController { } // Only copy the files if not copied yet - if (record.Status === 'pending') { + if (overwrite || record.Status === 'pending') { const { stagingBucket } = config.filestore; const sourcePrefix = VersionController.GetBucketPrefix(request) + '/'; @@ -174,6 +195,9 @@ export default class VersionController { config, ); + // Set defaultFile again in-case this is an overwrite + record.DefaultFile = request.defaultFile; + // Update status to assets-copied record.Status = 'assets-copied'; await record.Save(dbManager); @@ -185,7 +209,7 @@ export default class VersionController { // Get the API Gateway const apiId = config.apigwy.apiId; - if (record.Status === 'assets-copied') { + if (overwrite || record.Status === 'assets-copied') { // Get the account ID and region for API Gateway to Lambda permissions const accountId = config.awsAccountID; const region = config.awsRegion; @@ -235,7 +259,7 @@ export default class VersionController { // Add Integration pointing to Lambda Function Alias let integrationId = ''; - if (record.Status === 'permissioned') { + if (overwrite || record.Status === 'permissioned') { if (record.IntegrationID !== undefined && record.IntegrationID !== '') { integrationId = record.IntegrationID; } else { @@ -269,7 +293,7 @@ export default class VersionController { } } - if (record.Status === 'integrated') { + if (overwrite || record.Status === 'integrated') { // Add the routes to API Gateway for appName/version/{proxy+} try { await apigwyClient.send( diff --git a/packages/microapps-publish/src/commands/nextjs-docker-auto.ts b/packages/microapps-publish/src/commands/nextjs-docker-auto.ts index 89db3176..ce301009 100755 --- a/packages/microapps-publish/src/commands/nextjs-docker-auto.ts +++ b/packages/microapps-publish/src/commands/nextjs-docker-auto.ts @@ -5,7 +5,6 @@ import * as lambda from '@aws-sdk/client-lambda'; import * as s3 from '@aws-sdk/client-s3'; import * as sts from '@aws-sdk/client-sts'; import { Command, flags as flagsParser } from '@oclif/command'; -import * as chalk from 'chalk'; import * as path from 'path'; import { promises as fs, pathExists, createReadStream } from 'fs-extra'; import { Listr, ListrTask } from 'listr2'; @@ -112,6 +111,18 @@ export class DockerAutoCommand extends Command { description: 'Default file to return when the app is loaded via the router without a version (e.g. when app/ is requested).', }), + overwrite: flagsParser.boolean({ + char: 'o', + required: false, + default: false, + description: + 'Allow overwrite - Warn but do not fail if version exists. Discouraged outside of test envs if cacheable static files have changed.', + }), + noCache: flagsParser.boolean({ + required: false, + default: false, + description: 'Force revalidation of CloudFront and browser caching of static assets', + }), }; private VersionAndAlias: IVersions; @@ -136,6 +147,8 @@ export class DockerAutoCommand extends Command { const ecrRepo = parsedFlags.repoName ?? config.app.ecrRepoName; const staticAssetsPath = parsedFlags.staticAssetsPath ?? config.app.staticAssetsPath; const defaultFile = parsedFlags.defaultFile ?? config.app.defaultFile; + const overwrite = parsedFlags.overwrite; + const noCache = parsedFlags.noCache; // Override the config value config.deployer.lambdaName = deployerLambdaName; @@ -189,11 +202,8 @@ export class DockerAutoCommand extends Command { await restoreFiles(this.FILES_TO_MODIFY); }); - if (config === undefined) { - this.error('Failed to load the config file'); - } if (config.app.staticAssetsPath === undefined) { - this.error('StaticAssetsPath must be specified in the config file'); + this.error('staticAssetsPath must be specified'); } // @@ -240,13 +250,20 @@ export class DockerAutoCommand extends Command { task.output = `Checking if deployed app/version already exists for ${config.app.name}/${semVer}`; ctx.preflightResult = await DeployClient.DeployVersionPreflight({ config, + overwrite, output: (message: string) => (task.output = message), }); if (ctx.preflightResult.exists) { - task.output = `Warning: App/Version already exists: ${config.app.name}/${config.app.semVer}`; + if (!overwrite) { + throw new Error( + `App/Version already exists: ${config.app.name}/${config.app.semVer}`, + ); + } else { + task.title = `Warning: App/Version already exists: ${config.app.name}/${config.app.semVer}`; + } + } else { + task.title = `App/Version does not exist: ${config.app.name}/${config.app.semVer}`; } - - task.title = origTitle; }, }, { @@ -353,6 +370,16 @@ export class DockerAutoCommand extends Command { }, }); + // Setup caching on static assets + // NoCache - Only used for test deploys, requires browser and CloudFront to refetch every time + // Overwrite - Reduces default cache time period from 24 hours to 15 minutes + // Default - 24 hours + const CacheControl = noCache + ? 'max-age=0, must-revalidate, public' + : overwrite + ? `max-age=${15 * 60}, public` + : `max-age=${24 * 60 * 60}, public`; + const pathWithoutAppAndVer = path.join(S3Uploader.TempDir, destinationPrefix); const tasks: ListrTask[] = ctx.files.map((filePath) => ({ @@ -370,7 +397,7 @@ export class DockerAutoCommand extends Command { Key: path.relative(S3Uploader.TempDir, filePath), Body: createReadStream(filePath), ContentType: contentType(path.basename(filePath)) || 'application/octet-stream', - CacheControl: 'max-age=86400; public', + CacheControl, }, }); await upload.done(); @@ -398,7 +425,7 @@ export class DockerAutoCommand extends Command { task.title = RUNNING + origTitle; // Call Deployer to Create App if Not Exists - await DeployClient.CreateApp(config); + await DeployClient.CreateApp({ config }); task.title = origTitle; }, @@ -410,11 +437,12 @@ export class DockerAutoCommand extends Command { task.title = RUNNING + origTitle; // Call Deployer to Deploy AppName/Version - await DeployClient.DeployVersion( + await DeployClient.DeployVersion({ config, - 'lambda', - (message: string) => (task.output = message), - ); + appType: 'lambda', + overwrite, + output: (message: string) => (task.output = message), + }); task.title = origTitle; }, @@ -429,9 +457,6 @@ export class DockerAutoCommand extends Command { try { await tasks.run(); - // this.log(`Published: ${config.app.name}/${config.app.semVer}`); - } catch (error) { - this.log(`Caught exception: ${error.message}`); } finally { await S3Uploader.removeTempDirIfExists(); await restoreFiles(this.FILES_TO_MODIFY); diff --git a/packages/microapps-publish/src/commands/nextjs-version-restore.ts b/packages/microapps-publish/src/commands/nextjs-version-restore.ts index 979f6532..1c48cc74 100644 --- a/packages/microapps-publish/src/commands/nextjs-version-restore.ts +++ b/packages/microapps-publish/src/commands/nextjs-version-restore.ts @@ -1,6 +1,5 @@ import 'reflect-metadata'; import { Command, flags as flagsParser } from '@oclif/command'; -import * as chalk from 'chalk'; import { Listr } from 'listr2'; import { createVersions, IVersions, restoreFiles } from '../lib/Versions'; @@ -65,10 +64,6 @@ export class NextJSVersionRestoreCommand extends Command { }, ); - try { - await tasks.run(); - } catch (error) { - this.log(`Caught exception: ${error.message}`); - } + await tasks.run(); } } diff --git a/packages/microapps-publish/src/commands/nextjs-version.ts b/packages/microapps-publish/src/commands/nextjs-version.ts index f1ea50d5..a95f73df 100644 --- a/packages/microapps-publish/src/commands/nextjs-version.ts +++ b/packages/microapps-publish/src/commands/nextjs-version.ts @@ -1,6 +1,5 @@ import 'reflect-metadata'; import { Command, flags as flagsParser } from '@oclif/command'; -import * as chalk from 'chalk'; import { Listr } from 'listr2'; import { Config } from '../config/Config'; import { createVersions, IVersions, restoreFiles, writeNewVersions } from '../lib/Versions'; @@ -105,10 +104,6 @@ export class NextJSVersionCommand extends Command { }, ); - try { - await tasks.run(); - } catch (error) { - this.log(`Caught exception: ${error.message}`); - } + await tasks.run(); } } diff --git a/packages/microapps-publish/src/commands/preflight.ts b/packages/microapps-publish/src/commands/preflight.ts index e42b7ca2..5193ca49 100644 --- a/packages/microapps-publish/src/commands/preflight.ts +++ b/packages/microapps-publish/src/commands/preflight.ts @@ -1,7 +1,6 @@ import 'reflect-metadata'; import * as sts from '@aws-sdk/client-sts'; import { Command, flags as flagsParser } from '@oclif/command'; -import * as chalk from 'chalk'; import { Listr } from 'listr2'; import { Config } from '../config/Config'; import DeployClient from '../lib/DeployClient'; @@ -38,6 +37,13 @@ export class PreflightCommand extends Command { required: true, description: 'Name of the deployer lambda function', }), + overwrite: flagsParser.boolean({ + char: 'o', + required: false, + default: false, + description: + 'Allow overwrite - Warn but do not fail if version exists. Discouraged outside of test envs if cacheable static files have changed.', + }), }; async run(): Promise { @@ -52,6 +58,7 @@ export class PreflightCommand extends Command { const appName = parsedFlags.appName ?? config.app.name; const deployerLambdaName = parsedFlags.deployerLambdaName ?? config.deployer.lambdaName; const semVer = parsedFlags.newVersion ?? config.app.semVer; + const overwrite = parsedFlags.overwrite; // Override the config value config.deployer.lambdaName = deployerLambdaName; @@ -75,10 +82,6 @@ export class PreflightCommand extends Command { } } - if (config === undefined) { - this.error('Failed to load the config file'); - } - // // Setup Tasks // @@ -96,13 +99,20 @@ export class PreflightCommand extends Command { const preflightResult = await DeployClient.DeployVersionPreflight({ config, needS3Creds: false, + overwrite, output: (message: string) => (task.output = message), }); if (preflightResult.exists) { - task.output = `Warning: App/Version already exists: ${config.app.name}/${config.app.semVer}`; + if (!overwrite) { + throw new Error( + `App/Version already exists: ${config.app.name}/${config.app.semVer}`, + ); + } else { + task.title = `Warning: App/Version already exists: ${config.app.name}/${config.app.semVer}`; + } + } else { + task.title = `App/Version does not exist: ${config.app.name}/${config.app.semVer}`; } - - task.title = origTitle; }, }, ], @@ -113,10 +123,6 @@ export class PreflightCommand extends Command { }, ); - try { - await tasks.run(); - } catch (error) { - this.log(`Caught exception: ${error.message}`); - } + await tasks.run(); } } diff --git a/packages/microapps-publish/src/commands/publish-static.ts b/packages/microapps-publish/src/commands/publish-static.ts index 80e8e5dc..8b2e4976 100644 --- a/packages/microapps-publish/src/commands/publish-static.ts +++ b/packages/microapps-publish/src/commands/publish-static.ts @@ -1,26 +1,17 @@ import 'reflect-metadata'; -import * as util from 'util'; -import * as lambda from '@aws-sdk/client-lambda'; import * as s3 from '@aws-sdk/client-s3'; import * as sts from '@aws-sdk/client-sts'; import { Command, flags as flagsParser } from '@oclif/command'; import * as path from 'path'; import { pathExists, createReadStream } from 'fs-extra'; import { Listr, ListrTask } from 'listr2'; -import { Config, IConfig } from '../config/Config'; +import { Config } from '../config/Config'; import DeployClient, { IDeployVersionPreflightResult } from '../lib/DeployClient'; import S3Uploader from '../lib/S3Uploader'; import S3TransferUtility from '../lib/S3TransferUtility'; import { Upload } from '@aws-sdk/lib-storage'; import { contentType } from 'mime-types'; -import { TaskWrapper } from 'listr2/dist/lib/task-wrapper'; -import { DefaultRenderer } from 'listr2/dist/renderer/default.renderer'; import { createVersions, IVersions } from '../lib/Versions'; -const asyncSetTimeout = util.promisify(setTimeout); - -const lambdaClient = new lambda.LambdaClient({ - maxAttempts: 8, -}); interface IContext { preflightResult: IDeployVersionPreflightResult; @@ -79,6 +70,18 @@ export class PublishCommand extends Command { description: 'Default file to return when the app is loaded via the router without a version (e.g. when app/ is requested).', }), + overwrite: flagsParser.boolean({ + char: 'o', + required: false, + default: false, + description: + 'Allow overwrite - Warn but do not fail if version exists. Discouraged outside of test envs if cacheable static files have changed.', + }), + noCache: flagsParser.boolean({ + required: false, + default: false, + description: 'Force revalidation of CloudFront and browser caching of static assets', + }), }; private VersionAndAlias: IVersions; @@ -96,6 +99,8 @@ export class PublishCommand extends Command { const semVer = parsedFlags.newVersion ?? config.app.semVer; const staticAssetsPath = parsedFlags.staticAssetsPath ?? config.app.staticAssetsPath; const defaultFile = parsedFlags.defaultFile ?? config.app.defaultFile; + const overwrite = parsedFlags.overwrite; + const noCache = parsedFlags.noCache; // Override the config value config.deployer.lambdaName = deployerLambdaName; @@ -122,11 +127,11 @@ export class PublishCommand extends Command { this.VersionAndAlias = createVersions(semVer); - if (config === undefined) { - this.error('Failed to load the config file'); - } if (config.app.staticAssetsPath === undefined) { - this.error('StaticAssetsPath must be specified in the config file'); + this.error('staticAssetsPath must be specified'); + } + if (config.app.defaultFile === undefined || config.app.defaultFile === '') { + this.error('defaultFile must be specified'); } // @@ -146,13 +151,20 @@ export class PublishCommand extends Command { task.output = `Checking if deployed app/version already exists for ${config.app.name}/${semVer}`; ctx.preflightResult = await DeployClient.DeployVersionPreflight({ config, + overwrite, output: (message: string) => (task.output = message), }); if (ctx.preflightResult.exists) { - task.output = `Warning: App/Version already exists: ${config.app.name}/${config.app.semVer}`; + if (!overwrite) { + throw new Error( + `App/Version already exists: ${config.app.name}/${config.app.semVer}`, + ); + } else { + task.title = `Warning: App/Version already exists: ${config.app.name}/${config.app.semVer}`; + } + } else { + task.title = `App/Version does not exist: ${config.app.name}/${config.app.semVer}`; } - - task.title = origTitle; }, }, { @@ -216,6 +228,16 @@ export class PublishCommand extends Command { }, }); + // Setup caching on static assets + // NoCache - Only used for test deploys, requires browser and CloudFront to refetch every time + // Overwrite - Reduces default cache time period from 24 hours to 15 minutes + // Default - 24 hours + const CacheControl = noCache + ? 'max-age=0, must-revalidate, public' + : overwrite + ? `max-age=${15 * 60}, public` + : `max-age=${24 * 60 * 60}, public`; + const pathWithoutAppAndVer = path.join(S3Uploader.TempDir, destinationPrefix); const tasks: ListrTask[] = ctx.files.map((filePath) => ({ @@ -233,7 +255,7 @@ export class PublishCommand extends Command { Key: path.relative(S3Uploader.TempDir, filePath), Body: createReadStream(filePath), ContentType: contentType(path.basename(filePath)) || 'application/octet-stream', - CacheControl: 'max-age=86400; public', + CacheControl, }, }); await upload.done(); @@ -261,7 +283,7 @@ export class PublishCommand extends Command { task.title = RUNNING + origTitle; // Call Deployer to Create App if Not Exists - await DeployClient.CreateApp(config); + await DeployClient.CreateApp({ config }); task.title = origTitle; }, @@ -273,11 +295,12 @@ export class PublishCommand extends Command { task.title = RUNNING + origTitle; // Call Deployer to Deploy AppName/Version - await DeployClient.DeployVersion( + await DeployClient.DeployVersion({ config, - 'static', - (message: string) => (task.output = message), - ); + appType: 'static', + overwrite, + output: (message: string) => (task.output = message), + }); task.title = origTitle; }, @@ -292,9 +315,6 @@ export class PublishCommand extends Command { try { await tasks.run(); - // this.log(`Published: ${config.app.name}/${config.app.semVer}`); - } catch (error) { - this.log(`Caught exception: ${error.message}`); } finally { await S3Uploader.removeTempDirIfExists(); } diff --git a/packages/microapps-publish/src/commands/publish.ts b/packages/microapps-publish/src/commands/publish.ts index e77d57c5..507e366e 100644 --- a/packages/microapps-publish/src/commands/publish.ts +++ b/packages/microapps-publish/src/commands/publish.ts @@ -4,7 +4,6 @@ import * as lambda from '@aws-sdk/client-lambda'; import * as s3 from '@aws-sdk/client-s3'; import * as sts from '@aws-sdk/client-sts'; import { Command, flags as flagsParser } from '@oclif/command'; -import * as chalk from 'chalk'; import * as path from 'path'; import { pathExists, createReadStream } from 'fs-extra'; import { Listr, ListrTask } from 'listr2'; @@ -88,6 +87,18 @@ export class PublishCommand extends Command { description: 'Default file to return when the app is loaded via the router without a version (e.g. when app/ is requested).', }), + overwrite: flagsParser.boolean({ + char: 'o', + required: false, + default: false, + description: + 'Allow overwrite - Warn but do not fail if version exists. Discouraged outside of test envs if cacheable static files have changed.', + }), + noCache: flagsParser.boolean({ + required: false, + default: false, + description: 'Force revalidation of CloudFront and browser caching of static assets', + }), }; private VersionAndAlias: IVersions; @@ -106,6 +117,8 @@ export class PublishCommand extends Command { const semVer = parsedFlags.newVersion ?? config.app.semVer; const staticAssetsPath = parsedFlags.staticAssetsPath ?? config.app.staticAssetsPath; const defaultFile = parsedFlags.defaultFile ?? config.app.defaultFile; + const overwrite = parsedFlags.overwrite; + const noCache = parsedFlags.noCache; // Override the config value config.deployer.lambdaName = deployerLambdaName; @@ -132,11 +145,8 @@ export class PublishCommand extends Command { this.VersionAndAlias = createVersions(semVer); - if (config === undefined) { - this.error('Failed to load the config file'); - } if (config.app.staticAssetsPath === undefined) { - this.error('StaticAssetsPath must be specified in the config file'); + this.error('staticAssetsPath must be specified'); } // @@ -156,13 +166,20 @@ export class PublishCommand extends Command { task.output = `Checking if deployed app/version already exists for ${config.app.name}/${semVer}`; ctx.preflightResult = await DeployClient.DeployVersionPreflight({ config, + overwrite, output: (message: string) => (task.output = message), }); if (ctx.preflightResult.exists) { - task.output = `Warning: App/Version already exists: ${config.app.name}/${config.app.semVer}`; + if (!overwrite) { + throw new Error( + `App/Version already exists: ${config.app.name}/${config.app.semVer}`, + ); + } else { + task.title = `Warning: App/Version already exists: ${config.app.name}/${config.app.semVer}`; + } + } else { + task.title = `App/Version does not exist: ${config.app.name}/${config.app.semVer}`; } - - task.title = origTitle; }, }, { @@ -239,6 +256,16 @@ export class PublishCommand extends Command { }, }); + // Setup caching on static assets + // NoCache - Only used for test deploys, requires browser and CloudFront to refetch every time + // Overwrite - Reduces default cache time period from 24 hours to 15 minutes + // Default - 24 hours + const CacheControl = noCache + ? 'max-age=0, must-revalidate, public' + : overwrite + ? `max-age=${15 * 60}, public` + : `max-age=${24 * 60 * 60}, public`; + const pathWithoutAppAndVer = path.join(S3Uploader.TempDir, destinationPrefix); const tasks: ListrTask[] = ctx.files.map((filePath) => ({ @@ -256,7 +283,7 @@ export class PublishCommand extends Command { Key: path.relative(S3Uploader.TempDir, filePath), Body: createReadStream(filePath), ContentType: contentType(path.basename(filePath)) || 'application/octet-stream', - CacheControl: 'max-age=86400; public', + CacheControl, }, }); await upload.done(); @@ -284,7 +311,7 @@ export class PublishCommand extends Command { task.title = RUNNING + origTitle; // Call Deployer to Create App if Not Exists - await DeployClient.CreateApp(config); + await DeployClient.CreateApp({ config }); task.title = origTitle; }, @@ -296,11 +323,12 @@ export class PublishCommand extends Command { task.title = RUNNING + origTitle; // Call Deployer to Deploy AppName/Version - await DeployClient.DeployVersion( + await DeployClient.DeployVersion({ config, - 'lambda', - (message: string) => (task.output = message), - ); + appType: 'lambda', + overwrite, + output: (message: string) => (task.output = message), + }); task.title = origTitle; }, @@ -315,9 +343,6 @@ export class PublishCommand extends Command { try { await tasks.run(); - // this.log(`Published: ${config.app.name}/${config.app.semVer}`); - } catch (error) { - this.log(`Caught exception: ${error.message}`); } finally { await S3Uploader.removeTempDirIfExists(); } diff --git a/packages/microapps-publish/src/lib/DeployClient.ts b/packages/microapps-publish/src/lib/DeployClient.ts index 9533e44d..255a4081 100644 --- a/packages/microapps-publish/src/lib/DeployClient.ts +++ b/packages/microapps-publish/src/lib/DeployClient.ts @@ -19,7 +19,8 @@ export default class DeployClient { }); static readonly _decoder = new TextDecoder('utf-8'); - public static async CreateApp(config: IConfig): Promise { + public static async CreateApp(opts: { config: IConfig }): Promise { + const { config } = opts; const request = { type: 'createApp', appName: config.app.name, @@ -54,14 +55,16 @@ export default class DeployClient { public static async DeployVersionPreflight(opts: { config: IConfig; needS3Creds?: boolean; + overwrite: boolean; output: (message: string) => void; }): Promise { - const { config, needS3Creds = true, output } = opts; + const { config, needS3Creds = true, overwrite, output } = opts; const request = { type: 'deployVersionPreflight', appName: config.app.name, semVer: config.app.semVer, + overwrite, needS3Creds, } as IDeployVersionPreflightRequest; const response = await this._client.send( @@ -95,11 +98,13 @@ export default class DeployClient { * @param config * @param task */ - public static async DeployVersion( - config: IConfig, - appType: 'lambda' | 'static', - output: (message: string) => void, - ): Promise { + public static async DeployVersion(opts: { + config: IConfig; + appType: 'lambda' | 'static'; + overwrite: boolean; + output: (message: string) => void; + }): Promise { + const { config, appType, overwrite, output } = opts; const request = { type: 'deployVersion', appType, @@ -107,6 +112,7 @@ export default class DeployClient { semVer: config.app.semVer, defaultFile: config.app.defaultFile, lambdaARN: appType === 'lambda' ? config.app.lambdaARN : undefined, + overwrite, } as IDeployVersionRequest; const response = await this._client.send( new lambda.InvokeCommand({