Created
May 19, 2019 19:12
-
-
Save jmorrell/959e30d6f5ba5cdbb62a8557af03ff6d to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// @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