This repository has been archived by the owner on Feb 12, 2022. It is now read-only.
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
f528caf
commit f946442
Showing
3 changed files
with
375 additions
and
1 deletion.
There are no files selected for viewing
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,168 @@ | ||
var fs = require('fs'); | ||
var path = require('path'); | ||
var child = require('child_process'); | ||
|
||
var _ = require('lodash'); | ||
var exists = require('is-there'); | ||
var cli = require('heroku-cli-util'); | ||
var YAML = require('yamljs'); | ||
var camelcase = require('camelcase'); | ||
|
||
var docker = require('../lib/docker'); | ||
var safely = require('../lib/safely'); | ||
var directory = require('../lib/directory'); | ||
|
||
const ADDONS = require('../lib/addons'); | ||
|
||
module.exports = function(topic) { | ||
return { | ||
topic: topic, | ||
command: 'init', | ||
description: 'create Dockerfile and docker-compose.yml', | ||
help: 'Creates a Dockerfile and docker-compose.yml for the app specified in app.json', | ||
flags: [ | ||
{ name: 'image', char: 'i', description: 'the Docker image from which to inherit', hasValue: true }, | ||
{ name: 'force', char: 'f', description: 'overwrite existing Dockerfile and docker-compose.yml', hasValue: false }, | ||
{ name: 'dockerfile', char: 'd', description: 'use existing Dockerfile', hasValue: true } | ||
], | ||
run: safely(init) | ||
}; | ||
}; | ||
|
||
function init(context) { | ||
// Check preconditions | ||
abortOnMissing(context.cwd, 'Procfile'); | ||
abortOnClobber(context.cwd, docker.filename, context.flags.force || context.flags.dockerfile); | ||
abortOnClobber(context.cwd, docker.composeFilename, context.flags.force); | ||
|
||
// Inputs (Procfile & app.json) | ||
var procfile = readFile(context.cwd, 'Procfile', YAML.parse); | ||
var appJSON = exists(path.join(context.cwd, 'app.json')) ? | ||
readFile(context.cwd, 'app.json', JSON.parse) : | ||
createAppJSON(context.cwd, context.flags.image); | ||
|
||
// Outputs (app.json & Dockerfile & docker-compose.yml) | ||
appJSON.image = context.flags.image || appJSON.image; | ||
var dockerfile = context.flags.dockerfile ? | ||
readFile(context.cwd, context.flags.dockerfile) : | ||
createDockerfile(appJSON.image); | ||
var dockerCompose = createDockerCompose(procfile, appJSON.addons, appJSON.mount_dir); | ||
|
||
// All went well; write all files | ||
writeFile(context.cwd, 'app.json', JSON.stringify(appJSON, null, ' ') + "\n"); | ||
writeFile(context.cwd, docker.filename, dockerfile); | ||
writeFile(context.cwd, docker.composeFilename, dockerCompose); | ||
} | ||
|
||
function abortOnMissing(dir, filename) { | ||
if (!exists(path.join(dir, filename))) { | ||
throw new Error(`${ filename } required; aborting`); | ||
} | ||
} | ||
|
||
function abortOnClobber(dir, filename, force) { | ||
if (exists(path.join(dir, filename)) && !force) { | ||
throw new Error(`${ filename } already exists; use --force to overwrite`); | ||
} | ||
} | ||
|
||
function createAppJSON(dir, image) { | ||
return { | ||
name: path.basename(dir), | ||
image: image, | ||
addons: [] | ||
}; | ||
} | ||
|
||
function createDockerfile(image) { | ||
if (!image) { | ||
throw new Error(`docker image required: provide an --image flag or 'image' key in app.json`); | ||
} | ||
return `FROM ${ image }\n`; | ||
} | ||
|
||
function createDockerCompose(procfile, addons, mountDir) { | ||
var volumeMount = path.join('/app/user', mountDir || ''); | ||
|
||
// get the base addon name, ignoring plan types | ||
var addonNames = _.map(addons, nameWithoutPlan); | ||
|
||
// process only the addons that we have mappings for | ||
var mappedAddons = _.filter(addonNames, _.has.bind(this, ADDONS)); | ||
|
||
// hyphens are not valid in link names for docker-compose | ||
var links = _.map(mappedAddons || [], camelcase); | ||
|
||
// reduce all addon env vars into a single object | ||
var envs = _.reduce(mappedAddons, reduceEnv, {}); | ||
|
||
// compile a list of process types from the procfile | ||
var processServices = _.mapValues(procfile, processToService(volumeMount, links, envs)); | ||
|
||
// add a 'shell' process for persistent changes, one-off tasks | ||
processServices.shell = _.extend(_.cloneDeep(processServices.web), { | ||
command: 'bash', | ||
volumes: [`.:${ volumeMount }`] | ||
}); | ||
|
||
// zip all the addons into an object | ||
var addonServices = _.zipObject(links, _.map(mappedAddons, addonToService)); | ||
|
||
// combine processes and addons into a list of all services | ||
var services = _.extend({}, processServices, addonServices); | ||
|
||
// create docker-compose contents from the list of services | ||
return YAML.stringify(services, 4, 2); | ||
|
||
function nameWithoutPlan(addon) { | ||
return addon.split(':')[0]; | ||
} | ||
|
||
function reduceEnv(env, addon) { | ||
_.extend(env, ADDONS[addon].env); | ||
return env; | ||
} | ||
|
||
function processToService(mountDir, links, envs) { | ||
return function(command, procName) { | ||
var port = procName === 'web' ? docker.port : undefined; | ||
return _.pick({ | ||
build: '.', | ||
command: `bash -c '${command}'`, | ||
working_dir: mountDir, | ||
dockerfile: undefined, // TODO: docker.filename (once docker-compose 1.3.0 is released) | ||
environment: _.extend(port ? { PORT: port } : {}, envs), | ||
ports: port ? [`${ port }:${ port }`] : undefined, | ||
links: links.length ? links : undefined, | ||
envFile: undefined // TODO: detect an envFile? | ||
}, _.identity); | ||
}; | ||
} | ||
|
||
function addonToService(addon) { | ||
return { | ||
image: ADDONS[addon].image | ||
}; | ||
} | ||
} | ||
|
||
function readFile(dir, filename, transform) { | ||
try { | ||
var contents = fs.readFileSync(path.join(dir, filename), { encoding: 'utf8' }); | ||
return transform ? transform(contents) : contents; | ||
} | ||
catch (e) { | ||
throw new Error(`Error reading ${ filename } (${ e.message })`); | ||
} | ||
} | ||
|
||
function writeFile(dir, filename, contents) { | ||
try { | ||
var file = path.join(dir, filename); | ||
fs.writeFileSync(file, contents, { encoding: 'utf8' }); | ||
`Wrote ${ filename }` | ||
} | ||
catch (e) { | ||
throw new Error(`Error writing ${ filename }: (${ e.message })`); | ||
} | ||
} |
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,204 @@ | ||
var child = require('child_process'); | ||
var path = require('path'); | ||
var os = require('os'); | ||
var fs = require('fs'); | ||
var Heroku = require('heroku-client'); | ||
var request = require('request'); | ||
var agent = require('superagent'); | ||
var cli = require('heroku-cli-util'); | ||
var _ = require('lodash'); | ||
var ProgressBar = require('progress'); | ||
|
||
var directory = require('../lib/directory'); | ||
var docker = require('../lib/docker'); | ||
var safely = require('../lib/safely'); | ||
|
||
const ADDONS = require('../lib/addons'); | ||
|
||
module.exports = function(topic) { | ||
return { | ||
topic: topic, | ||
command: 'release', | ||
description: 'create and release slug to app', | ||
help: 'Create slug tarball from Docker image and release it to Heroku app', | ||
needsApp: true, | ||
needsAuth: true, | ||
run: safely(release) | ||
}; | ||
}; | ||
|
||
function release(context) { | ||
var procfile = directory.readProcfile(context.cwd); | ||
var mountDir = directory.determineMountDir(context.cwd); | ||
var modifiedProc = _.mapValues(procfile, prependMountDir(mountDir)); | ||
var heroku = context.heroku || new Heroku({ token: context.auth.password }); | ||
var app = context.heroku ? context.app : heroku.apps(context.app); | ||
var appJSONLocation = path.join(context.cwd, 'app.json'); | ||
var appJSON = JSON.parse(fs.readFileSync(appJSONLocation, { encoding: 'utf8' })); | ||
|
||
request = context.request || request; | ||
|
||
if (!procfile) throw new Error('Procfile required. Aborting'); | ||
|
||
return app.info() | ||
.then(readRemoteAddons) | ||
.then(compareLocalAddons) | ||
.then(addMissingAddons) | ||
.then(createLocalSlug) | ||
.then(createRemoteSlug) | ||
.then(uploadSlug) | ||
.then(releaseSlug) | ||
.then(showMessage); | ||
|
||
function prependMountDir(mountDir) { | ||
return function(cmd) { | ||
return `cd ${ mountDir } && ${ cmd }` | ||
} | ||
} | ||
|
||
function readRemoteAddons() { | ||
return app.addons().list(); | ||
} | ||
|
||
function compareLocalAddons(remoteAddons) { | ||
var remoteNames = _.map(remoteAddons, getServiceName); | ||
var localNames = appJSON.addons || []; | ||
var missingAddons = _.filter(localNames, isMissingFrom.bind(this, remoteNames)); | ||
|
||
console.log(`Remote addons: ${ remoteNames.join(', ')} (${ remoteNames.length })`); | ||
console.log(`Local addons: ${ localNames.join(', ') } (${ localNames.length })`); | ||
console.log(`Missing addons: ${ missingAddons.join(', ') } (${ missingAddons.length })`); | ||
|
||
return Promise.resolve(missingAddons); | ||
|
||
function getServiceName(addon) { | ||
return addon.addon_service.name; | ||
} | ||
|
||
function isMissingFrom(list, addon) { | ||
var name = addon.split(':')[0]; | ||
return list.indexOf(name) === -1; | ||
} | ||
} | ||
|
||
function addMissingAddons(addons) { | ||
return Promise.all(addons.map(createAddon)); | ||
|
||
function createAddon(name) { | ||
console.log(`Provisioning ${ name }...`) | ||
return app.addons().create({ | ||
plan: name | ||
}); | ||
} | ||
} | ||
|
||
function createLocalSlug() { | ||
cli.log('Creating local slug...'); | ||
|
||
return new Promise(function(resolve, reject) { | ||
var slugPath = os.tmpdir(); | ||
var output = ''; | ||
var build = child.spawn('docker-compose', ['build', 'web']); | ||
|
||
build.stdout.pipe(process.stdout); | ||
build.stderr.pipe(process.stderr); | ||
build.stdout.on('data', saveOutput); | ||
build.on('exit', onBuildExit); | ||
|
||
function saveOutput(data) { | ||
output += data; | ||
} | ||
|
||
function onBuildExit(code) { | ||
if (code !== 0) { | ||
cli.log('Build failed. Make sure `docker-compose build web` returns a 0 exit status.'); | ||
process.exit(1); | ||
} | ||
|
||
var tokens = output.match(/\S+/g); | ||
var fromMatch = output.match(/FROM ([^\s]+)/) || []; | ||
var imageName = fromMatch[1]; | ||
var imageId = tokens[tokens.length - 1]; | ||
tar(imageName, imageId); | ||
} | ||
|
||
function tar(imageName, imageId) { | ||
cli.log('extracting slug from container...'); | ||
var containerId = child.execSync(`docker run -d ${imageId} tar cfvz /tmp/slug.tgz -C / --exclude=.git --exclude=.cache --exclude=.buildpack ./app`, { | ||
encoding: 'utf8' | ||
}).trim(); | ||
child.execSync(`docker wait ${containerId}`); | ||
child.execSync(`docker cp ${containerId}:/tmp/slug.tgz ${slugPath}`); | ||
child.execSync(`docker rm -f ${containerId}`); | ||
resolve({ | ||
path: path.join(slugPath, 'slug.tgz'), | ||
name: imageName | ||
}); | ||
} | ||
}); | ||
} | ||
|
||
function createRemoteSlug(slug) { | ||
var lang = `heroku-container-tools (${ slug.name || 'unknown'})`; | ||
cli.log(`creating remote slug...`); | ||
cli.log(`language-pack: ${ lang }`); | ||
cli.log('remote process types:', modifiedProc); | ||
var slugInfo = app.slugs().create({ | ||
process_types: modifiedProc, | ||
buildpack_provided_description: lang | ||
}); | ||
return Promise.all([slug.path, slugInfo]) | ||
} | ||
|
||
function uploadSlug(slug) { | ||
var slugPath = slug[0]; | ||
var slugInfo = slug[1]; | ||
var size = fs.statSync(slugPath).size; | ||
var mbs = Math.round(size / 1024 / 1024) | ||
var bar = new ProgressBar(`uploading slug [:bar] :percent of ${ mbs } MB, :etas`, { | ||
width: 20, | ||
total: size | ||
}); | ||
|
||
return new Promise(function(resolve, reject) { | ||
var outStream = request({ | ||
method: 'PUT', | ||
url: slugInfo.blob.url, | ||
headers: { | ||
'content-type': '', | ||
'content-length': size | ||
} | ||
}); | ||
|
||
fs.createReadStream(slugPath) | ||
.on('error', reject) | ||
.on('data', updateProgress) | ||
.pipe(outStream) | ||
.on('error', reject) | ||
.on('response', resolve.bind(this, slugInfo.id)); | ||
|
||
function updateProgress(chunk) { | ||
bar.tick(chunk.length); | ||
} | ||
}); | ||
} | ||
|
||
function releaseSlug(id) { | ||
cli.log('releasing slug...'); | ||
|
||
return heroku.request({ | ||
method: 'POST', | ||
path: `${ app.path }/releases`, | ||
headers: { | ||
'Heroku-Deploy-Type': 'heroku-container-tools' | ||
}, | ||
body: { | ||
slug: id | ||
} | ||
}); | ||
} | ||
|
||
function showMessage(results) { | ||
console.log(`Successfully released ${ results.app.name }!`); | ||
} | ||
} |
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