unity-builder/src/model/cloud-runner/aws/aws-build-platform.ts

386 lines
13 KiB
TypeScript
Raw Normal View History

import * as SDK from 'aws-sdk';
import { customAlphabet } from 'nanoid';
2021-09-29 22:34:39 +00:00
import CloudRunnerSecret from '../cloud-runner-services/cloud-runner-secret';
import CloudRunnerEnvironmentVariable from '../cloud-runner-services/cloud-runner-environment-variable';
import * as fs from 'fs';
import * as core from '@actions/core';
2021-09-29 22:34:39 +00:00
import CloudRunnerTaskDef from '../cloud-runner-services/cloud-runner-task-def';
import CloudRunnerConstants from '../cloud-runner-services/cloud-runner-constants';
import AWSBuildRunner from './aws-build-runner';
2021-09-29 22:34:39 +00:00
import { CloudRunnerProviderInterface } from '../cloud-runner-services/cloud-runner-provider-interface';
2021-09-22 20:05:21 +00:00
import BuildParameters from '../../build-parameters';
2021-09-29 22:34:39 +00:00
import CloudRunnerLogger from '../cloud-runner-services/cloud-runner-logger';
2021-08-08 09:20:52 +00:00
const crypto = require('crypto');
2021-08-17 20:09:42 +00:00
class AWSBuildEnvironment implements CloudRunnerProviderInterface {
2021-08-13 19:59:01 +00:00
private baseStackName: string;
constructor(buildParameters: BuildParameters) {
2021-08-13 19:59:01 +00:00
this.baseStackName = buildParameters.awsBaseStackName;
}
cleanupSharedBuildResources(
// eslint-disable-next-line no-unused-vars
buildGuid: string,
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
) {}
setupSharedBuildResources(
// eslint-disable-next-line no-unused-vars
buildGuid: string,
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
) {}
2021-06-19 04:49:32 +00:00
async runBuildTask(
buildId: string,
image: string,
commands: string[],
mountdir: string,
workingdir: string,
2021-08-17 22:13:46 +00:00
environment: CloudRunnerEnvironmentVariable[],
secrets: CloudRunnerSecret[],
): Promise<void> {
const ECS = new SDK.ECS();
const CF = new SDK.CloudFormation();
const entrypoint = ['/bin/sh'];
const t0 = Date.now();
const taskDef = await this.setupCloudFormations(
CF,
buildId,
image,
entrypoint,
commands,
mountdir,
workingdir,
secrets,
);
let t2;
try {
const t1 = Date.now();
2021-09-21 18:27:04 +00:00
CloudRunnerLogger.log(`Setup job time: ${Math.floor((t1 - t0) / 1000)}s`);
await AWSBuildRunner.runTask(taskDef, ECS, CF, environment, buildId, commands);
t2 = Date.now();
2021-09-21 18:27:04 +00:00
CloudRunnerLogger.log(`Run job time: ${Math.floor((t2 - t1) / 1000)}s`);
} finally {
await this.cleanupResources(CF, taskDef);
const t3 = Date.now();
2021-09-21 18:27:04 +00:00
if (t2 !== undefined) CloudRunnerLogger.log(`Cleanup job time: ${Math.floor((t3 - t2) / 1000)}s`);
}
}
getParameterTemplate(p1) {
return `
${p1}:
Type: String
Default: ''
`;
}
getSecretTemplate(p1) {
return `
${p1}Secret:
Type: AWS::SecretsManager::Secret
Properties:
Name: !Join [ "", [ '${p1}', !Ref BUILDID ] ]
SecretString: !Ref ${p1}
`;
}
getSecretDefinitionTemplate(p1, p2) {
return `
- Name: '${p1}'
ValueFrom: !Ref ${p2}Secret
`;
}
insertAtTemplate(template, insertionKey, insertion) {
const index = template.search(insertionKey) + insertionKey.length + '\n'.length;
template = [template.slice(0, index), insertion, template.slice(index)].join('');
return template;
}
async setupCloudFormations(
CF: SDK.CloudFormation,
buildGuid: string,
image: string,
entrypoint: string[],
commands: string[],
mountdir: string,
workingdir: string,
2021-08-17 22:13:46 +00:00
secrets: CloudRunnerSecret[],
2021-08-17 20:09:42 +00:00
): Promise<CloudRunnerTaskDef> {
2021-08-17 22:13:46 +00:00
const logGuid = customAlphabet(CloudRunnerConstants.alphabet, 9)();
commands[1] += `
echo "${logGuid}"
`;
2021-08-08 08:48:18 +00:00
await this.setupBaseStack(CF);
const taskDefStackName = `${this.baseStackName}-${buildGuid}`;
let taskDefCloudFormation = this.readTaskCloudFormationTemplate();
const cleanupTaskDefStackName = `${taskDefStackName}-cleanup`;
const cleanupCloudFormation = fs.readFileSync(`${__dirname}/cloud-formations/cloudformation-stack-ttl.yml`, 'utf8');
try {
for (const secret of secrets) {
if (typeof secret.ParameterValue == 'number') {
secret.ParameterValue = `${secret.ParameterValue}`;
}
2021-08-15 18:13:15 +00:00
if (!secret.ParameterValue || secret.ParameterValue === '') {
2021-08-15 18:19:18 +00:00
secrets = secrets.filter((x) => x !== secret);
2021-08-15 18:13:15 +00:00
continue;
}
taskDefCloudFormation = this.insertAtTemplate(
taskDefCloudFormation,
'p1 - input',
this.getParameterTemplate(secret.ParameterKey.replace(/[^\dA-Za-z]/g, '')),
);
taskDefCloudFormation = this.insertAtTemplate(
taskDefCloudFormation,
'p2 - secret',
this.getSecretTemplate(secret.ParameterKey.replace(/[^\dA-Za-z]/g, '')),
);
taskDefCloudFormation = this.insertAtTemplate(
taskDefCloudFormation,
'p3 - container def',
this.getSecretDefinitionTemplate(secret.EnvironmentVariable, secret.ParameterKey.replace(/[^\dA-Za-z]/g, '')),
);
}
const secretsMappedToCloudFormationParameters = secrets.map((x) => {
return { ParameterKey: x.ParameterKey.replace(/[^\dA-Za-z]/g, ''), ParameterValue: x.ParameterValue };
});
await CF.createStack({
StackName: taskDefStackName,
TemplateBody: taskDefCloudFormation,
Capabilities: ['CAPABILITY_IAM'],
Parameters: [
{
ParameterKey: 'EnvironmentName',
ParameterValue: this.baseStackName,
},
{
ParameterKey: 'ImageUrl',
ParameterValue: image,
},
{
ParameterKey: 'ServiceName',
ParameterValue: taskDefStackName,
},
{
ParameterKey: 'Command',
ParameterValue: 'echo "this template should be overwritten when running a task"',
},
{
ParameterKey: 'EntryPoint',
ParameterValue: entrypoint.join(','),
},
{
ParameterKey: 'WorkingDirectory',
ParameterValue: workingdir,
},
{
ParameterKey: 'EFSMountDirectory',
ParameterValue: mountdir,
},
{
ParameterKey: 'BUILDID',
ParameterValue: buildGuid,
},
...secretsMappedToCloudFormationParameters,
],
}).promise();
2021-09-21 18:27:04 +00:00
CloudRunnerLogger.log('Creating cloud runner job');
await CF.createStack({
StackName: cleanupTaskDefStackName,
TemplateBody: cleanupCloudFormation,
Capabilities: ['CAPABILITY_IAM'],
Parameters: [
{
ParameterKey: 'StackName',
ParameterValue: taskDefStackName,
},
{
ParameterKey: 'DeleteStackName',
ParameterValue: cleanupTaskDefStackName,
},
{
ParameterKey: 'TTL',
ParameterValue: '100',
},
{
ParameterKey: 'BUILDID',
ParameterValue: buildGuid,
},
2021-09-15 03:10:41 +00:00
{
ParameterKey: 'EnvironmentName',
ParameterValue: this.baseStackName,
},
],
}).promise();
2021-09-21 18:27:04 +00:00
// Side effect: CloudRunnerLogger.log('Creating cleanup double checker cron job...');
await CF.waitFor('stackCreateComplete', { StackName: taskDefStackName }).promise();
} catch (error) {
await this.handleStackCreationFailure(error, CF, taskDefStackName, taskDefCloudFormation, secrets);
throw error;
}
const taskDefResources = (
await CF.describeStackResources({
StackName: taskDefStackName,
}).promise()
).StackResources;
2021-08-13 19:59:01 +00:00
const baseResources = (await CF.describeStackResources({ StackName: this.baseStackName }).promise()).StackResources;
2021-07-12 23:23:19 +00:00
// TODO: offer a parameter to decide if you want the guarenteed shutdown or fastest startup time possible
return {
taskDefStackName,
taskDefCloudFormation,
taskDefStackNameTTL: cleanupTaskDefStackName,
ttlCloudFormation: cleanupCloudFormation,
taskDefResources,
baseResources,
logid: logGuid,
};
}
2021-08-08 08:48:18 +00:00
async setupBaseStack(CF: SDK.CloudFormation) {
2021-08-13 19:59:01 +00:00
const baseStackName = this.baseStackName;
2021-08-08 08:48:18 +00:00
const baseStack = fs.readFileSync(`${__dirname}/cloud-formations/base-setup.yml`, 'utf8');
// Cloud Formation Input
2021-08-08 09:05:16 +00:00
const describeStackInput: SDK.CloudFormation.DescribeStacksInput = {
StackName: baseStackName,
};
const parametersWithoutHash: SDK.CloudFormation.Parameter[] = [
{ ParameterKey: 'EnvironmentName', ParameterValue: baseStackName },
{ ParameterKey: 'Storage', ParameterValue: `${baseStackName}-storage` },
];
const hash = crypto
.createHash('md5')
.update(baseStack + JSON.stringify(parametersWithoutHash))
.digest('hex');
const parameters: SDK.CloudFormation.Parameter[] = [
...parametersWithoutHash,
...[{ ParameterKey: 'Version', ParameterValue: hash }],
];
const updateInput: SDK.CloudFormation.UpdateStackInput = {
StackName: baseStackName,
TemplateBody: baseStack,
Parameters: parameters,
Capabilities: ['CAPABILITY_IAM'],
};
const createStackInput: SDK.CloudFormation.CreateStackInput = {
StackName: baseStackName,
TemplateBody: baseStack,
Parameters: parameters,
Capabilities: ['CAPABILITY_IAM'],
};
2021-08-15 16:38:40 +00:00
const stacks = (
await CF.listStacks({ StackStatusFilter: ['UPDATE_COMPLETE', 'CREATE_COMPLETE'] }).promise()
).StackSummaries?.map((x) => x.StackName);
2021-08-13 18:16:17 +00:00
const stackExists: Boolean = stacks?.includes(baseStackName) || false;
const describeStack = async () => {
return await CF.describeStacks(describeStackInput).promise();
};
try {
if (!stackExists) {
2021-09-21 18:27:04 +00:00
CloudRunnerLogger.log(`${baseStackName} stack does not exist (${JSON.stringify(stacks)})`);
await CF.createStack(createStackInput).promise();
2021-09-21 18:27:04 +00:00
CloudRunnerLogger.log(`created stack (version: ${hash})`);
2021-08-08 09:05:16 +00:00
}
const CFState = await describeStack();
let stack = CFState.Stacks?.[0];
2021-08-13 18:16:17 +00:00
if (!stack) {
throw new Error(`Base stack doesn't exist, even after creation, stackExists check: ${stackExists}`);
2021-08-13 18:16:17 +00:00
}
const stackVersion = stack.Parameters?.find((x) => x.ParameterKey === 'Version')?.ParameterValue;
if (stack.StackStatus === 'CREATE_IN_PROGRESS') {
await CF.waitFor('stackCreateComplete', describeStackInput).promise();
}
if (stackExists) {
2021-09-21 18:27:04 +00:00
CloudRunnerLogger.log(`Base stack exists (version: ${stackVersion}, local version: ${hash})`);
if (hash !== stackVersion) {
2021-09-21 18:27:04 +00:00
CloudRunnerLogger.log(`Updating`);
await CF.updateStack(updateInput).promise();
} else {
2021-09-21 18:27:04 +00:00
CloudRunnerLogger.log(`No update required`);
}
stack = (await describeStack()).Stacks?.[0];
if (!stack) {
throw new Error(
`Base stack doesn't exist, even after updating and creation, stackExists check: ${stackExists}`,
);
}
if (stack.StackStatus === 'UPDATE_IN_PROGRESS') {
await CF.waitFor('stackUpdateComplete', describeStackInput).promise();
}
2021-08-13 18:16:17 +00:00
}
2021-09-21 18:27:04 +00:00
CloudRunnerLogger.log('base stack is ready');
} catch (error) {
core.error(JSON.stringify(await describeStack(), undefined, 4));
throw error;
2021-08-08 09:05:16 +00:00
}
2021-08-08 08:48:18 +00:00
}
async handleStackCreationFailure(
error: any,
CF: SDK.CloudFormation,
taskDefStackName: string,
taskDefCloudFormation: string,
2021-08-17 22:13:46 +00:00
secrets: CloudRunnerSecret[],
) {
2021-09-21 18:27:04 +00:00
CloudRunnerLogger.log(JSON.stringify(secrets, undefined, 4));
CloudRunnerLogger.log(taskDefCloudFormation);
2021-08-15 18:35:27 +00:00
core.error(error);
2021-09-21 18:27:04 +00:00
CloudRunnerLogger.log('Getting events and resources for task stack');
const events = (await CF.describeStackEvents({ StackName: taskDefStackName }).promise()).StackEvents;
const resources = (await CF.describeStackResources({ StackName: taskDefStackName }).promise()).StackResources;
2021-09-21 18:27:04 +00:00
CloudRunnerLogger.log(JSON.stringify(events, undefined, 4));
CloudRunnerLogger.log(JSON.stringify(resources, undefined, 4));
}
readTaskCloudFormationTemplate(): string {
return fs.readFileSync(`${__dirname}/cloud-formations/task-def-formation.yml`, 'utf8');
}
2021-08-17 20:09:42 +00:00
async cleanupResources(CF: SDK.CloudFormation, taskDef: CloudRunnerTaskDef) {
2021-09-21 18:27:04 +00:00
CloudRunnerLogger.log('Cleanup starting');
await CF.deleteStack({
StackName: taskDef.taskDefStackName,
}).promise();
await CF.deleteStack({
StackName: taskDef.taskDefStackNameTTL,
}).promise();
await CF.waitFor('stackDeleteComplete', {
StackName: taskDef.taskDefStackName,
}).promise();
await CF.waitFor('stackDeleteComplete', {
StackName: taskDef.taskDefStackNameTTL,
}).promise();
2021-09-15 03:20:04 +00:00
const stacks = (await CF.listStacks().promise()).StackSummaries?.filter((x) => x.StackStatus !== 'DELETE_COMPLETE');
2021-09-21 18:27:04 +00:00
CloudRunnerLogger.log(`Deleted Stacks: ${taskDef.taskDefStackName}, ${taskDef.taskDefStackNameTTL}`);
CloudRunnerLogger.log(`Stacks: ${JSON.stringify(stacks, undefined, 4)}`);
2021-09-15 03:20:04 +00:00
2021-09-21 18:27:04 +00:00
CloudRunnerLogger.log('Cleanup complete');
}
}
export default AWSBuildEnvironment;