Skip to content

Instantly share code, notes, and snippets.

@jmorrell
Created May 19, 2019 19:12
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jmorrell/959e30d6f5ba5cdbb62a8557af03ff6d to your computer and use it in GitHub Desktop.
Save jmorrell/959e30d6f5ba5cdbb62a8557af03ff6d to your computer and use it in GitHub Desktop.
// @flow
/* This allows you to take a directory that contains an application:
```
├── Dockerfile
├── heroku.yml
└── webapp
├── app.py
├── requirements.txt
├── tests.py
└── wsgi.py
```
and:
* Create a new temporary Heroku app
* Deploy the contents of the directory to that app
* Run the build
* Execute an arbitrary `run` command
* Capture the output
* Clean up the app
Example:
```
let result = await run('./my-app-directory', 'make build-new-language-binary');
```
*/
let url = require('url');
let uniqid = require('uniqid');
let tmp = require('tmp');
let fs = require('fs-extra');
let tar = require('tar');
let fetch = require('node-fetch');
let Heroku = require('heroku-client');
let { Dyno } = require('@heroku-cli/plugin-run-v5');
let config = require('../config');
let { HEROKU_TOKEN } = config;
type RunOptions = {
modifyTemplate?: string => Promise<void>,
// formatted like `A=foo;B=bar`
env?: string,
size?: 'standard-1X' | 'standard-2X' | 'performance-m' | 'performance-l',
};
type RunState =
| { state: 'INIT' }
| { state: 'BUILD', app: Object, source: Object }
| { state: 'BUILDING', app: Object, build: Object }
| { state: 'BUILT', app: Object, buildLog: string }
| { state: 'FAILED_BUILD', app: Object, buildLog: string }
| {
state: 'FAILED_COMMAND',
app: Object,
log: string,
exitCode: number | void,
buildLog: string,
}
| { state: 'ERROR', error: Error }
| {
state: 'FINISHED',
app: Object,
log: string,
success: boolean,
buildLog: string,
};
type ResultType =
| 'SUCCESS'
| 'FAILED_COMMAND'
| 'FAILED_BUILD'
| 'UNKNOWN_ERROR';
type RunResult =
| {
resultType: 'SUCCESS',
success: true,
buildLog: string,
log: string,
exitCode: number,
}
| {
resultType: 'FAILED_COMMAND',
success: false,
buildLog: string,
log: string,
exitCode: number | void,
}
| { resultType: 'FAILED_BUILD', success: false, buildLog: string }
| { resultType: 'UNKNOWN_ERROR', success: false, error: Error | void };
let heroku = new Heroku({ token: HEROKU_TOKEN });
// I havent' figured out a way to get the log from the execution
// of the dyno using the existing class, so this overrides the original
// `attach` method and pipes the output into a string instead of stdout
class CustomDyno extends Dyno {
constructor(opts) {
super(opts);
}
attach() {
this.log = '';
this.on('data', chunk => {
// Not sure why we get windows line endings
this.log += chunk.toString().replace(/\r\n/g, '\n');
});
this.on('end', () => {});
this.uri = url.parse(this.dyno.attach_url);
if (this._useSSH) {
this.p = this._ssh();
} else {
this.p = this._rendezvous();
}
return this.p.then(() => {
this.end();
});
}
}
async function createApp(name): Object {
let params = {
name,
stack: 'container',
};
let app = await heroku.request({
method: 'POST',
path: '/apps',
body: params,
});
return app;
}
async function deleteApp(name) {
let res = await heroku.request({
method: 'DELETE',
path: `/apps/${name}`,
});
return res;
}
async function createSource(): Object {
let source = await heroku.post('/sources');
return source;
}
function copyAppDir(dir) {
// create a temp dir
let { name, removeCallback } = tmp.dirSync();
// copy the template directory to the temp dir
fs.copySync(dir, name);
return { directory: name, cleanup: removeCallback };
}
async function prepareAppTarball(dir, cb) {
let { directory } = copyAppDir(dir);
// If the user passes in a callback to modify the contents
// of the directory before deploy, let's run that now
if (cb) {
await cb(directory);
}
// tar and gzip the directory
let tmpTarFile = tmp.fileSync({ postfix: '.tgz ' });
let files = fs.readdirSync(directory);
await tar.c(
{
gzip: true,
file: tmpTarFile.name,
cwd: directory,
// This is necessary if you're running on OS X, otherwise extracting the tgz
// will fail on Heroku's Ubuntu OS
portable: true,
},
files
);
return tmpTarFile.name;
}
async function scaleWeb(appName) {
let res = await heroku.request({
method: 'PATCH',
path: `/apps/${appName}/formation`,
body: { updates: [{ quantity: 0, size: 'standard-1X', type: 'web' }] },
});
return res;
}
function timeout(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// steps:
//
// - take the template directory and copy it
// - run a user-provided callback to customize it
// - tar & gzip that directory
// - create a heroku app
// - prepare the source
// - create the source blob
// - kick off build with that source
// - wait for build to complete
// - use `heroku run` to run user's task
// - delete app
// - cleanup temp dir
async function run(
dir: string,
cmd: string,
options: RunOptions = {}
): Promise<RunResult> {
let state: RunState = { state: 'INIT' };
while (true) {
try {
switch (state.state) {
case 'INIT':
{
// generate a random app name
let appName = uniqid('nodebin-');
// create a tarball from the app template dir
let archive = await prepareAppTarball(dir, options.modifyTemplate);
// create a Heroku source url
let source = await createSource();
// create a Heroku app
let app = await createApp(appName);
let bufferContent = fs.readFileSync(archive);
let res = await fetch(source.source_blob.put_url, {
method: 'PUT',
headers: {
'Content-Type': '',
'Content-Length': fs.statSync(archive).size,
},
body: bufferContent,
});
state = { state: 'BUILD', app, source };
}
break;
case 'BUILD':
{
let { app, source } = state;
// kick off the build
let build = await heroku.request({
method: 'POST',
path: `/apps/${app.name}/builds`,
headers: {
Accept:
'application/vnd.heroku+json; version=3.streaming-build-output',
},
body: {
source_blob: {
url: source.source_blob.get_url,
version: 'v1.0.0',
},
},
});
state = { state: 'BUILDING', app, build };
}
break;
case 'BUILDING':
{
let { app, build } = state;
// find out what our build is up to
let buildStatus = await heroku.request({
method: 'GET',
path: `/apps/${app.name}/builds/${build.id}`,
});
let { status } = buildStatus;
switch (status) {
case 'pending':
// wait half a second and try again
await timeout(500);
break;
case 'succeeded':
let res = await fetch(buildStatus.output_stream_url);
let buildLog = await res.text();
// Due to a quirk in how Heroku prices dynos, in order to use non-hobby dynos,
// we have to scale the web dyno to a non-hobby size, but zero of them is fine
await scaleWeb(app.name);
state = { state: 'BUILT', buildLog, app };
break;
case 'failed':
let r = await fetch(buildStatus.output_stream_url);
let log = await r.text();
state = { state: 'FAILED_BUILD', app, buildLog: log };
break;
default:
let q = await fetch(buildStatus.output_stream_url);
let log2 = await q.text();
state = { state: 'FAILED_BUILD', app, buildLog: log2 };
break;
}
}
break;
case 'BUILT':
{
let { app, buildLog } = state;
// start our 1-off dyno
let dyno = new CustomDyno({
heroku,
app: app.name,
command: cmd,
size: options.size || 'standard-1X',
attach: true,
env: options.env || '',
'exit-code': true,
});
try {
await dyno.start();
state = {
state: 'FINISHED',
success: true,
app,
log: dyno.log,
buildLog,
};
} catch (e) {
let exitCode;
if (e.exitCode) {
exitCode = e.exitCode;
}
state = {
state: 'FAILED_COMMAND',
app,
exitCode,
log: dyno.log,
buildLog,
};
}
}
break;
case 'FINISHED': {
let { app, log, success, buildLog } = state;
let res = await deleteApp(app.name);
return {
success: true,
resultType: 'SUCCESS',
exitCode: 0,
buildLog,
log,
};
}
case 'FAILED_COMMAND': {
let { app, exitCode, log, buildLog } = state;
let res = await deleteApp(app.name);
return {
success: false,
resultType: 'FAILED_COMMAND',
exitCode,
buildLog,
log,
};
}
case 'FAILED_BUILD': {
let { app, buildLog } = state;
let res = await deleteApp(app.name);
return {
success: false,
resultType: 'FAILED_BUILD',
buildLog,
};
}
case 'ERROR': {
let { error } = state;
return { success: false, error, resultType: 'UNKNOWN_ERROR' };
}
default:
(state.state: empty);
break;
}
} catch (e) {
state = { state: 'ERROR', error: e };
}
}
// I don't know how you would ever get here, but adding this makes flow happy
return { resultType: 'UNKNOWN_ERROR', success: false, error: undefined };
}
module.exports = { run };
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment