From 39a7c1f0bafb1cf3f51fbe09053e443c0d87487e Mon Sep 17 00:00:00 2001 From: Andrew Gillis Date: Thu, 14 Jul 2022 01:39:02 +0100 Subject: [PATCH] feat(cli): --force flag and glob-style key matches for context --reset (#19890) New pull request for glob style key matches in context --reset (#19840) Adds the `--force` flag to ignore missing key errors. This is in response to #19888 ---- ### All Submissions: * [x] Have you followed the guidelines in our [Contributing guide?](https://github.com/aws/aws-cdk/blob/master/CONTRIBUTING.md) ### Adding new Unconventional Dependencies: * [ ] This PR adds new unconventional dependencies following the process described [here](https://github.com/aws/aws-cdk/blob/master/CONTRIBUTING.md/#adding-new-unconventional-dependencies) ### New Features * [ ] Have you added the new feature to an [integration test](https://github.com/aws/aws-cdk/blob/master/INTEGRATION_TESTS.md)? * [ ] Did you use `yarn integ` to deploy the infrastructure and generate the snapshot (i.e. `yarn integ` without `--dry-run`)? *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/aws-cdk/lib/cli.ts | 1 + packages/aws-cdk/lib/commands/context.ts | 87 +++++- .../test/commands/context-command.test.ts | 265 +++++++++++++++--- 3 files changed, 295 insertions(+), 58 deletions(-) diff --git a/packages/aws-cdk/lib/cli.ts b/packages/aws-cdk/lib/cli.ts index c037a24ba9d33..7c2f5227ff640 100644 --- a/packages/aws-cdk/lib/cli.ts +++ b/packages/aws-cdk/lib/cli.ts @@ -238,6 +238,7 @@ async function parseCommandLineArguments() { ) .command('context', 'Manage cached context values', (yargs: Argv) => yargs .option('reset', { alias: 'e', desc: 'The context key (or its index) to reset', type: 'string', requiresArg: true }) + .option('force', { alias: 'f', desc: 'Ignore missing key error', type: 'boolean', default: false }) .option('clear', { desc: 'Clear all context', type: 'boolean' })) .command(['docs', 'doc'], 'Opens the reference documentation in a browser', (yargs: Argv) => yargs .option('browser', { diff --git a/packages/aws-cdk/lib/commands/context.ts b/packages/aws-cdk/lib/commands/context.ts index 21ec2f923e37d..52600e9a4f5db 100644 --- a/packages/aws-cdk/lib/commands/context.ts +++ b/packages/aws-cdk/lib/commands/context.ts @@ -1,19 +1,19 @@ import * as chalk from 'chalk'; +import * as minimatch from 'minimatch'; import * as version from '../../lib/version'; import { CommandOptions } from '../command-api'; -import { print } from '../logging'; -import { Context, PROJECT_CONFIG } from '../settings'; +import { print, error, warning } from '../logging'; +import { Context, PROJECT_CONFIG, PROJECT_CONTEXT, USER_DEFAULTS } from '../settings'; import { renderTable } from '../util'; export async function realHandler(options: CommandOptions): Promise { const { configuration, args } = options; - if (args.clear) { configuration.context.clear(); await configuration.saveContext(); print('All context values cleared.'); } else if (args.reset) { - invalidateContext(configuration.context, args.reset as string); + invalidateContext(configuration.context, args.reset as string, args.force as boolean); await configuration.saveContext(); } else { // List -- support '--json' flag @@ -48,30 +48,93 @@ function listContext(context: Context) { const jsonWithoutNewlines = JSON.stringify(context.all[key], undefined, 2).replace(/\s+/g, ' '); data.push([i, key, jsonWithoutNewlines]); } - - print(`Context found in ${chalk.blue(PROJECT_CONFIG)}:\n`); - + print('Context found in %s:', chalk.blue(PROJECT_CONFIG)); + print(''); print(renderTable(data, process.stdout.columns)); // eslint-disable-next-line max-len print(`Run ${chalk.blue('cdk context --reset KEY_OR_NUMBER')} to remove a context key. It will be refreshed on the next CDK synthesis run.`); } -function invalidateContext(context: Context, key: string) { +function invalidateContext(context: Context, key: string, force: boolean) { const i = parseInt(key, 10); if (`${i}` === key) { // was a number and we fully parsed it. key = keyByNumber(context, i); } - // Unset! if (context.has(key)) { context.unset(key); - print(`Context value ${chalk.blue(key)} reset. It will be refreshed on next synthesis`); - } else { - print(`No context value with key ${chalk.blue(key)}`); + // check if the value was actually unset. + if (!context.has(key)) { + print('Context value %s reset. It will be refreshed on next synthesis', chalk.blue(key)); + return; + } + + // Value must be in readonly bag + error('Only context values specified in %s can be reset through the CLI', chalk.blue(PROJECT_CONTEXT)); + if (!force) { + throw new Error(`Cannot reset readonly context value with key: ${key}`); + } + } + + // check if value is expression matching keys + const matches = keysByExpression(context, key); + + if (matches.length > 0) { + + matches.forEach((match) => { + context.unset(match); + }); + + const { unset, readonly } = getUnsetAndReadonly(context, matches); + + // output the reset values + printUnset(unset); + + // warn about values not reset + printReadonly(readonly); + + // throw when none of the matches were reset + if (!force && unset.length === 0) { + throw new Error('None of the matched context values could be reset'); + } + return; + } + if (!force) { + throw new Error(`No context value matching key: ${key}`); } } +function printUnset(unset: string[]) { + if (unset.length === 0) return; + print('The following matched context values reset. They will be refreshed on next synthesis'); + unset.forEach((match) => { + print(' %s', match); + }); +} +function printReadonly(readonly: string[]) { + if (readonly.length === 0) return; + warning('The following matched context values could not be reset through the CLI'); + readonly.forEach((match) => { + print(' %s', match); + }); + print(''); + print('This usually means they are configured in %s or %s', chalk.blue(PROJECT_CONFIG), chalk.blue(USER_DEFAULTS)); +} +function keysByExpression(context: Context, expression: string) { + return context.keys.filter(minimatch.filter(expression)); +} + +function getUnsetAndReadonly(context: Context, matches: string[]) { + return matches.reduce<{ unset: string[], readonly: string[] }>((acc, match) => { + if (context.has(match)) { + acc.readonly.push(match); + } else { + acc.unset.push(match); + } + return acc; + }, { unset: [], readonly: [] }); +} function keyByNumber(context: Context, n: number) { for (const [i, key] of contextKeys(context)) { diff --git a/packages/aws-cdk/test/commands/context-command.test.ts b/packages/aws-cdk/test/commands/context-command.test.ts index 6a73c8008b52e..3da5b2b9b7065 100644 --- a/packages/aws-cdk/test/commands/context-command.test.ts +++ b/packages/aws-cdk/test/commands/context-command.test.ts @@ -1,64 +1,237 @@ import { realHandler } from '../../lib/commands/context'; -import { Configuration } from '../../lib/settings'; +import { Configuration, Settings, Context } from '../../lib/settings'; -test('context list', async() => { - // GIVEN - const configuration = new Configuration(); - configuration.context.set('foo', 'bar'); +describe('context --list', () => { + test('runs', async() => { + // GIVEN + const configuration = new Configuration(); + configuration.context.set('foo', 'bar'); - expect(configuration.context.all).toEqual({ - foo: 'bar', - }); + expect(configuration.context.all).toEqual({ + foo: 'bar', + }); - // WHEN - await realHandler({ - configuration, - args: {}, - } as any); + // WHEN + await realHandler({ + configuration, + args: {}, + } as any); + }); }); -test('context reset can remove a context key', async () => { - // GIVEN - const configuration = new Configuration(); - configuration.context.set('foo', 'bar'); - configuration.context.set('baz', 'quux'); +describe('context --reset', () => { + test('can remove a context key', async () => { + // GIVEN + const configuration = new Configuration(); + configuration.context.set('foo', 'bar'); + configuration.context.set('baz', 'quux'); + + expect(configuration.context.all).toEqual({ + foo: 'bar', + baz: 'quux', + }); - expect(configuration.context.all).toEqual({ - foo: 'bar', - baz: 'quux', + // WHEN + await realHandler({ + configuration, + args: { reset: 'foo' }, + } as any); + + // THEN + expect(configuration.context.all).toEqual({ + baz: 'quux', + }); }); - // WHEN - await realHandler({ - configuration, - args: { reset: 'foo' }, - } as any); + test('can remove a context key using number', async () => { + // GIVEN + const configuration = new Configuration(); + configuration.context.set('foo', 'bar'); + configuration.context.set('baz', 'quux'); + + expect(configuration.context.all).toEqual({ + foo: 'bar', + baz: 'quux', + }); + + // WHEN + await realHandler({ + configuration, + args: { reset: '1' }, + } as any); - // THEN - expect(configuration.context.all).toEqual({ - baz: 'quux', + // THEN + expect(configuration.context.all).toEqual({ + foo: 'bar', + }); }); -}); -test('context reset can remove a context key using number', async () => { - // GIVEN - const configuration = new Configuration(); - configuration.context.set('foo', 'bar'); - configuration.context.set('baz', 'quux'); - expect(configuration.context.all).toEqual({ - foo: 'bar', - baz: 'quux', + test('can reset matched pattern', async () => { + // GIVEN + const configuration = new Configuration(); + configuration.context.set('foo', 'bar'); + configuration.context.set('match-a', 'baz'); + configuration.context.set('match-b', 'qux'); + + expect(configuration.context.all).toEqual({ + 'foo': 'bar', + 'match-a': 'baz', + 'match-b': 'qux', + }); + + // WHEN + await realHandler({ + configuration, + args: { reset: 'match-*' }, + } as any); + + // THEN + expect(configuration.context.all).toEqual({ + foo: 'bar', + }); + }); + + + test('prefers an exact match', async () => { + // GIVEN + const configuration = new Configuration(); + configuration.context.set('foo', 'bar'); + configuration.context.set('fo*', 'baz'); + + expect(configuration.context.all).toEqual({ + 'foo': 'bar', + 'fo*': 'baz', + }); + + // WHEN + await realHandler({ + configuration, + args: { reset: 'fo*' }, + } as any); + + // THEN + expect(configuration.context.all).toEqual({ + foo: 'bar', + }); }); - // WHEN - await realHandler({ - configuration, - args: { reset: '1' }, - } as any); - // THEN - expect(configuration.context.all).toEqual({ - foo: 'bar', + test('doesn\'t throw when at least one match is reset', async () => { + // GIVEN + const configuration = new Configuration(); + const readOnlySettings = new Settings({ + 'foo': 'bar', + 'match-a': 'baz', + }, true); + configuration.context = new Context(readOnlySettings, new Settings()); + configuration.context.set('match-b', 'quux'); + + // When + await expect(realHandler({ + configuration, + args: { reset: 'match-*' }, + } as any)); + + // Then + expect(configuration.context.all).toEqual({ + 'foo': 'bar', + 'match-a': 'baz', + }); + }); + + test('throws when key not found', async () => { + // GIVEN + const configuration = new Configuration(); + configuration.context.set('foo', 'bar'); + + expect(configuration.context.all).toEqual({ + foo: 'bar', + }); + + // THEN + await expect(realHandler({ + configuration, + args: { reset: 'baz' }, + } as any)).rejects.toThrow(/No context value matching key/); }); + + test('Doesn\'t throw when key not found and --force is set', async () => { + // GIVEN + const configuration = new Configuration(); + configuration.context.set('foo', 'bar'); + + expect(configuration.context.all).toEqual({ + foo: 'bar', + }); + + // THEN + await expect(realHandler({ + configuration, + args: { reset: 'baz', force: true }, + } as any)); + }); + + + test('throws when no key of index found', async () => { + // GIVEN + const configuration = new Configuration(); + configuration.context.set('foo', 'bar'); + + expect(configuration.context.all).toEqual({ + foo: 'bar', + }); + + // THEN + await expect(realHandler({ + configuration, + args: { reset: '2' }, + } as any)).rejects.toThrow(/No context key with number/); + }); + + + test('throws when resetting read-only values', async () => { + // GIVEN + const configuration = new Configuration(); + const readOnlySettings = new Settings({ + foo: 'bar', + }, true); + configuration.context = new Context(readOnlySettings); + + expect(configuration.context.all).toEqual({ + foo: 'bar', + }); + + // THEN + await expect(realHandler({ + configuration, + args: { reset: 'foo' }, + } as any)).rejects.toThrow(/Cannot reset readonly context value with key/); + }); + + + test('throws when no matches could be reset', async () => { + // GIVEN + const configuration = new Configuration(); + const readOnlySettings = new Settings({ + 'foo': 'bar', + 'match-a': 'baz', + 'match-b': 'quux', + }, true); + configuration.context = new Context(readOnlySettings); + + expect(configuration.context.all).toEqual({ + 'foo': 'bar', + 'match-a': 'baz', + 'match-b': 'quux', + }); + + // THEN + await expect(realHandler({ + configuration, + args: { reset: 'match-*' }, + } as any)).rejects.toThrow(/None of the matched context values could be reset/); + }); + }); +