balena-cli/lib/actions/deploy.js

332 lines
10 KiB
JavaScript
Raw Normal View History

/**
* @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 <appName> [image]',
description:
'Deploy a single image or a multicontainer project to a balena application',
help: `\
Usage: \`deploy <appName> ([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 <appOwnerUsername>/<appName>\`.
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,
}),
);
});
},
};