Skip to content

feat: "cdk flags" command reports active and missing feature flags (unstable) #699

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jul 15, 2025
Merged
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
5 changes: 4 additions & 1 deletion .projenrc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ import { TypecheckTests } from './projenrc/TypecheckTests';

const TYPESCRIPT_VERSION = '5.8';

// This is a temporary aws-cdk-lib version until this PR is released: https://github.com/aws/aws-cdk/pull/34919
const AWS_CDK_LIB_VERSION = '2.203.0';

/**
* When adding an SDK dependency for a library, use this function
*
Expand Down Expand Up @@ -851,7 +854,7 @@ const toolkitLib = configureProject(
'@smithy/util-stream',
'@types/fs-extra',
'@types/split2',
'aws-cdk-lib',
`aws-cdk-lib@${AWS_CDK_LIB_VERSION}`,
'aws-sdk-client-mock',
'aws-sdk-client-mock-jest',
'fast-check',
Expand Down
104 changes: 52 additions & 52 deletions packages/@aws-cdk/cli-lib-alpha/THIRD_PARTY_LICENSES

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/@aws-cdk/cli-lib-alpha/package.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

102 changes: 51 additions & 51 deletions packages/@aws-cdk/integ-runner/THIRD_PARTY_LICENSES

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/@aws-cdk/integ-runner/package.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/@aws-cdk/toolkit-lib/.projen/deps.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/@aws-cdk/toolkit-lib/.projen/tasks.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion packages/@aws-cdk/toolkit-lib/lib/api/io/toolkit-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ export type ToolkitAction =
| 'metadata'
| 'init'
| 'migrate'
| 'refactor';
| 'refactor'
| 'flags';
33 changes: 31 additions & 2 deletions packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import '../private/dispose-polyfill';
import * as path from 'node:path';
import type { FeatureFlagReportProperties } from '@aws-cdk/cloud-assembly-schema';
import { ArtifactType } from '@aws-cdk/cloud-assembly-schema';
import type { TemplateDiff } from '@aws-cdk/cloudformation-diff';
import * as cxapi from '@aws-cdk/cx-api';
import * as chalk from 'chalk';
Expand All @@ -9,7 +11,7 @@ import { NonInteractiveIoHost } from './non-interactive-io-host';
import type { ToolkitServices } from './private';
import { assemblyFromSource } from './private';
import { ToolkitError } from './toolkit-error';
import type { DeployResult, DestroyResult, RollbackResult } from './types';
import type { FeatureFlag, DeployResult, DestroyResult, RollbackResult } from './types';
import type {
BootstrapEnvironments,
BootstrapOptions,
Expand Down Expand Up @@ -142,7 +144,7 @@ export interface ToolkitOptions {
* Names of toolkit features that are still under development, and may change in
* the future.
*/
export type UnstableFeature = 'refactor';
export type UnstableFeature = 'refactor' | 'flags';

/**
* The AWS CDK Programmatic Toolkit
Expand Down Expand Up @@ -1271,6 +1273,33 @@ export class Toolkit extends CloudAssemblySourceBuilder {
}
}

/**
* Retrieve feature flag information from the cloud assembly
*/

public async flags(cx: ICloudAssemblySource): Promise<FeatureFlag[]> {
this.requireUnstableFeature('flags');

const ioHelper = asIoHelper(this.ioHost, 'flags');
await using assembly = await assemblyFromSource(ioHelper, cx);
const artifacts = assembly.cloudAssembly.manifest.artifacts;

return Object.values(artifacts!)
.filter(a => a.type === ArtifactType.FEATURE_FLAG_REPORT)
.flatMap(report => {
const properties = report.properties as FeatureFlagReportProperties;
const moduleName = properties.module;

return Object.entries(properties.flags).map(([flagName, flagInfo]) => ({
module: moduleName,
name: flagName,
recommendedValue: flagInfo.recommendedValue,
userValue: flagInfo.userValue ?? undefined,
explanation: flagInfo.explanation ?? '',
}));
});
}

private requireUnstableFeature(requestedFeature: UnstableFeature) {
if (!this.unstableFeatures.includes(requestedFeature)) {
throw new ToolkitError(`Unstable feature '${requestedFeature}' is not enabled. Please enable it under 'unstableFeatures'`);
Expand Down
8 changes: 8 additions & 0 deletions packages/@aws-cdk/toolkit-lib/lib/toolkit/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,11 @@ export interface RolledBackStack extends PhysicalStack {
}

export type StackRollbackResult = 'rolled-back' | 'already-stable';

export interface FeatureFlag {
readonly module: string;
readonly name: string;
readonly recommendedValue: unknown;
readonly userValue?: unknown;
readonly explanation?: string;
}
2 changes: 1 addition & 1 deletion packages/@aws-cdk/toolkit-lib/package.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

183 changes: 183 additions & 0 deletions packages/@aws-cdk/toolkit-lib/test/toolkit-flags.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { ArtifactType } from '@aws-cdk/cloud-assembly-schema';
import type { CloudAssembly } from '@aws-cdk/cx-api';
import { appFixture, TestIoHost, builderFixture } from './_helpers';
import { Toolkit } from '../lib/toolkit/toolkit';

let ioHost: TestIoHost;
beforeEach(() => {
jest.restoreAllMocks();
ioHost = new TestIoHost();
});

const toolkit = new Toolkit({
ioHost: new TestIoHost(), unstableFeatures: ['flags'],
});

function createMockCloudAssemblySource(artifacts: any) {
return {
async produce() {
const mockCloudAssembly = {
manifest: {
artifacts: artifacts,
},
} as CloudAssembly;

return {
cloudAssembly: mockCloudAssembly,
dispose: jest.fn(),
[Symbol.asyncDispose]: jest.fn(),
_unlock: jest.fn(),
};
},
};
}

describe('Toolkit.flags() method', () => {
test('requires acknowledgment that the feature is unstable', async () => {
const tk = new Toolkit({ ioHost });
const cx = await builderFixture(tk, 'stack-with-bucket');

await expect(
tk.flags(cx),
).rejects.toThrow("Unstable feature 'flags' is not enabled. Please enable it under 'unstableFeatures'");
});

test('should retrieve feature flags in correct structure', async () => {
const tk = new Toolkit({ ioHost, unstableFeatures: ['flags'] });
const cx = await appFixture(toolkit, 'two-empty-stacks');
const flags = await tk.flags(cx);

expect(flags.length).toBeGreaterThan(0);
expect(Array.isArray(flags)).toBe(true);

flags.forEach((flag) => {
expect(flag).toHaveProperty('module');
expect(flag).toHaveProperty('recommendedValue');
expect(flag).toHaveProperty('userValue');
expect(flag).toHaveProperty('explanation');
expect(flag).toHaveProperty('name');
});
});

test('processes feature flag correctly when mocked cloud assembly is used', async () => {
const mockCloudAssemblySource = createMockCloudAssemblySource({
'aws-cdk-lib/feature-flag-report': {
type: 'cdk:feature-flag-report',
properties: {
module: 'aws-cdk-lib',
flags: {
'@aws-cdk/core:enableStackNameDuplicates': {
recommendedValue: true,
explanation: 'Allow multiple stacks with the same name',
},
},
},
},
});

const mockFlags = await toolkit.flags(mockCloudAssemblySource as any);

expect(mockFlags.length).toBe(1);
expect(mockFlags[0].module).toEqual('aws-cdk-lib');
expect(mockFlags[0].name).toEqual('@aws-cdk/core:enableStackNameDuplicates');
expect(mockFlags[0].userValue).toBeUndefined();
expect(mockFlags[0].recommendedValue).toEqual(true);
expect(mockFlags[0].explanation).toEqual('Allow multiple stacks with the same name');
});

test('handles multiple feature flag modules', async () => {
const mockCloudAssemblySource = createMockCloudAssemblySource({
'module1-flags': {
type: ArtifactType.FEATURE_FLAG_REPORT,
properties: {
module: 'module1',
flags: {
flag1: {
userValue: true,
recommendedValue: false,
explanation: 'Module 1 flag',
},
},
},
},
'module2-flags': {
type: ArtifactType.FEATURE_FLAG_REPORT,
properties: {
module: 'module2',
flags: {
flag2: {
userValue: 'value',
recommendedValue: 'recommended',
explanation: 'Module 2 flag',
},
},
},
},
});

const mockFlags = await toolkit.flags(mockCloudAssemblySource as any);

expect(mockFlags.length).toBe(2);
expect(mockFlags[0].module).toBe('module1');
expect(mockFlags[0].explanation).toEqual('Module 1 flag');
expect(mockFlags[0].name).toEqual('flag1');
expect(mockFlags[0].userValue).toEqual(true);
expect(mockFlags[0].recommendedValue).toEqual(false);
expect(mockFlags[1].module).toBe('module2');
expect(mockFlags[1].explanation).toEqual('Module 2 flag');
expect(mockFlags[1].name).toEqual('flag2');
expect(mockFlags[1].userValue).toEqual('value');
expect(mockFlags[1].recommendedValue).toEqual('recommended');
});

test('handles various data types for flag values', async () => {
const mockCloudAssemblySource = createMockCloudAssemblySource({
'feature-flag-report': {
type: ArtifactType.FEATURE_FLAG_REPORT,
properties: {
module: 'testModule',
flags: {
stringFlag: {
userValue: 'string-value',
recommendedValue: 'recommended-string',
explanation: 'String flag',
},
numberFlag: {
userValue: 123,
recommendedValue: 456,
explanation: 'Number flag',
},
booleanFlag: {
userValue: true,
recommendedValue: false,
explanation: 'Boolean flag',
},
arrayFlag: {
userValue: ['a', 'b'],
recommendedValue: ['x', 'y'],
explanation: 'Array flag',
},
objectFlag: {
userValue: { key: 'value' },
recommendedValue: { key: 'recommended' },
explanation: 'Object flag',
},
},
},
},
});

const mockFlags = await toolkit.flags(mockCloudAssemblySource as any);

expect(mockFlags[0].userValue).toBe('string-value');
expect(mockFlags[0].recommendedValue).toBe('recommended-string');
expect(mockFlags[1].userValue).toBe(123);
expect(mockFlags[1].recommendedValue).toBe(456);
expect(mockFlags[2].userValue).toBe(true);
expect(mockFlags[2].recommendedValue).toBe(false);
expect(mockFlags[3].userValue).toEqual(['a', 'b']);
expect(mockFlags[3].recommendedValue).toEqual(['x', 'y']);
expect(mockFlags[4].userValue).toEqual({ key: 'value' });
expect(mockFlags[4].recommendedValue).toEqual({ key: 'recommended' });
});
});
Loading
Loading