Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/@aws-cdk/toolkit-lib/lib/actions/watch/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { DeploymentMethod, BaseDeployOptions } from '../deploy';
import type { SynthOptions } from '../synth';
import type { ValidateOptions } from '../validate';

/**
Expand Down Expand Up @@ -44,6 +45,12 @@ export interface WatchOptions extends WatchFileOptions, BaseDeployOptions {
readonly deploymentMethod?: DeploymentMethod;
}

/**
* Options for watch-synth mode.
*/
export interface WatchSynthOptions extends WatchFileOptions, SynthOptions {
}

/**
* Options for watch-validate mode.
*/
Expand Down
50 changes: 44 additions & 6 deletions packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ import type { RefactorOptions } from '../actions/refactor';
import { type RollbackOptions } from '../actions/rollback';
import { type SynthOptions } from '../actions/synth';
import type { ValidateOptions, ValidateResult } from '../actions/validate';
import type { IWatcher, WatchFileOptions, WatchOptions, WatchValidateOptions } from '../actions/watch';
import type { IWatcher, WatchFileOptions, WatchOptions, WatchSynthOptions, WatchValidateOptions } from '../actions/watch';
import { countAssemblyResults } from './private/count-assembly-results';
import { WATCH_EXCLUDE_DEFAULTS } from '../actions/watch/private';
import { EnvironmentAccess } from '../api';
Expand Down Expand Up @@ -353,9 +353,20 @@ export class Toolkit extends CloudAssemblySourceBuilder {
const ioHelper = asIoHelper(this.ioHost, 'synth');

await using assembly = new AsyncDisposableBox(await synthAndMeasure(ioHelper, cx, stacksOpt(options)));
const stacks = await assembly.value.selectStacksV2(stacksOpt(options));
const autoValidateStacks = options.validateStacks ? [assembly.value.selectStacksForValidation()] : [];
await throwIfValidationFailures(assembly.value, stacks.concat(...autoValidateStacks), this.assemblyFailureAt, ioHelper);
await this._synth(assembly.value, options);
return new CachedCloudAssembly(assembly.take());
}

/**
* Helper to allow synth being called with an already-produced assembly,
* e.g. as part of the watch action which reuses the startup assembly.
*/
private async _synth(assembly: StackAssembly, options: SynthOptions = {}): Promise<void> {
const ioHelper = asIoHelper(this.ioHost, 'synth');

const stacks = await assembly.selectStacksV2(stacksOpt(options));
const autoValidateStacks = options.validateStacks ? [assembly.selectStacksForValidation()] : [];
await throwIfValidationFailures(assembly, stacks.concat(...autoValidateStacks), this.assemblyFailureAt, ioHelper);

// if we have a single stack, print it to STDOUT
const message = `Successfully synthesized to ${chalk.blue(path.resolve(stacks.assembly.directory))}`;
Expand Down Expand Up @@ -384,8 +395,6 @@ export class Toolkit extends CloudAssemblySourceBuilder {
await ioHelper.notify(IO.CDK_TOOLKIT_I1902.msg(chalk.green(message), assemblyData));
await ioHelper.defaults.info(`Supply a stack id (${stacks.stackArtifacts.map((s) => chalk.green(s.hierarchicalId)).join(', ')}) to display its template.`);
}

return new CachedCloudAssembly(assembly.take());
}

/**
Expand Down Expand Up @@ -1191,6 +1200,35 @@ export class Toolkit extends CloudAssemblySourceBuilder {
});
}

/**
* Continuously observe project files and re-synthesize the selected stacks
* automatically when changes are detected.
*
* Never deploys: each iteration re-synthesizes the app to the cloud assembly
* output directory and runs the same checks as the `synth` action.
*
* This function returns immediately, starting a watcher in the background.
*/
public async watchSynth(cx: ICloudAssemblySource, options: WatchSynthOptions = {}): Promise<IWatcher> {
const ioHelper = asIoHelper(this.ioHost, 'synth');

return this._watch(cx, options, {
command: 'cdk synth',
activity: 'synthesis',
// `_watch` runs this inside `invokeSafe`, which reports (and swallows)
// failures so the loop survives synth errors while the user is mid-edit.
invoke: async (initialAssembly) => {
// Reuse the initial assembly, for the same reason as watchDeploy()
if (initialAssembly) {
await this._synth(initialAssembly, options);
return;
}
await using assembly = await synthAndMeasure(ioHelper, cx, stacksOpt(options));
await this._synth(assembly, options);
},
});
}

/**
* Continuously observe project files and validate the selected stacks
* automatically when changes are detected.
Expand Down
223 changes: 223 additions & 0 deletions packages/@aws-cdk/toolkit-lib/test/actions/watch-synth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
// We need to mock the chokidar library, used by 'cdk watch'
// This needs to happen ABOVE the import statements because
// jest.mock commands are hoisted just below the import statements,
// which would otherwise bring in the real chokidar.
const mockChokidarWatcherOn = jest.fn();
const mockChokidarWatcherClose = jest.fn();
const fakeChokidarWatcher = {
on: mockChokidarWatcherOn,
close: mockChokidarWatcherClose,
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
} satisfies Partial<ReturnType<typeof import('chokidar')['watch']>>;
const fakeChokidarWatcherOn = {
get readyCallback(): () => Promise<void> {
expect(mockChokidarWatcherOn.mock.calls.length).toBeGreaterThanOrEqual(1);
const firstCall = mockChokidarWatcherOn.mock.calls[0];
expect(firstCall[0]).toBe('ready');
return firstCall[1];
},

get fileEventCallback(): (
event: 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir',
path: string,
) => Promise<void> {
expect(mockChokidarWatcherOn.mock.calls.length).toBeGreaterThanOrEqual(2);
const secondCall = mockChokidarWatcherOn.mock.calls[1];
expect(secondCall[0]).not.toBe('ready');
return secondCall[1];
},
};

const mockChokidarWatch = jest.fn();
jest.mock('chokidar', () => ({
watch: mockChokidarWatch,
}));

import { StackSelectionStrategy } from '../../lib/api/cloud-assembly';
import { Toolkit } from '../../lib/toolkit';
import { builderFixture, TestIoHost } from '../_helpers';

const ioHost = new TestIoHost();
const toolkit = new Toolkit({ ioHost });
// watchSynth reuses the assembly produced by the watch loop and calls the
// private `_synth(assembly, options)` directly, so we spy on that.
const synthSpy = jest.spyOn(toolkit as any, '_synth').mockResolvedValue(undefined);
const deploySpy = jest.spyOn(toolkit as any, '_deploy').mockResolvedValue({});

beforeEach(() => {
ioHost.notifySpy.mockClear();
ioHost.requestSpy.mockClear();
jest.clearAllMocks();

mockChokidarWatch.mockReturnValue(fakeChokidarWatcher);
// on() in chokidar's Watcher returns 'this'
mockChokidarWatcherOn.mockReturnValue(fakeChokidarWatcher);
});

describe('watchSynth', () => {
test('runs an initial synthesis on the ready event', async () => {
// GIVEN
const cx = await builderFixture(toolkit, 'stack-with-role');
await toolkit.watchSynth(cx, { include: [] });

// WHEN
await fakeChokidarWatcherOn.readyCallback();

// THEN
expect(synthSpy).toHaveBeenCalledTimes(1);
});

test('synthesizes again on a file change', async () => {
// GIVEN
const cx = await builderFixture(toolkit, 'stack-with-role');
await toolkit.watchSynth(cx, { include: [] });
await fakeChokidarWatcherOn.readyCallback();

// WHEN
await fakeChokidarWatcherOn.fileEventCallback('change', 'app.ts');

// THEN
expect(synthSpy).toHaveBeenCalledTimes(
1 // from ready event
+ 1, // from file event
);
});

test('never deploys', async () => {
// GIVEN
const cx = await builderFixture(toolkit, 'stack-with-role');
await toolkit.watchSynth(cx, { include: [] });

// WHEN
await fakeChokidarWatcherOn.readyCallback();
await fakeChokidarWatcherOn.fileEventCallback('change', 'app.ts');

// THEN
expect(deploySpy).not.toHaveBeenCalled();
});

test('passes synth options through to each invocation', async () => {
// GIVEN
const cx = await builderFixture(toolkit, 'stack-with-role');
const options = {
include: [],
validateStacks: true,
stacks: { patterns: ['Stack1'], strategy: StackSelectionStrategy.PATTERN_MATCH },
};
await toolkit.watchSynth(cx, options);

// WHEN
await fakeChokidarWatcherOn.readyCallback();

// THEN
// match anything but null or undefined, since we are only testing for options here
expect(synthSpy).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
validateStacks: true,
stacks: { patterns: ['Stack1'], strategy: StackSelectionStrategy.PATTERN_MATCH },
}));
});

test('keeps watching after a synthesis error', async () => {
// GIVEN
const cx = await builderFixture(toolkit, 'stack-with-role');
synthSpy.mockRejectedValueOnce(new Error('synth failed mid-edit'));
await toolkit.watchSynth(cx, { include: [] });

// WHEN: the initial synthesis fails ...
await fakeChokidarWatcherOn.readyCallback();

// THEN: the error is reported ...
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
level: 'error',
message: expect.stringContaining('synth failed mid-edit'),
}));

// ... and the loop is still alive: the next file change synthesizes again
await fakeChokidarWatcherOn.fileEventCallback('change', 'app.ts');
expect(synthSpy).toHaveBeenCalledTimes(2);
});

test('batches file changes that arrive during a synthesis', async () => {
// GIVEN
const cx = await builderFixture(toolkit, 'stack-with-role');
await toolkit.watchSynth(cx, { include: [] });
await fakeChokidarWatcherOn.readyCallback();

// Simulate a slow synthesis so subsequent changes queue up. `started`
// resolves once the synthesis is actually running (each iteration first
// produces the assembly, so we cannot rely on a fixed delay), and
// `resolveSynth` lets us release it on demand.
let resolveSynth!: () => void;
let signalStarted!: () => void;
const started = new Promise<void>((resolve) => {
signalStarted = resolve;
});
synthSpy.mockImplementationOnce(() => new Promise<void>((resolve) => {
resolveSynth = resolve;
signalStarted();
}));

// WHEN: a file change starts the slow synthesis ...
const firstEvent = fakeChokidarWatcherOn.fileEventCallback('change', 'file1.ts');
await started;

// ... and more changes arrive while it is still running
const queuedEvents = [
fakeChokidarWatcherOn.fileEventCallback('change', 'file2.ts'),
fakeChokidarWatcherOn.fileEventCallback('unlink', 'file3.ts'),
];

resolveSynth();
// eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism
await Promise.all([firstEvent, ...queuedEvents]);

// THEN: the queued changes are batched into a single follow-up synthesis
expect(synthSpy).toHaveBeenCalledTimes(
1 // from ready event
+ 1 // from the first file event
+ 1, // from the batched queued events
);
});

test('returns a watcher that can be disposed', async () => {
// GIVEN
const cx = await builderFixture(toolkit, 'stack-with-role');
const watcher = await toolkit.watchSynth(cx, { include: [] });

expect(mockChokidarWatcherClose).not.toHaveBeenCalled();

// WHEN
// eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism
await Promise.all([
watcher.waitForEnd(),
watcher.dispose(),
]);

// THEN
expect(mockChokidarWatcherClose).toHaveBeenCalled();
});

test('reuses the startup assembly for the initial synthesis, re-produces on file changes', async () => {
// GIVEN
const cx = await builderFixture(toolkit, 'stack-with-role');
const produceSpy = jest.spyOn(cx, 'produce');

await toolkit.watchSynth(cx, { include: [] });

// WHEN: initial synthesis (ready event) ...
await fakeChokidarWatcherOn.readyCallback();
const producesAfterReady = produceSpy.mock.calls.length;

// ... then two file-change iterations
await fakeChokidarWatcherOn.fileEventCallback('change', 'app.ts');
await fakeChokidarWatcherOn.fileEventCallback('change', 'lib.ts');
const producesTotal = produceSpy.mock.calls.length;

// THEN: the initial synthesis reuses the assembly produced at watch startup
// (fresh by definition), so no extra synth happens for it; every file-change
// iteration re-produces so the changes are picked up.
expect(synthSpy).toHaveBeenCalledTimes(3);
expect(producesAfterReady).toBe(1); // startup produce, reused by the initial synthesis
expect(producesTotal).toBe(3); // startup + one per file-change iteration
});
});
42 changes: 41 additions & 1 deletion packages/aws-cdk/lib/cli/cdk-toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { format } from 'node:util';
import type { IManifestEntry } from '@aws-cdk/cdk-assets-lib';
import * as cxapi from '@aws-cdk/cloud-assembly-api';
import { RequireApproval } from '@aws-cdk/cloud-assembly-schema';
import type { ConfirmationRequest, DeploymentMethod, DiagnoseOptions, PublishAssetsOptions, StackSelector, ToolkitAction, ToolkitOptions, UnstableFeature, ValidateOptions } from '@aws-cdk/toolkit-lib';
import type { ConfirmationRequest, DeploymentMethod, DiagnoseOptions, PublishAssetsOptions, StackSelector, SynthOptions as ToolkitSynthOptions, ToolkitAction, ToolkitOptions, UnstableFeature, ValidateOptions } from '@aws-cdk/toolkit-lib';
import { PermissionChangeType, Toolkit, ToolkitError, AbortError } from '@aws-cdk/toolkit-lib';
import chalk from 'chalk';
import * as chokidar from 'chokidar';
Expand Down Expand Up @@ -1130,6 +1130,46 @@ export class CdkToolkit {
return undefined;
}

/**
* Continuously synthesize the project, re-synthesizing on every file change.
* Never deploys, and never prints templates to stdout.
*
* The files to observe are configured with the "watch" key of `cdk.json`,
* exactly like `cdk deploy --watch`.
*/
public async synthWatch(options: ToolkitSynthOptions): Promise<void> {
const rootDir = path.dirname(path.resolve(PROJECT_CONFIG));

const watchSettings: { include?: string | string[]; exclude?: string | string[] } | undefined =
this.props.configuration.settings.get(['watch']);
if (!watchSettings) {
throw new ToolkitError(
'WatchConfigMissing',
"Cannot use '--watch' without specifying at least one directory to monitor. " +
'Make sure to add a "watch" key to your cdk.json',
);
}

const include = this.patternsArrayForWatch(watchSettings.include, {
defaultPattern: '**',
returnDefaultIfEmpty: true,
});
// Pass the user's excludes explicitly; toolkit-lib always appends the
// outdir, dot-file, and node_modules excludes on top of these.
const exclude = this.patternsArrayForWatch(watchSettings.exclude, {
defaultPattern: '',
returnDefaultIfEmpty: false,
});

const watcher = await this.toolkit.watchSynth(this.props.cloudExecutable.uncachedSource(), {
...options,
watchDir: rootDir,
include,
exclude,
});
return watcher.waitForEnd();
}

/**
* Bootstrap the CDK Toolkit stack in the accounts used by the specified stack(s).
*
Expand Down
6 changes: 6 additions & 0 deletions packages/aws-cdk/lib/cli/cli-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ export async function makeConfig(): Promise<CliConfig> {
exclusively: { type: 'boolean', alias: 'e', desc: 'Only synthesize requested stacks, don\'t include dependencies' },
validation: { type: 'boolean', desc: 'After synthesis, validate stacks with the "validateOnSynth" attribute set (can also be controlled with CDK_VALIDATION)', default: true },
quiet: { type: 'boolean', alias: 'q', desc: 'Do not output CloudFormation Template to stdout', default: false },
watch: {
type: 'boolean',
desc: 'Continuously observe the project files, ' +
'and synthesize the given stack(s) automatically when changes are detected. ' +
'Never deploys, and never prints templates to stdout (--quiet and --json have no effect)',
},
},
},
'bootstrap': {
Expand Down
Loading
Loading