From 722c578ff4c5f6c729446bb33a74936b371c7b25 Mon Sep 17 00:00:00 2001 From: Chelsea Shaw <82459713+hashishaw@users.noreply.github.com> Date: Wed, 17 May 2023 11:41:02 -0500 Subject: [PATCH] UI/console update (#20590) --- changelog/20590.txt | 3 + ui/app/components/console/log-help.js | 8 - ui/app/components/console/ui-panel.js | 28 ++- ui/app/components/sidebar/frame.hbs | 2 +- ...{console-helpers.js => console-helpers.ts} | 166 ++++++++++++------ ui/app/services/console.js | 18 +- .../styles/components/console-ui-panel.scss | 4 + .../templates/components/console/log-help.hbs | 1 + .../templates/components/console/ui-panel.hbs | 14 +- .../addon/templates/components/swagger-ui.hbs | 5 +- ui/package.json | 1 + ui/tests/unit/lib/console-helpers-test.js | 119 ++++++++----- ui/tests/unit/services/console-test.js | 58 +++++- ui/yarn.lock | 8 + 14 files changed, 301 insertions(+), 134 deletions(-) create mode 100644 changelog/20590.txt delete mode 100644 ui/app/components/console/log-help.js rename ui/app/lib/{console-helpers.js => console-helpers.ts} (51%) diff --git a/changelog/20590.txt b/changelog/20590.txt new file mode 100644 index 000000000..c1c7c9e2b --- /dev/null +++ b/changelog/20590.txt @@ -0,0 +1,3 @@ +```release-note:improvement +ui: Update Web CLI with examples and a new `kv-get` command for reading kv v2 data and metadata +``` diff --git a/ui/app/components/console/log-help.js b/ui/app/components/console/log-help.js deleted file mode 100644 index 34319e25f..000000000 --- a/ui/app/components/console/log-help.js +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -import Component from '@ember/component'; - -export default Component.extend({}); diff --git a/ui/app/components/console/ui-panel.js b/ui/app/components/console/ui-panel.js index 3b2d39458..9b1194703 100644 --- a/ui/app/components/console/ui-panel.js +++ b/ui/app/components/console/ui-panel.js @@ -8,15 +8,17 @@ import { alias, or } from '@ember/object/computed'; import Component from '@ember/component'; import { getOwner } from '@ember/application'; import { schedule } from '@ember/runloop'; +import { camelize } from '@ember/string'; import { task } from 'ember-concurrency'; import ControlGroupError from 'vault/lib/control-group-error'; import { parseCommand, - extractDataAndFlags, logFromResponse, logFromError, - logErrorFromInput, + formattedErrorFromInput, executeUICommand, + extractFlagsFromStrings, + extractDataFromStrings, } from 'vault/lib/console-helpers'; export default Component.extend({ @@ -64,29 +66,25 @@ export default Component.extend({ // parse to verify it's valid try { - serviceArgs = parseCommand(command, shouldThrow); + serviceArgs = parseCommand(command); } catch (e) { - this.logAndOutput(command, { type: 'help' }); - return; - } - // we have a invalid command but don't want to throw - if (serviceArgs === false) { + if (shouldThrow) { + this.logAndOutput(command, { type: 'help' }); + } return; } - const [method, flagArray, path, dataArray] = serviceArgs; + const { method, flagArray, path, dataArray } = serviceArgs; + const flags = extractFlagsFromStrings(flagArray, method); + const data = extractDataFromStrings(dataArray); - if (dataArray || flagArray) { - var { data, flags } = extractDataAndFlags(method, dataArray, flagArray); - } - - const inputError = logErrorFromInput(path, method, flags, dataArray); + const inputError = formattedErrorFromInput(path, method, flags, dataArray); if (inputError) { this.logAndOutput(command, inputError); return; } try { - const resp = yield service[method].call(service, path, data, flags.wrapTTL); + const resp = yield service[camelize(method)].call(service, path, data, flags); this.logAndOutput(command, logFromResponse(resp, path, method, flags)); } catch (error) { if (error instanceof ControlGroupError) { diff --git a/ui/app/components/sidebar/frame.hbs b/ui/app/components/sidebar/frame.hbs index cc1627f70..639869256 100644 --- a/ui/app/components/sidebar/frame.hbs +++ b/ui/app/components/sidebar/frame.hbs @@ -39,7 +39,7 @@ - + {{! outlet for app content }} diff --git a/ui/app/lib/console-helpers.js b/ui/app/lib/console-helpers.ts similarity index 51% rename from ui/app/lib/console-helpers.js rename to ui/app/lib/console-helpers.ts index 6a3b943d6..bc636d55a 100644 --- a/ui/app/lib/console-helpers.js +++ b/ui/app/lib/console-helpers.ts @@ -4,66 +4,107 @@ */ import keys from 'vault/lib/keycodes'; -import argTokenizer from './arg-tokenizer'; +import AdapterError from '@ember-data/adapter/error'; import { parse } from 'shell-quote'; -const supportedCommands = ['read', 'write', 'list', 'delete']; +import argTokenizer from './arg-tokenizer'; +import { StringMap } from 'vault/vault/app-types'; + +// Add new commands to `log-help` component for visibility +const supportedCommands = ['read', 'write', 'list', 'delete', 'kv-get']; const uiCommands = ['api', 'clearall', 'clear', 'fullscreen', 'refresh']; -export function extractDataAndFlags(method, data, flags) { - return data.concat(flags).reduce( - (accumulator, val) => { - // will be "key=value" or "-flag=value" or "foo=bar=baz" - // split on the first = - // default to value of empty string - const [item, value = ''] = val.split(/=(.+)?/); - if (item.startsWith('-')) { - let flagName = item.replace(/^-/, ''); - if (flagName === 'wrap-ttl') { - flagName = 'wrapTTL'; - } else if (method === 'write') { - if (flagName === 'f' || flagName === '-force') { - flagName = 'force'; - } - } - accumulator.flags[flagName] = value || true; - return accumulator; - } - // if it exists in data already, then we have multiple - // foo=bar in the list and need to make it an array - if (accumulator.data[item]) { - accumulator.data[item] = [].concat(accumulator.data[item], value); - return accumulator; - } - accumulator.data[item] = value; - return accumulator; - }, - { data: {}, flags: {} } - ); +interface DataObj { + [key: string]: string | string[]; } -export function executeUICommand(command, logAndOutput, commandFns) { +export function extractDataFromStrings(dataArray: string[]): DataObj { + if (!dataArray) return {}; + return dataArray.reduce((accumulator: DataObj, val: string) => { + // will be "key=value" or "foo=bar=baz" + // split on the first = + // default to value of empty string + const [item = '', value = ''] = val.split(/=(.+)?/); + if (!item) return accumulator; + + // if it exists in data already, then we have multiple + // foo=bar in the list and need to make it an array + const existingValue = accumulator[item]; + if (existingValue) { + accumulator[item] = Array.isArray(existingValue) ? [...existingValue, value] : [existingValue, value]; + return accumulator; + } + accumulator[item] = value; + return accumulator; + }, {}); +} + +interface Flags { + field?: string; + format?: string; + force?: boolean; + wrapTTL?: boolean; + [key: string]: string | boolean | undefined; +} +export function extractFlagsFromStrings(flagArray: string[], method: string): Flags { + if (!flagArray) return {}; + return flagArray.reduce((accumulator: Flags, val: string) => { + // val will be "-flag=value" or "--force" + // split on the first = + // default to value or true + const [item, value] = val.split(/=(.+)?/); + if (!item) return accumulator; + + let flagName = item.replace(/^-/, ''); + if (flagName === 'wrap-ttl') { + flagName = 'wrapTTL'; + } else if (method === 'write') { + if (flagName === 'f' || flagName === '-force') { + flagName = 'force'; + } + } + accumulator[flagName] = value || true; + return accumulator; + }, {}); +} + +interface CommandFns { + [key: string]: CallableFunction; +} + +export function executeUICommand( + command: string, + logAndOutput: CallableFunction, + commandFns: CommandFns +): boolean { const cmd = command.startsWith('api') ? 'api' : command; const isUICommand = uiCommands.includes(cmd); if (isUICommand) { logAndOutput(command); } - if (typeof commandFns[cmd] === 'function') { - commandFns[cmd](); + const execCommand = commandFns[cmd]; + if (execCommand && typeof execCommand === 'function') { + execCommand(); } return isUICommand; } -export function parseCommand(command, shouldThrow) { - const args = argTokenizer(parse(command)); +interface ParsedCommand { + method: string; + path: string; + flagArray: string[]; + dataArray: string[]; +} +export function parseCommand(command: string): ParsedCommand { + const args: string[] = argTokenizer(parse(command)); if (args[0] === 'vault') { args.shift(); } - const [method, ...rest] = args; - let path; - const flags = []; - const data = []; + const [method = '', ...rest] = args; + let path = ''; + const flags: string[] = []; + const data: string[] = []; rest.forEach((arg) => { if (arg.startsWith('-')) { @@ -86,24 +127,28 @@ export function parseCommand(command, shouldThrow) { }); if (!supportedCommands.includes(method)) { - if (shouldThrow) { - throw new Error('invalid command'); - } - return false; + throw new Error('invalid command'); } - return [method, flags, path, data]; + return { method, flagArray: flags, path, dataArray: data }; } -export function logFromResponse(response, path, method, flags) { +interface LogResponse { + auth?: StringMap; + data?: StringMap; + wrap_info?: StringMap; + [key: string]: unknown; +} + +export function logFromResponse(response: LogResponse, path: string, method: string, flags: Flags) { const { format, field } = flags; - let secret = response && (response.auth || response.data || response.wrap_info); - if (!secret) { + const respData: StringMap | undefined = response && (response.auth || response.data || response.wrap_info); + const secret: StringMap | LogResponse = respData || response; + + if (!respData) { if (method === 'write') { return { type: 'success', content: `Success! Data written to: ${path}` }; } else if (method === 'delete') { return { type: 'success', content: `Success! Data deleted (if it existed) at: ${path}` }; - } else { - secret = response; } } @@ -143,11 +188,17 @@ export function logFromResponse(response, path, method, flags) { return { type: 'object', content: secret }; } -export function logFromError(error, vaultPath, method) { +interface CustomError extends AdapterError { + httpStatus: number; + path: string; + errors: string[]; +} +export function logFromError(error: CustomError, vaultPath: string, method: string) { let content; const { httpStatus, path } = error; const verbClause = { read: 'reading from', + 'kv-get': 'reading secret', write: 'writing to', list: 'listing', delete: 'deleting at', @@ -162,7 +213,11 @@ export function logFromError(error, vaultPath, method) { return { type: 'error', content }; } -export function shiftCommandIndex(keyCode, history, index) { +interface CommandLog { + type: string; + content?: string; +} +export function shiftCommandIndex(keyCode: number, history: CommandLog[], index: number) { let newInputValue; const commandHistoryLength = history.length; @@ -186,17 +241,18 @@ export function shiftCommandIndex(keyCode, history, index) { } if (newInputValue !== '') { - newInputValue = history.objectAt(index).content; + newInputValue = history.objectAt(index)?.content; } return [index, newInputValue]; } -export function logErrorFromInput(path, method, flags, dataArray) { +export function formattedErrorFromInput(path: string, method: string, flags: Flags, dataArray: string[]) { if (path === undefined) { return { type: 'error', content: 'A path is required to make a request.' }; } if (method === 'write' && !flags.force && dataArray.length === 0) { return { type: 'error', content: 'Must supply data or use -force' }; } + return; } diff --git a/ui/app/services/console.js b/ui/app/services/console.js index 0d02fa3b4..9b4d6bd58 100644 --- a/ui/app/services/console.js +++ b/ui/app/services/console.js @@ -84,11 +84,22 @@ export default Service.extend({ }); }, - read(path, data, wrapTTL) { + kvGet(path, data, flags = {}) { + const { wrapTTL, metadata } = flags; + // Split on first / to find backend and secret path + const pathSegment = metadata ? 'metadata' : 'data'; + const [backend, secretPath] = path.split(/\/(.+)?/); + const kvPath = `${backend}/${pathSegment}/${secretPath}`; + return this.ajax('read', sanitizePath(kvPath), { wrapTTL }); + }, + + read(path, data, flags) { + const wrapTTL = flags?.wrapTTL; return this.ajax('read', sanitizePath(path), { wrapTTL }); }, - write(path, data, wrapTTL) { + write(path, data, flags) { + const wrapTTL = flags?.wrapTTL; return this.ajax('write', sanitizePath(path), { data, wrapTTL }); }, @@ -96,7 +107,8 @@ export default Service.extend({ return this.ajax('delete', sanitizePath(path)); }, - list(path, data, wrapTTL) { + list(path, data, flags) { + const wrapTTL = flags?.wrapTTL; const listPath = ensureTrailingSlash(sanitizePath(path)); return this.ajax('list', listPath, { data: { diff --git a/ui/app/styles/components/console-ui-panel.scss b/ui/app/styles/components/console-ui-panel.scss index d8240eb1a..3338726ee 100644 --- a/ui/app/styles/components/console-ui-panel.scss +++ b/ui/app/styles/components/console-ui-panel.scss @@ -125,6 +125,10 @@ $console-close-height: 35px; min-height: 400px; } +.main--console-open { + padding-bottom: 400px; +} + .panel-open .console-ui-panel.fullscreen { bottom: 0; right: 0; diff --git a/ui/app/templates/components/console/log-help.hbs b/ui/app/templates/components/console/log-help.hbs index 9ed233ca8..33b323240 100644 --- a/ui/app/templates/components/console/log-help.hbs +++ b/ui/app/templates/components/console/log-help.hbs @@ -6,6 +6,7 @@ Commands: read Read data and retrieves secrets + kv-get Read data for kv v2 secret engines. Use -metadata flag to read metadata write Write data, configuration, and secrets delete Delete secrets and configuration list List data or secrets diff --git a/ui/app/templates/components/console/ui-panel.hbs b/ui/app/templates/components/console/ui-panel.hbs index a580db267..2982d7dd2 100644 --- a/ui/app/templates/components/console/ui-panel.hbs +++ b/ui/app/templates/components/console/ui-panel.hbs @@ -5,10 +5,18 @@
-

- The Vault Browser CLI provides an easy way to execute the most common CLI commands, such as write, read, delete, and - list. +

+ The Vault Browser CLI provides an easy way to execute common Vault CLI commands, such as write, read, delete, and list. + It does not include kv v2 write or put commands. For guidance, type `help`.

+

Examples:

+

→ Write secrets to kv v1: write <mount>/my-secret foo=bar

+

→ List kv v1 secret keys: list <mount>/

+

→ Read a kv v1 secret: read <mount>/my-secret

+

→ Mount a kv v2 secret engine: write sys/mounts/<mount> type=kv + options=version=2

+

→ Read a kv v2 secret: kv-get <mount>/secret-path

+

→ Read a kv v2 secret's metadata: kv-get <mount>/secret-path -metadata

+ { - parseCommand(command, true); + parseCommand(command); }, /invalid command/, - 'throws on invalid command when `shouldThrow` is true' + 'throws on invalid command' ); }); @@ -132,14 +136,12 @@ module('Unit | Lib | console helpers', function () { { method: 'read', name: 'data fields', - input: [ - [ - 'access_key=AKIAJWVN5Z4FOFT7NLNA', - 'secret_key=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', - 'region=us-east-1', - ], - [], + dataInput: [ + 'access_key=AKIAJWVN5Z4FOFT7NLNA', + 'secret_key=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', + 'region=us-east-1', ], + flagInput: [], expected: { data: { access_key: 'AKIAJWVN5Z4FOFT7NLNA', @@ -152,7 +154,8 @@ module('Unit | Lib | console helpers', function () { { method: 'read', name: 'repeated data and a flag', - input: [['allowed_domains=example.com', 'allowed_domains=foo.example.com'], ['-wrap-ttl=2h']], + dataInput: ['allowed_domains=example.com', 'allowed_domains=foo.example.com'], + flagInput: ['-wrap-ttl=2h'], expected: { data: { allowed_domains: ['example.com', 'foo.example.com'], @@ -162,10 +165,27 @@ module('Unit | Lib | console helpers', function () { }, }, }, + { + method: 'read', + name: 'triple data', + dataInput: [ + 'allowed_domains=example.com', + 'allowed_domains=foo.example.com', + 'allowed_domains=dev.example.com', + ], + flagInput: [], + expected: { + data: { + allowed_domains: ['example.com', 'foo.example.com', 'dev.example.com'], + }, + flags: {}, + }, + }, { method: 'read', name: 'data with more than one equals sign', - input: [['foo=bar=baz', 'foo=baz=bop', 'some=value=val'], []], + dataInput: ['foo=bar=baz', 'foo=baz=bop', 'some=value=val'], + flagInput: [], expected: { data: { foo: ['bar=baz', 'baz=bop'], @@ -177,7 +197,8 @@ module('Unit | Lib | console helpers', function () { { method: 'read', name: 'data with empty values', - input: [[`foo=`, 'some=thing'], []], + dataInput: [`foo=`, 'some=thing'], + flagInput: [], expected: { data: { foo: '', @@ -189,7 +210,8 @@ module('Unit | Lib | console helpers', function () { { method: 'write', name: 'write with force flag', - input: [[], ['-force']], + dataInput: [], + flagInput: ['-force'], expected: { data: {}, flags: { @@ -200,7 +222,8 @@ module('Unit | Lib | console helpers', function () { { method: 'write', name: 'write with force short flag', - input: [[], ['-f']], + dataInput: [], + flagInput: ['-f'], expected: { data: {}, flags: { @@ -211,7 +234,8 @@ module('Unit | Lib | console helpers', function () { { method: 'write', name: 'write with GNU style force flag', - input: [[], ['--force']], + dataInput: [], + flagInput: ['--force'], expected: { data: {}, flags: { @@ -222,9 +246,12 @@ module('Unit | Lib | console helpers', function () { ]; testExtractCases.forEach(function (testCase) { - test(`#extractDataAndFlags: ${testCase.name}`, function (assert) { - const { data, flags } = extractDataAndFlags(testCase.method, ...testCase.input); + test(`#extractDataFromStrings: ${testCase.name}`, function (assert) { + const data = extractDataFromStrings(testCase.dataInput); assert.deepEqual(data, testCase.expected.data, 'has expected data'); + }); + test(`#extractFlagsFromStrings: ${testCase.name}`, function (assert) { + const flags = extractFlagsFromStrings(testCase.flagInput, testCase.method); assert.deepEqual(flags, testCase.expected.flags, 'has expected flags'); }); }); @@ -469,8 +496,8 @@ module('Unit | Lib | console helpers', function () { ]; testCommandCases.forEach(function (testCase) { - test(`#logErrorFromInput: ${testCase.name}`, function (assert) { - const data = logErrorFromInput(...testCase.args); + test(`#formattedErrorFromInput: ${testCase.name}`, function (assert) { + const data = formattedErrorFromInput(...testCase.args); assert.deepEqual( data, diff --git a/ui/tests/unit/services/console-test.js b/ui/tests/unit/services/console-test.js index 2594a7996..950bd0a1b 100644 --- a/ui/tests/unit/services/console-test.js +++ b/ui/tests/unit/services/console-test.js @@ -38,7 +38,7 @@ module('Unit | Service | console', function (hooks) { { method: 'read', - args: ['/secrets/foo/bar', {}, '30m'], + args: ['/secrets/foo/bar', {}, { wrapTTL: '30m' }], expectedURL: 'secrets/foo/bar', expectedVerb: 'GET', expectedOptions: { data: undefined, wrapTTL: '30m' }, @@ -65,7 +65,7 @@ module('Unit | Service | console', function (hooks) { { method: 'list', - args: ['secret/mounts', {}, '1h'], + args: ['secret/mounts', {}, { wrapTTL: '1h' }], expectedURL: 'secret/mounts/', expectedVerb: 'GET', expectedOptions: { data: { list: true }, wrapTTL: '1h' }, @@ -102,4 +102,58 @@ module('Unit | Service | console', function (hooks) { assert.deepEqual(options, testCase.expectedOptions, `${testCase.method}: uses the correct options`); }); }); + + const kvTestCases = [ + { + method: 'kvGet', + args: ['kv/foo'], + expectedURL: 'kv/data/foo', + expectedVerb: 'GET', + expectedOptions: { data: undefined, wrapTTL: undefined }, + }, + { + method: 'kvGet', + args: ['kv/foo', {}, { metadata: true }], + expectedURL: 'kv/metadata/foo', + expectedVerb: 'GET', + expectedOptions: { data: undefined, wrapTTL: undefined }, + }, + { + method: 'kvGet', + args: ['kv/foo', {}, { wrapTTL: '10m' }], + expectedURL: 'kv/data/foo', + expectedVerb: 'GET', + expectedOptions: { data: undefined, wrapTTL: '10m' }, + }, + { + method: 'kvGet', + args: ['kv/foo', {}, { metadata: true, wrapTTL: '10m' }], + expectedURL: 'kv/metadata/foo', + expectedVerb: 'GET', + expectedOptions: { data: undefined, wrapTTL: '10m' }, + }, + ]; + + test('it reads kv secret and metadata', function (assert) { + assert.expect(12); + const ajax = sinon.stub(); + const uiConsole = this.owner.factoryFor('service:console').create({ + adapter() { + return { + buildURL(url) { + return url; + }, + ajax, + }; + }, + }); + + kvTestCases.forEach((testCase) => { + uiConsole[testCase.method](...testCase.args); + const [url, verb, options] = ajax.lastCall.args; + assert.strictEqual(url, testCase.expectedURL, `${testCase.method}: uses correct url`); + assert.strictEqual(verb, testCase.expectedVerb, `${testCase.method}: uses the correct verb`); + assert.deepEqual(options, testCase.expectedOptions, `${testCase.method}: uses the correct options`); + }); + }); }); diff --git a/ui/yarn.lock b/ui/yarn.lock index f05e97f4b..b5e3a4d60 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -5648,6 +5648,13 @@ __metadata: languageName: node linkType: hard +"@types/shell-quote@npm:^1.7.1": + version: 1.7.1 + resolution: "@types/shell-quote@npm:1.7.1" + checksum: 51e58326b8c6dcb72846b94cebe3dc4c84f3514469a8e52bd29c52c601e784b427b851d7477acbeef47bfcccf25d2a5768684d27e7fc95fdd003393c1bbb7bc3 + languageName: node + linkType: hard + "@types/symlink-or-copy@npm:^1.2.0": version: 1.2.0 resolution: "@types/symlink-or-copy@npm:1.2.0" @@ -24224,6 +24231,7 @@ __metadata: "@types/ember__utils": ^4.0.2 "@types/qunit": ^2.19.3 "@types/rsvp": ^4.0.4 + "@types/shell-quote": ^1.7.1 "@typescript-eslint/eslint-plugin": ^5.19.0 "@typescript-eslint/parser": ^5.19.0 asn1js: ^2.2.0