push: Add --release-tag flag

You can have 0 or multiple keys without values,
if you use values then you should have as many
values as you have keys. If you don't want to set
a value for a key set its value to "" (bash, cmd.exe)
or '""' (powershell).

Connects-to: #892
Change-type: minor
Signed-off-by: Marios Balamatsias <mbalamatsias@gmail.com>
This commit is contained in:
Marios Balamatsias 2020-12-16 16:42:25 +02:00
parent 3bff569758
commit 34557e35ee
3 changed files with 199 additions and 75 deletions

View File

@ -2544,6 +2544,7 @@ Examples:
$ balena push myApp $ balena push myApp
$ balena push myApp --source <source directory> $ balena push myApp --source <source directory>
$ balena push myApp -s <source directory> $ balena push myApp -s <source directory>
$ balena push myApp --release-tag key1 "" key2 "value2 with spaces"
$ balena push 10.0.0.1 $ balena push 10.0.0.1
$ balena push 10.0.0.1 --source <source directory> $ balena push 10.0.0.1 --source <source directory>
@ -2658,6 +2659,12 @@ Consider .gitignore files in addition to the .dockerignore file. This reverts
to the CLI v11 behavior/implementation (deprecated) if compatibility is to the CLI v11 behavior/implementation (deprecated) if compatibility is
required until your project can be adapted. required until your project can be adapted.
#### --release-tag RELEASE-TAG
Set release tags if the push to a cloud application 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).
# Settings # Settings
## settings ## settings

View File

@ -22,6 +22,7 @@ import { getBalenaSdk, stripIndent } from '../utils/lazy';
import { dockerignoreHelp, registrySecretsHelp } from '../utils/messages'; import { dockerignoreHelp, registrySecretsHelp } from '../utils/messages';
import type { BalenaSDK, Application, Organization } from 'balena-sdk'; import type { BalenaSDK, Application, Organization } from 'balena-sdk';
import { ExpectedError, instanceOf } from '../errors'; import { ExpectedError, instanceOf } from '../errors';
import type { RegistrySecrets } from 'resin-multibuild';
enum BuildTarget { enum BuildTarget {
Cloud, Cloud,
@ -46,6 +47,7 @@ interface FlagsDef {
'convert-eol'?: boolean; 'convert-eol'?: boolean;
'noconvert-eol'?: boolean; 'noconvert-eol'?: boolean;
'multi-dockerignore'?: boolean; 'multi-dockerignore'?: boolean;
'release-tag'?: string[];
help: void; help: void;
} }
@ -92,6 +94,7 @@ export default class PushCmd extends Command {
'$ balena push myApp', '$ balena push myApp',
'$ balena push myApp --source <source directory>', '$ balena push myApp --source <source directory>',
'$ balena push myApp -s <source directory>', '$ balena push myApp -s <source directory>',
'$ balena push myApp --release-tag key1 "" key2 "value2 with spaces"',
'', '',
'$ balena push 10.0.0.1', '$ balena push 10.0.0.1',
'$ balena push 10.0.0.1 --source <source directory>', '$ balena push 10.0.0.1 --source <source directory>',
@ -224,6 +227,15 @@ export default class PushCmd extends Command {
char: 'g', char: 'g',
exclusive: ['multi-dockerignore'], exclusive: ['multi-dockerignore'],
}), }),
'release-tag': flags.string({
description: stripIndent`
Set release tags if the push to a cloud application 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,
exclusive: ['detached'],
}),
help: cf.help, help: cf.help,
}; };
@ -259,19 +271,81 @@ export default class PushCmd extends Command {
const buildTarget = await this.getBuildTarget(appOrDevice); const buildTarget = await this.getBuildTarget(appOrDevice);
switch (buildTarget) { switch (buildTarget) {
case BuildTarget.Cloud: case BuildTarget.Cloud:
await this.pushToCloud(
options,
sdk,
appOrDevice,
dockerfilePath,
registrySecrets,
convertEol,
source,
nogitignore,
);
break;
case BuildTarget.Device:
await this.pushToDevice(
options,
sdk,
appOrDevice,
dockerfilePath,
registrySecrets,
convertEol,
source,
nogitignore,
);
break;
default:
throw new ExpectedError(stripIndent`
Build target not recognized. Please provide either an application name or
device IP address.`);
}
}
async pushToCloud(
options: FlagsDef,
sdk: BalenaSDK,
appOrDevice: string,
dockerfilePath: string,
registrySecrets: RegistrySecrets,
convertEol: boolean,
source: string,
nogitignore: boolean,
) {
const _ = await import('lodash');
const remote = await import('../utils/remote-build'); const remote = await import('../utils/remote-build');
// Check for invalid options // Check for invalid options
const localOnlyOptions = ['nolive', 'service', 'system', 'env']; const localOnlyOptions: Array<keyof FlagsDef> = [
'nolive',
'service',
'system',
'env',
];
this.checkInvalidOptions(
localOnlyOptions,
options,
'is only valid when pushing to a local mode device',
);
localOnlyOptions.forEach((opt) => { const releaseTags = options['release-tag'] ?? [];
// @ts-ignore : Not sure why typescript wont let me do this? const releaseTagKeys = releaseTags.filter((_v, i) => i % 2 === 0);
if (options[opt]) { 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( throw new ExpectedError(
`The --${opt} flag is only valid when pushing to a local mode device`, `Error: --release-tag keys cannot contain whitespaces`,
); );
} }
}); });
if (releaseTagKeys.length !== releaseTagValues.length) {
releaseTagValues.push('');
}
const app = appOrDevice; const app = appOrDevice;
await Command.checkLoggedIn(); await Command.checkLoggedIn();
@ -300,10 +374,41 @@ export default class PushCmd extends Command {
sdk, sdk,
opts, opts,
}; };
await remote.startRemoteBuild(args); const releaseId = await remote.startRemoteBuild(args);
break; if (releaseId) {
// Above we have checked that releaseTagKeys and releaseTagValues are of the same size
await Promise.all(
(_.zip(releaseTagKeys, releaseTagValues) as Array<
[string, string]
>).map(async ([key, value]) => {
await sdk.models.release.tags.set(releaseId, key, value);
}),
);
} else if (releaseTagKeys.length > 0) {
throw new Error(stripIndent`
A release ID could not be parsed out of the builder's output.
As a result, the release tags have not been set.`);
}
}
async pushToDevice(
options: FlagsDef,
_sdk: BalenaSDK,
appOrDevice: string,
dockerfilePath: string,
registrySecrets: RegistrySecrets,
convertEol: boolean,
source: string,
nogitignore: boolean,
) {
// Check for invalid options
const remoteOnlyOptions: Array<keyof FlagsDef> = ['release-tag'];
this.checkInvalidOptions(
remoteOnlyOptions,
options,
'is only valid when pushing to an application',
);
case BuildTarget.Device:
const deviceDeploy = await import('../utils/device/deploy'); const deviceDeploy = await import('../utils/device/deploy');
const device = appOrDevice; const device = appOrDevice;
const servicesToDisplay = options.service; const servicesToDisplay = options.service;
@ -335,13 +440,6 @@ export default class PushCmd extends Command {
throw e; throw e;
} }
} }
break;
default:
throw new ExpectedError(stripIndent`
Build target not recognized. Please provide either an application name or
device IP address.`);
}
} }
async getBuildTarget(appOrDevice: string): Promise<BuildTarget | null> { async getBuildTarget(appOrDevice: string): Promise<BuildTarget | null> {
@ -416,4 +514,16 @@ export default class PushCmd extends Command {
return selected.extra; return selected.extra;
} }
checkInvalidOptions(
invalidOptions: Array<keyof FlagsDef>,
options: FlagsDef,
errorMessage: string,
) {
invalidOptions.forEach((opt) => {
if (options[opt]) {
throw new ExpectedError(`The --${opt} flag ${errorMessage}`);
}
});
}
} }

View File

@ -108,7 +108,9 @@ async function getBuilderEndpoint(
return `${builderUrl}/v3/build?${args}`; return `${builderUrl}/v3/build?${args}`;
} }
export async function startRemoteBuild(build: RemoteBuild): Promise<void> { export async function startRemoteBuild(
build: RemoteBuild,
): Promise<number | undefined> {
const [buildRequest, stream] = await getRemoteBuildStream(build); const [buildRequest, stream] = await getRemoteBuildStream(build);
// Setup CTRL-C handler so the user can interrupt the build // Setup CTRL-C handler so the user can interrupt the build
@ -133,7 +135,7 @@ export async function startRemoteBuild(build: RemoteBuild): Promise<void> {
try { try {
if (build.opts.headless) { if (build.opts.headless) {
await handleHeadlessBuildStream(stream); await handleHeadlessBuildStream(build, stream);
} else { } else {
await handleRemoteBuildStream(build, stream); await handleRemoteBuildStream(build, stream);
} }
@ -142,6 +144,7 @@ export async function startRemoteBuild(build: RemoteBuild): Promise<void> {
globalLogger.outputDeferredMessages(); globalLogger.outputDeferredMessages();
await cancellationPromise; await cancellationPromise;
} }
return build.releaseId;
} }
async function handleRemoteBuildStream( async function handleRemoteBuildStream(
@ -159,7 +162,10 @@ async function handleRemoteBuildStream(
} }
} }
async function handleHeadlessBuildStream(stream: Stream.Stream) { async function handleHeadlessBuildStream(
build: RemoteBuild,
stream: Stream.Stream,
) {
// We're running a headless build, which means we'll // We're running a headless build, which means we'll
// get a single object back, detailing if the build has // get a single object back, detailing if the build has
// been started // been started
@ -182,6 +188,7 @@ async function handleHeadlessBuildStream(stream: Stream.Stream) {
if (message.started) { if (message.started) {
console.log('Build successfully started'); console.log('Build successfully started');
console.log(` Release ID: ${message.releaseId!}`); console.log(` Release ID: ${message.releaseId!}`);
build.releaseId = message.releaseId;
} else { } else {
console.log('Failed to start remote build'); console.log('Failed to start remote build');
console.log(` Error: ${message.error!}`); console.log(` Error: ${message.error!}`);