caccl-deploy
Version:
A cli tool for managing ECS/Fargate app deployments
894 lines (817 loc) • 23.6 kB
JavaScript
/* eslint-disable no-use-before-define */
const SharedIniFile = require('aws-sdk/lib/shared-ini').iniLoader;
const { camelCase } = require('camel-case');
const {
ExistingSecretWontDelete,
CfnStackNotFound,
AwsProfileNotFound,
} = require('./errors');
const { sleep, looksLikeSemver, readFile } = require('./helpers');
let AWS;
let assumedRoleArn;
let assumedRoleCredentials;
try {
process.env.AWS_SDK_LOAD_CONFIG = 1;
// eslint-disable-next-line global-require
AWS = require('aws-sdk');
} catch (err) {
// ingore if error is due to missing credentials;
if (
process.env.NODE_ENV !== 'test' &&
(err.code !== 'ENOENT' || !err.message.includes('.aws/credentials'))
) {
throw err;
}
}
const aws = {
EC2_INSTANCE_CONNECT_USER: 'ec2-user',
/**
* checks that the AWS package interface has the configuration it needs
* @returns {boolean}
*/
isConfigured: () => {
try {
return [AWS, AWS.config.credentials, AWS.config.region].every((thing) => {
return thing !== undefined && thing !== null;
});
} catch (err) {
return false;
}
},
/**
* Split an ECR ARN value into parts. For example the ARN
* "arn:aws:ecr:us-east-1:12345678901:repository/foo/tool:1.0.0"
* would return
* {
* service: ecr,
* region: us-east-1,
* account: 12345678901,
* repoName: foo/tool,
* imageTag: 1.0.0
* }
* @param {string} arn - an ECR ARN value
* @returns {object} an object representing the parsed ECR image ARN
*/
parseEcrArn: (arn) => {
const parts = arn.split(':');
const [relativeId, imageTag] = parts.slice(-2);
const repoName = relativeId.replace('repository/', '');
return {
service: parts[2],
region: parts[3],
account: parts[4],
repoName,
imageTag,
};
},
/**
* Transforms an ECR ARN value into it's URI form
* for example, this:
* arn:aws:ecr:us-east-1:12345678901:repository/foo/tool:1.0.0
* becomes this:
* 12345678901.dkr.ecr.us-east-1.amazonaws.com/foo/toool
* @param {string} arn - an ECR ARN value
* @returns {string} and ECR image URI
*/
ecrArnToImageId: (arn) => {
const parsedArn = aws.parseEcrArn(arn);
const host = [
parsedArn.account,
'dkr.ecr',
parsedArn.region,
'amazonaws.com',
].join('.');
return `${host}/${parsedArn.repoName}:${parsedArn.imageTag}`;
},
/**
* Reassembles the result of `parseEcrArn` into a string
* @param {object} arnObj
* @returns {string} an ECR image ARN
*/
createEcrArn: (arnObj) => {
return [
'arn:aws:ecr',
arnObj.region,
arnObj.account,
`repository/${arnObj.repoName}`,
arnObj.imageTag,
].join(':');
},
/**
* Initialize the aws-sdk library with credentials from a
* specific profile.
* @param {string} profileName
*/
initProfile: (profileName) => {
const awsCredentials = SharedIniFile.loadFrom();
if (awsCredentials[profileName] === undefined) {
throw new AwsProfileNotFound(
`Tried to init a non-existent profile: '${profileName}'`,
);
}
const profileCreds = awsCredentials[profileName];
AWS.config.update({
credentials: new AWS.Credentials({
accessKeyId: profileCreds.aws_access_key_id,
secretAccessKey: profileCreds.aws_secret_access_key,
}),
});
},
/**
* Set an IAM role for AWS clients to assume
* @param {string} roleArn
*/
setAssumedRoleArn: (roleArn) => {
assumedRoleArn = roleArn;
},
/**
* @returns {string} the AWS account id of the current user
*/
getAccountId: async () => {
const sts = new AWS.STS();
const identity = await sts.getCallerIdentity({}).promise();
return identity.Account;
},
/**
* Returns the configured region.
* The region can be set in a couple of ways:
* - the usual env vars, AWS_REGION, etc
* - a region configured in the user's AWS profile/credentials
* @returns {string}
*/
getCurrentRegion: () => {
return AWS.config.region;
},
/**
* Returns a list of available infrastructure stacks. It assumes
* any CloudFormation stack with an output named `InfraStackName`
* is a compatible stack.
* @returns {string[]}
*/
getInfraStackList: async () => {
const cfn = new AWS.CloudFormation();
const stackList = [];
const stacks = await getPaginatedResponse(
cfn.describeStacks.bind(cfn),
{},
'Stacks',
);
stacks.forEach((stack) => {
if (stack.Outputs) {
const ouptutKeys = stack.Outputs.map((o) => {
return o.OutputKey;
});
if (ouptutKeys.indexOf('InfraStackName') >= 0) {
stackList.push(stack.StackName);
}
}
});
return stackList;
},
/**
* Return all the unqique app parameter namespaces, i.e., all the
* distinct values that come after `/[prefix]` in the hierarchy.
*
* The SSM API doesn't have a great way to search/filter for parameter
* store entries
*
* @param {string} prefix - name prefix used by the app CloudFormation stacks
* @returns {string[]}
*/
getAppList: async (prefix) => {
const ssm = new AWS.SSM();
const searchParams = {
MaxResults: 50, // lord i hope we never have this many apps
ParameterFilters: [
{
Key: 'Name',
Option: 'Contains',
// making an assumption that all configurations will include this
Values: ['/infraStackName'],
},
],
};
const paramEntries = await getPaginatedResponse(
ssm.describeParameters.bind(ssm),
searchParams,
'Parameters',
);
const filtered = paramEntries.filter((p) => {
return p.Name.startsWith(prefix);
});
return filtered.map((p) => {
return p.Name.split('/')[2];
});
},
/**
* @returns {string[]} - array of ECR repository names
*/
getRepoList: async () => {
const ecr = await getAssumedRoleClient(AWS.ECR);
const edtechAppRepos = [];
const repos = await getPaginatedResponse(
ecr.describeRepositories.bind(ecr),
{},
'repositories',
);
for (let i = 0; i < repos.length; i += 1) {
const r = repos[i];
const tagResp = await ecr
.listTagsForResource({
resourceArn: r.repositoryArn,
})
.promise();
const isAnEdtechAppRepo = tagResp.tags.some((t) => {
return t.Key === 'product' && t.Value === 'edtech-apps';
});
if (isAnEdtechAppRepo) {
edtechAppRepos.push(r.repositoryName);
}
}
return edtechAppRepos;
},
/**
* @param {string} repo - ECR repository name, e.g. 'hdce/fooapp'
* @param {boolean} all - return all tags; don't filter for master, stage,
* tags that look like semver, etc
* @returns {object[]}
*/
getRepoImageList: async (repo, all) => {
const ecr = await getAssumedRoleClient(AWS.ECR);
const images = await getPaginatedResponse(
ecr.describeImages.bind(ecr),
{
repositoryName: repo,
maxResults: 1000,
filter: {
tagStatus: 'TAGGED',
},
},
'imageDetails',
);
// sort the images by the date they were pushed to ECR
images.sort((a, b) => {
if (a.imagePushedAt < b.imagePushedAt) {
return 1;
}
if (a.imagePushedAt > b.imagePushedAt) {
return -1;
}
return 0;
});
if (!all) {
// find the latest semver tagged image
return images.filter((i) => {
return i.imageTags.some((t) => {
return looksLikeSemver(t) || ['main', 'master', 'stage'].includes(t);
});
});
}
return images;
},
/**
* Confirms that a repo/tag combo exists
* @param {string} repoName - ECR repository name
* @param {string} tag - ECR image tag
* @returns {boolean}
*/
imageTagExists: async (repoName, tag) => {
const imageList = await aws.getRepoImageList(repoName, true);
return imageList.some((i) => {
return i.imageTags.includes(tag);
});
},
/**
* Confirms that a tag is the latest for a repo
* @param {string} repoName
* @param {string} tag
* @returns {boolean}
*/
isLatestTag: async (repoName, tag) => {
const imageList = await aws.getRepoImageList(repoName);
return imageList.length && imageList[0].imageTags.includes(tag);
},
/**
* Confirm that a secretsmanager entry exists
* @param {string} secretName
* @returns {boolean}
*/
secretExists: async (secretName) => {
const sm = new AWS.SecretsManager();
const params = {
Filters: [
{
Key: 'name',
Values: [secretName],
},
],
};
const resp = await sm.listSecrets(params).promise();
return resp.SecretList.length > 0;
},
/**
* Fetch the secret value for a secretsmanager entry
* @param {string} secretArn
* @returns {string}
*/
resolveSecret: async (secretArn) => {
const sm = new AWS.SecretsManager();
const resp = await sm
.getSecretValue({
SecretId: secretArn,
})
.promise();
return resp.SecretString;
},
/**
* creates or updates a secrets manager entry
* NOTE: the update + tagging operation is NOT atomic! I wish the
* sdk made this easier
* @param {object} [secretOpts={}] - secret entry options
* @param {string} [secretOpts.Name] - name of the secrets manager entry
* @param {string} [secretOpts.Description] - description of the entry
* @param {string} [secretOpts.SecretString] - value of the secret
* @param {array} [tags=[]] - aws tags [{ Name: '...', Value: '...'}]
* @returns {string} - the secretsmanager entry ARN
*/
putSecret: async (secretOpts, tags, retries = 0) => {
const sm = new AWS.SecretsManager();
const { Name: SecretId, Description, SecretString } = secretOpts;
let secretResp;
try {
const exists = await aws.secretExists(SecretId);
if (exists) {
secretResp = await sm
.updateSecret({
SecretId,
Description,
SecretString,
})
.promise();
console.log(`secretsmanager entry ${SecretId} updated`);
if (tags.length) {
await sm
.tagResource({
SecretId,
Tags: tags,
})
.promise();
console.log(`secretsmanager entry ${SecretId} tagged`);
}
} else {
secretResp = await sm
.createSecret({
Name: SecretId,
Description,
SecretString,
Tags: tags,
})
.promise();
console.log(`secretsmanager entry ${SecretId} created`);
}
} catch (err) {
if (err.message.includes('already scheduled for deletion')) {
if (retries < 5) {
// eslint-disable-next-line no-param-reassign
retries += 1;
await sleep(2 ** retries * 1000);
return aws.putSecret(secretOpts, tags, retries);
}
console.error('putSecret failed after 5 retries');
throw new ExistingSecretWontDelete(
`Failed to overwrite existing secret ${SecretId}`,
);
}
throw err;
}
return secretResp.ARN;
},
/**
* delete one or more secretsmanager entries
* @param {string[]} secretArns
*/
deleteSecrets: async (secretArns) => {
const sm = new AWS.SecretsManager();
for (let i = 0; i < secretArns.length; i += 1) {
await sm
.deleteSecret({
SecretId: secretArns[i],
ForceDeleteWithoutRecovery: true,
})
.promise();
console.log(`secret ${secretArns[i]} deleted`);
}
},
/**
* @param {object} opts - the parameter details, name, value, etc
* @param {object[]} tags - aws resource tags
* @returns {object}
*/
putSsmParameter: async (opts, tags = []) => {
const ssm = new AWS.SSM();
const paramOptions = { ...opts };
const paramResp = await ssm.putParameter(paramOptions).promise();
if (tags.length) {
await ssm
.addTagsToResource({
ResourceId: paramOptions.Name,
ResourceType: 'Parameter',
Tags: tags,
})
.promise();
}
return paramResp;
},
/**
* Delete one or more parameter store entries
* @param {string[]} paramNames
*/
deleteSsmParameters: async (paramNames) => {
const ssm = new AWS.SSM();
const maxParams = 10;
let idx = 0;
while (idx < paramNames.length) {
const paramNamesSlice = paramNames.slice(idx, maxParams + idx);
idx += maxParams;
await ssm
.deleteParameters({
Names: paramNamesSlice,
})
.promise();
paramNamesSlice.forEach((name) => {
console.log(`ssm parameter ${name} deleted`);
});
}
},
/**
* Fetch a set of parameter store entries based on a name prefix,
* e.g. `/caccl-deploy/foo-app`
* @param {string} prefix
* @returns {object[]}
*/
getSsmParametersByPrefix: async (prefix) => {
const ssm = new AWS.SSM();
return getPaginatedResponse(
ssm.getParametersByPath.bind(ssm),
{
Path: prefix,
Recursive: true,
},
'Parameters',
);
},
/**
* Fetch a single parameter store entry
* @param {string} paramName
* @returns {object}
*/
getSsmParameter: async (paramName) => {
const ssm = new AWS.SSM();
return ssm
.getParameter({
Name: paramName,
})
.promise();
},
/**
* Confirm that a CloudFormation stack exists
* @param {string} stackName
* @return {boolean}
*/
cfnStackExists: async (stackName) => {
try {
await aws.getCfnStackExports(stackName);
return true;
} catch (err) {
if (!(err instanceof CfnStackNotFound)) {
throw err;
}
}
return false;
},
/**
* Return a list of Cloudformation stacks with names matching a prefix
* @param {string} stackPrefix
* @returns {string[]}
*/
getCfnStacks: async (stackPrefix) => {
const cfn = new AWS.CloudFormation();
const resp = await getPaginatedResponse(
cfn.describeStacks.bind(cfn),
{},
'Stacks',
);
return resp.filter((s) => {
return s.StackName.startsWith(stackPrefix);
});
},
/**
* Returns an array of objects representing a Cloudformation stack's exports
* @param {string} stackName
* @returns {object[]}
*/
getCfnStackExports: async (stackName) => {
const cnf = new AWS.CloudFormation();
let exports;
try {
const resp = await cnf
.describeStacks({
StackName: stackName,
})
.promise();
if (resp.Stacks === undefined || !resp.Stacks.length) {
throw new CfnStackNotFound(`Unable to find stack ${stackName}`);
}
exports = resp.Stacks[0].Outputs.reduce((obj, output) => {
if (output.ExportName === undefined) {
return { ...obj };
}
const outputKey = camelCase(
output.ExportName.replace(`${stackName}-`, ''),
);
return {
...obj,
[outputKey]: output.OutputValue,
};
}, {});
} catch (err) {
if (err.message.includes('does not exist')) {
throw new CfnStackNotFound(
`Cloudformation stack ${stackName} does not exist`,
);
}
throw err;
}
return exports;
},
/**
* Fetch data on available ACM certificates
* @returns {object[]}
*/
getAcmCertList: async () => {
const acm = new AWS.ACM();
return getPaginatedResponse(
acm.listCertificates.bind(acm),
{},
'CertificateSummaryList',
);
},
/**
*
* @param {string} cluster
* @param {string} serviceName
* @param {string} taskDefName
* @param {string} execOptions.command - the command to be executed in
* the app container context
* @param {number} execOptions.timeout - number of seconds to allow the
* task to complete
* @param {array} execOptions.environment - an array of environment
* variable additions or overrides in the form
* { name: <name>, value: <value> }
* @returns {string} - the arn of the started task
*/
execTask: async (execOptions) => {
const ecs = new AWS.ECS();
const {
clusterName,
serviceName,
taskDefName,
command,
environment = [],
} = execOptions;
const service = await aws.getService(clusterName, serviceName);
// re-use networking config from the service description
const { networkConfiguration } = service;
const execResp = await ecs
.runTask({
cluster: clusterName,
taskDefinition: taskDefName,
networkConfiguration,
launchType: 'FARGATE',
platformVersion: '1.3.0',
overrides: {
containerOverrides: [
{
name: 'AppOnlyContainer',
command: ['/bin/sh', '-c', command],
environment,
},
],
},
})
.promise();
return execResp.tasks[0].taskArn;
},
/**
* Fetches the data for an ECS service
* @param {string} cluster
* @param {string} service
* @returns {object}
*/
getService: async (cluster, service) => {
const ecs = new AWS.ECS();
const resp = await ecs
.describeServices({
cluster,
services: [service],
})
.promise();
if (!resp.services) {
throw new Error(`service ${service} not found`);
}
return resp.services[0];
},
/**
* Fetches the data for an ECS task definition
* @param {string} taskDefName
* @returns {string}
*/
getTaskDefinition: async (taskDefName) => {
const ecs = new AWS.ECS();
const resp = await ecs
.describeTaskDefinition({
taskDefinition: taskDefName,
})
.promise();
if (resp.taskDefinition === undefined) {
throw new Error(`task def ${taskDefName} not found`);
}
return resp.taskDefinition;
},
/**
* Updates a Fargate task definition, replacing the app container's
* ECR image URI value
* @param {string} taskDefName
* @param {string} imageArn
* @returns {string} - the full ARN (incl family:revision) of the newly
* registered task definition
*/
updateTaskDefAppImage: async (taskDefName, imageArn, containerDefName) => {
const ecs = new AWS.ECS();
const taskDefinition = await aws.getTaskDefinition(taskDefName);
// get existing tag set to include with the new task def
const tagResp = await ecs
.listTagsForResource({
resourceArn: taskDefinition.taskDefinitionArn,
})
.promise();
/**
* tasks have multiple container definitions (app, proxy, etc), so we need
* to get the index for the one we're changing ('AppContainer')
*/
const containerIdx = taskDefinition.containerDefinitions.findIndex((cd) => {
return cd.name === containerDefName;
});
const newImageId = aws.ecrArnToImageId(imageArn);
// use a copy of the task definition object for the update
const newTaskDef = JSON.parse(JSON.stringify(taskDefinition));
// replace the image id with our new one
newTaskDef.containerDefinitions[containerIdx].image = newImageId;
// add the tags from our tag set request
newTaskDef.tags = tagResp.tags;
/**
* delete invalid params that are returned by `returnTaskDefinition` but
* not allowed by `registerTaskDefinition`
*/
const registerTaskDefinitionParams = [
'containerDefinitions',
'cpu',
'executionRoleArn',
'family',
'memory',
'networkMode',
'placementConstraints',
'requiresCompatibilities',
'taskRoleArn',
'volumes',
];
Object.keys(newTaskDef).forEach((k) => {
if (!registerTaskDefinitionParams.includes(k)) {
delete newTaskDef[k];
}
});
const registerResp = await ecs.registerTaskDefinition(newTaskDef).promise();
console.log('done');
return registerResp.taskDefinition.taskDefinitionArn;
},
/**
* Restart an app's ECS service
* @param {string} cluster
* @param {string} service
* @param {boolean} wait
*/
restartEcsServcie: async (cluster, service, restartOpts) => {
const { newTaskDefArn, wait } = restartOpts;
const ecs = new AWS.ECS();
console.log(
[
'Console link for monitoring: ',
`https://console.aws.amazon.com/ecs/home?region=${aws.getCurrentRegion()}`,
`#/clusters/${cluster}/`,
`services/${service}/tasks`,
].join(''),
);
const updateServiceParams = {
cluster,
service,
forceNewDeployment: true,
};
if (newTaskDefArn) {
updateServiceParams.taskDefinition = newTaskDefArn;
}
// execute the service deployment
await ecs.updateService(updateServiceParams).promise();
// return immediately if
if (!wait) {
return;
}
let allDone = false;
ecs
.waitFor('servicesStable', {
cluster,
services: [service],
})
.promise()
.then(() => {
allDone = true;
});
let counter = 0;
while (!allDone) {
console.log('Waiting for deployment to stablize...');
counter += 1;
await sleep(2 ** counter * 1000);
}
console.log('all done!');
},
sendSSHPublicKey: async (opts) => {
const { instanceAz, instanceId, sshKeyPath } = opts;
const ec2ic = new AWS.EC2InstanceConnect();
const resp = await ec2ic
.sendSSHPublicKey({
AvailabilityZone: instanceAz,
InstanceId: instanceId,
InstanceOSUser: aws.EC2_INSTANCE_CONNECT_USER,
SSHPublicKey: readFile(sshKeyPath),
})
.promise();
return resp;
},
};
/**
* Returns an AWS service client that has been reconfigured with
* temporary credentials from assuming an IAM role
* @param {class} ClientClass
* @returns {object}
*/
const getAssumedRoleClient = async (ClientClass) => {
const client = new ClientClass();
if (
assumedRoleArn === undefined ||
assumedRoleArn.includes(await aws.getAccountId())
) {
return client;
}
if (assumedRoleCredentials === undefined) {
const sts = new AWS.STS();
const resp = await sts
.assumeRole({
RoleArn: assumedRoleArn,
RoleSessionName: 'caccl-deploy-assume-role-session',
})
.promise();
assumedRoleCredentials = resp.Credentials;
}
client.config.update({
accessKeyId: assumedRoleCredentials.AccessKeyId,
secretAccessKey: assumedRoleCredentials.SecretAccessKey,
sessionToken: assumedRoleCredentials.SessionToken,
});
return client;
};
/**
* Convenience function for fetching larger responses that might
* get paginated by the AWS api
* @param {function} func
* @param {object} params
* @param {string} itemKey
* @returns {object[]}
*/
const getPaginatedResponse = async (func, params, itemKey) => {
const items = [];
async function getItems(nextTokenArg) {
const paramsCopy = { ...params };
if (nextTokenArg !== undefined) {
paramsCopy.NextToken = nextTokenArg;
}
const resp = await func(paramsCopy).promise();
if (itemKey in resp) {
items.push(...resp[itemKey]);
}
if (resp.NextToken !== undefined) {
await getItems(resp.NextToken);
}
}
await getItems();
return items;
};
module.exports = aws;