From edb411925cf84ebe38e5a45acdec20f339087ea6 Mon Sep 17 00:00:00 2001 From: Andrew Gillis Date: Mon, 11 Apr 2022 09:02:00 -0400 Subject: [PATCH] feat(cli): glob-style key matching to context --reset (#19840) Implementation of one of the solutions I proposed in #19797 using glob-style expressions to match keys. Uses minimatch which already existed as a dependency to match stack names in the synth and deploy commands. Makes the --reset command throw on no-ops i.e when trying to reset context defined in cdk.json or ~/.cdk.json Adds tests and prints messages to clarify previously undocumented behavior. ---- ### 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/commands/context.ts | 80 +++++- .../test/commands/context-command.test.ts | 249 ++++++++++++++---- 2 files changed, 273 insertions(+), 56 deletions(-) diff --git a/packages/aws-cdk/lib/commands/context.ts b/packages/aws-cdk/lib/commands/context.ts index 21ec2f923e37d..d51b730febe22 100644 --- a/packages/aws-cdk/lib/commands/context.ts +++ b/packages/aws-cdk/lib/commands/context.ts @@ -1,13 +1,13 @@ 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(); @@ -48,9 +48,8 @@ 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 @@ -63,14 +62,75 @@ function invalidateContext(context: Context, key: string) { // 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)); + 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 (unset.length === 0) { + throw new Error('None of the matched context values could be reset'); + } + return; + } + + 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) { diff --git a/packages/aws-cdk/test/commands/context-command.test.ts b/packages/aws-cdk/test/commands/context-command.test.ts index 6a73c8008b52e..88023c49aa730 100644 --- a/packages/aws-cdk/test/commands/context-command.test.ts +++ b/packages/aws-cdk/test/commands/context-command.test.ts @@ -1,64 +1,221 @@ 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', + }); - // THEN - expect(configuration.context.all).toEqual({ - baz: 'quux', + // WHEN + await realHandler({ + configuration, + args: { reset: '1' }, + } as any); + + // 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', + }); }); - // WHEN - await realHandler({ - configuration, - args: { reset: '1' }, - } 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', + }); + }); + + + 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('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/); + }); + }); +