diff --git a/lib/actions/deploy.coffee b/lib/actions/deploy.coffee deleted file mode 100644 index ce1c0ec7..00000000 --- a/lib/actions/deploy.coffee +++ /dev/null @@ -1,270 +0,0 @@ -###* -# @license -# Copyright 2016-2020 Balena Ltd. -# -# 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. -### - -# Imported here because it's needed for the setup -# of this action -Promise = require('bluebird') -dockerUtils = require('../utils/docker') -compose = require('../utils/compose') -{ registrySecretsHelp } = require('../utils/messages') -{ ExpectedError } = require('../errors') -{ getBalenaSdk, getChalk } = require('../utils/lazy') - -### -Opts must be an object with the following keys: - - app: the application instance to deploy to - image: the image to deploy; optional - dockerfilePath: name of an alternative Dockerfile; optional - shouldPerformBuild - shouldUploadLogs - buildEmulated - buildOpts: arguments to forward to docker build command -### -deployProject = (docker, logger, composeOpts, opts) -> - _ = require('lodash') - doodles = require('resin-doodles') - sdk = getBalenaSdk() - { loadProject } = require('../utils/compose_ts') - - Promise.resolve(loadProject(logger, composeOpts, opts.image)) - .then (project) -> - if project.descriptors.length > 1 and !opts.app.application_type?[0]?.supports_multicontainer - throw new Error('Target application does not support multiple containers. Aborting!') - - # find which services use images that already exist locally - Promise.map project.descriptors, (d) -> - # unconditionally build (or pull) if explicitly requested - return d if opts.shouldPerformBuild - docker.getImage(d.image.tag ? d.image).inspect() - .return(d.serviceName) - .catchReturn() - .filter (d) -> !!d - .then (servicesToSkip) -> - # multibuild takes in a composition and always attempts to - # build or pull all services. we workaround that here by - # passing a modified composition. - compositionToBuild = _.cloneDeep(project.composition) - compositionToBuild.services = _.omit(compositionToBuild.services, servicesToSkip) - if _.size(compositionToBuild.services) is 0 - logger.logInfo('Everything is up to date (use --build to force a rebuild)') - return {} - compose.buildProject( - docker - logger - project.path - project.name - compositionToBuild - opts.app.arch - opts.app.device_type - opts.buildEmulated - opts.buildOpts - composeOpts.inlineLogs - opts.convertEol - composeOpts.dockerfilePath - ) - .then (builtImages) -> - _.keyBy(builtImages, 'serviceName') - .then (builtImages) -> - project.descriptors.map (d) -> - builtImages[d.serviceName] ? { - serviceName: d.serviceName, - name: d.image.tag ? d.image - logs: 'Build skipped; image for service already exists.' - props: {} - } - .then (images) -> - if opts.app.application_type?[0]?.is_legacy - { deployLegacy } = require('../utils/deploy-legacy') - - msg = getChalk().yellow('Target application requires legacy deploy method.') - logger.logWarn(msg) - - return Promise.join( - docker - logger - sdk.auth.getToken() - sdk.auth.whoami() - sdk.settings.get('balenaUrl') - { - # opts.appName may be prefixed by 'owner/', unlike opts.app.app_name - appName: opts.appName - imageName: images[0].name - buildLogs: images[0].logs - shouldUploadLogs: opts.shouldUploadLogs - } - deployLegacy - ) - .then (releaseId) -> - sdk.models.release.get(releaseId, $select: [ 'commit' ]) - Promise.join( - sdk.auth.getUserId() - sdk.auth.getToken() - sdk.settings.get('apiUrl') - (userId, auth, apiEndpoint) -> - compose.deployProject( - docker - logger - project.composition - images - opts.app.id - userId - "Bearer #{auth}" - apiEndpoint - !opts.shouldUploadLogs - ) - ) - .then (release) -> - logger.outputDeferredMessages() - logger.logSuccess('Deploy succeeded!') - logger.logSuccess("Release: #{release.commit}") - console.log() - console.log(doodles.getDoodle()) # Show charlie - console.log() - .tapCatch (e) -> - logger.logError('Deploy failed') - -module.exports = - signature: 'deploy [image]' - description: 'Deploy a single image or a multicontainer project to a balena application' - help: """ - Usage: `deploy ([image] | --build [--source build-dir])` - - Use this command to deploy an image or a complete multicontainer project to an - application, optionally building it first. The source images are searched for - (and optionally built) using the docker daemon in your development machine or - balena device. (See also the `balena push` command for the option of building - the image in the balenaCloud build servers.) - - Unless an image is specified, this command will look into the current directory - (or the one specified by --source) for a docker-compose.yml file. If one is - found, this command will deploy each service defined in the compose file, - building it first if an image for it doesn't exist. If a compose file isn't - found, the command will look for a Dockerfile[.template] file (or alternative - Dockerfile specified with the `-f` option), and if yet that isn't found, it - will try to generate one. - - To deploy to an app on which you're a collaborator, use - `balena deploy /`. - - When --build is used, all options supported by `balena build` are also supported - by this command. - - #{registrySecretsHelp} - - Examples: - - $ balena deploy myApp - $ balena deploy myApp --build --source myBuildDir/ - $ balena deploy myApp myApp/myImage - """ - permission: 'user' - primary: true - options: dockerUtils.appendOptions compose.appendOptions [ - { - signature: 'source' - parameter: 'source' - description: 'Specify an alternate source directory; default is the working directory' - alias: 's' - }, - { - signature: 'build' - boolean: true - description: 'Force a rebuild before deploy' - alias: 'b' - }, - { - signature: 'nologupload' - description: "Don't upload build logs to the dashboard with image (if building)" - boolean: true - }, - ] - action: (params, options) -> - # compositions with many services trigger misleading warnings - require('events').defaultMaxListeners = 1000 - sdk = getBalenaSdk() - { ExpectedError } = require('../errors') - { getRegistrySecrets, validateProjectDirectory } = require('../utils/compose_ts') - helpers = require('../utils/helpers') - Logger = require('../utils/logger') - - logger = Logger.getLogger() - logger.logDebug('Parsing input...') - - # when Capitano converts a positional parameter (but not an option) - # to a number, the original value is preserved with the _raw suffix - { appName, appName_raw, image } = params - - # look into "balena build" options if appName isn't given - appName = appName_raw || appName || options.application - delete options.application - - options.convertEol = options['convert-eol'] || false - delete options['convert-eol'] - if options.convertEol and not options.build - return Promise.reject(new ExpectedError('The --eol-conversion flag is only valid with --build.')) - - Promise.try -> - if not appName? - throw new ExpectedError('Please specify the name of the application to deploy') - - if image? and options.build - throw new ExpectedError('Build option is not applicable when specifying an image') - .then -> - if image - getRegistrySecrets(sdk, options['registry-secrets']) - .then (registrySecrets) -> - options['registry-secrets'] = registrySecrets - else - validateProjectDirectory(sdk, { - dockerfilePath: options.dockerfile, - noParentCheck: options['noparent-check'] || false, - projectPath: options.source || '.', - registrySecretsPath: options['registry-secrets'], - }) - .then ({ dockerfilePath, registrySecrets }) -> - options.dockerfile = dockerfilePath - options['registry-secrets'] = registrySecrets - .then -> - Promise.join( - helpers.getApplication(appName) - helpers.getArchAndDeviceType(appName) - (app, { arch, device_type }) -> - app.arch = arch - app.device_type = device_type - return app - ) - .then (app) -> - [ app, image, !!options.build, !options.nologupload] - - .then ([ app, image, shouldPerformBuild, shouldUploadLogs, convertEol ]) -> - Promise.join( - dockerUtils.getDocker(options) - dockerUtils.generateBuildOpts(options) - compose.generateOpts(options) - (docker, buildOpts, composeOpts) -> - deployProject(docker, logger, composeOpts, { - app - appName # may be prefixed by 'owner/', unlike app.app_name - image - shouldPerformBuild - shouldUploadLogs - buildEmulated: !!options.emulated - buildOpts - convertEol: options.convertEol - }) - ) diff --git a/lib/actions/deploy.js b/lib/actions/deploy.js new file mode 100644 index 00000000..07098986 --- /dev/null +++ b/lib/actions/deploy.js @@ -0,0 +1,331 @@ +/** + * @license + * Copyright 2016-2020 Balena Ltd. + * + * 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. + */ + +// Imported here because it's needed for the setup +// of this action +import * as Promise from 'bluebird'; + +import * as dockerUtils from '../utils/docker'; +import * as compose from '../utils/compose'; +import { registrySecretsHelp } from '../utils/messages'; +import { ExpectedError } from '../errors'; +import { getBalenaSdk, getChalk } from '../utils/lazy'; + +/* +Opts must be an object with the following keys: + + app: the application instance to deploy to + image: the image to deploy; optional + dockerfilePath: name of an alternative Dockerfile; optional + shouldPerformBuild + shouldUploadLogs + buildEmulated + buildOpts: arguments to forward to docker build command +*/ +const deployProject = function(docker, logger, composeOpts, opts) { + const _ = require('lodash'); + const doodles = require('resin-doodles'); + const sdk = getBalenaSdk(); + const { loadProject } = require('../utils/compose_ts'); + + return Promise.resolve(loadProject(logger, composeOpts, opts.image)) + .then(function(project) { + if ( + project.descriptors.length > 1 && + !opts.app.application_type?.[0]?.supports_multicontainer + ) { + throw new Error( + 'Target application does not support multiple containers. Aborting!', + ); + } + + // find which services use images that already exist locally + return Promise.map(project.descriptors, function(d) { + // unconditionally build (or pull) if explicitly requested + if (opts.shouldPerformBuild) { + return d; + } + return docker + .getImage(typeof d.image === 'string' ? d.image : d.image.tag) + .inspect() + .return(d.serviceName) + .catchReturn(); + }) + .filter(d => !!d) + .then(function(servicesToSkip) { + // multibuild takes in a composition and always attempts to + // build or pull all services. we workaround that here by + // passing a modified composition. + const compositionToBuild = _.cloneDeep(project.composition); + compositionToBuild.services = _.omit( + compositionToBuild.services, + servicesToSkip, + ); + if (_.size(compositionToBuild.services) === 0) { + logger.logInfo( + 'Everything is up to date (use --build to force a rebuild)', + ); + return {}; + } + return compose + .buildProject( + docker, + logger, + project.path, + project.name, + compositionToBuild, + opts.app.arch, + opts.app.device_type, + opts.buildEmulated, + opts.buildOpts, + composeOpts.inlineLogs, + opts.convertEol, + composeOpts.dockerfilePath, + ) + .then(builtImages => _.keyBy(builtImages, 'serviceName')); + }) + .then(builtImages => + project.descriptors.map( + d => + builtImages[d.serviceName] ?? { + serviceName: d.serviceName, + name: typeof d.image === 'string' ? d.image : d.image.tag, + logs: 'Build skipped; image for service already exists.', + props: {}, + }, + ), + ) + .then(function(images) { + if (opts.app.application_type?.[0]?.is_legacy) { + const { deployLegacy } = require('../utils/deploy-legacy'); + + const msg = getChalk().yellow( + 'Target application requires legacy deploy method.', + ); + logger.logWarn(msg); + + return Promise.join( + docker, + logger, + sdk.auth.getToken(), + sdk.auth.whoami(), + sdk.settings.get('balenaUrl'), + { + // opts.appName may be prefixed by 'owner/', unlike opts.app.app_name + appName: opts.appName, + imageName: images[0].name, + buildLogs: images[0].logs, + shouldUploadLogs: opts.shouldUploadLogs, + }, + deployLegacy, + ).then(releaseId => + // @ts-ignore releaseId should be inferred as a number because that's what deployLegacy is + // typed as returning but the .js type-checking doesn't manage to infer it correctly due to + // Promise.join typings + sdk.models.release.get(releaseId, { $select: ['commit'] }), + ); + } + return Promise.join( + sdk.auth.getUserId(), + sdk.auth.getToken(), + sdk.settings.get('apiUrl'), + (userId, auth, apiEndpoint) => + compose.deployProject( + docker, + logger, + project.composition, + images, + opts.app.id, + userId, + `Bearer ${auth}`, + apiEndpoint, + !opts.shouldUploadLogs, + ), + ); + }); + }) + .then(function(release) { + logger.outputDeferredMessages(); + logger.logSuccess('Deploy succeeded!'); + logger.logSuccess(`Release: ${release.commit}`); + console.log(); + console.log(doodles.getDoodle()); // Show charlie + console.log(); + }) + .tapCatch(() => { + logger.logError('Deploy failed'); + }); +}; + +export const deploy = { + signature: 'deploy [image]', + description: + 'Deploy a single image or a multicontainer project to a balena application', + help: `\ +Usage: \`deploy ([image] | --build [--source build-dir])\` + +Use this command to deploy an image or a complete multicontainer project to an +application, optionally building it first. The source images are searched for +(and optionally built) using the docker daemon in your development machine or +balena device. (See also the \`balena push\` command for the option of building +the image in the balenaCloud build servers.) + +Unless an image is specified, this command will look into the current directory +(or the one specified by --source) for a docker-compose.yml file. If one is +found, this command will deploy each service defined in the compose file, +building it first if an image for it doesn't exist. If a compose file isn't +found, the command will look for a Dockerfile[.template] file (or alternative +Dockerfile specified with the \`-f\` option), and if yet that isn't found, it +will try to generate one. + +To deploy to an app on which you're a collaborator, use +\`balena deploy /\`. + +When --build is used, all options supported by \`balena build\` are also supported +by this command. + +${registrySecretsHelp} + +Examples: + + $ balena deploy myApp + $ balena deploy myApp --build --source myBuildDir/ + $ balena deploy myApp myApp/myImage\ +`, + permission: 'user', + primary: true, + options: dockerUtils.appendOptions( + compose.appendOptions([ + { + signature: 'source', + parameter: 'source', + description: + 'Specify an alternate source directory; default is the working directory', + alias: 's', + }, + { + signature: 'build', + boolean: true, + description: 'Force a rebuild before deploy', + alias: 'b', + }, + { + signature: 'nologupload', + description: + "Don't upload build logs to the dashboard with image (if building)", + boolean: true, + }, + ]), + ), + action(params, options) { + // compositions with many services trigger misleading warnings + // @ts-ignore editing property that isn't typed but does exist + require('events').defaultMaxListeners = 1000; + const sdk = getBalenaSdk(); + const { + getRegistrySecrets, + validateProjectDirectory, + } = require('../utils/compose_ts'); + const helpers = require('../utils/helpers'); + const Logger = require('../utils/logger'); + + const logger = Logger.getLogger(); + logger.logDebug('Parsing input...'); + + // when Capitano converts a positional parameter (but not an option) + // to a number, the original value is preserved with the _raw suffix + let { appName, appName_raw, image } = params; + + // look into "balena build" options if appName isn't given + appName = appName_raw || appName || options.application; + delete options.application; + + options.convertEol = options['convert-eol'] || false; + delete options['convert-eol']; + if (options.convertEol && !options.build) { + return Promise.reject( + new ExpectedError( + 'The --eol-conversion flag is only valid with --build.', + ), + ); + } + + return Promise.try(function() { + if (appName == null) { + throw new ExpectedError( + 'Please specify the name of the application to deploy', + ); + } + + if (image != null && options.build) { + throw new ExpectedError( + 'Build option is not applicable when specifying an image', + ); + } + }) + .then(function() { + if (image) { + return getRegistrySecrets(sdk, options['registry-secrets']).then( + registrySecrets => { + options['registry-secrets'] = registrySecrets; + }, + ); + } else { + return validateProjectDirectory(sdk, { + dockerfilePath: options.dockerfile, + noParentCheck: options['noparent-check'] || false, + projectPath: options.source || '.', + registrySecretsPath: options['registry-secrets'], + }).then(function({ dockerfilePath, registrySecrets }) { + options.dockerfile = dockerfilePath; + options['registry-secrets'] = registrySecrets; + }); + } + }) + .then(() => + Promise.join( + helpers.getApplication(appName), + helpers.getArchAndDeviceType(appName), + function(app, { arch, device_type }) { + // @ts-ignore extending the app object + app.arch = arch; + app.device_type = device_type; + return app; + }, + ).then(app => [app, !!options.build, !options.nologupload]), + ) + + .then(function([app, shouldPerformBuild, shouldUploadLogs]) { + return Promise.join( + dockerUtils.getDocker(options), + dockerUtils.generateBuildOpts(options), + compose.generateOpts(options), + (docker, buildOpts, composeOpts) => + deployProject(docker, logger, composeOpts, { + app, + appName, // may be prefixed by 'owner/', unlike app.app_name + image, + shouldPerformBuild, + shouldUploadLogs, + buildEmulated: !!options.emulated, + buildOpts, + convertEol: options.convertEol, + }), + ); + }); + }, +}; diff --git a/lib/actions/index.coffee b/lib/actions/index.coffee index eb7fbb26..1dcd3e6c 100644 --- a/lib/actions/index.coffee +++ b/lib/actions/index.coffee @@ -27,7 +27,7 @@ module.exports = config: require('./config') ssh: require('./ssh') build: require('./build').build - deploy: require('./deploy') + deploy: require('./deploy').deploy util: require('./util') preload: require('./preload').preload push: require('./push') diff --git a/lib/utils/deploy-legacy.js b/lib/utils/deploy-legacy.js index 7458e368..f7cf5758 100644 --- a/lib/utils/deploy-legacy.js +++ b/lib/utils/deploy-legacy.js @@ -87,6 +87,9 @@ const uploadToPromise = (uploadRequest, logger) => uploadRequest.on('error', reject).on('data', handleMessage); }); +/** + * @returns {Promise<{ buildId: number }>} + */ const uploadImage = function( imageStream, token,