diff --git a/CHANGELOG.md b/CHANGELOG.md index c85985cd..1f4ace1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +### Added + +- Preload support + ## [6.4.0] - 2017-08-11 ### Changed diff --git a/build/actions/index.js b/build/actions/index.js index e0478b77..ca3a0b95 100644 --- a/build/actions/index.js +++ b/build/actions/index.js @@ -35,5 +35,6 @@ module.exports = { internal: require('./internal'), build: require('./build'), deploy: require('./deploy'), - util: require('./util') + util: require('./util'), + preload: require('./preload') }; diff --git a/build/actions/preload.js b/build/actions/preload.js new file mode 100644 index 00000000..8fc96c8b --- /dev/null +++ b/build/actions/preload.js @@ -0,0 +1,217 @@ +// Generated by CoffeeScript 1.12.7 + +/* +Copyright 2016-2017 Resin.io + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + */ +var LATEST, dockerUtils, getApplicationsWithSuccessfulBuilds, offerToDisableAutomaticUpdates, selectApplication, selectApplicationCommit; + +dockerUtils = require('../utils/docker'); + +LATEST = 'latest'; + +getApplicationsWithSuccessfulBuilds = function(resin, deviceType) { + return resin.pine.get({ + resource: 'my_application', + options: { + filter: { + device_type: deviceType, + build: { + $any: { + $alias: 'b', + $expr: { + b: { + status: 'success' + } + } + } + } + }, + expand: { + environment_variable: { + $select: ['name', 'value'] + }, + build: { + $select: ['id', 'commit_hash', 'push_timestamp', 'status'], + $orderby: 'push_timestamp desc' + } + }, + select: ['id', 'app_name', 'device_type', 'commit'], + orderby: 'app_name asc' + } + }).then(function(applications) { + applications.forEach(function(application) { + return application.build = application.build.filter(function(build) { + return build.status === 'success'; + }); + }); + return applications; + }); +}; + +selectApplication = function(expectedError, resin, form, deviceType) { + return getApplicationsWithSuccessfulBuilds(resin, deviceType).then(function(applications) { + if (applications.length === 0) { + expectedError("You have no apps with successful builds for a '" + deviceType + "' device type."); + } + return form.ask({ + message: 'Select an application', + type: 'list', + choices: applications.map(function(app) { + return { + name: app.app_name, + value: app + }; + }) + }); + }); +}; + +selectApplicationCommit = function(expectedError, resin, form, builds) { + var DEFAULT_CHOICE, choices; + if (builds.length === 0) { + expectedError('This application has no successful builds.'); + } + DEFAULT_CHOICE = { + 'name': LATEST, + 'value': LATEST + }; + choices = [DEFAULT_CHOICE].concat(builds.map(function(build) { + return { + name: build.push_timestamp + " - " + build.commit_hash, + value: build.commit_hash + }; + })); + return form.ask({ + message: 'Select a build', + type: 'list', + "default": LATEST, + choices: choices + }); +}; + +offerToDisableAutomaticUpdates = function(Promise, form, resin, application, commit) { + var message; + if (commit === LATEST || !application.should_track_latest_release) { + return Promise.resolve(); + } + message = '\nThis application is set to automatically update all devices to the latest available version.\nThis might be unexpected behaviour: with this enabled, the preloaded device will still\ndownload and install the latest build once it is online.\n\nDo you want to disable automatic updates for this application?'; + return form.ask({ + message: message, + type: 'confirm' + }).then(function(update) { + if (!update) { + return; + } + return resin.pine.patch({ + resource: 'application', + id: application.id, + body: { + should_track_latest_release: false + } + }); + }); +}; + +module.exports = { + signature: 'preload ', + description: '(beta) preload an app on a disk image', + help: 'Warning: "resin preload" requires Docker to be correctly installed in\nyour shell environment. For more information (including Windows support)\nplease check the README here: https://github.com/resin-io/resin-cli .\n\nUse this command to preload an application to a local disk image with a\nbuilt commit from Resin.io.\nThis can be used with cloud builds, or images deployed with resin deploy.\n\nExamples:\n $ resin preload resin.img --app 1234 --commit e1f2592fc6ee949e68756d4f4a48e49bff8d72a0 --splash-image some-image.png\n $ resin preload resin.img', + permission: 'user', + primary: true, + options: dockerUtils.appendConnectionOptions([ + { + signature: 'app', + parameter: 'appId', + description: 'id of the application to preload', + alias: 'a' + }, { + signature: 'commit', + parameter: 'hash', + description: 'a specific application commit to preload (ignored if no appId is given)', + alias: 'c' + }, { + signature: 'splash-image', + parameter: 'splashImage.png', + description: 'path to a png image to replace the splash screen', + alias: 's' + }, { + signature: 'dont-detect-flasher-type-images', + boolean: true, + description: 'Disables the flasher type images detection: treats all images as non flasher types' + } + ]), + action: function(params, options, done) { + var Promise, _, errors, expectedError, form, preload, resin, streamToPromise; + _ = require('lodash'); + Promise = require('bluebird'); + resin = require('resin-sdk-preconfigured'); + streamToPromise = require('stream-to-promise'); + form = require('resin-cli-form'); + preload = require('resin-preload'); + errors = require('resin-errors'); + expectedError = require('../utils/patterns').expectedError; + options.image = params.image; + options.appId = options.app; + delete options.app; + options.dontDetectFlasherTypeImages = options['dont-detect-flasher-type-images']; + delete options['dont-detect-flasher-type-images']; + return dockerUtils.getDocker(options).then(function(docker) { + var buildOutputStream; + buildOutputStream = preload.build(docker); + buildOutputStream.pipe(process.stdout); + return streamToPromise(buildOutputStream).then(resin.settings.getAll).then(function(settings) { + options.proxy = settings.proxy; + options.apiHost = settings.apiUrl; + return preload.getDeviceTypeSlug(docker, options)["catch"](preload.errors.ResinError, expectedError); + }).then(function(deviceType) { + return Promise["try"](function() { + if (options.appId) { + return preload.getApplication(resin, options.appId)["catch"](errors.ResinApplicationNotFound, expectedError); + } + return selectApplication(expectedError, resin, form, deviceType); + }).then(function(application) { + options.application = application; + if (deviceType !== application.device_type) { + expectedError("Image device type (" + application.device_type + ") and application device type (" + deviceType + ") do not match"); + } + return Promise["try"](function() { + if (options.commit) { + if (_.map(builds, 'commit_hash').indexOf(options.commit) === -1) { + expectedError('There is no build matching this commit'); + } + return options.commit; + } + return selectApplicationCommit(expectedError, resin, form, application.build); + }).then(function(commit) { + if (commit !== LATEST) { + options.commit = commit; + } + return offerToDisableAutomaticUpdates(Promise, form, resin, application, commit); + }); + }); + }).then(function() { + return preload.run(resin, docker, options)["catch"](preload.errors.ResinError, expectedError); + }); + }).then(function(info) { + info.stdout.pipe(process.stdout); + info.stderr.pipe(process.stderr); + return info.statusCodePromise; + }).then(function(statusCode) { + if (statusCode !== 0) { + return process.exit(statusCode); + } + }).then(done); + } +}; diff --git a/build/app.js b/build/app.js index 9fb9dd64..769bf445 100644 --- a/build/app.js +++ b/build/app.js @@ -199,6 +199,8 @@ capitano.command(actions.logs); capitano.command(actions.sync); +capitano.command(actions.preload); + capitano.command(actions.ssh); capitano.command(actions.local.configure); diff --git a/build/utils/docker.js b/build/utils/docker.js index a3e31d68..5c53eeba 100644 --- a/build/utils/docker.js +++ b/build/utils/docker.js @@ -1,11 +1,11 @@ // Generated by CoffeeScript 1.12.7 -var QEMU_BIN_NAME, QEMU_VERSION, cacheHighlightStream, copyQemu, ensureDockerSeemsAccessible, generateConnectOpts, getQemuPath, hasQemu, installQemu, parseBuildArgs, platformNeedsQemu, tarDirectory; +var QEMU_BIN_NAME, QEMU_VERSION, appendConnectionOptions, cacheHighlightStream, copyQemu, ensureDockerSeemsAccessible, generateConnectOpts, getQemuPath, hasQemu, installQemu, parseBuildArgs, platformNeedsQemu, tarDirectory; QEMU_VERSION = 'v2.5.50-resin-execve'; QEMU_BIN_NAME = 'qemu-execve'; -exports.appendOptions = function(opts) { +exports.appendConnectionOptions = appendConnectionOptions = function(opts) { return opts.concat([ { signature: 'docker', @@ -34,7 +34,13 @@ exports.appendOptions = function(opts) { signature: 'key', parameter: 'key', description: 'Docker host TLS key file' - }, { + } + ]); +}; + +exports.appendOptions = function(opts) { + return appendConnectionOptions(opts).concat([ + { signature: 'tag', parameter: 'tag', description: 'The alias to the generated image', diff --git a/build/utils/patterns.js b/build/utils/patterns.js index a3056113..9de8bdc1 100644 --- a/build/utils/patterns.js +++ b/build/utils/patterns.js @@ -219,3 +219,11 @@ exports.printErrorMessage = function(message) { console.error(chalk.red(message)); return console.error(chalk.red("\n" + messages.getHelp + "\n")); }; + +exports.expectedError = function(message) { + if (message instanceof Error) { + message = message.message; + } + console.error(chalk.red(message)); + return process.exit(1); +}; diff --git a/lib/actions/index.coffee b/lib/actions/index.coffee index b1c4d60f..6137e9f9 100644 --- a/lib/actions/index.coffee +++ b/lib/actions/index.coffee @@ -35,3 +35,4 @@ module.exports = build: require('./build') deploy: require('./deploy') util: require('./util') + preload: require('./preload') diff --git a/lib/actions/preload.coffee b/lib/actions/preload.coffee new file mode 100644 index 00000000..70f3437b --- /dev/null +++ b/lib/actions/preload.coffee @@ -0,0 +1,223 @@ +### +Copyright 2016-2017 Resin.io + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +### + +dockerUtils = require('../utils/docker') + +LATEST = 'latest' + +getApplicationsWithSuccessfulBuilds = (resin, deviceType) -> + resin.pine.get + resource: 'my_application' + options: + filter: + device_type: deviceType + build: + $any: + $alias: 'b' + $expr: + b: + status: 'success' + expand: + environment_variable: + $select: ['name', 'value'] + build: + $select: [ 'id', 'commit_hash', 'push_timestamp', 'status' ] + $orderby: 'push_timestamp desc' + # FIXME: The filter is commented because it causes an api error. + # We manually filter out successful builds below. + # We should move that here once this API error is resolved. + #$filter: + # status: 'success' + select: [ 'id', 'app_name', 'device_type', 'commit' ] + orderby: 'app_name asc' + # manual filtering + .then (applications) -> + applications.forEach (application) -> + application.build = application.build.filter (build) -> + build.status == 'success' + applications + +selectApplication = (expectedError, resin, form, deviceType) -> + getApplicationsWithSuccessfulBuilds(resin, deviceType) + .then (applications) -> + if applications.length == 0 + expectedError("You have no apps with successful builds for a '#{deviceType}' device type.") + form.ask + message: 'Select an application' + type: 'list' + choices: applications.map (app) -> + name: app.app_name + value: app + +selectApplicationCommit = (expectedError, resin, form, builds) -> + if builds.length == 0 + expectedError('This application has no successful builds.') + DEFAULT_CHOICE = {'name': LATEST, 'value': LATEST} + choices = [ DEFAULT_CHOICE ].concat builds.map (build) -> + name: "#{build.push_timestamp} - #{build.commit_hash}" + value: build.commit_hash + return form.ask + message: 'Select a build' + type: 'list' + default: LATEST + choices: choices + +offerToDisableAutomaticUpdates = (Promise, form, resin, application, commit) -> + if commit == LATEST or not application.should_track_latest_release + return Promise.resolve() + message = ''' + + This application is set to automatically update all devices to the latest available version. + This might be unexpected behaviour: with this enabled, the preloaded device will still + download and install the latest build once it is online. + + Do you want to disable automatic updates for this application? + ''' + form.ask + message: message, + type: 'confirm' + .then (update) -> + if not update + return + resin.pine.patch + resource: 'application' + id: application.id + body: + should_track_latest_release: false + +module.exports = + signature: 'preload ' + description: '(beta) preload an app on a disk image' + help: ''' + Warning: "resin preload" requires Docker to be correctly installed in + your shell environment. For more information (including Windows support) + please check the README here: https://github.com/resin-io/resin-cli . + + Use this command to preload an application to a local disk image with a + built commit from Resin.io. + This can be used with cloud builds, or images deployed with resin deploy. + + Examples: + $ resin preload resin.img --app 1234 --commit e1f2592fc6ee949e68756d4f4a48e49bff8d72a0 --splash-image some-image.png + $ resin preload resin.img + ''' + permission: 'user' + primary: true + options: dockerUtils.appendConnectionOptions [ + { + signature: 'app' + parameter: 'appId' + description: 'id of the application to preload' + alias: 'a' + } + { + signature: 'commit' + parameter: 'hash' + description: 'a specific application commit to preload (ignored if no appId is given)' + alias: 'c' + } + { + signature: 'splash-image' + parameter: 'splashImage.png' + description: 'path to a png image to replace the splash screen' + alias: 's' + } + { + signature: 'dont-detect-flasher-type-images' + boolean: true + description: 'Disables the flasher type images detection: treats all images as non flasher types' + } + ] + action: (params, options, done) -> + _ = require('lodash') + Promise = require('bluebird') + resin = require('resin-sdk-preconfigured') + streamToPromise = require('stream-to-promise') + form = require('resin-cli-form') + preload = require('resin-preload') + errors = require('resin-errors') + { expectedError } = require('../utils/patterns') + + options.image = params.image + options.appId = options.app + delete options.app + + options.dontDetectFlasherTypeImages = options['dont-detect-flasher-type-images'] + delete options['dont-detect-flasher-type-images'] + + # Get a configured dockerode instance + dockerUtils.getDocker(options) + .then (docker) -> + + # Build the preloader image + buildOutputStream = preload.build(docker) + buildOutputStream.pipe(process.stdout) + streamToPromise(buildOutputStream) + + # Get resin sdk settings so we can pass them to the preloader + .then(resin.settings.getAll) + .then (settings) -> + options.proxy = settings.proxy + options.apiHost = settings.apiUrl + + # Use the preloader docker image to extract the deviceType of the image + preload.getDeviceTypeSlug(docker, options) + .catch(preload.errors.ResinError, expectedError) + .then (deviceType) -> + + # Use the appId given as --app or show an interactive app selection menu + Promise.try -> + if options.appId + return preload.getApplication(resin, options.appId) + .catch(errors.ResinApplicationNotFound, expectedError) + selectApplication(expectedError, resin, form, deviceType) + .then (application) -> + options.application = application + + # Check that the app device type and the image device type match + if deviceType != application.device_type + expectedError( + "Image device type (#{application.device_type}) and application device type (#{deviceType}) do not match" + ) + + # Use the commit given as --commit or show an interactive commit selection menu + Promise.try -> + if options.commit + if _.map(builds, 'commit_hash').indexOf(options.commit) == -1 + expectedError('There is no build matching this commit') + return options.commit + selectApplicationCommit(expectedError, resin, form, application.build) + .then (commit) -> + + # No commit specified => use the latest commit + if commit != LATEST + options.commit = commit + + # Propose to disable automatic app updates if the commit is not the latest + offerToDisableAutomaticUpdates(Promise, form, resin, application, commit) + .then -> + + # All options are ready: preload the image. + preload.run(resin, docker, options) + .catch(preload.errors.ResinError, expectedError) + .then (info) -> + info.stdout.pipe(process.stdout) + info.stderr.pipe(process.stderr) + info.statusCodePromise + .then (statusCode) -> + if statusCode != 0 + process.exit(statusCode) + .then(done) diff --git a/lib/app.coffee b/lib/app.coffee index ff6a689e..a3026e9f 100644 --- a/lib/app.coffee +++ b/lib/app.coffee @@ -172,6 +172,9 @@ capitano.command(actions.logs) # ---------- Sync Module ---------- capitano.command(actions.sync) +# ---------- Preload Module ---------- +capitano.command(actions.preload) + # ---------- SSH Module ---------- capitano.command(actions.ssh) diff --git a/lib/utils/docker.coffee b/lib/utils/docker.coffee index 60684944..73e53654 100644 --- a/lib/utils/docker.coffee +++ b/lib/utils/docker.coffee @@ -9,7 +9,7 @@ QEMU_BIN_NAME = 'qemu-execve' # # NOTE: Care MUST be taken when using the function, so as to # not redefine/override options already provided. -exports.appendOptions = (opts) -> +exports.appendConnectionOptions = appendConnectionOptions = (opts) -> opts.concat [ { signature: 'docker' @@ -44,6 +44,16 @@ exports.appendOptions = (opts) -> parameter: 'key' description: 'Docker host TLS key file' }, + ] + +# 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) -> + appendConnectionOptions(opts).concat [ { signature: 'tag' parameter: 'tag' diff --git a/lib/utils/patterns.coffee b/lib/utils/patterns.coffee index 75ee62d7..f279e161 100644 --- a/lib/utils/patterns.coffee +++ b/lib/utils/patterns.coffee @@ -173,3 +173,9 @@ exports.inferOrSelectDevice = (preferredUuid) -> exports.printErrorMessage = (message) -> console.error(chalk.red(message)) console.error(chalk.red("\n#{messages.getHelp}\n")) + +exports.expectedError = (message) -> + if message instanceof Error + message = message.message + console.error(chalk.red(message)) + process.exit(1) diff --git a/package.json b/package.json index 7545c431..169bdeec 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "resin-doodles": "0.0.1", "resin-image-fs": "^2.3.0", "resin-image-manager": "^4.1.1", + "resin-preload": "^2.0.0", "resin-sdk-preconfigured": "^6.4.1", "resin-settings-client": "^3.6.1", "resin-stream-logger": "^0.0.4",