balena-cli/lib/commands/deploy.ts
Robert Günzler e04242db64
doc: Document the image nameing scheme used by deploy
When using deploy to create a release for a multi-container project one
needs to follow this to avoid triggering a build. Relevant for CI/CD scenarios
with their own build pipeline.

Change-type: patch
Signed-off-by: Robert Günzler <robertg@balena.io>
2021-05-24 16:24:26 +02:00

393 lines
11 KiB
TypeScript

/**
* @license
* Copyright 2016-2021 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.
*/
import { flags } from '@oclif/command';
import type { ImageDescriptor } from 'resin-compose-parse';
import Command from '../command';
import { ExpectedError } from '../errors';
import { getBalenaSdk, getChalk, stripIndent } from '../utils/lazy';
import {
dockerignoreHelp,
registrySecretsHelp,
buildArgDeprecation,
} from '../utils/messages';
import * as compose from '../utils/compose';
import type {
BuiltImage,
ComposeCliFlags,
ComposeOpts,
Release as ComposeReleaseInfo,
} from '../utils/compose-types';
import type { DockerCliFlags } from '../utils/docker';
import {
applyReleaseTagKeysAndValues,
buildProject,
composeCliFlags,
isBuildConfig,
parseReleaseTagKeysAndValues,
} from '../utils/compose_ts';
import { dockerCliFlags } from '../utils/docker';
import type {
Application,
ApplicationType,
DeviceType,
Release,
} from 'balena-sdk';
interface ApplicationWithArch extends Application {
arch: string;
}
interface FlagsDef extends ComposeCliFlags, DockerCliFlags {
source?: string;
build: boolean;
nologupload: boolean;
'release-tag'?: string[];
help: void;
}
interface ArgsDef {
appName: string;
image?: string;
}
export default class DeployCmd extends Command {
public static description = `\
Deploy a single image or a multicontainer project to a balena application.
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. Image names will be looked
up according to the scheme: \`<projectName>_<serviceName>\`.
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>\`.
${registrySecretsHelp}
${dockerignoreHelp}
`;
public static examples = [
'$ balena deploy myApp',
'$ balena deploy myApp --build --source myBuildDir/',
'$ balena deploy myApp myApp/myImage',
'$ balena deploy myApp myApp/myImage --release-tag key1 "" key2 "value2 with spaces"',
];
public static args = [
{
name: 'appName',
description: 'the name of the application to deploy to',
required: true,
},
{
name: 'image',
description: 'the image to deploy',
},
];
public static usage = 'deploy <appName> [image]';
public static flags: flags.Input<FlagsDef> = {
source: flags.string({
description:
'specify an alternate source directory; default is the working directory',
char: 's',
}),
build: flags.boolean({
description: 'force a rebuild before deploy',
char: 'b',
}),
nologupload: flags.boolean({
description:
"don't upload build logs to the dashboard with image (if building)",
}),
'release-tag': flags.string({
description: stripIndent`
Set release tags if the image deployment is successful. Multiple
arguments may be provided, alternating tag keys and values (see examples).
Hint: Empty values may be specified with "" (bash, cmd.exe) or '""' (PowerShell).
`,
multiple: true,
}),
...composeCliFlags,
...dockerCliFlags,
// NOTE: Not supporting -h for help, because of clash with -h in DockerCliFlags
// Revisit this in future release.
help: flags.help({}),
};
public static authenticated = true;
public static primary = true;
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
DeployCmd,
);
// compositions with many services trigger misleading warnings
// @ts-ignore editing property that isn't typed but does exist
(await import('events')).defaultMaxListeners = 1000;
const logger = await Command.getLogger();
logger.logDebug('Parsing input...');
const { appName, image } = params;
// Build args are under consideration for removal - warn user
if (options.buildArg) {
console.log(buildArgDeprecation);
}
if (image != null && options.build) {
throw new ExpectedError(
'Build option is not applicable when specifying an image',
);
}
const sdk = getBalenaSdk();
const { getRegistrySecrets, validateProjectDirectory } = await import(
'../utils/compose_ts'
);
const { releaseTagKeys, releaseTagValues } = parseReleaseTagKeysAndValues(
options['release-tag'] ?? [],
);
if (image) {
options['registry-secrets'] = await getRegistrySecrets(
sdk,
options['registry-secrets'],
);
} else {
const {
dockerfilePath,
registrySecrets,
} = await validateProjectDirectory(sdk, {
dockerfilePath: options.dockerfile,
noParentCheck: options['noparent-check'] || false,
projectPath: options.source || '.',
registrySecretsPath: options['registry-secrets'],
});
options.dockerfile = dockerfilePath;
options['registry-secrets'] = registrySecrets;
}
const helpers = await import('../utils/helpers');
const app = await helpers.getAppWithArch(appName);
const dockerUtils = await import('../utils/docker');
const [docker, buildOpts, composeOpts] = await Promise.all([
dockerUtils.getDocker(options),
dockerUtils.generateBuildOpts(options),
compose.generateOpts(options),
]);
const release = await this.deployProject(docker, logger, composeOpts, {
app,
appName, // may be prefixed by 'owner/', unlike app.app_name
image,
shouldPerformBuild: !!options.build,
shouldUploadLogs: !options.nologupload,
buildEmulated: !!options.emulated,
buildOpts,
});
await applyReleaseTagKeysAndValues(
sdk,
release.id,
releaseTagKeys,
releaseTagValues,
);
}
async deployProject(
docker: import('dockerode'),
logger: import('../utils/logger'),
composeOpts: ComposeOpts,
opts: {
app: ApplicationWithArch; // the application instance to deploy to
appName: string;
image?: string;
dockerfilePath?: string; // alternative Dockerfile
shouldPerformBuild: boolean;
shouldUploadLogs: boolean;
buildEmulated: boolean;
buildOpts: any; // arguments to forward to docker build command
},
) {
const _ = await import('lodash');
const doodles = await import('resin-doodles');
const sdk = getBalenaSdk();
const { deployProject: $deployProject, loadProject } = await import(
'../utils/compose_ts'
);
const appType = (opts.app?.application_type as ApplicationType[])?.[0];
try {
const project = await loadProject(logger, composeOpts, opts.image);
if (project.descriptors.length > 1 && !appType?.supports_multicontainer) {
throw new ExpectedError(
'Target application does not support multiple containers. Aborting!',
);
}
// find which services use images that already exist locally
let servicesToSkip: string[] = await Promise.all(
project.descriptors.map(async function (d: ImageDescriptor) {
// unconditionally build (or pull) if explicitly requested
if (opts.shouldPerformBuild) {
return '';
}
try {
await docker
.getImage((isBuildConfig(d.image) ? d.image.tag : d.image) || '')
.inspect();
return d.serviceName;
} catch {
// Ignore
return '';
}
}),
);
servicesToSkip = servicesToSkip.filter((d) => !!d);
// 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,
);
let builtImagesByService: Dictionary<BuiltImage> = {};
if (_.size(compositionToBuild.services) === 0) {
logger.logInfo(
'Everything is up to date (use --build to force a rebuild)',
);
} else {
const builtImages = await buildProject({
docker,
logger,
projectPath: project.path,
projectName: project.name,
composition: compositionToBuild,
arch: opts.app.arch,
deviceType: (opts.app?.is_for__device_type as DeviceType[])?.[0].slug,
emulated: opts.buildEmulated,
buildOpts: opts.buildOpts,
inlineLogs: composeOpts.inlineLogs,
convertEol: composeOpts.convertEol,
dockerfilePath: composeOpts.dockerfilePath,
nogitignore: composeOpts.nogitignore,
multiDockerignore: composeOpts.multiDockerignore,
});
builtImagesByService = _.keyBy(builtImages, 'serviceName');
}
const images: BuiltImage[] = project.descriptors.map(
(d) =>
builtImagesByService[d.serviceName] ?? {
serviceName: d.serviceName,
name: (isBuildConfig(d.image) ? d.image.tag : d.image) || '',
logs: 'Build skipped; image for service already exists.',
props: {},
},
);
let release: Release | ComposeReleaseInfo['release'];
if (appType?.is_legacy) {
const { deployLegacy } = require('../utils/deploy-legacy');
const msg = getChalk().yellow(
'Target application requires legacy deploy method.',
);
logger.logWarn(msg);
const [token, username, url, options] = await Promise.all([
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,
},
]);
const releaseId = await deployLegacy(
docker,
logger,
token,
username,
url,
options,
);
release = await sdk.models.release.get(releaseId, {
$select: ['commit'],
});
} else {
const [userId, auth, apiEndpoint] = await Promise.all([
sdk.auth.getUserId(),
sdk.auth.getToken(),
sdk.settings.get('apiUrl'),
]);
release = await $deployProject(
docker,
logger,
project.composition,
images,
opts.app.id,
userId,
`Bearer ${auth}`,
apiEndpoint,
!opts.shouldUploadLogs,
);
}
logger.outputDeferredMessages();
logger.logSuccess('Deploy succeeded!');
logger.logSuccess(`Release: ${release.commit}`);
console.log();
console.log(doodles.getDoodle()); // Show charlie
console.log();
return release;
} catch (err) {
logger.logError('Deploy failed');
throw err;
}
}
}