From 34557e35eed227c22482b7fe91b7239c6e5908b9 Mon Sep 17 00:00:00 2001 From: Marios Balamatsias Date: Wed, 16 Dec 2020 16:42:25 +0200 Subject: [PATCH 1/2] 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 --- doc/cli.markdown | 7 ++ lib/commands/push.ts | 254 +++++++++++++++++++++++++++----------- lib/utils/remote-build.ts | 13 +- 3 files changed, 199 insertions(+), 75 deletions(-) diff --git a/doc/cli.markdown b/doc/cli.markdown index 7099722d..cf965222 100644 --- a/doc/cli.markdown +++ b/doc/cli.markdown @@ -2544,6 +2544,7 @@ Examples: $ balena push myApp $ balena push myApp --source $ balena push myApp -s + $ balena push myApp --release-tag key1 "" key2 "value2 with spaces" $ balena push 10.0.0.1 $ balena push 10.0.0.1 --source @@ -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 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 diff --git a/lib/commands/push.ts b/lib/commands/push.ts index 07b7a05a..1676e661 100644 --- a/lib/commands/push.ts +++ b/lib/commands/push.ts @@ -22,6 +22,7 @@ import { getBalenaSdk, stripIndent } from '../utils/lazy'; import { dockerignoreHelp, registrySecretsHelp } from '../utils/messages'; import type { BalenaSDK, Application, Organization } from 'balena-sdk'; import { ExpectedError, instanceOf } from '../errors'; +import type { RegistrySecrets } from 'resin-multibuild'; enum BuildTarget { Cloud, @@ -46,6 +47,7 @@ interface FlagsDef { 'convert-eol'?: boolean; 'noconvert-eol'?: boolean; 'multi-dockerignore'?: boolean; + 'release-tag'?: string[]; help: void; } @@ -92,6 +94,7 @@ export default class PushCmd extends Command { '$ balena push myApp', '$ balena push myApp --source ', '$ balena push myApp -s ', + '$ balena push myApp --release-tag key1 "" key2 "value2 with spaces"', '', '$ balena push 10.0.0.1', '$ balena push 10.0.0.1 --source ', @@ -224,6 +227,15 @@ export default class PushCmd extends Command { char: 'g', 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, }; @@ -259,82 +271,29 @@ export default class PushCmd extends Command { const buildTarget = await this.getBuildTarget(appOrDevice); switch (buildTarget) { case BuildTarget.Cloud: - const remote = await import('../utils/remote-build'); - - // Check for invalid options - const localOnlyOptions = ['nolive', 'service', 'system', 'env']; - - localOnlyOptions.forEach((opt) => { - // @ts-ignore : Not sure why typescript wont let me do this? - if (options[opt]) { - throw new ExpectedError( - `The --${opt} flag is only valid when pushing to a local mode device`, - ); - } - }); - - const app = appOrDevice; - await Command.checkLoggedIn(); - const [token, baseUrl, owner] = await Promise.all([ - sdk.auth.getToken(), - sdk.settings.get('balenaUrl'), - this.getAppOwner(sdk, app), - ]); - - const opts = { - dockerfilePath, - emulated: options.emulated || false, - multiDockerignore: options['multi-dockerignore'] || false, - nocache: options.nocache || false, - registrySecrets, - headless: options.detached || false, - convertEol, - }; - const args = { - app, - owner, - source, - auth: token, - baseUrl, - nogitignore, + await this.pushToCloud( + options, sdk, - opts, - }; - await remote.startRemoteBuild(args); + appOrDevice, + dockerfilePath, + registrySecrets, + convertEol, + source, + nogitignore, + ); break; case BuildTarget.Device: - const deviceDeploy = await import('../utils/device/deploy'); - const device = appOrDevice; - const servicesToDisplay = options.service; - - // TODO: Support passing a different port - try { - await deviceDeploy.deployToDevice({ - source, - deviceHost: device, - dockerfilePath, - registrySecrets, - multiDockerignore: options['multi-dockerignore'] || false, - nocache: options.nocache || false, - pull: options.pull || false, - nogitignore, - noParentCheck: options['noparent-check'] || false, - nolive: options.nolive || false, - detached: options.detached || false, - services: servicesToDisplay, - system: options.system || false, - env: options.env || [], - convertEol, - }); - } catch (e) { - const { BuildError } = await import('../utils/device/errors'); - if (instanceOf(e, BuildError)) { - throw new ExpectedError(e.toString()); - } else { - throw e; - } - } + await this.pushToDevice( + options, + sdk, + appOrDevice, + dockerfilePath, + registrySecrets, + convertEol, + source, + nogitignore, + ); break; default: @@ -344,6 +303,145 @@ export default class PushCmd extends Command { } } + 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'); + + // Check for invalid options + const localOnlyOptions: Array = [ + 'nolive', + 'service', + 'system', + 'env', + ]; + this.checkInvalidOptions( + localOnlyOptions, + options, + 'is only valid when pushing to a local mode device', + ); + + const releaseTags = options['release-tag'] ?? []; + const releaseTagKeys = releaseTags.filter((_v, i) => i % 2 === 0); + 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(''); + } + + const app = appOrDevice; + await Command.checkLoggedIn(); + const [token, baseUrl, owner] = await Promise.all([ + sdk.auth.getToken(), + sdk.settings.get('balenaUrl'), + this.getAppOwner(sdk, app), + ]); + + const opts = { + dockerfilePath, + emulated: options.emulated || false, + multiDockerignore: options['multi-dockerignore'] || false, + nocache: options.nocache || false, + registrySecrets, + headless: options.detached || false, + convertEol, + }; + const args = { + app, + owner, + source, + auth: token, + baseUrl, + nogitignore, + sdk, + opts, + }; + const releaseId = await remote.startRemoteBuild(args); + 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 = ['release-tag']; + this.checkInvalidOptions( + remoteOnlyOptions, + options, + 'is only valid when pushing to an application', + ); + + const deviceDeploy = await import('../utils/device/deploy'); + const device = appOrDevice; + const servicesToDisplay = options.service; + + // TODO: Support passing a different port + try { + await deviceDeploy.deployToDevice({ + source, + deviceHost: device, + dockerfilePath, + registrySecrets, + multiDockerignore: options['multi-dockerignore'] || false, + nocache: options.nocache || false, + pull: options.pull || false, + nogitignore, + noParentCheck: options['noparent-check'] || false, + nolive: options.nolive || false, + detached: options.detached || false, + services: servicesToDisplay, + system: options.system || false, + env: options.env || [], + convertEol, + }); + } catch (e) { + const { BuildError } = await import('../utils/device/errors'); + if (instanceOf(e, BuildError)) { + throw new ExpectedError(e.toString()); + } else { + throw e; + } + } + } + async getBuildTarget(appOrDevice: string): Promise { const { validateApplicationName, @@ -416,4 +514,16 @@ export default class PushCmd extends Command { return selected.extra; } + + checkInvalidOptions( + invalidOptions: Array, + options: FlagsDef, + errorMessage: string, + ) { + invalidOptions.forEach((opt) => { + if (options[opt]) { + throw new ExpectedError(`The --${opt} flag ${errorMessage}`); + } + }); + } } diff --git a/lib/utils/remote-build.ts b/lib/utils/remote-build.ts index 2bf2403a..3f7aa58d 100644 --- a/lib/utils/remote-build.ts +++ b/lib/utils/remote-build.ts @@ -108,7 +108,9 @@ async function getBuilderEndpoint( return `${builderUrl}/v3/build?${args}`; } -export async function startRemoteBuild(build: RemoteBuild): Promise { +export async function startRemoteBuild( + build: RemoteBuild, +): Promise { const [buildRequest, stream] = await getRemoteBuildStream(build); // Setup CTRL-C handler so the user can interrupt the build @@ -133,7 +135,7 @@ export async function startRemoteBuild(build: RemoteBuild): Promise { try { if (build.opts.headless) { - await handleHeadlessBuildStream(stream); + await handleHeadlessBuildStream(build, stream); } else { await handleRemoteBuildStream(build, stream); } @@ -142,6 +144,7 @@ export async function startRemoteBuild(build: RemoteBuild): Promise { globalLogger.outputDeferredMessages(); await cancellationPromise; } + return build.releaseId; } 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 // get a single object back, detailing if the build has // been started @@ -182,6 +188,7 @@ async function handleHeadlessBuildStream(stream: Stream.Stream) { if (message.started) { console.log('Build successfully started'); console.log(` Release ID: ${message.releaseId!}`); + build.releaseId = message.releaseId; } else { console.log('Failed to start remote build'); console.log(` Error: ${message.error!}`); From 074fe010bdc543c30754ac2eddba58d68bd8e0ae Mon Sep 17 00:00:00 2001 From: Marios Balamatsias Date: Tue, 22 Dec 2020 16:39:48 +0200 Subject: [PATCH 2/2] errors: Make all exclusive flag errors expected eg Don't report errors if during a push --release-tag and --detached flags are used. Change-type: minor Signed-off-by: Marios Balamatsias --- lib/errors.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/errors.ts b/lib/errors.ts index ae6f6a07..695e23b3 100644 --- a/lib/errors.ts +++ b/lib/errors.ts @@ -180,6 +180,7 @@ const messages: { // related issue https://github.com/balena-io/balena-sdk/issues/1025 // related issue https://github.com/balena-io/balena-cli/issues/2126 const EXPECTED_ERROR_REGEXES = [ + /cannot also be provided when using/, // Exclusive flag errors are all expected /^BalenaSettingsPermissionError/, // balena-settings-storage /^BalenaAmbiguousApplication/, // balena-sdk /^BalenaAmbiguousDevice/, // balena-sdk