diff --git a/CHANGELOG.md b/CHANGELOG.md index 039e98ab..07480c26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,13 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +### Added + +- Add ability to build and deploy an image to resin's infrastructure + ### Fixed -- Capture and report errors happening during the program initialization, like parsing invalid YAML config +- Capture and report errors happening during the program initialization, like parsing invalid YAML config ## [5.7.2] - 2017-04-18 diff --git a/build/actions/build.js b/build/actions/build.js new file mode 100644 index 00000000..54b3991d --- /dev/null +++ b/build/actions/build.js @@ -0,0 +1,57 @@ +// Generated by CoffeeScript 1.12.5 +var Promise, dockerUtils, getBundleInfo; + +Promise = require('bluebird'); + +dockerUtils = require('../utils/docker'); + +getBundleInfo = Promise.method(function(options) { + var helpers; + helpers = require('../utils/helpers'); + if (options.application != null) { + return helpers.getAppInfo(options.application).then(function(app) { + return [app.arch, app.device_type]; + }); + } else if ((options.arch != null) && (options.deviceType != null)) { + return [options.arch, options.deviceType]; + } else { + return void 0; + } +}); + +module.exports = { + signature: 'build [source]', + description: 'Build a container locally', + permission: 'user', + help: 'Use this command to build a container with a provided docker daemon.\n\nYou must provide either an application or a device-type/architecture\npair to use the resin Dockerfile pre-processor\n(e.g. Dockerfile.template -> Dockerfile).\n\nExamples:\n\n $ resin build\n $ resin build ./source/\n $ resin build --deviceType raspberrypi3 --arch armhf\n $ resin build --application MyApp ./source/\n $ resin build --docker \'/var/run/docker.sock\'\n $ resin build --dockerHost my.docker.host --dockerPort 2376 --ca ca.pem --key key.pem --cert cert.pem', + options: dockerUtils.appendOptions([ + { + signature: 'arch', + parameter: 'arch', + description: 'The architecture to build for', + alias: 'A' + }, { + signature: 'devicetype', + parameter: 'deviceType', + description: 'The type of device this build is for', + alias: 'd' + }, { + signature: 'application', + parameter: 'application', + description: 'The target resin.io application this build is for', + alias: 'a' + }, { + signature: 'tag', + parameter: 'tag', + description: 'The alias to the generated image', + alias: 't' + }, { + signature: 'nocache', + description: "Don't use docker layer caching when building", + boolean: true + } + ]), + action: function(params, options, done) { + return dockerUtils.runBuild(params, options, getBundleInfo).asCallback(done); + } +}; diff --git a/build/actions/deploy.js b/build/actions/deploy.js new file mode 100644 index 00000000..b54371b1 --- /dev/null +++ b/build/actions/deploy.js @@ -0,0 +1,166 @@ +// Generated by CoffeeScript 1.12.5 +var Promise, formatImageName, getBuilderPushEndpoint, getBundleInfo, parseInput, performUpload, pushProgress, uploadToPromise; + +Promise = require('bluebird'); + +getBuilderPushEndpoint = function(baseUrl, owner, app) { + var escApp, escOwner; + escOwner = encodeURIComponent(owner); + escApp = encodeURIComponent(app); + return "https://builder." + baseUrl + "/v1/push?owner=" + escOwner + "&app=" + escApp; +}; + +formatImageName = function(image) { + return image.split('/').pop(); +}; + +parseInput = Promise.method(function(params, options) { + var appName, context, image; + if (params.appName == null) { + throw new Error('Need an application to deploy to!'); + } + appName = params.appName; + image = void 0; + if (params.image != null) { + if (options.build || (options.source != null)) { + throw new Error('Build and source parameters are not applicable when specifying an image'); + } + options.build = false; + image = params.image; + } else if (options.build) { + context = options.source || '.'; + } else { + throw new Error('Need either an image or a build flag!'); + } + return [appName, options.build, context, image]; +}); + +pushProgress = function(imageSize, request, timeout) { + var progressReporter; + if (timeout == null) { + timeout = 250; + } + process.stdout.write('Initialising...'); + return progressReporter = setInterval(function() { + var percent, sent; + sent = request.req.connection._bytesDispatched; + percent = (sent / imageSize) * 100; + if (percent >= 100) { + clearInterval(progressReporter); + percent = 100; + } + process.stdout.clearLine(); + process.stdout.cursorTo(0); + process.stdout.write("Uploaded " + (percent.toFixed(1)) + "%"); + if (percent === 100) { + return console.log(); + } + }, timeout); +}; + +getBundleInfo = function(options) { + var helpers; + helpers = require('../utils/helpers'); + return helpers.getAppInfo(options.appName).then(function(app) { + return [app.arch, app.device_type]; + }); +}; + +performUpload = function(image, token, username, url, size, appName) { + var post, request; + request = require('request'); + url = url || process.env.RESINRC_RESIN_URL; + post = request.post({ + url: getBuilderPushEndpoint(url, username, appName), + auth: { + bearer: token + }, + body: image + }); + return uploadToPromise(post, size); +}; + +uploadToPromise = function(request, size) { + return new Promise(function(resolve, reject) { + var handleMessage; + handleMessage = function(data) { + var obj; + data = data.toString(); + if (process.env.DEBUG) { + console.log("Received data: " + data); + } + obj = JSON.parse(data); + if (obj.type != null) { + switch (obj.type) { + case 'error': + return reject(new Error("Remote error: " + obj.error)); + case 'success': + return resolve(obj.image); + case 'status': + return console.log("Remote: " + obj.message); + default: + return reject(new Error("Received unexpected reply from remote: " + data)); + } + } else { + return reject(new Error("Received unexpected reply from remote: " + data)); + } + }; + request.on('error', reject).on('data', handleMessage); + return pushProgress(size, request); + }); +}; + +module.exports = { + signature: 'deploy [image]', + description: 'Deploy a container to a resin.io application', + help: 'Use this command to deploy and optionally build an image to an application.\n\nUsage: deploy ([image] | --build [--source build-dir])\n\nNote: If building with this command, all options supported by `resin build`\nare also support with this command.\n\nExamples:\n$ resin deploy myApp --build --source myBuildDir/\n$ resin deploy myApp myApp/myImage', + permission: 'user', + options: [ + { + signature: 'build', + boolean: true, + description: 'Build image then deploy', + alias: 'b' + }, { + signature: 'source', + parameter: 'source', + description: 'The source directory to use when building the image', + alias: 's' + } + ], + action: function(params, options, done) { + var _, docker, dockerUtils, resin, tmp, tmpNameAsync; + _ = require('lodash'); + tmp = require('tmp'); + tmpNameAsync = Promise.promisify(tmp.tmpName); + resin = require('resin-sdk-preconfigured'); + dockerUtils = require('../utils/docker'); + tmp.setGracefulCleanup(); + docker = dockerUtils.getDocker(options); + return parseInput(params, options).then(function(arg) { + var appName, build, context, imageName; + appName = arg[0], build = arg[1], context = arg[2], imageName = arg[3]; + return tmpNameAsync().then(function(tmpPath) { + options = _.assign({}, options, { + appName: appName + }); + params = _.assign({}, params, { + context: context + }); + return Promise["try"](function() { + if (build) { + return dockerUtils.runBuild(params, options, getBundleInfo); + } else { + return imageName; + } + }).then(function(imageName) { + return Promise.join(dockerUtils.bufferImage(docker, imageName, tmpPath), resin.auth.getToken(), resin.auth.whoami(), resin.settings.get('resinUrl'), dockerUtils.getImageSize(docker, imageName), params.appName, performUpload); + })["finally"](function() { + return require('fs').unlink(tmpPath); + }); + }); + }).then(function(imageName) { + return console.log("Successfully deployed image: " + (formatImageName(imageName))); + }).asCallback(done); + } +}; diff --git a/build/actions/index.js b/build/actions/index.js index bac67848..a450abd4 100644 --- a/build/actions/index.js +++ b/build/actions/index.js @@ -32,5 +32,7 @@ module.exports = { config: require('./config'), sync: require('./sync'), ssh: require('./ssh'), - internal: require('./internal') + internal: require('./internal'), + build: require('./build'), + deploy: require('./deploy') }; diff --git a/build/app.js b/build/app.js index 8f48271c..29574ef9 100644 --- a/build/app.js +++ b/build/app.js @@ -49,6 +49,8 @@ plugins = require('./utils/plugins'); update = require('./utils/update'); +require('any-promise/register/bluebird'); + capitano.permission('user', function(done) { return resin.auth.isLoggedIn().then(function(isLoggedIn) { if (!isLoggedIn) { @@ -186,6 +188,10 @@ capitano.command(actions.local.stop); capitano.command(actions.internal.osInit); +capitano.command(actions.build); + +capitano.command(actions.deploy); + update.notify(); plugins.register(/^resin-plugin-(.+)$/).then(function() { diff --git a/build/resin-sdk.js b/build/resin-sdk.js deleted file mode 100644 index 050337c2..00000000 --- a/build/resin-sdk.js +++ /dev/null @@ -1,19 +0,0 @@ -// Generated by CoffeeScript 1.12.2 -(function() { - var getSdk, opts, settings; - - getSdk = require('resin-sdk'); - - settings = require('resin-settings-client'); - - opts = { - apiUrl: settings.get('apiUrl'), - imageMakerUrl: settings.get('imageMakerUrl'), - dataDirectory: settings.get('dataDirectory'), - apiVersion: 'v2', - retries: 2 - }; - - module.exports = getSdk(opts); - -}).call(this); diff --git a/build/utils/docker.js b/build/utils/docker.js new file mode 100644 index 00000000..caae50be --- /dev/null +++ b/build/utils/docker.js @@ -0,0 +1,182 @@ +// Generated by CoffeeScript 1.12.5 +var generateConnectOpts, tarDirectory; + +exports.appendOptions = function(opts) { + return opts.concat([ + { + signature: 'docker', + parameter: 'docker', + description: 'Path to a local docker socket', + alias: 'P' + }, { + signature: 'dockerHost', + parameter: 'dockerHost', + description: 'The address of the host containing the docker daemon', + alias: 'h' + }, { + signature: 'dockerPort', + parameter: 'dockerPort', + description: 'The port on which the host docker daemon is listening', + alias: 'p' + }, { + signature: 'ca', + parameter: 'ca', + description: 'Docker host TLS certificate authority file' + }, { + signature: 'cert', + parameter: 'cert', + description: 'Docker host TLS certificate file' + }, { + signature: 'key', + parameter: 'key', + description: 'Docker host TLS key file' + } + ]); +}; + +exports.generateConnectOpts = generateConnectOpts = function(opts) { + var connectOpts; + connectOpts = {}; + if ((opts.docker != null) && (opts.dockerHost == null)) { + connectOpts.socketPath = opts.docker; + } else if ((opts.dockerHost != null) && (opts.docker == null)) { + connectOpts.host = opts.dockerHost; + connectOpts.port = opts.dockerPort || 2376; + } else if ((opts.docker != null) && (opts.dockerHost != null)) { + throw new Error("Both a local docker socket and docker host have been provided. Don't know how to continue."); + } else { + connectOpts.socketPath = '/var/run/docker.sock'; + } + if ((opts.ca != null) || (opts.cert != null) || (opts.key != null)) { + if (!((opts.ca != null) && (opts.cert != null) && (opts.key != null))) { + throw new Error('You must provide a CA, certificate and key in order to use TLS'); + } + connectOpts.ca = opts.ca; + connectOpts.cert = opts.cert; + connectOpts.key = opts.key; + } + return connectOpts; +}; + +exports.tarDirectory = tarDirectory = function(dir) { + var Promise, fs, getFiles, klaw, pack, path, streamToPromise, tar; + Promise = require('bluebird'); + tar = require('tar-stream'); + klaw = require('klaw'); + path = require('path'); + fs = require('mz/fs'); + streamToPromise = require('stream-to-promise'); + getFiles = function() { + return streamToPromise(klaw(dir)).filter(function(item) { + return !item.stats.isDirectory(); + }).map(function(item) { + return item.path; + }); + }; + pack = tar.pack(); + return getFiles(dir).map(function(file) { + var relPath; + relPath = path.relative(path.resolve(dir), file); + return Promise.join(relPath, fs.stat(file), fs.readFile(file), function(filename, stats, data) { + return pack.entryAsync({ + name: filename, + size: stats.size + }, data); + }); + }).then(function() { + pack.finalize(); + return pack; + }); +}; + +exports.runBuild = function(params, options, getBundleInfo) { + var Promise, dockerBuild, resolver; + Promise = require('bluebird'); + dockerBuild = require('resin-docker-build'); + resolver = require('resin-bundle-resolve'); + if (params.context == null) { + params.context = '.'; + } + return tarDirectory(params.context).then(function(tarStream) { + return new Promise(function(resolve, reject) { + var builder, connectOpts, hooks, opts; + hooks = { + buildSuccess: function(image) { + if (options.tag != null) { + console.log("Tagging image as " + options.tag); + } + return resolve(image); + }, + buildFailure: reject, + buildStream: function(stream) { + getBundleInfo(options).then(function(info) { + var arch, bundle, deviceType; + if (info == null) { + console.log('Warning: No architecture/device type or application information provided.\n Dockerfile/project pre-processing will not be performed.'); + return tarStream.pipe(stream); + } else { + arch = info[0], deviceType = info[1]; + bundle = new resolver.Bundle(tarStream, deviceType, arch); + return resolver.resolveBundle(bundle, resolver.getDefaultResolvers()).then(function(resolved) { + console.log("Building " + resolved.projectType + " project"); + return resolved.tarStream.pipe(stream); + }); + } + })["catch"](reject); + return stream.pipe(process.stdout); + } + }; + connectOpts = generateConnectOpts(options); + if (process.env.DEBUG != null) { + console.log('Connecting with the following options:'); + console.log(JSON.stringify(connectOpts, null, ' ')); + } + builder = new dockerBuild.Builder(connectOpts); + opts = {}; + if (options.tag != null) { + opts['t'] = options.tag; + } + if (options.nocache != null) { + opts['nocache'] = true; + } + return builder.createBuildStream(opts, hooks, reject); + }); + }); +}; + +exports.bufferImage = function(docker, imageId, tmpFile) { + var Promise, fs, image, stream; + Promise = require('bluebird'); + fs = require('fs'); + stream = fs.createWriteStream(tmpFile); + image = docker.getImage(imageId); + return image.get().then(function(img) { + return new Promise(function(resolve, reject) { + return img.on('error', reject).on('data', function(data) { + return stream.write(data); + }).on('end', function() { + stream.close(); + return resolve(); + }); + }); + }).then(function() { + return new Promise(function(resolve, reject) { + return fs.createReadStream(tmpFile).on('open', function() { + return resolve(this); + }).on('error', reject); + }); + }); +}; + +exports.getDocker = function(options) { + var Docker, Promise, connectOpts; + Docker = require('dockerode'); + Promise = require('bluebird'); + connectOpts = generateConnectOpts(options); + connectOpts['Promise'] = Promise; + return new Docker(connectOpts); +}; + +exports.getImageSize = function(docker, image) { + return docker.getImage(image).inspectAsync().get('Size'); +}; diff --git a/build/utils/helpers.js b/build/utils/helpers.js index c7231ffd..defef6e3 100644 --- a/build/utils/helpers.js +++ b/build/utils/helpers.js @@ -94,3 +94,19 @@ exports.osProgressHandler = function(step) { }); return rindle.wait(step); }; + +exports.getAppInfo = function(application) { + var _, resin; + resin = require('resin-sdk-preconfigured'); + _ = require('lodash'); + return Promise.join(resin.models.application.get(application), resin.models.config.getDeviceTypes(), function(app, config) { + config = _.find(config, { + 'slug': app.device_type + }); + if (config == null) { + throw new Error('Could not read application information!'); + } + app.arch = config.arch; + return app; + }); +}; diff --git a/lib/actions/build.coffee b/lib/actions/build.coffee new file mode 100644 index 00000000..db70613e --- /dev/null +++ b/lib/actions/build.coffee @@ -0,0 +1,74 @@ +# Imported here because it's needed for the setup +# of this action +Promise = require('bluebird') +dockerUtils = require('../utils/docker') + +getBundleInfo = Promise.method (options) -> + helpers = require('../utils/helpers') + + if options.application? + # An application was provided + return helpers.getAppInfo(options.application) + .then (app) -> + return [app.arch, app.device_type] + else if options.arch? and options.deviceType? + return [options.arch, options.deviceType] + else + # No information, cannot do resolution + return undefined + +module.exports = + signature: 'build [source]' + description: 'Build a container locally' + permission: 'user' + help: ''' + Use this command to build a container with a provided docker daemon. + + You must provide either an application or a device-type/architecture + pair to use the resin Dockerfile pre-processor + (e.g. Dockerfile.template -> Dockerfile). + + Examples: + + $ resin build + $ resin build ./source/ + $ resin build --deviceType raspberrypi3 --arch armhf + $ resin build --application MyApp ./source/ + $ resin build --docker '/var/run/docker.sock' + $ resin build --dockerHost my.docker.host --dockerPort 2376 --ca ca.pem --key key.pem --cert cert.pem + ''' + options: dockerUtils.appendOptions [ + { + signature: 'arch' + parameter: 'arch' + description: 'The architecture to build for' + alias: 'A' + }, + { + signature: 'devicetype' + parameter: 'deviceType' + description: 'The type of device this build is for' + alias: 'd' + }, + { + signature: 'application' + parameter: 'application' + description: 'The target resin.io application this build is for' + alias: 'a' + }, + { + signature: 'tag' + parameter: 'tag' + description: 'The alias to the generated image' + alias: 't' + }, + { + signature: 'nocache' + description: "Don't use docker layer caching when building" + boolean: true + }, + ] + action: (params, options, done) -> + dockerUtils.runBuild(params, options, getBundleInfo) + .asCallback(done) + diff --git a/lib/actions/deploy.coffee b/lib/actions/deploy.coffee new file mode 100644 index 00000000..ba33e8b4 --- /dev/null +++ b/lib/actions/deploy.coffee @@ -0,0 +1,157 @@ +Promise = require('bluebird') + +getBuilderPushEndpoint = (baseUrl, owner, app) -> + escOwner = encodeURIComponent(owner) + escApp = encodeURIComponent(app) + "https://builder.#{baseUrl}/v1/push?owner=#{escOwner}&app=#{escApp}" + +formatImageName = (image) -> + image.split('/').pop() + +parseInput = Promise.method (params, options) -> + if not params.appName? + throw new Error('Need an application to deploy to!') + appName = params.appName + image = undefined + if params.image? + if options.build or options.source? + throw new Error('Build and source parameters are not applicable when specifying an image') + options.build = false + image = params.image + else if options.build + context = options.source || '.' + else + throw new Error('Need either an image or a build flag!') + + return [appName, options.build, context, image] + +pushProgress = (imageSize, request, timeout = 250) -> + process.stdout.write('Initialising...') + progressReporter = setInterval -> + sent = request.req.connection._bytesDispatched + percent = (sent / imageSize) * 100 + if percent >= 100 + clearInterval(progressReporter) + percent = 100 + process.stdout.clearLine() + process.stdout.cursorTo(0) + process.stdout.write("Uploaded #{percent.toFixed(1)}%") + console.log() if percent == 100 + , timeout + +getBundleInfo = (options) -> + helpers = require('../utils/helpers') + + helpers.getAppInfo(options.appName) + .then (app) -> + [app.arch, app.device_type] + +performUpload = (image, token, username, url, size, appName) -> + request = require('request') + url = url || process.env.RESINRC_RESIN_URL + post = request.post + url: getBuilderPushEndpoint(url, username, appName) + auth: + bearer: token + body: image + + uploadToPromise(post, size) + +uploadToPromise = (request, size) -> + new Promise (resolve, reject) -> + + handleMessage = (data) -> + data = data.toString() + if process.env.DEBUG + console.log("Received data: #{data}") + + obj = JSON.parse(data) + if obj.type? + switch obj.type + when 'error' then reject(new Error("Remote error: #{obj.error}")) + when 'success' then resolve(obj.image) + when 'status' then console.log("Remote: #{obj.message}") + else reject(new Error("Received unexpected reply from remote: #{data}")) + else + reject(new Error("Received unexpected reply from remote: #{data}")) + + request + .on('error', reject) + .on('data', handleMessage) + + # Set up upload reporting + pushProgress(size, request) + + +module.exports = + signature: 'deploy [image]' + description: 'Deploy a container to a resin.io application' + help: ''' + Use this command to deploy and optionally build an image to an application. + + Usage: deploy ([image] | --build [--source build-dir]) + + Note: If building with this command, all options supported by `resin build` + are also support with this command. + + Examples: + $ resin deploy myApp --build --source myBuildDir/ + $ resin deploy myApp myApp/myImage + ''' + permission: 'user' + options: [ + { + signature: 'build' + boolean: true + description: 'Build image then deploy' + alias: 'b' + }, + { + signature: 'source' + parameter: 'source' + description: 'The source directory to use when building the image' + alias: 's' + } + ] + action: (params, options, done) -> + _ = require('lodash') + tmp = require('tmp') + tmpNameAsync = Promise.promisify(tmp.tmpName) + resin = require('resin-sdk-preconfigured') + + dockerUtils = require('../utils/docker') + + # Ensure the tmp files gets deleted + tmp.setGracefulCleanup() + + docker = dockerUtils.getDocker(options) + # Check input parameters + parseInput(params, options) + .then ([appName, build, context, imageName]) -> + tmpNameAsync() + .then (tmpPath) -> + + # Setup the build args for how the build routine expects them + options = _.assign({}, options, { appName }) + params = _.assign({}, params, { context }) + + Promise.try -> + if build + dockerUtils.runBuild(params, options, getBundleInfo) + else + imageName + .then (imageName) -> + Promise.join( + dockerUtils.bufferImage(docker, imageName, tmpPath) + resin.auth.getToken() + resin.auth.whoami() + resin.settings.get('resinUrl') + dockerUtils.getImageSize(docker, imageName) + params.appName + performUpload + ) + .finally -> + require('fs').unlink(tmpPath) + .then (imageName) -> + console.log("Successfully deployed image: #{formatImageName(imageName)}") + .asCallback(done) diff --git a/lib/actions/index.coffee b/lib/actions/index.coffee index c0822784..9fc982ea 100644 --- a/lib/actions/index.coffee +++ b/lib/actions/index.coffee @@ -32,3 +32,5 @@ module.exports = sync: require('./sync') ssh: require('./ssh') internal: require('./internal') + build: require('./build') + deploy: require('./deploy') diff --git a/lib/app.coffee b/lib/app.coffee index b80b7de8..3fa78cbb 100644 --- a/lib/app.coffee +++ b/lib/app.coffee @@ -34,6 +34,12 @@ events = require('./events') plugins = require('./utils/plugins') update = require('./utils/update') +# Assign bluebird as the global promise library +# stream-to-promise will produce native promises if not +# for this module, which could wreak havoc in this +# bluebird-only codebase. +require('any-promise/register/bluebird') + capitano.permission 'user', (done) -> resin.auth.isLoggedIn().then (isLoggedIn) -> if not isLoggedIn @@ -147,6 +153,10 @@ capitano.command(actions.local.stop) # ---------- Internal utils ---------- capitano.command(actions.internal.osInit) +#------------ Local build and deploy ------- +capitano.command(actions.build) +capitano.command(actions.deploy) + update.notify() plugins.register(/^resin-plugin-(.+)$/).then -> diff --git a/lib/utils/docker.coffee b/lib/utils/docker.coffee new file mode 100644 index 00000000..790ad1b3 --- /dev/null +++ b/lib/utils/docker.coffee @@ -0,0 +1,200 @@ +# Functions to help actions which rely on using docker + +# Use this function to seed an action's list of capitano options +# with the docker options. Using this interface means that +# all functions using docker will expose the same interface +# +# NOTE: Care MUST be taken when using the function, so as to +# not redefine/override options already provided. +exports.appendOptions = (opts) -> + opts.concat [ + { + signature: 'docker' + parameter: 'docker' + description: 'Path to a local docker socket' + alias: 'P' + }, + { + signature: 'dockerHost' + parameter: 'dockerHost' + description: 'The address of the host containing the docker daemon' + alias: 'h' + }, + { + signature: 'dockerPort' + parameter: 'dockerPort' + description: 'The port on which the host docker daemon is listening' + alias: 'p' + }, + { + signature: 'ca' + parameter: 'ca' + description: 'Docker host TLS certificate authority file' + }, + { + signature: 'cert' + parameter: 'cert' + description: 'Docker host TLS certificate file' + }, + { + signature: 'key' + parameter: 'key' + description: 'Docker host TLS key file' + } + ] + +exports.generateConnectOpts = generateConnectOpts = (opts) -> + connectOpts = {} + # Firsly need to decide between a local docker socket + # and a host available over a host:port combo + if opts.docker? and not opts.dockerHost? + # good, local docker socket + connectOpts.socketPath = opts.docker + else if opts.dockerHost? and not opts.docker? + # Good a host is provided, and local socket isn't + connectOpts.host = opts.dockerHost + connectOpts.port = opts.dockerPort || 2376 + else if opts.docker? and opts.dockerHost? + # Both provided, no obvious way to continue + throw new Error("Both a local docker socket and docker host have been provided. Don't know how to continue.") + else + # None provided, assume default docker local socket + connectOpts.socketPath = '/var/run/docker.sock' + + # Now need to check if the user wants to connect over TLS + # to the host + + # If any are set... + if (opts.ca? or opts.cert? or opts.key?) + # but not all + if not (opts.ca? and opts.cert? and opts.key?) + throw new Error('You must provide a CA, certificate and key in order to use TLS') + connectOpts.ca = opts.ca + connectOpts.cert = opts.cert + connectOpts.key = opts.key + + return connectOpts + +exports.tarDirectory = tarDirectory = (dir) -> + Promise = require('bluebird') + tar = require('tar-stream') + klaw = require('klaw') + path = require('path') + fs = require('mz/fs') + streamToPromise = require('stream-to-promise') + + getFiles = -> + streamToPromise(klaw(dir)) + .filter((item) -> not item.stats.isDirectory()) + .map((item) -> item.path) + + pack = tar.pack() + getFiles(dir) + .map (file) -> + relPath = path.relative(path.resolve(dir), file) + Promise.join relPath, fs.stat(file), fs.readFile(file), + (filename, stats, data) -> + pack.entryAsync({ name: filename, size: stats.size }, data) + .then -> + pack.finalize() + return pack + +# Pass in the command line parameters and options and also +# a function which will return the information about the bundle +exports.runBuild = (params, options, getBundleInfo) -> + Promise = require('bluebird') + dockerBuild = require('resin-docker-build') + resolver = require('resin-bundle-resolve') + + # The default build context is the current directory + params.context ?= '.' + + # Tar up the directory, ready for the build stream + tarDirectory(params.context) + .then (tarStream) -> + new Promise (resolve, reject) -> + hooks = + buildSuccess: (image) -> + if options.tag? + console.log("Tagging image as #{options.tag}") + resolve(image) + buildFailure: reject + buildStream: (stream) -> + getBundleInfo(options) + .then (info) -> + if !info? + console.log ''' + Warning: No architecture/device type or application information provided. + Dockerfile/project pre-processing will not be performed. + ''' + tarStream.pipe(stream) + else + [arch, deviceType] = info + # Perform type resolution on the project + bundle = new resolver.Bundle(tarStream, deviceType, arch) + resolver.resolveBundle(bundle, resolver.getDefaultResolvers()) + .then (resolved) -> + console.log("Building #{resolved.projectType} project") + # Send the resolved tar stream to the docker daemon + resolved.tarStream.pipe(stream) + .catch(reject) + + # And print the output + stream.pipe(process.stdout) + + # Create a builder + connectOpts = generateConnectOpts(options) + + # Allow degugging output, hidden behind an env var + if process.env.DEBUG? + console.log('Connecting with the following options:') + console.log(JSON.stringify(connectOpts, null, ' ')) + + builder = new dockerBuild.Builder(connectOpts) + opts = {} + + if options.tag? + opts['t'] = options.tag + if options.nocache? + opts['nocache'] = true + + builder.createBuildStream(opts, hooks, reject) + +# Given an image id or tag, export the image to a tar archive. +# Also needs the options generated by the appendOptions() +# function, and a tmpFile to buffer the data into. +exports.bufferImage = (docker, imageId, tmpFile) -> + Promise = require('bluebird') + fs = require('fs') + + stream = fs.createWriteStream(tmpFile) + + image = docker.getImage(imageId) + image.get() + .then (img) -> + new Promise (resolve, reject) -> + img + .on('error', reject) + .on 'data', (data) -> + stream.write(data) + .on 'end', -> + stream.close() + resolve() + .then -> + new Promise (resolve, reject) -> + fs.createReadStream(tmpFile) + .on 'open', -> + resolve(this) + .on('error', reject) + +exports.getDocker = (options) -> + Docker = require('dockerode') + Promise = require('bluebird') + connectOpts = generateConnectOpts(options) + # Use bluebird's promises + connectOpts['Promise'] = Promise + new Docker(connectOpts) + +exports.getImageSize = (docker, image) -> + docker.getImage(image).inspectAsync() + .get('Size') diff --git a/lib/utils/helpers.coffee b/lib/utils/helpers.coffee index 7ec2c737..72fb5a96 100644 --- a/lib/utils/helpers.coffee +++ b/lib/utils/helpers.coffee @@ -91,3 +91,17 @@ exports.osProgressHandler = (step) -> progressBars[state.type].update(state) return rindle.wait(step) + +exports.getAppInfo = (application) -> + resin = require('resin-sdk-preconfigured') + _ = require('lodash') + Promise.join( + resin.models.application.get(application), + resin.models.config.getDeviceTypes(), + (app, config) -> + config = _.find(config, 'slug': app.device_type) + if !config? + throw new Error('Could not read application information!') + app.arch = config.arch + return app + ) diff --git a/package.json b/package.json index d95e4f39..cf19449d 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,9 @@ "gulp-shell": "^0.5.2" }, "dependencies": { + "any-promise": "^1.3.0", + "babel-preset-es2015": "^6.16.0", + "babel-register": "^6.16.3", "bluebird": "^3.3.3", "capitano": "^1.7.0", "chalk": "^1.1.3", @@ -40,32 +43,41 @@ "columnify": "^1.5.2", "denymount": "^2.2.0", "docker-toolbelt": "^1.3.3", + "dockerode": "^2.4.2", "drivelist": "^5.0.16", "etcher-image-write": "^9.0.3", "inquirer": "^3.0.6", "is-root": "^1.0.0", + "js-yaml": "^3.7.0", + "klaw": "^1.3.1", "lodash": "^3.10.0", "mixpanel": "^0.4.0", "moment": "^2.12.0", + "mz": "^2.6.0", "nplugm": "^3.0.0", "president": "^2.0.1", "prettyjson": "^1.1.3", - "reconfix": "^0.0.3", "raven": "^1.2.0", "resin-cli-auth": "^1.1.3", + "reconfix": "^0.0.3", + "request": "^2.81.0", + "resin-bundle-resolve": "^0.0.2", + "resin-cli-auth": "^1.0.0", "resin-cli-errors": "^1.2.0", "resin-cli-form": "^1.4.1", "resin-cli-visuals": "^1.3.0", "resin-config-json": "^1.0.0", "resin-device-config": "^3.0.0", "resin-device-init": "^2.2.1", + "resin-docker-build": "^0.4.0", "resin-image-fs": "^2.1.2", "resin-image-manager": "^4.1.1", "resin-sdk-preconfigured": "^6.0.0", "resin-sync": "^7.0.0", "rimraf": "^2.4.3", "rindle": "^1.0.0", - "tmp": "^0.0.31", + "stream-to-promise": "^2.2.0", + "tmp": "0.0.31", "umount": "^1.1.5", "underscore.string": "^3.1.1", "unzip2": "^0.2.5",