From 979284b0712348bb320fa153ec90a77db65f4883 Mon Sep 17 00:00:00 2001 From: Scott Lowe Date: Tue, 26 May 2020 11:17:30 +0200 Subject: [PATCH] Convert `tags`, `tag set`, `tag rm` to oclif. Change-type: patch Resolves: #1805 Signed-off-by: Scott Lowe --- automation/capitanodoc/capitanodoc.ts | 6 +- doc/cli.markdown | 100 +++++---- lib/actions-oclif/tag/rm.ts | 134 ++++++++++++ lib/actions-oclif/tag/set.ts | 157 ++++++++++++++ lib/actions-oclif/tags.ts | 129 +++++++++++ lib/actions/index.ts | 16 +- lib/actions/tags.ts | 295 -------------------------- lib/app-capitano.js | 5 - lib/preparser.ts | 3 + lib/utils/common-flags.ts | 5 + lib/utils/normalization.ts | 67 ++++-- lib/utils/validation.ts | 9 +- tests/commands/help.spec.ts | 6 +- tests/utils/normalization.spec.ts | 209 ++++++++++++++++++ tests/utils/validation.spec.ts | 45 ++-- 15 files changed, 794 insertions(+), 392 deletions(-) create mode 100644 lib/actions-oclif/tag/rm.ts create mode 100644 lib/actions-oclif/tag/set.ts create mode 100644 lib/actions-oclif/tags.ts delete mode 100644 lib/actions/tags.ts create mode 100644 tests/utils/normalization.spec.ts diff --git a/automation/capitanodoc/capitanodoc.ts b/automation/capitanodoc/capitanodoc.ts index eb014a4b..be779d50 100644 --- a/automation/capitanodoc/capitanodoc.ts +++ b/automation/capitanodoc/capitanodoc.ts @@ -66,7 +66,11 @@ const capitanoDoc = { }, { title: 'Tags', - files: ['build/actions/tags.js'], + files: [ + 'build/actions-oclif/tags.js', + 'build/actions-oclif/tag/rm.js', + 'build/actions-oclif/tag/set.js', + ], }, { title: 'Help and Version', diff --git a/doc/cli.markdown b/doc/cli.markdown index 6e3ab7c2..522c1fe6 100644 --- a/doc/cli.markdown +++ b/doc/cli.markdown @@ -194,8 +194,8 @@ Users are encouraged to regularly update the balena CLI to the latest version. - Tags - [tags](#tags) - - [tag set <tagKey> [value]](#tag-set-tagkey-value) - - [tag rm <tagKey>](#tag-rm-tagkey) + - [tag rm <tagkey>](#tag-rm-tagkey) + - [tag set <tagkey> [value]](#tag-set-tagkey-value) - Help and Version @@ -991,12 +991,10 @@ select a service variable (may be used together with the --device option) ## tags -Use this command to list all tags for -a particular application, device or release. +List all tags and their values for a particular application, +device or release. -This command lists all application/device/release tags. - -Example: +Examples: $ balena tags --application MyApp $ balena tags --device 7cf02a6 @@ -1005,24 +1003,63 @@ Example: ### Options -#### --application, -a, --app <application> +#### -a, --application APPLICATION application name -#### --device, -d <device> +#### -d, --device DEVICE -device uuid +device UUID -#### --release, -r <release> +#### -r, --release RELEASE release id +#### --app APP + +same as '--application' + +## tag rm <tagKey> + +Remove a tag from an application, device or release. + +Examples: + + $ balena tag rm myTagKey --application MyApp + $ balena tag rm myTagKey --device 7cf02a6 + $ balena tag rm myTagKey --release 1234 + $ balena tag rm myTagKey --release b376b0e544e9429483b656490e5b9443b4349bd6 + +### Arguments + +#### TAGKEY + +the key string of the tag + +### Options + +#### -a, --application APPLICATION + +application name + +#### -d, --device DEVICE + +device UUID + +#### -r, --release RELEASE + +release id + +#### --app APP + +same as '--application' + ## tag set <tagKey> [value] -Use this command to set a tag to an application, device or release. +Set a tag on an application, device or release. You can optionally provide a value to be associated with the created -tag, as an extra argument after the tag key. When the value isn't +tag, as an extra argument after the tag key. If a value isn't provided, a tag with an empty value is created. Examples: @@ -1035,45 +1072,34 @@ Examples: $ balena tag set myCompositeTag --release 1234 $ balena tag set myCompositeTag --release b376b0e544e9429483b656490e5b9443b4349bd6 -### Options +### Arguments -#### --application, -a, --app <application> +#### TAGKEY -application name +the key string of the tag -#### --device, -d <device> +#### VALUE -device uuid - -#### --release, -r <release> - -release id - -## tag rm <tagKey> - -Use this command to remove a tag from an application, device or release. - -Examples: - - $ balena tag rm myTagKey --application MyApp - $ balena tag rm myTagKey --device 7cf02a6 - $ balena tag rm myTagKey --release 1234 - $ balena tag rm myTagKey --release b376b0e544e9429483b656490e5b9443b4349bd6 +the optional value associated with the tag ### Options -#### --application, -a, --app <application> +#### -a, --application APPLICATION application name -#### --device, -d <device> +#### -d, --device DEVICE -device uuid +device UUID -#### --release, -r <release> +#### -r, --release RELEASE release id +#### --app APP + +same as '--application' + # Help and Version ## help [command...] diff --git a/lib/actions-oclif/tag/rm.ts b/lib/actions-oclif/tag/rm.ts new file mode 100644 index 00000000..8690b6c4 --- /dev/null +++ b/lib/actions-oclif/tag/rm.ts @@ -0,0 +1,134 @@ +/** + * @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. + */ + +import { flags } from '@oclif/command'; +import { stripIndent } from 'common-tags'; +import Command from '../../command'; +import { ExpectedError } from '../../errors'; +import * as cf from '../../utils/common-flags'; +import { getBalenaSdk } from '../../utils/lazy'; +import { disambiguateReleaseParam } from '../../utils/normalization'; +import { tryAsInteger } from '../../utils/validation'; + +interface FlagsDef { + application?: string; + device?: string; + release?: string; + help: void; + app?: string; +} + +interface ArgsDef { + tagKey: string; +} + +export default class TagRmCmd extends Command { + public static description = stripIndent` + Remove a tag from an application, device or release. + + Remove a tag from an application, device or release. + `; + + public static examples = [ + '$ balena tag rm myTagKey --application MyApp', + '$ balena tag rm myTagKey --device 7cf02a6', + '$ balena tag rm myTagKey --release 1234', + '$ balena tag rm myTagKey --release b376b0e544e9429483b656490e5b9443b4349bd6', + ]; + + public static args = [ + { + name: 'tagKey', + description: 'the key string of the tag', + required: true, + }, + ]; + + public static usage = 'tag rm '; + + public static flags: flags.Input = { + application: { + ...cf.application, + exclusive: ['app', 'device', 'release'], + }, + device: { + ...cf.device, + exclusive: ['app', 'application', 'release'], + }, + release: { + ...cf.release, + exclusive: ['app', 'application', 'device'], + }, + help: cf.help, + app: flags.string({ + description: "same as '--application'", + exclusive: ['application', 'device', 'release'], + }), + }; + + public static authenticated = true; + + public async run() { + const { args: params, flags: options } = this.parse( + TagRmCmd, + ); + + // Prefer options.application over options.app + options.application = options.application || options.app; + delete options.app; + + const balena = getBalenaSdk(); + + // Check user has specified one of application/device/release + if (!options.application && !options.device && !options.release) { + throw new ExpectedError(TagRmCmd.missingResourceMessage); + } + + if (options.application) { + return balena.models.application.tags.remove( + tryAsInteger(options.application), + params.tagKey, + ); + } + if (options.device) { + return balena.models.device.tags.remove( + tryAsInteger(options.device), + params.tagKey, + ); + } + if (options.release) { + const releaseParam = await disambiguateReleaseParam( + balena, + options.release, + ); + + return balena.models.release.tags.remove(releaseParam, params.tagKey); + } + } + + protected static missingResourceMessage = stripIndent` + To remove a resource tag, you must provide exactly one of: + + * An application, with --application + * A device, with --device + * A release, with --release + + See the help page for examples: + + $ balena help tag rm + `; +} diff --git a/lib/actions-oclif/tag/set.ts b/lib/actions-oclif/tag/set.ts new file mode 100644 index 00000000..e73c134c --- /dev/null +++ b/lib/actions-oclif/tag/set.ts @@ -0,0 +1,157 @@ +/** + * @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. + */ + +import { flags } from '@oclif/command'; +import { stripIndent } from 'common-tags'; +import Command from '../../command'; +import { ExpectedError } from '../../errors'; +import * as cf from '../../utils/common-flags'; +import { getBalenaSdk } from '../../utils/lazy'; +import { disambiguateReleaseParam } from '../../utils/normalization'; +import { tryAsInteger } from '../../utils/validation'; + +interface FlagsDef { + application?: string; + device?: string; + release?: string; + help: void; + app?: string; +} + +interface ArgsDef { + tagKey: string; + value?: string; +} + +export default class TagSetCmd extends Command { + public static description = stripIndent` + Set a tag on an application, device or release. + + Set a tag on an application, device or release. + + You can optionally provide a value to be associated with the created + tag, as an extra argument after the tag key. If a value isn't + provided, a tag with an empty value is created. + `; + + public static examples = [ + '$ balena tag set mySimpleTag --application MyApp', + '$ balena tag set myCompositeTag myTagValue --application MyApp', + '$ balena tag set myCompositeTag myTagValue --device 7cf02a6', + '$ balena tag set myCompositeTag "my tag value with whitespaces" --device 7cf02a6', + '$ balena tag set myCompositeTag myTagValue --release 1234', + '$ balena tag set myCompositeTag --release 1234', + '$ balena tag set myCompositeTag --release b376b0e544e9429483b656490e5b9443b4349bd6', + ]; + + public static args = [ + { + name: 'tagKey', + description: 'the key string of the tag', + required: true, + }, + { + name: 'value', + description: 'the optional value associated with the tag', + required: false, + }, + ]; + + public static usage = 'tag set [value]'; + + public static flags: flags.Input = { + application: { + ...cf.application, + exclusive: ['app', 'device', 'release'], + }, + device: { + ...cf.device, + exclusive: ['app', 'application', 'release'], + }, + release: { + ...cf.release, + exclusive: ['app', 'application', 'device'], + }, + help: cf.help, + app: flags.string({ + description: "same as '--application'", + exclusive: ['application', 'device', 'release'], + }), + }; + + public static authenticated = true; + + public async run() { + const { args: params, flags: options } = this.parse( + TagSetCmd, + ); + + // Prefer options.application over options.app + options.application = options.application || options.app; + delete options.app; + + const balena = getBalenaSdk(); + + // Check user has specified one of application/device/release + if (!options.application && !options.device && !options.release) { + throw new ExpectedError(TagSetCmd.missingResourceMessage); + } + + if (params.value == null) { + params.value = ''; + } + + if (options.application) { + return balena.models.application.tags.set( + tryAsInteger(options.application), + params.tagKey, + params.value, + ); + } + if (options.device) { + return balena.models.device.tags.set( + tryAsInteger(options.device), + params.tagKey, + params.value, + ); + } + if (options.release) { + const releaseParam = await disambiguateReleaseParam( + balena, + options.release, + ); + + return balena.models.release.tags.set( + releaseParam, + params.tagKey, + params.value, + ); + } + } + + protected static missingResourceMessage = stripIndent` + To set a resource tag, you must provide exactly one of: + + * An application, with --application + * A device, with --device + * A release, with --release + + See the help page for examples: + + $ balena help tag set + `; +} diff --git a/lib/actions-oclif/tags.ts b/lib/actions-oclif/tags.ts new file mode 100644 index 00000000..4774dacc --- /dev/null +++ b/lib/actions-oclif/tags.ts @@ -0,0 +1,129 @@ +/** + * @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. + */ + +import { flags } from '@oclif/command'; +import { stripIndent } from 'common-tags'; +import Command from '../command'; +import { ExpectedError } from '../errors'; +import * as cf from '../utils/common-flags'; +import { getBalenaSdk, getVisuals } from '../utils/lazy'; +import { disambiguateReleaseParam } from '../utils/normalization'; +import { tryAsInteger } from '../utils/validation'; + +interface FlagsDef { + application?: string; + device?: string; + release?: string; + help: void; + app?: string; +} + +export default class TagsCmd extends Command { + public static description = stripIndent` + List all tags for an application, device or release. + + List all tags and their values for a particular application, + device or release. + `; + + public static examples = [ + '$ balena tags --application MyApp', + '$ balena tags --device 7cf02a6', + '$ balena tags --release 1234', + '$ balena tags --release b376b0e544e9429483b656490e5b9443b4349bd6', + ]; + + public static usage = 'tags'; + + public static flags: flags.Input = { + application: { + ...cf.application, + exclusive: ['app', 'device', 'release'], + }, + device: { + ...cf.device, + exclusive: ['app', 'application', 'release'], + }, + release: { + ...cf.release, + exclusive: ['app', 'application', 'device'], + }, + help: cf.help, + app: flags.string({ + description: "same as '--application'", + exclusive: ['application', 'device', 'release'], + }), + }; + + public static authenticated = true; + + public async run() { + const { flags: options } = this.parse(TagsCmd); + + // Prefer options.application over options.app + options.application = options.application || options.app; + delete options.app; + + const balena = getBalenaSdk(); + + // Check user has specified one of application/device/release + if (!options.application && !options.device && !options.release) { + throw new ExpectedError(this.missingResourceMessage); + } + + let tags; + + if (options.application) { + tags = await balena.models.application.tags.getAllByApplication( + tryAsInteger(options.application), + ); + } + if (options.device) { + tags = await balena.models.device.tags.getAllByDevice( + tryAsInteger(options.device), + ); + } + if (options.release) { + const releaseParam = await disambiguateReleaseParam( + balena, + options.release, + ); + + tags = await balena.models.release.tags.getAllByRelease(releaseParam); + } + + if (!tags || tags.length === 0) { + throw new ExpectedError('No tags found'); + } + + console.log( + getVisuals().table.horizontal(tags, ['id', 'tag_key', 'value']), + ); + } + + protected missingResourceMessage = stripIndent` + To list tags for a resource, you must provide exactly one of: + + * An application, with --application + * A device, with --device + * A release, with --release + + See the help page for examples: + + $ balena help tags + `; +} diff --git a/lib/actions/index.ts b/lib/actions/index.ts index 2ba46d9e..6f2efd20 100644 --- a/lib/actions/index.ts +++ b/lib/actions/index.ts @@ -23,24 +23,10 @@ import * as logs from './logs'; import * as os from './os'; import * as push from './push'; import * as ssh from './ssh'; -import * as tags from './tags'; import * as tunnel from './tunnel'; import * as util from './util'; -export { - auth, - device, - tags, - logs, - local, - help, - os, - config, - ssh, - util, - push, - tunnel, -}; +export { auth, device, logs, local, help, os, config, ssh, util, push, tunnel }; export { build } from './build'; diff --git a/lib/actions/tags.ts b/lib/actions/tags.ts deleted file mode 100644 index 7d1756ee..00000000 --- a/lib/actions/tags.ts +++ /dev/null @@ -1,295 +0,0 @@ -/* -Copyright 2016-2018 Balena - -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 { ApplicationTag, DeviceTag, ReleaseTag } from 'balena-sdk'; -import { CommandDefinition } from 'capitano'; -import { stripIndent } from 'common-tags'; -import { getBalenaSdk, getVisuals } from '../utils/lazy'; -import { - disambiguateReleaseParam, - normalizeUuidProp, -} from '../utils/normalization'; -import * as commandOptions from './command-options'; - -export const list: CommandDefinition< - {}, - { - application?: string; - device?: string; - release?: number | string; - release_raw?: string; - } -> = { - signature: 'tags', - description: 'list all resource tags', - help: stripIndent` - Use this command to list all tags for - a particular application, device or release. - - This command lists all application/device/release tags. - - Example: - - $ balena tags --application MyApp - $ balena tags --device 7cf02a6 - $ balena tags --release 1234 - $ balena tags --release b376b0e544e9429483b656490e5b9443b4349bd6 - `, - options: [ - commandOptions.optionalApplication, - commandOptions.optionalDevice, - commandOptions.optionalRelease, - ], - permission: 'user', - async action(_params, options) { - normalizeUuidProp(options, 'device'); - const Bluebird = await import('bluebird'); - const _ = await import('lodash'); - const balena = getBalenaSdk(); - - const { exitWithExpectedError } = await import('../errors'); - - return Bluebird.try( - async () => { - const wrongParametersError = stripIndent` - To list resource tags, you must provide exactly one of: - - * An application, with --application - * A device, with --device - * A release, with --release - - See the help page for examples: - - $ balena help tags - `; - - if ( - _.filter([options.application, options.device, options.release]) - .length !== 1 - ) { - return exitWithExpectedError(wrongParametersError); - } - - if (options.application) { - return balena.models.application.tags.getAllByApplication( - options.application, - ); - } - if (options.device) { - return balena.models.device.tags.getAllByDevice(options.device); - } - if (options.release) { - const releaseParam = await disambiguateReleaseParam( - balena, - options.release, - options.release_raw, - ); - return balena.models.release.tags.getAllByRelease(releaseParam); - } - - // return never, so that TS typings are happy - return exitWithExpectedError(wrongParametersError); - }, - ).tap(function(environmentVariables) { - if (_.isEmpty(environmentVariables)) { - exitWithExpectedError('No tags found'); - } - - console.log( - getVisuals().table.horizontal(environmentVariables, [ - 'id', - 'tag_key', - 'value', - ]), - ); - }); - }, -}; - -export const set: CommandDefinition< - { - tagKey: string; - value?: string; - }, - { - application?: string; - device?: string; - release?: number | string; - release_raw: string; - } -> = { - signature: 'tag set [value]', - description: 'set a resource tag', - help: stripIndent` - Use this command to set a tag to an application, device or release. - - You can optionally provide a value to be associated with the created - tag, as an extra argument after the tag key. When the value isn't - provided, a tag with an empty value is created. - - Examples: - - $ balena tag set mySimpleTag --application MyApp - $ balena tag set myCompositeTag myTagValue --application MyApp - $ balena tag set myCompositeTag myTagValue --device 7cf02a6 - $ balena tag set myCompositeTag "my tag value with whitespaces" --device 7cf02a6 - $ balena tag set myCompositeTag myTagValue --release 1234 - $ balena tag set myCompositeTag --release 1234 - $ balena tag set myCompositeTag --release b376b0e544e9429483b656490e5b9443b4349bd6 - `, - options: [ - commandOptions.optionalApplication, - commandOptions.optionalDevice, - commandOptions.optionalRelease, - ], - permission: 'user', - async action(params, options) { - normalizeUuidProp(options, 'device'); - const _ = await import('lodash'); - const balena = getBalenaSdk(); - - const { exitWithExpectedError } = await import('../errors'); - - if (_.isEmpty(params.tagKey)) { - return exitWithExpectedError('No tag key was provided'); - } - - if ( - _.filter([options.application, options.device, options.release]) - .length !== 1 - ) { - return exitWithExpectedError(stripIndent` - To set a resource tag, you must provide exactly one of: - - * An application, with --application - * A device, with --device - * A release, with --release - - See the help page for examples: - - $ balena help tag set - `); - } - - if (params.value == null) { - params.value = ''; - } - - if (options.application) { - return balena.models.application.tags.set( - options.application, - params.tagKey, - params.value, - ); - } - if (options.device) { - return balena.models.device.tags.set( - options.device, - params.tagKey, - params.value, - ); - } - if (options.release) { - const releaseParam = await disambiguateReleaseParam( - balena, - options.release, - options.release_raw, - ); - - return balena.models.release.tags.set( - releaseParam, - params.tagKey, - params.value, - ); - } - }, -}; - -export const remove: CommandDefinition< - { - tagKey: string; - }, - { - application?: string; - device?: string; - release?: number | string; - release_raw?: string; - } -> = { - signature: 'tag rm ', - description: 'remove a resource tag', - help: stripIndent` - Use this command to remove a tag from an application, device or release. - - Examples: - - $ balena tag rm myTagKey --application MyApp - $ balena tag rm myTagKey --device 7cf02a6 - $ balena tag rm myTagKey --release 1234 - $ balena tag rm myTagKey --release b376b0e544e9429483b656490e5b9443b4349bd6 - `, - options: [ - commandOptions.optionalApplication, - commandOptions.optionalDevice, - commandOptions.optionalRelease, - ], - permission: 'user', - async action(params, options) { - const _ = await import('lodash'); - const balena = getBalenaSdk(); - const { exitWithExpectedError } = await import('../errors'); - - if (_.isEmpty(params.tagKey)) { - return exitWithExpectedError('No tag key was provided'); - } - - if ( - _.filter([options.application, options.device, options.release]) - .length !== 1 - ) { - return exitWithExpectedError(stripIndent` - To remove a resource tag, you must provide exactly one of: - - * An application, with --application - * A device, with --device - * A release, with --release - - See the help page for examples: - - $ balena help tag rm - `); - } - - if (options.application) { - return balena.models.application.tags.remove( - options.application, - params.tagKey, - ); - } - if (options.device) { - return balena.models.device.tags.remove(options.device, params.tagKey); - } - if (options.release) { - const releaseParam = await disambiguateReleaseParam( - balena, - options.release, - options.release_raw, - ); - - return balena.models.release.tags.remove(releaseParam, params.tagKey); - } - }, -}; diff --git a/lib/app-capitano.js b/lib/app-capitano.js index 697d49aa..bcbd6694 100644 --- a/lib/app-capitano.js +++ b/lib/app-capitano.js @@ -71,11 +71,6 @@ capitano.command(actions.device.move); capitano.command(actions.device.osUpdate); capitano.command(actions.device.info); -// ---------- Tags Module ---------- -capitano.command(actions.tags.list); -capitano.command(actions.tags.set); -capitano.command(actions.tags.remove); - // ---------- OS Module ---------- capitano.command(actions.os.versions); capitano.command(actions.os.download); diff --git a/lib/preparser.ts b/lib/preparser.ts index c2fe5804..70131025 100644 --- a/lib/preparser.ts +++ b/lib/preparser.ts @@ -159,6 +159,9 @@ export const convertedCommands = [ 'note', 'os:configure', 'settings', + 'tags', + 'tag:rm', + 'tag:set', 'version', 'scan', ]; diff --git a/lib/utils/common-flags.ts b/lib/utils/common-flags.ts index 7b688d5d..4fc36b71 100644 --- a/lib/utils/common-flags.ts +++ b/lib/utils/common-flags.ts @@ -37,6 +37,11 @@ export const quiet: IBooleanFlag = flags.boolean({ default: false, }); +export const release = flags.string({ + char: 'r', + description: 'release id', +}); + export const service = flags.string({ char: 's', description: 'service name', diff --git a/lib/utils/normalization.ts b/lib/utils/normalization.ts index 35036328..e9248aa1 100644 --- a/lib/utils/normalization.ts +++ b/lib/utils/normalization.ts @@ -16,6 +16,7 @@ limitations under the License. import { BalenaSDK } from 'balena-sdk'; import _ = require('lodash'); +import { ExpectedError } from '../errors'; export function normalizeUuidProp( params: { [key: string]: any }, @@ -27,25 +28,59 @@ export function normalizeUuidProp( } } +/** + * Takes a string which may represent one of: + * - Integer release id + * - String uuid, 7, 32, or 62 char + * - String commit hash, 40 char, with short hashes being 7+ chars (more as needed to avoid collisions) + * and returns the correctly typed value (integer|string). + * @param balena balena sdk + * @param release string representation of release reference (id/hash) + */ export async function disambiguateReleaseParam( balena: BalenaSDK, - param: string | number, - paramRaw: string | undefined, + release: string, ) { - // the user has passed an argument that was parsed as a string - if (typeof param !== 'number') { - return param; + // Reject empty values or invalid characters + const mixedCaseHex = /^[a-fA-F0-9]+$/; + if (!release || !mixedCaseHex.test(release)) { + throw new ExpectedError('Invalid release parameter'); } - // check whether the argument was indeed an ID - return balena.models.release - .get(param, { $select: 'id' }) - .catch(error => { - // we couldn't find a release by id, - // try whether it was a commit with all numeric digits - return balena.models.release - .get(paramRaw || _.toString(param), { $select: 'id' }) - .catchThrow(error); - }) - .then(({ id }) => id); + // Accepting short hashes of 7,8,9 chars. + const possibleUuidHashLength = [7, 8, 9, 32, 40, 62].includes(release.length); + const hasLeadingZero = release[0] === '0'; + const isOnlyNumerical = /^[0-9]+$/.test(release); + + // Reject non-numerical values with invalid uuid/hash lengths + if (!isOnlyNumerical && !possibleUuidHashLength) { + throw new ExpectedError('Invalid release parameter'); + } + + // Reject leading-zero values with invalid uuid/hash lengths + if (hasLeadingZero && !possibleUuidHashLength) { + throw new ExpectedError('Invalid release parameter'); + } + + // If alphanumeric, or has leading zero must now be uuid/hash. + if (!isOnlyNumerical || hasLeadingZero) { + return release; + } + + // Now very likely an integer id (but still could be number only uuid/hash) + // Check integer id with API + try { + return ( + await balena.models.release.get(parseInt(release, 10), { + $select: 'id', + }) + ).id; + } catch (e) { + if (e.name !== 'BalenaReleaseNotFound') { + throw e; + } + } + + // Must be a number only uuid/hash (or nonexistent release) + return (await balena.models.release.get(release, { $select: 'id' })).id; } diff --git a/lib/utils/validation.ts b/lib/utils/validation.ts index 6a5615f5..296d1964 100644 --- a/lib/utils/validation.ts +++ b/lib/utils/validation.ts @@ -58,7 +58,7 @@ export function validateDotLocalUrl(input: string): boolean { } export function validateLongUuid(input: string): boolean { - if (input.length !== 32 && input.length !== 64) { + if (input.length !== 32 && input.length !== 62) { return false; } return UUID_REGEX.test(input); @@ -89,12 +89,9 @@ export function parseAsInteger(input: string, paramName?: string) { return Number(input); } -export function tryAsInteger( - input: string, - paramName?: string, -): number | string { +export function tryAsInteger(input: string): number | string { try { - return parseAsInteger(input, paramName); + return parseAsInteger(input); } catch { return input; } diff --git a/tests/commands/help.spec.ts b/tests/commands/help.spec.ts index f232aa94..a6b829dc 100644 --- a/tests/commands/help.spec.ts +++ b/tests/commands/help.spec.ts @@ -69,9 +69,9 @@ Additional commands: os initialize initialize an os image os versions show the available balenaOS versions for the given device type settings print current settings - tag rm remove a resource tag - tag set [value] set a resource tag - tags list all resource tags + tag rm remove a tag from an application, device or release + tag set [value] set a tag on an application, device or release + tags list all tags for an application, device or release util available-drives list available drives version display version information for the balena CLI and/or Node.js whoami get current username and email address diff --git a/tests/utils/normalization.spec.ts b/tests/utils/normalization.spec.ts new file mode 100644 index 00000000..e4979ef3 --- /dev/null +++ b/tests/utils/normalization.spec.ts @@ -0,0 +1,209 @@ +/** + * @license + * Copyright 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. + */ + +import { BalenaReleaseNotFound } from 'balena-errors'; +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { ExpectedError } from '../../build/errors'; +import { disambiguateReleaseParam } from '../../build/utils/normalization'; + +describe('disambiguateReleaseParam() function', () => { + it('should reject empty values', async () => { + try { + await disambiguateReleaseParam(null as any, ''); + throw new Error('should not be reached'); + } catch (e) { + expect(e).to.be.an.instanceOf(ExpectedError); + expect(e.message).to.equal('Invalid release parameter'); + } + }); + + it('should reject values containing invalid chars', async () => { + const invalidCharExamples = ' .,-_=!@#$%^&*() '; + + for (const char of invalidCharExamples) { + try { + await disambiguateReleaseParam(null as any, char); + throw new Error('should not be reached'); + } catch (e) { + expect(e).to.be.an.instanceOf(ExpectedError); + expect(e.message).to.equal('Invalid release parameter'); + } + } + }); + + it('should reject non-numerical values with invalid uuid/hash lengths', async () => { + const invalidLengthValue = 'abcd'; + + try { + await disambiguateReleaseParam(null as any, invalidLengthValue); + throw new Error('should not be reached'); + } catch (e) { + expect(e).to.be.an.instanceOf(ExpectedError); + expect(e.message).to.equal('Invalid release parameter'); + } + }); + + it('should reject leading-zero numerical values with invalid uuid/hash lengths', async () => { + const invalidLengthValue = '01234'; + + try { + await disambiguateReleaseParam(null as any, invalidLengthValue); + throw new Error('should not be reached'); + } catch (e) { + expect(e).to.be.an.instanceOf(ExpectedError); + expect(e.message).to.equal('Invalid release parameter'); + } + }); + + it('should return non-numerical values with valid hash lengths as string, without SDK calls', async () => { + const uuid7 = 'a'.repeat(7); + const uuid32 = 'a'.repeat(32); + const uuid62 = 'a'.repeat(62); + const hash8 = 'a'.repeat(8); + const hash9 = 'a'.repeat(9); + const hash40 = 'a'.repeat(40); + + expect(await disambiguateReleaseParam(null as any, uuid7)).to.equal(uuid7); + expect(await disambiguateReleaseParam(null as any, uuid32)).to.equal( + uuid32, + ); + expect(await disambiguateReleaseParam(null as any, uuid62)).to.equal( + uuid62, + ); + expect(await disambiguateReleaseParam(null as any, hash8)).to.equal(hash8); + expect(await disambiguateReleaseParam(null as any, hash9)).to.equal(hash9); + expect(await disambiguateReleaseParam(null as any, hash40)).to.equal( + hash40, + ); + }); + + it('should return numerical, leading zero values with valid uuid/hash lengths as string, without SDK calls', async () => { + const uuid7 = '0' + '1'.repeat(6); + const uuid32 = '0' + '1'.repeat(31); + const uuid62 = '0' + '1'.repeat(61); + const hash8 = '0' + '1'.repeat(7); + const hash9 = '0' + '1'.repeat(8); + const hash40 = '0' + '1'.repeat(39); + + expect(await disambiguateReleaseParam(null as any, uuid7)).to.equal(uuid7); + expect(await disambiguateReleaseParam(null as any, uuid32)).to.equal( + uuid32, + ); + expect(await disambiguateReleaseParam(null as any, uuid62)).to.equal( + uuid62, + ); + expect(await disambiguateReleaseParam(null as any, hash8)).to.equal(hash8); + expect(await disambiguateReleaseParam(null as any, hash9)).to.equal(hash9); + expect(await disambiguateReleaseParam(null as any, hash40)).to.equal( + hash40, + ); + }); + + it('should return id from SDK on first call, if match is found', async () => { + const input = '1234'; + const output = 1234; + const getRelease = sinon.stub().returns(Promise.resolve({ id: output })); + const sdk: any = { + models: { + release: { + get: getRelease, + }, + }, + }; + + const result = await disambiguateReleaseParam(sdk, input); + + expect(result).to.equal(output); + expect(getRelease.calledOnce).to.be.true; + expect(getRelease.getCall(0).args[0]).to.equal(parseInt(input, 10)); + }); + + it('should return id from SDK on second call, if match is found', async () => { + const input = '1234'; + const output = 1234; + const getRelease = sinon + .stub() + .onCall(0) + .returns(Promise.reject(new BalenaReleaseNotFound(input))) + .onCall(1) + .returns(Promise.resolve({ id: output })); + + const sdk: any = { + models: { + release: { + get: getRelease, + }, + }, + }; + + const result = await disambiguateReleaseParam(sdk, input); + + expect(result).to.equal(output); + expect(getRelease.calledTwice).to.be.true; + expect(getRelease.getCall(0).args[0]).to.equal(parseInt(input, 10)); + expect(getRelease.getCall(1).args[0]).to.equal(input); + }); + + it('should throw error if no match found', async () => { + const input = '1234'; + const getRelease = sinon + .stub() + .returns(Promise.reject(new BalenaReleaseNotFound(input))); + + const sdk: any = { + models: { + release: { + get: getRelease, + }, + }, + }; + + try { + await disambiguateReleaseParam(sdk, input); + throw new Error('should not be reached'); + } catch (e) { + expect(e).to.be.an.instanceOf(BalenaReleaseNotFound); + expect(getRelease.calledTwice).to.be.true; + } + }); + + it('should throw error if unknown error returned from SDK', async () => { + const input = '1234'; + + const getRelease = sinon + .stub() + .returns(Promise.reject(new Error('some error'))); + + const sdk: any = { + models: { + release: { + get: getRelease, + }, + }, + }; + + try { + await disambiguateReleaseParam(sdk, input); + throw new Error('should not be reached'); + } catch (e) { + expect(e).to.be.an.instanceOf(Error); + expect(e.message).to.equal('some error'); + expect(getRelease.calledOnce).to.be.true; + } + }); +}); diff --git a/tests/utils/validation.spec.ts b/tests/utils/validation.spec.ts index cc94ef42..fc6d05ab 100644 --- a/tests/utils/validation.spec.ts +++ b/tests/utils/validation.spec.ts @@ -114,17 +114,17 @@ describe('validateDotLocalUrl() function', () => { }); describe('validateLongUuid() function', () => { - it('should return false for strings with length other than 32 or 64', () => { + it('should return false for strings with length other than 32 or 62', () => { expect(v.validateLongUuid('')).to.equal(false); expect(v.validateLongUuid('abc')).to.equal(false); expect(v.validateLongUuid('a'.repeat(31))).to.equal(false); expect(v.validateLongUuid('a'.repeat(33))).to.equal(false); - expect(v.validateLongUuid('a'.repeat(63))).to.equal(false); - expect(v.validateLongUuid('a'.repeat(65))).to.equal(false); + expect(v.validateLongUuid('a'.repeat(64))).to.equal(false); }); - it('should return false for strings with characters other than a-z,0-9', () => { + it('should return false for strings with characters other than a-f,0-9', () => { expect(v.validateLongUuid('a'.repeat(31) + 'A')).to.equal(false); + expect(v.validateLongUuid('a'.repeat(31) + 'g')).to.equal(false); expect(v.validateLongUuid('a'.repeat(31) + '.')).to.equal(false); expect(v.validateLongUuid('a'.repeat(31) + '-')).to.equal(false); expect(v.validateLongUuid('a'.repeat(31) + '_')).to.equal(false); @@ -134,9 +134,7 @@ describe('validateLongUuid() function', () => { expect(v.validateLongUuid('8ab84942d20b4753e08243a9e3a177e2')).to.equal( true, ); - expect( - v.validateLongUuid('8ab84942d20b4753e08243a9e3a177e2'.repeat(2)), - ).to.equal(true); + expect(v.validateLongUuid('a'.repeat(62))).to.equal(true); }); }); @@ -161,19 +159,19 @@ describe('validateShortUuid() function', () => { }); describe('validateUuid() function', () => { - it('should return false for strings with length other than 7, 32 or 64', () => { + it('should return false for strings with length other than 7, 32 or 62', () => { expect(v.validateUuid('')).to.equal(false); expect(v.validateUuid('abc')).to.equal(false); expect(v.validateUuid('a'.repeat(6))).to.equal(false); expect(v.validateUuid('a'.repeat(8))).to.equal(false); expect(v.validateUuid('a'.repeat(31))).to.equal(false); expect(v.validateUuid('a'.repeat(33))).to.equal(false); - expect(v.validateUuid('a'.repeat(63))).to.equal(false); - expect(v.validateUuid('a'.repeat(65))).to.equal(false); + expect(v.validateUuid('a'.repeat(64))).to.equal(false); }); - it('should return false for strings with characters other than a-z,0-9', () => { + it('should return false for strings with characters other than a-f,0-9', () => { expect(v.validateUuid('a'.repeat(31) + 'A')).to.equal(false); + expect(v.validateUuid('a'.repeat(31) + 'g')).to.equal(false); expect(v.validateUuid('a'.repeat(31) + '.')).to.equal(false); expect(v.validateUuid('a'.repeat(31) + '-')).to.equal(false); expect(v.validateUuid('a'.repeat(31) + '_')).to.equal(false); @@ -182,9 +180,7 @@ describe('validateUuid() function', () => { it('should return true for valid UUIDs', () => { expect(v.validateUuid('8ab8494')).to.equal(true); expect(v.validateUuid('8ab84942d20b4753e08243a9e3a177e2')).to.equal(true); - expect( - v.validateUuid('8ab84942d20b4753e08243a9e3a177e2'.repeat(2)), - ).to.equal(true); + expect(v.validateLongUuid('a'.repeat(62))).to.equal(true); }); }); @@ -221,3 +217,24 @@ describe('parseAsInteger() function', () => { expect(v.parseAsInteger('0')).to.be.a('number'); }); }); + +describe('tryAsInteger() function', () => { + it('should return string with non-numeric characters as string', () => { + expect(v.tryAsInteger('abc')).to.be.a('string'); + expect(v.tryAsInteger('1a')).to.be.a('string'); + expect(v.tryAsInteger('a1')).to.be.a('string'); + expect(v.tryAsInteger('a')).to.be.a('string'); + expect(v.tryAsInteger('1.0')).to.be.a('string'); + }); + + it('should return numerical strings with leading zeros as string', () => { + expect(v.tryAsInteger('01')).to.be.a('string'); + expect(v.tryAsInteger('001')).to.be.a('string'); + }); + + it('should return numerical strings without leading zeros as number', () => { + expect(v.tryAsInteger('100')).to.be.a('number'); + expect(v.tryAsInteger('256')).to.be.a('number'); + expect(v.tryAsInteger('0')).to.be.a('number'); + }); +});