UNPKG

caccl-deploy

Version:

A cli tool for managing ECS/Fargate app deployments

1,181 lines (1,066 loc) 36.9 kB
#!/usr/bin/env node const { execSync } = require('child_process'); const chalk = require('chalk'); const { Command } = require('commander'); const figlet = require('figlet'); const moment = require('moment'); const { table } = require('table'); const tempy = require('tempy'); const untildify = require('untildify'); const yn = require('yn'); const aws = require('./lib/aws'); const { conf, setConfigDefaults, configDefaults } = require('./lib/conf'); const { promptAppName, confirm, confirmProductionOp, } = require('./lib/configPrompts'); const DeployConfig = require('./lib/deployConfig'); const { AppNotFound, UserCancel, AwsProfileNotFound, NoPromptChoices, CfnStackNotFound, } = require('./lib/errors'); const cacclDeployVersion = require('./lib/generateVersion')(); const { looksLikeSemver, validSSMParamName, warnAboutVersionDiff, } = require('./lib/helpers'); const { description: packageDescription } = require('./package.json'); /** * Setting this env var is the equivalent of passing the `--yes` argument * to any subcommand. It tells caccl-deploy to not prompt for confirmations. * This includes production account failsafe prompts, so be careful. */ const { CACCL_DEPLOY_NON_INTERACTIVE = false } = process.env; const bye = (msg = 'bye!', exitCode = 0) => { console.log(msg); process.exit(exitCode); }; const exitWithSuccess = (msg) => { bye(msg); }; const exitWithError = (msg) => { bye(msg, 1); }; const byeWithCredentialsError = () => { exitWithError( [ 'Looks like there is a problem with your AWS credentials configuration.', 'Did you run `aws configure`? Did you set a region? Default profile?', ].join('\n'), ); }; /** * callback function for the `--profile` option * @param {string} profile */ const initAwsProfile = (profile) => { try { aws.initProfile(profile); return profile; } catch (err) { if (err instanceof AwsProfileNotFound) { exitWithError(err.message); } else { throw err; } } }; const isProdAccount = async () => { const prodAccounts = conf.get('productionAccounts'); const accountId = await aws.getAccountId(); return prodAccounts && prodAccounts.includes(accountId); }; /** * Extends the base commander.js class to add convenience methods * and some common options * @extends Command */ class CacclDeployCommander extends Command { /** * custom command creator * @param {string} name */ createCommand(name) { const cmd = new CacclDeployCommander(name) /** * Enabling the following two command options allows our `action()` block * to receive the command object as an argument and to reference command * options as properties of that object, e.g. the value of `--app` can be * accessed via `cmd.app`, or `this.app` in the added methods in this * class */ .passCommandToAction() .storeOptionsAsProperties() // adds a bunch of options (mostly) common to all the subcommands .commonOptions(); return cmd; } /** * Convenience method for getting the combined root prefix plus app name * used for the SSM Paramter Store parameter names * @param {string} appName */ getAppPrefix(appName) { if ( this.ssmRootPrefix === undefined || (this.app === undefined && appName === undefined) ) { throw Error('Attempted to make an ssm prefix with undefined values'); } return `${this.ssmRootPrefix}/${appName || this.app}`; } /** * Convenience method for getting the name of the app's CloudFormation stack * @param {string} appName */ getCfnStackName(appName) { if ( this.cfnStackPrefix === undefined || (this.app === undefined && appName === undefined) ) { throw Error( 'Attempted to make a cloudformation stack name with undefined values', ); } return `${this.cfnStackPrefix}${appName || this.app}`; } /** * Retruns the DeployConfig object representing the subcommand's * * @param {boolean} keepSecretArns - if true, for any parameter store values * that reference secretsmanager entries, preserve the secretsmanager ARN * value rather than dereferencing */ async getDeployConfig(keepSecretArns) { const appPrefix = this.getAppPrefix(); try { const deployConfig = await DeployConfig.fromSsmParams( appPrefix, keepSecretArns, ); return deployConfig; } catch (err) { if (err instanceof AppNotFound) { exitWithError(`${this.app} app configuration not found!`); } } } /** * Will add another confirm prompt that warns if the deployed stack's * version is more than a patch version different from the cli tool. * * @return {CacclDeployCommander} * @memberof CacclDeployCommander */ async stackVersionDiffCheck() { const cfnStackName = this.getCfnStackName(); const cfnExports = await aws.getCfnStackExports(cfnStackName); const stackVersion = cfnExports.cacclDeployVersion; const cliVersion = cacclDeployVersion; if ( cliVersion === stackVersion || !warnAboutVersionDiff(stackVersion, cliVersion) ) { return true; } const confirmMsg = `Stack deployed with ${chalk.redBright( stackVersion, )}; you are using ${chalk.redBright(cliVersion)}. Proceed?`; return confirm(confirmMsg, false); } /** * For assigning some common options to all commands * * @return {CacclDeployCommander} * @memberof CacclDeployCommander */ commonOptions() { return this.option( '--profile <string>', 'activate a specific aws config/credential profile', initAwsProfile, ) .option( '--ecr-access-role-arn <string>', 'IAM role ARN for cross account ECR repo access', conf.get('ecrAccessRoleArn'), ) .requiredOption( '--ssm-root-prefix <string>', 'The root prefix for ssm parameter store entries', conf.get('ssmRootPrefix'), ) .requiredOption( '--cfn-stack-prefix <string>', 'cloudformation stack name prefix, e.g. "CacclDeploy-"', conf.get('cfnStackPrefix'), ) .option( '-y --yes', 'non-interactive, yes to everything, overwrite existing, etc', yn(CACCL_DEPLOY_NON_INTERACTIVE), ); } /** * Add the `--app` option to a command * * @param {boolean} optional - unless true the resulting command option * will be required * @return {CacclDeployCommander} * @memberof CacclDeployCommander */ appOption(optional) { const args = ['-a --app <string>', 'name of the app to work with']; return optional ? this.option(...args) : this.requiredOption(...args); } } async function main() { // confirm ASAP that the user's AWS creds/config is good to go if (!aws.isConfigured() && process.env.NODE_ENV !== 'test') { byeWithCredentialsError(); } /* * check if this is the first time running and if so create the * config file with defaults */ if (!conf.get('ssmRootPrefix')) { console.log(chalk.greenBright(figlet.textSync('Caccl-Deploy!'))); console.log( [ 'It looks like this is your first time running caccl-deploy. ', `A preferences file has been created at ${chalk.yellow(conf.path)}`, 'with the following default values:', '', ...Object.entries(configDefaults).map(([k, v]) => { return ` - ${chalk.yellow(k)}: ${chalk.bold(JSON.stringify(v))}`; }), '', 'Please see the docs for explanations of these settings', ].join('\n'), ); CACCL_DEPLOY_NON_INTERACTIVE || (await confirm('Continue?', true)) || exitWithSuccess(); setConfigDefaults(); } const cli = new CacclDeployCommander() .version(cacclDeployVersion) .description([packageDescription, `config: ${conf.path}`].join('\n')); cli .command('apps') .option( '--full-status', 'show the full status of each app including CFN stack and config state', ) .description('list available app configurations') .action(async (cmd) => { const apps = await aws.getAppList(cmd.ssmRootPrefix); if (!apps.length) { exitWithError( `No app configurations found using ssm root prefix ${cmd.ssmRootPrefix}`, ); } const appData = {}; const tableColumns = ['App']; for (let i = 0; i < apps.length; i++) { const app = apps[i]; appData[app] = []; } if (cmd.fullStatus) { tableColumns.push( 'Infra Stack', 'Stack Status', 'Config Drift', 'caccl-deploy Version', ); const cfnStacks = await aws.getCfnStacks(cmd.cfnStackPrefix); for (let i = 0; i < apps.length; i++) { const app = apps[i]; const cfnStackName = cmd.getCfnStackName(app); const appPrefix = cmd.getAppPrefix(app); const deployConfig = await DeployConfig.fromSsmParams(appPrefix); appData[app].push(deployConfig.infraStackName); const cfnStack = cfnStacks.find((s) => { return ( s.StackName === cfnStackName && s.StackStatus !== 'DELETE_COMPLETE' ); }); if (!cfnStack) { // config exists but cfn stack not deployed yet (or was destroyed) appData[app].push('', '', ''); continue; } /** * Compare a hash of the config used during stack deployment to the * has of the current config */ let configDrift = '?'; const cfnStackDeployConfigHashOutput = cfnStack.Outputs.find((o) => { return o.OutputKey.startsWith('DeployConfigHash'); }); if (cfnStackDeployConfigHashOutput) { const deployConfigHash = deployConfig.toHash(); const cfnOutputValue = cfnStackDeployConfigHashOutput.OutputValue; configDrift = cfnOutputValue !== deployConfigHash ? 'yes' : 'no'; } appData[app].push(cfnStack.StackStatus, configDrift); const cfnStackCacclDeployVersion = cfnStack.Outputs.find((o) => { return o.OutputKey.startsWith('CacclDeployVersion'); }); appData[app].push(cfnStackCacclDeployVersion.OutputValue); } } const tableData = Object.keys(appData).map((app) => { return [app, ...appData[app]]; }); exitWithSuccess(table([tableColumns, ...tableData])); }); cli .command('new') .description('create a new app deploy config via import and/or prompts') .appOption(true) .option( '-i --import <string>', 'import new deploy config from a json file or URL', ) .description('create a new app configuration') .action(async (cmd) => { if (cmd.ecrAccessRoleArn !== undefined) { aws.setAssumedRoleArn(cmd.ecrAccessRoleArn); } const existingApps = await aws.getAppList(cmd.ssmRootPrefix); let appName; try { appName = cmd.app || (await promptAppName()); } catch (err) { if (err instanceof UserCancel) { exitWithSuccess(); } throw err; } const appPrefix = cmd.getAppPrefix(appName); if (existingApps.includes(appName)) { const cfnStackName = cmd.getCfnStackName(appName); if (await aws.cfnStackExists(cfnStackName)) { exitWithError('A deployed app with that name already exists'); } else { console.log(`Configuration for ${cmd.app} already exists`); } if (cmd.yes || (await confirm('Overwrite?'))) { if (!(await confirmProductionOp(cmd.yes))) { exitWithSuccess(); } await DeployConfig.wipeExisting(appPrefix); } else { exitWithSuccess(); } } /** * Allow importing some or all of a deploy config. * What gets imported will be passed to the `generate` * operation to complete any missing settings */ let importedConfig; if (cmd.import !== undefined) { importedConfig = /^http(s):\//.test(cmd.import) ? await DeployConfig.fromUrl(cmd.import) : DeployConfig.fromFile(cmd.import); } let deployConfig; try { deployConfig = await DeployConfig.generate(importedConfig); } catch (err) { if (err instanceof UserCancel) { exitWithSuccess(); } else if (err instanceof NoPromptChoices) { exitWithError( [ 'Something went wrong trying to generate your config: ', err.message, ].join('\n'), ); } throw err; } await deployConfig.syncToSsm(appPrefix); exitWithSuccess( [ chalk.yellowBright(figlet.textSync(`${appName}!`)), '', 'Your new app deployment configuration is created!', 'Next steps:', ` * modify or add settings with 'caccl-deploy update -a ${appName} [...]'`, ` * deploy the app stack with 'caccl-deploy stack -a ${appName} deploy'`, '', ].join('\n'), ); }); cli .command('delete') .description('delete an app configuration') .appOption() .action(async (cmd) => { const cfnStackName = cmd.getCfnStackName(); if (await aws.cfnStackExists(cfnStackName)) { exitWithError( [ `You must first run "caccl-deploy stack -a ${cmd.app} destroy" to delete`, `the deployed ${cfnStackName} CloudFormation stack before deleting it's config.`, ].join('\n'), ); } try { console.log( `This will delete all deployment configuation for ${cmd.app}`, ); if (!(cmd.yes || (await confirm('Are you sure?')))) { exitWithSuccess(); } // extra confirm if this is a production deployment if (!(await confirmProductionOp(cmd.yes))) { exitWithSuccess(); } await DeployConfig.wipeExisting(cmd.getAppPrefix(), false); exitWithSuccess(`${cmd.app} configuration deleted`); } catch (err) { if (err instanceof AppNotFound) { exitWithError(`${cmd.app} app configuration not found!`); } } }); cli .command('show') .description("display an app's current configuration") .appOption() .option('-f --flat', 'display the flattened, key: value form of the config') .option('-s --sha', 'output a sha1 hash of the current configuration') .option( '--keep-secret-arns', 'show app environment secret value ARNs instead of dereferencing', ) .action(async (cmd) => { // we only want to see that sha1 hash (likely for debugging) if (cmd.sha) { exitWithSuccess((await cmd.getDeployConfig()).toHash()); } exitWithSuccess( (await cmd.getDeployConfig(cmd.keepSecretArns)).toString( true, cmd.flat, ), ); }); cli .command('update') .description('update (or delete) a single deploy config setting') .appOption() .option( '-D --delete', 'delete the named parameter instead of creating/updating', ) .action(async (cmd) => { const deployConfig = await cmd.getDeployConfig(true); if (!(await confirmProductionOp(cmd.yes))) { exitWithSuccess(); } if (cmd.args.length > 2) { exitWithError('Too many arguments!'); } try { if (cmd.delete) { const [param] = cmd.args; await deployConfig.delete(cmd.getAppPrefix(), param); } else { const [param, value] = cmd.args; if (!validSSMParamName(param)) { throw new Error(`Invalid param name: '${param}'`); } await deployConfig.update(cmd.getAppPrefix(), param, value); } } catch (err) { exitWithError(`Something went wrong: ${err.message}`); } }); cli .command('repos') .description('list the available ECR repositories') .action(async (cmd) => { // see the README section on cross-account ECR access if (cmd.ecrAccessRoleArn !== undefined) { aws.setAssumedRoleArn(cmd.ecrAccessRoleArn); } const repos = await aws.getRepoList(); const data = repos.map((r) => { return [r]; }); if (data.length) { const tableOutput = table([['Respository Name'], ...data]); exitWithSuccess(tableOutput); } exitWithError('No ECR repositories found'); }); cli .command('images') .description('list the most recent available ECR images for an app') .requiredOption( '-r --repo <string>', 'the name of the ECR repo; use `caccl-deploy app repos` for available repos', ) .option( '-A --all', 'show all images; default is to show only semver-tagged releases', ) .action(async (cmd) => { // see the README section on cross-account ECR access if (cmd.ecrAccessRoleArn !== undefined) { aws.setAssumedRoleArn(cmd.ecrAccessRoleArn); } const images = await aws.getRepoImageList(cmd.repo, cmd.all); const region = aws.getCurrentRegion(); /** * Function to filter for the image tags we want. * If `--all` flag is provided this will return true for all tags. * Otherwise only tags that look like e.g. "1.1.1" or master/stage * will be included. */ const includeThisTag = (t) => { return cmd.all || looksLikeSemver(t) || ['master', 'stage'].includes(t); }; const data = images.map((i) => { const imageTags = i.imageTags.filter(includeThisTag).join('\n'); /** * Filter then list of image ids for just the ones that correspond * to the image tags we want to include */ const imageArns = i.imageTags .reduce((collect, t) => { if (includeThisTag(t)) { collect.push( aws.createEcrArn({ repoName: cmd.repo, imageTag: t, account: i.registryId, region, }), ); } return collect; }, []) .join('\n'); return [moment(i.imagePushedAt).format(), imageTags, imageArns]; }); if (data.length) { const tableOutput = table([['Pushed On', 'Tags', 'ARNs'], ...data]); exitWithSuccess(tableOutput); } exitWithError('No images found'); }); cli .command('stack') .description("diff, deploy, or delete the app's AWS resources") .appOption() .action(async (cmd) => { // get this without resolved secrets for passing to cdk const deployConfig = await cmd.getDeployConfig(true); // get it again with resolved secrets so we can make our hash const deployConfigHash = (await cmd.getDeployConfig()).toHash(); /** * Get the important ids/names from our infrastructure stack: * - id of the vpc * - name of the ECS cluster * - name of the S3 bucket where the load balancer will send logs */ const cfnStackName = cmd.getCfnStackName(); const stackExists = await aws.cfnStackExists(cfnStackName); const { vpcId, ecsClusterName, albLogBucketName } = await aws.getCfnStackExports(deployConfig.infraStackName); /** * Create an object structure with all the info * the CDK stack operation will need */ const cdkStackProps = { vpcId, ecsClusterName, albLogBucketName, cacclDeployVersion, deployConfigHash, stackName: cfnStackName, awsAccountId: await aws.getAccountId(), awsRegion: process.env.AWS_REGION || 'us-east-1', deployConfig, }; const envAdditions = { AWS_REGION: process.env.AWS_REGION || 'us-east-1', CDK_DISABLE_VERSION_CHECK: true, }; // all args/options following the `stack` subcommand get passed to cdk const cdkArgs = [...cmd.args]; // default cdk operation is `list` if (!cdkArgs.length) { cdkArgs.push('list'); } else if (cdkArgs[0] === 'dump') { exitWithSuccess(JSON.stringify(cdkStackProps, null, ' ')); } else if (cdkArgs[0] === 'info') { if (!stackExists) { exitWithError(`Stack ${cfnStackName} has not been deployed yet`); } const stackExports = await aws.getCfnStackExports(cfnStackName); exitWithSuccess(JSON.stringify(stackExports, null, ' ')); } else if (cdkArgs[0] === 'changeset') { cdkArgs.shift(); cdkArgs.unshift('deploy', '--no-execute'); } // tell cdk to use the same profile if (cmd.profile !== undefined) { cdkArgs.push('--profile', cmd.profile); envAdditions.AWS_PROFILE = cmd.profile; } // disable cdk prompting if user included `--yes` flag if ( cmd.yes && (cdkArgs.includes('deploy') || cdkArgs.includes('changeset')) ) { cdkArgs.push('--require-approval-never'); } if ( ['deploy', 'destroy', 'changeset'].some((c) => { return cdkArgs.includes(c); }) ) { // check that we're not using a wildly different version of the cli if (stackExists && !cmd.yes && !(await cmd.stackVersionDiffCheck())) { exitWithSuccess(); } // production failsafe if we're actually changing anything if (!(await confirmProductionOp(cmd.yes))) { exitWithSuccess(); } } // Set some default removal policy options depending on if this is a "prod" account if ( cdkStackProps.deployConfig.dbOptions && !cdkStackProps.deployConfig.dbOptions.removalPolicy ) { cdkStackProps.deployConfig.dbOptions.removalPolicy = (await isProdAccount()) ? 'RETAIN' : 'DESTROY'; } /** * Write out the stack properties to a temp json file for * the CDK subprocess to pick up */ await tempy.write.task( JSON.stringify(cdkStackProps, null, 2), async (tempPath) => { // tell the CDK subprocess where to find the stack properties file envAdditions.CDK_STACK_PROPS_FILE_PATH = tempPath; const execOpts = { stdio: 'inherit', // exec the cdk process in the cdk directory cwd: __dirname, // path.join(__dirname, 'cdk'), // inject our additional env vars env: { ...process.env, ...envAdditions }, }; try { execSync(['node_modules/.bin/cdk', ...cdkArgs].join(' '), execOpts); exitWithSuccess('done!'); } catch (err) { exitWithError(err.msg); } }, ); }); cli .command('restart') .description('no changes; just force a restart') .appOption() .action(async (cmd) => { const cfnStackName = cmd.getCfnStackName(); let cfnExports; try { cfnExports = await aws.getCfnStackExports(cfnStackName); } catch (err) { if (err instanceof CfnStackNotFound) { exitWithError(err.message); } throw err; } const { clusterName, serviceName } = cfnExports; console.log( `Restarting service ${serviceName} on cluster ${clusterName}`, ); if (!(await confirmProductionOp(cmd.yes))) { exitWithSuccess(); } // restart the service await aws.restartEcsServcie(clusterName, serviceName, { wait: true }); exitWithSuccess('done'); }); cli .command('release') .description('release a new version of an app') .appOption() .requiredOption( '-i --image-tag <string>', 'the docker image version tag to release', ) .option( '--no-deploy', "Update the Fargate Task Definition but don't restart the service", ) .action(async (cmd) => { // see the README section on cross-account ECR access if (cmd.ecrAccessRoleArn !== undefined) { aws.setAssumedRoleArn(cmd.ecrAccessRoleArn); } const deployConfig = await cmd.getDeployConfig(); const cfnStackName = cmd.getCfnStackName(); let cfnExports; try { cfnExports = await aws.getCfnStackExports(cfnStackName); ['taskDefName', 'clusterName', 'serviceName'].forEach((exportValue) => { if (cfnExports[exportValue] === undefined) { throw new Error(`Incomplete app stack: missing ${exportValue}`); } }); } catch (err) { if ( err instanceof CfnStackNotFound || err.message.includes('Incomplete') ) { exitWithError(err.message); } throw err; } /** * caccl-deploy allows non-ECR images, but in the `caccl-deploy` context * we can assume that `appImage` will be an ECR repo ARN */ const repoArn = aws.parseEcrArn(deployConfig.appImage); // check that we're actually releasing a different image if (repoArn.imageTag === cmd.imageTag && !cmd.yes) { const confirmMsg = `${cmd.app} is already using image tag ${cmd.imageTag}`; (await confirm(`${confirmMsg}. Proceed?`)) || exitWithSuccess(); } // check that the specified image tag is legit console.log(`Checking that an image exists with the tag ${cmd.imageTag}`); const imageTagExists = await aws.imageTagExists( repoArn.repoName, cmd.imageTag, ); if (!imageTagExists) { exitWithError( `No image with tag ${cmd.imageTag} in ${repoArn.repoName}`, ); } // check if it's the latest release and prompt if not console.log(`Checking ${cmd.imageTag} is the latest tag`); const isLatestTag = await aws.isLatestTag(repoArn.repoName, cmd.imageTag); if (!isLatestTag && !cmd.yes) { console.log(`${cmd.imageTag} is not the most recent release`); (await confirm('Proceed?')) || exitWithSuccess(); } // generate the new repo image arn to be deployed const newAppImage = aws.createEcrArn({ ...repoArn, imageTag: cmd.imageTag, }); /** * Note that the app's current in-use task def name has to be registered * as a cloudformation stack output value because it's too painful to try * to get/extract it via the api. `taskDefName` here is also known as the * "family" and doesn't include the task def revision/version number */ const { taskDefName, appOnlyTaskDefName, clusterName, serviceName } = cfnExports; // check that we're not using a wildly different version of the cli if (!this.yes && !(await cmd.stackVersionDiffCheck())) { exitWithSuccess(); } if (!(await confirmProductionOp(cmd.yes))) { exitWithSuccess(); } // create a new version of the taskdef with the updated image console.log(`Updating ${cmd.app} task definitions to use ${newAppImage}`); // the app's service task def const newTaskDefArn = await aws.updateTaskDefAppImage( taskDefName, newAppImage, 'AppContainer', ); // the app-only one-off task definition await aws.updateTaskDefAppImage( appOnlyTaskDefName, newAppImage, 'AppOnlyContainer', ); // update the ssm parameter console.log('Updating stored deployment configuration'); await deployConfig.update(cmd.getAppPrefix(), 'appImage', newAppImage); // restart the service if (cmd.deploy) { console.log(`Restarting the ${serviceName} service...`); await aws.restartEcsServcie(clusterName, serviceName, { newTaskDefArn, wait: true, }); exitWithSuccess('done.'); } exitWithSuccess( [ 'Redployment skipped', 'WARNING: service is out-of-sync with stored deployment configuration', ].join('\n'), ); }); cli .command('exec') .description('execute a one-off task using the app image') .appOption() .requiredOption('-c, --command <string>', 'the app task command to run') .option( '-e, --env <value>', 'add or override container environment variables', (e, collected) => { const [k, v] = e.split('='); return collected.concat([ { name: k, value: v, }, ]); }, [], ) .action(async (cmd) => { const cfnStackName = cmd.getCfnStackName(); const { appOnlyTaskDefName, clusterName, serviceName } = await aws.getCfnStackExports(cfnStackName); // check that we're not using a wildly different version of the cli if (!this.yes && !(await cmd.stackVersionDiffCheck())) { exitWithSuccess(); } if (!(await confirmProductionOp(cmd.yes))) { exitWithSuccess(); } console.log( `Running command '${cmd.command}' on service ${serviceName} using task def ${appOnlyTaskDefName}`, ); const taskArn = await aws.execTask({ clusterName, serviceName, taskDefName: appOnlyTaskDefName, command: cmd.command, environment: cmd.env, }); exitWithSuccess(`Task ${taskArn} started`); }); cli .command('connect') .description("connect to an app's peripheral services (db, redis, etc)") .appOption() .option('-l, --list', 'list the things to connect to') .option( '-s, --service <string>', 'service to connect to; use `--list` to see what is available', ) .option( '-k, --public-key <string>', 'path to the ssh public key file to use', untildify('~/.ssh/id_rsa.pub'), ) .option( '--local-port <string>', 'attach tunnel to a non-default local port', ) .option('-q, --quiet', 'output only the ssh tunnel command') .option( '-S, --sleep <string>', 'keep the tunnel alive for this long without activity', 60, ) .action(async (cmd) => { if (!cmd.list && !cmd.service) { exitWithError('One of `--list` or `--service` is required'); } const deployConfig = await cmd.getDeployConfig(); const services = new Set(); ['dbOptions', 'cacheOptions'].forEach((optsKey) => { if (deployConfig[optsKey]) { services.add(deployConfig[optsKey].engine); } }); if (yn(deployConfig.docDb)) { exitWithError( [ 'Deployment configuration is out-of-date', 'Replace `docDb*` with `dbOptions: {...}`', ].join('\n'), ); } if (cmd.list) { exitWithSuccess( ['Valid `--service=` options:', ...services].join('\n '), ); } if (!services.has(cmd.service)) { exitWithError(`'${cmd.service}' is not a valid option`); } const cfnStackName = cmd.getCfnStackName(); const cfnStackExports = await aws.getCfnStackExports(cfnStackName); const { bastionHostAz, bastionHostId, bastionHostIp, dbPasswordSecretArn, } = cfnStackExports; try { await aws.sendSSHPublicKey({ instanceAz: bastionHostAz, instanceId: bastionHostId, sshKeyPath: cmd.publicKey, }); } catch (err) { exitWithError(err.message); } let endpoint; let localPort; let clientCommand; if (['mysql', 'docdb'].includes(cmd.service)) { endpoint = cfnStackExports.dbClusterEndpoint; const password = await aws.resolveSecret(dbPasswordSecretArn); if (cmd.service === 'mysql') { localPort = cmd.localPort || '3306'; clientCommand = `mysql -uroot -p${password} --port ${localPort} -h 127.0.0.1`; } else { localPort = cmd.localPort || '27017'; const tlsOpts = '--ssl --sslAllowInvalidHostnames --sslAllowInvalidCertificates'; clientCommand = `mongo ${tlsOpts} --username root --password ${password} --port ${localPort}`; } } else if (cmd.service === 'redis') { endpoint = cfnStackExports.cacheEndpoint; localPort = cmd.localPort || '6379'; clientCommand = `redis-cli -p ${localPort}`; } else { exitWithError(`not sure what to do with ${cmd.service}`); } const tunnelCommand = [ 'ssh -f -L', `${cmd.localPort || localPort}:${endpoint}`, '-o StrictHostKeyChecking=no', `${aws.EC2_INSTANCE_CONNECT_USER}@${bastionHostIp}`, `sleep ${cmd.sleep}`, ].join(' '); if (cmd.quiet) { exitWithSuccess(tunnelCommand); } exitWithSuccess( [ `Your public key, ${cmd.publicKey}, has temporarily been placed on the bastion instance`, 'You have ~60s to establish the ssh tunnel', '', `# tunnel command:\n${tunnelCommand}`, `# ${cmd.service} client command:\n${clientCommand}`, ].join('\n'), ); }); cli .command('schedule') .description( 'create a scheduled task that executes the app image with a custom command', ) .appOption() .option('-l, --list', 'list the existing scheduled tasks') .option( '-t, --task-id <string>', 'give the taska a string id; by default one will be generated', ) .option( '-d, --task-description <string>', 'description of what the task does', ) .option('-D, --delete <string>', 'delete a scheduled task') .option( '-s, --task-schedule <string>', 'a cron expression, e.g. "0 4 * * *"', ) .option('-c, --task-command <string>', 'the app task command to run') .action(async (cmd) => { const deployConfig = await cmd.getDeployConfig(); const existingTasks = deployConfig.scheduledTasks || {}; const existingTaskIds = Object.keys(existingTasks); if (cmd.list) { // format existing as a table and exitWithSuccess if (existingTaskIds.length) { const tableRows = existingTaskIds.map((id) => { const taskSettings = existingTasks[id]; const { command, schedule, description } = taskSettings; return [id, schedule, command, description]; }); const tableOutput = table([ ['ID', 'Schedule', 'Command', 'Description'], ...tableRows, ]); exitWithSuccess(tableOutput); } exitWithSuccess('No scheduled tasks configured'); } else if (cmd.delete) { // delete the existing entry if (!existingTaskIds.includes(cmd.delete)) { exitWithError(`No scheduled task with id ${cmd.delete}`); } const existingTask = existingTasks[cmd.delete]; if ( !(cmd.yes || (await confirm(`Delete scheduled task ${cmd.delete}?`))) ) { exitWithSuccess(); } const existingTaskParams = Object.keys(existingTask); for (let i = 0; i < existingTaskParams.length; i++) { await deployConfig.delete( cmd.getAppPrefix(), `scheduledTasks/${cmd.delete}/${existingTaskParams[i]}`, ); } exitWithSuccess(`Scheduled task ${cmd.delete} deleted`); } else if (!(cmd.taskSchedule && cmd.taskCommand)) { exitWithError('Invalid options. See `--help` output'); } const taskId = cmd.taskId || Math.random().toString(36).substr(2, 16); const taskDescription = cmd.taskDescription || ''; const { taskSchedule } = cmd; const taskComman = cmd.taskCommand; if (!validSSMParamName(taskId)) { exitWithError( `Invalid ${taskId} value; '/^([a-z0-9:/_-]+)$/i' allowed only`, ); } if ( existingTaskIds.some((t) => { return t.id === taskId; }) ) { exitWithError( `A schedule task with id ${taskId} already exists for ${cmd.app}`, ); } const params = { [`scheduledTasks/${taskId}/description`]: taskDescription, [`scheduledTasks/${taskId}/schedule`]: taskSchedule, [`scheduledTasks/${taskId}/command`]: taskComman, }; await deployConfig.syncToSsm(cmd.getAppPrefix(), params); exitWithSuccess('task scheduled'); }); await cli.parseAsync(process.argv); } main().catch((err) => { console.error(err); process.exit(1); });