Merge pull request #2154 from balena-io/add_release_tags_to_deploy

Add release-tag on deploy command
This commit is contained in:
bulldozer-balena[bot] 2021-01-14 23:24:51 +00:00 committed by GitHub
commit 743de66138
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 129 additions and 40 deletions

View File

@ -3052,6 +3052,7 @@ Examples:
$ balena deploy myApp $ balena deploy myApp
$ balena deploy myApp --build --source myBuildDir/ $ balena deploy myApp --build --source myBuildDir/
$ balena deploy myApp myApp/myImage $ balena deploy myApp myApp/myImage
$ balena deploy myApp myApp/myImage --release-tag key1 "" key2 "value2 with spaces"
### Arguments ### Arguments
@ -3077,6 +3078,12 @@ force a rebuild before deploy
don't upload build logs to the dashboard with image (if building) don't upload build logs to the dashboard with image (if building)
#### --release-tag RELEASE-TAG
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).
#### -e, --emulated #### -e, --emulated
Use QEMU for ARM architecture emulation during the image build Use QEMU for ARM architecture emulation during the image build

View File

@ -20,22 +20,30 @@ import type { ImageDescriptor } from 'resin-compose-parse';
import Command from '../command'; import Command from '../command';
import { ExpectedError } from '../errors'; import { ExpectedError } from '../errors';
import { getBalenaSdk, getChalk } from '../utils/lazy'; import { getBalenaSdk, getChalk, stripIndent } from '../utils/lazy';
import { dockerignoreHelp, registrySecretsHelp } from '../utils/messages'; import { dockerignoreHelp, registrySecretsHelp } from '../utils/messages';
import * as compose from '../utils/compose'; import * as compose from '../utils/compose';
import type { import type {
BuiltImage, BuiltImage,
ComposeCliFlags, ComposeCliFlags,
ComposeOpts, ComposeOpts,
Release as ComposeReleaseInfo,
} from '../utils/compose-types'; } from '../utils/compose-types';
import type { DockerCliFlags } from '../utils/docker'; import type { DockerCliFlags } from '../utils/docker';
import { import {
applyReleaseTagKeysAndValues,
buildProject, buildProject,
composeCliFlags, composeCliFlags,
isBuildConfig, isBuildConfig,
parseReleaseTagKeysAndValues,
} from '../utils/compose_ts'; } from '../utils/compose_ts';
import { dockerCliFlags } from '../utils/docker'; import { dockerCliFlags } from '../utils/docker';
import type { Application, ApplicationType, DeviceType } from 'balena-sdk'; import type {
Application,
ApplicationType,
DeviceType,
Release,
} from 'balena-sdk';
interface ApplicationWithArch extends Application { interface ApplicationWithArch extends Application {
arch: string; arch: string;
@ -45,6 +53,7 @@ interface FlagsDef extends ComposeCliFlags, DockerCliFlags {
source?: string; source?: string;
build: boolean; build: boolean;
nologupload: boolean; nologupload: boolean;
'release-tag'?: string[];
help: void; help: void;
} }
@ -85,6 +94,7 @@ ${dockerignoreHelp}
'$ balena deploy myApp', '$ balena deploy myApp',
'$ balena deploy myApp --build --source myBuildDir/', '$ balena deploy myApp --build --source myBuildDir/',
'$ balena deploy myApp myApp/myImage', '$ balena deploy myApp myApp/myImage',
'$ balena deploy myApp myApp/myImage --release-tag key1 "" key2 "value2 with spaces"',
]; ];
public static args = [ public static args = [
@ -115,6 +125,14 @@ ${dockerignoreHelp}
description: description:
"don't upload build logs to the dashboard with image (if building)", "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, ...composeCliFlags,
...dockerCliFlags, ...dockerCliFlags,
// NOTE: Not supporting -h for help, because of clash with -h in DockerCliFlags // NOTE: Not supporting -h for help, because of clash with -h in DockerCliFlags
@ -151,6 +169,10 @@ ${dockerignoreHelp}
'../utils/compose_ts' '../utils/compose_ts'
); );
const { releaseTagKeys, releaseTagValues } = parseReleaseTagKeysAndValues(
options['release-tag'] ?? [],
);
if (image) { if (image) {
options['registry-secrets'] = await getRegistrySecrets( options['registry-secrets'] = await getRegistrySecrets(
sdk, sdk,
@ -180,7 +202,7 @@ ${dockerignoreHelp}
compose.generateOpts(options), compose.generateOpts(options),
]); ]);
await this.deployProject(docker, logger, composeOpts, { const release = await this.deployProject(docker, logger, composeOpts, {
app, app,
appName, // may be prefixed by 'owner/', unlike app.app_name appName, // may be prefixed by 'owner/', unlike app.app_name
image, image,
@ -189,6 +211,12 @@ ${dockerignoreHelp}
buildEmulated: !!options.emulated, buildEmulated: !!options.emulated,
buildOpts, buildOpts,
}); });
await applyReleaseTagKeysAndValues(
sdk,
release.id,
releaseTagKeys,
releaseTagValues,
);
} }
async deployProject( async deployProject(
@ -286,7 +314,7 @@ ${dockerignoreHelp}
}, },
); );
let release; let release: Release | ComposeReleaseInfo['release'];
if (appType?.is_legacy) { if (appType?.is_legacy) {
const { deployLegacy } = require('../utils/deploy-legacy'); const { deployLegacy } = require('../utils/deploy-legacy');
@ -344,6 +372,7 @@ ${dockerignoreHelp}
console.log(); console.log();
console.log(doodles.getDoodle()); // Show charlie console.log(doodles.getDoodle()); // Show charlie
console.log(); console.log();
return release;
} catch (err) { } catch (err) {
logger.logError('Deploy failed'); logger.logError('Deploy failed');
throw err; throw err;

View File

@ -25,12 +25,20 @@ import { ExpectedError, instanceOf } from '../errors';
import { isV13 } from '../utils/version'; import { isV13 } from '../utils/version';
import { RegistrySecrets } from 'resin-multibuild'; import { RegistrySecrets } from 'resin-multibuild';
import { lowercaseIfSlug } from '../utils/normalization'; import { lowercaseIfSlug } from '../utils/normalization';
import {
applyReleaseTagKeysAndValues,
parseReleaseTagKeysAndValues,
} from '../utils/compose_ts';
enum BuildTarget { enum BuildTarget {
Cloud, Cloud,
Device, Device,
} }
interface ArgsDef {
applicationOrDevice: string;
}
interface FlagsDef { interface FlagsDef {
source: string; source: string;
emulated: boolean; emulated: boolean;
@ -53,10 +61,6 @@ interface FlagsDef {
help: void; help: void;
} }
interface ArgsDef {
applicationOrDevice: string;
}
export default class PushCmd extends Command { export default class PushCmd extends Command {
public static description = stripIndent` public static description = stripIndent`
Start a build on the remote balenaCloud build servers, or a local mode device. Start a build on the remote balenaCloud build servers, or a local mode device.
@ -339,23 +343,9 @@ export default class PushCmd extends Command {
'is only valid when pushing to a local mode device', 'is only valid when pushing to a local mode device',
); );
const releaseTags = options['release-tag'] ?? []; const { releaseTagKeys, releaseTagValues } = parseReleaseTagKeysAndValues(
const releaseTagKeys = releaseTags.filter((_v, i) => i % 2 === 0); options['release-tag'] ?? [],
const releaseTagValues = releaseTags.filter((_v, i) => i % 2 === 1);
releaseTagKeys.forEach((key) => {
if (key === '') {
throw new ExpectedError(`Error: --release-tag keys cannot be empty`);
}
if (/\s/.test(key)) {
throw new ExpectedError(
`Error: --release-tag keys cannot contain whitespaces`,
); );
}
});
if (releaseTagKeys.length !== releaseTagValues.length) {
releaseTagValues.push('');
}
await Command.checkLoggedIn(); await Command.checkLoggedIn();
const [token, baseUrl] = await Promise.all([ const [token, baseUrl] = await Promise.all([
@ -395,14 +385,11 @@ export default class PushCmd extends Command {
}; };
const releaseId = await remote.startRemoteBuild(args); const releaseId = await remote.startRemoteBuild(args);
if (releaseId) { if (releaseId) {
// Above we have checked that releaseTagKeys and releaseTagValues are of the same size await applyReleaseTagKeysAndValues(
const _ = await import('lodash'); sdk,
await Promise.all( releaseId,
(_.zip(releaseTagKeys, releaseTagValues) as Array< releaseTagKeys,
[string, string] releaseTagValues,
>).map(async ([key, value]) => {
await sdk.models.release.tags.set(releaseId, key, value);
}),
); );
} else if (releaseTagKeys.length > 0) { } else if (releaseTagKeys.length > 0) {
throw new Error(stripIndent` throw new Error(stripIndent`

View File

@ -81,7 +81,16 @@ export interface ComposeProject {
export interface Release { export interface Release {
client: ReturnType<typeof import('balena-release').createClient>; client: ReturnType<typeof import('balena-release').createClient>;
release: Partial<import('balena-release/build/models').ReleaseModel>; release: Pick<
import('balena-release/build/models').ReleaseModel,
| 'id'
| 'status'
| 'commit'
| 'composition'
| 'source'
| 'start_timestamp'
| 'end_timestamp'
>;
serviceImages: Partial<import('balena-release/build/models').ImageModel>; serviceImages: Partial<import('balena-release/build/models').ImageModel>;
} }

View File

@ -200,11 +200,14 @@ export const createRelease = async function (
return { return {
client, client,
release: _.omit(release, [ release: _.pick(release, [
'created_at', 'id',
'belongs_to__application', 'status',
'is_created_by__user', 'commit',
'__metadata', 'composition',
'source',
'start_timestamp',
'end_timestamp',
]), ]),
serviceImages: _.mapValues(serviceImages, (serviceImage) => serviceImages: _.mapValues(serviceImages, (serviceImage) =>
_.omit(serviceImage, [ _.omit(serviceImage, [

View File

@ -44,6 +44,60 @@ import type { DeviceInfo } from './device/api';
import { getBalenaSdk, getChalk, stripIndent } from './lazy'; import { getBalenaSdk, getChalk, stripIndent } from './lazy';
import Logger = require('./logger'); import Logger = require('./logger');
/**
* Given an array representing the raw `--release-tag` flag of the deploy and
* push commands, parse it into separate arrays of release tag keys and values.
* The returned keys and values arrays are guaranteed to be of the same length.
*/
export function parseReleaseTagKeysAndValues(
releaseTags: string[],
): { releaseTagKeys: string[]; releaseTagValues: string[] } {
if (releaseTags.length === 0) {
return { releaseTagKeys: [], releaseTagValues: [] };
}
const releaseTagKeys = releaseTags.filter((_v, i) => i % 2 === 0);
const releaseTagValues = releaseTags.filter((_v, i) => i % 2 === 1);
releaseTagKeys.forEach((key: string) => {
if (key === '') {
throw new ExpectedError(`Error: --release-tag keys cannot be empty`);
}
if (/\s/.test(key)) {
throw new ExpectedError(
`Error: --release-tag keys cannot contain whitespaces`,
);
}
});
if (releaseTagKeys.length !== releaseTagValues.length) {
releaseTagValues.push('');
}
return { releaseTagKeys, releaseTagValues };
}
/**
* Use the balena SDK `models.release.tags.set()` method to set release tags
* for the given release ID. The releaseTagKeys and releaseTagValues arrays
* must be of the same length; their items map 1-to-1 to form key-value pairs.
*/
export async function applyReleaseTagKeysAndValues(
sdk: BalenaSDK,
releaseId: number,
releaseTagKeys: string[],
releaseTagValues: string[],
) {
if (releaseTagKeys.length === 0) {
return;
}
await Promise.all(
(_.zip(releaseTagKeys, releaseTagValues) as Array<[string, string]>).map(
async ([key, value]) => {
await sdk.models.release.tags.set(releaseId, key, value);
},
),
);
}
const exists = async (filename: string) => { const exists = async (filename: string) => {
try { try {
await fs.access(filename); await fs.access(filename);
@ -1199,7 +1253,7 @@ export async function deployProject(
auth: string, auth: string,
apiEndpoint: string, apiEndpoint: string,
skipLogUpload: boolean, skipLogUpload: boolean,
): Promise<Partial<import('balena-release/build/models').ReleaseModel>> { ): Promise<import('balena-release/build/models').ReleaseModel> {
const releaseMod = await import('balena-release'); const releaseMod = await import('balena-release');
const { createRelease, tagServiceImages } = await import('./compose'); const { createRelease, tagServiceImages } = await import('./compose');
const tty = (await import('./tty'))(process.stdout); const tty = (await import('./tty'))(process.stdout);