Skip to content

Commit

Permalink
feat(cli): glob-style key matching to context --reset (aws#19840)
Browse files Browse the repository at this point in the history
Implementation of one of the solutions I proposed in aws#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*
  • Loading branch information
gillisandrew authored and Stephen Potter committed Apr 27, 2022
1 parent fd173d4 commit 79915f4
Show file tree
Hide file tree
Showing 2 changed files with 273 additions and 56 deletions.
80 changes: 70 additions & 10 deletions packages/aws-cdk/lib/commands/context.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
const { configuration, args } = options;

if (args.clear) {
configuration.context.clear();
await configuration.saveContext();
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down
249 changes: 203 additions & 46 deletions packages/aws-cdk/test/commands/context-command.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});

});

0 comments on commit 79915f4

Please sign in to comment.