Skip to content

Commit f59d1f1

Browse files
satyakighZee2413
authored andcommitted
Make feature flags and regions backwards compatible
1 parent 2b5e5fa commit f59d1f1

File tree

10 files changed

+124
-59
lines changed

10 files changed

+124
-59
lines changed

assets/featureFlag/alpha.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@
4141
"me-south-1",
4242
"me-central-1",
4343
"af-south-1",
44-
"il-central-1"
44+
"il-central-1",
45+
"test-region"
4546
]
4647
}
4748
}

src/featureFlag/FeatureFlag.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { AwsRegion, getRegion } from '../utils/Region';
21
import { FeatureFlag, TargetedFeatureFlag } from './FeatureFlagI';
32
import { PartialFleetSelector } from './PartialFleetSelector';
43

@@ -37,18 +36,18 @@ export class FleetTargetedFeatureFlag implements TargetedFeatureFlag<string> {
3736
}
3837

3938
export class RegionAllowlistFeatureFlag implements TargetedFeatureFlag<string> {
40-
private readonly allowlist: Set<AwsRegion>;
39+
private readonly allowlist: Set<string>;
4140

4241
constructor(
4342
private readonly featureName: string,
44-
allowedRegions: AwsRegion[],
43+
allowedRegions: string[],
4544
) {
4645
this.allowlist = new Set(allowedRegions);
4746
}
4847

4948
isEnabled(region: string): boolean {
5049
try {
51-
return this.allowlist.has(getRegion(region));
50+
return this.allowlist.has(region);
5251
} catch {
5352
return false;
5453
}

src/featureFlag/FeatureFlagBuilder.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import { z } from 'zod';
2-
import { AwsRegion } from '../utils/Region';
32
import { AndFeatureFlag, CompoundFeatureFlag, LocalHostTargetedFeatureFlag } from './CombinedFeatureFlags';
43
import { FleetTargetedFeatureFlag, RegionAllowlistFeatureFlag, StaticFeatureFlag } from './FeatureFlag';
54
import { FeatureFlag, TargetedFeatureFlag } from './FeatureFlagI';
65

76
const FeatureFlagSchema = z.object({
87
enabled: z.boolean(),
98
fleetPercentage: z.number().optional(),
10-
allowlistedRegions: z.array(z.enum(Object.values(AwsRegion))).optional(),
9+
allowlistedRegions: z.array(z.string()).optional(),
1110
});
1211

1312
export const FeatureFlagConfigSchema = z.object({
@@ -47,7 +46,7 @@ export function buildLocalHost(name: string, config?: FeatureFlagConfigType) {
4746
}
4847

4948
export function buildRegional(name: string, config?: FeatureFlagConfigType) {
50-
let allowlist: AwsRegion[] = [];
49+
let allowlist: string[] = [];
5150

5251
if (config?.allowlistedRegions !== undefined) {
5352
allowlist = config.allowlistedRegions;

src/featureFlag/FeatureFlagProvider.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,16 @@ export class FeatureFlagProvider implements Closeable {
2121
private readonly getLatestFeatureFlags: (env: string) => Promise<unknown>,
2222
private readonly localFile = join(__dirname, 'assets', 'featureFlag', `${AwsEnv.toLowerCase()}.json`),
2323
) {
24-
this.config = JSON.parse(readFileIfExists(localFile, 'utf8'));
24+
this.config = defaultConfig(localFile);
2525

26-
this.supplier = new FeatureFlagSupplier(() => {
27-
return this.config;
28-
});
26+
this.supplier = new FeatureFlagSupplier(
27+
() => {
28+
return this.config;
29+
},
30+
() => {
31+
return defaultConfig(localFile);
32+
},
33+
);
2934

3035
// https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28#primary-rate-limit-for-unauthenticated-users
3136
// GitHub rate limits unauthenticated users to 60 requests per minute, so our refresh cycle has to be less than that
@@ -87,3 +92,7 @@ export function getFromGitHub(env: string): Promise<unknown> {
8792
`https://raw.githubusercontent.com/aws-cloudformation/cloudformation-languageserver/refs/heads/main/assets/featureFlag/${env.toLowerCase()}.json`,
8893
);
8994
}
95+
96+
function defaultConfig(configFile: string): unknown {
97+
return JSON.parse(readFileIfExists(configFile, 'utf8'));
98+
}

src/featureFlag/FeatureFlagSupplier.ts

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,22 @@ export class FeatureFlagSupplier implements Closeable {
2323
TargetedFeatureFlag<unknown> | DynamicTargetedFeatureFlag<unknown>
2424
>();
2525

26-
constructor(configSupplier: () => unknown) {
26+
constructor(configSupplier: () => unknown, defaultConfig: () => unknown) {
2727
for (const [key, builder] of Object.entries(FeatureBuilders)) {
28-
const ff = new DynamicFeatureFlag(key, () => featureConfigSupplier(key, configSupplier), builder);
28+
const ff = new DynamicFeatureFlag(
29+
key,
30+
() => featureConfigSupplier(key, configSupplier, defaultConfig),
31+
builder,
32+
);
2933
this._featureFlags.set(key, ff);
3034
}
3135

3236
for (const [key, builder] of Object.entries(TargetedFeatureBuilders)) {
33-
const ff = new DynamicTargetedFeatureFlag(key, () => featureConfigSupplier(key, configSupplier), builder);
37+
const ff = new DynamicTargetedFeatureFlag(
38+
key,
39+
() => featureConfigSupplier(key, configSupplier, defaultConfig),
40+
builder,
41+
);
3442
this._targetedFeatureFlags.set(key, ff);
3543
}
3644
}
@@ -65,17 +73,16 @@ export class FeatureFlagSupplier implements Closeable {
6573
}
6674
}
6775

68-
function featureConfigSupplier(key: string, configSupplier: () => unknown): FeatureFlagConfigType | undefined {
76+
function featureConfigSupplier(
77+
key: string,
78+
configSupplier: () => unknown,
79+
defaultConfig: () => unknown,
80+
): FeatureFlagConfigType | undefined {
6981
try {
70-
const config = configSupplier();
71-
if (!config) {
72-
return undefined;
73-
}
74-
const parsed = FeatureFlagConfigSchema.parse(config);
75-
return parsed.features[key];
82+
return FeatureFlagConfigSchema.parse(configSupplier()).features[key];
7683
} catch (err) {
77-
log.error(err, `Failed to parse feature flag config\n${toString(configSupplier())}`);
78-
return undefined;
84+
log.warn(err, `Failed to parse feature flag config: \n${toString(configSupplier())}. Using defaults instead`);
85+
return FeatureFlagConfigSchema.parse(defaultConfig()).features[key];
7986
}
8087
}
8188

src/settings/SettingsParser.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { z } from 'zod';
2-
import { AwsRegion } from '../utils/Region';
2+
import { getRegion } from '../utils/Region';
33
import { ProfileSettings, Settings } from './Settings';
44

5-
const AwsRegionSchema = z.enum(Object.values(AwsRegion));
6-
75
function createProfileSchema(defaults: Settings['profile']) {
86
return z
97
.object({
10-
region: AwsRegionSchema.nullish().transform((val) => val ?? defaults.region),
8+
region: z
9+
.string()
10+
.nullish()
11+
.transform((val) => getRegion(val ?? defaults.region)),
1112
profile: z
1213
.string()
1314
.nullish()

src/utils/Region.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/resource-type-schemas.html
2-
import { dashesToUnderscores } from './String';
2+
import { LoggerFactory } from '../telemetry/LoggerFactory';
3+
import { dashesToUnderscores, toString } from './String';
34

45
// Make sure keys and values are exactly the same (ignore casing, '-', '_')
56
export enum AwsRegion {
@@ -65,7 +66,8 @@ export function getRegion(region: unknown): AwsRegion {
6566

6667
const value = AwsRegion[enumKey as keyof typeof AwsRegion];
6768
if (!value) {
68-
throw new Error(`Unknown region ${String(region)}`);
69+
LoggerFactory.getLogger('Region').warn(`Unknown region ${toString(region)}`);
70+
return enumKey.replaceAll('_', '-').toLowerCase() as AwsRegion;
6971
}
7072

7173
return value;
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { readFileSync } from 'fs';
2+
import { join } from 'path';
3+
import { describe, it, expect } from 'vitest';
4+
import { FeatureFlagConfigSchema } from '../../../src/featureFlag/FeatureFlagBuilder';
5+
6+
describe('FeatureFlagProvider', () => {
7+
it('can parse feature flags', () => {
8+
[
9+
join(__dirname, '..', '..', '..', 'assets', 'featureFlag', 'alpha.json'),
10+
join(__dirname, '..', '..', '..', 'assets', 'featureFlag', 'beta.json'),
11+
join(__dirname, '..', '..', '..', 'assets', 'featureFlag', 'prod.json'),
12+
].map((path) => {
13+
const file = readFileSync(path, 'utf8');
14+
expect(file).toBeDefined();
15+
expect(FeatureFlagConfigSchema.parse(JSON.parse(file))).toBeDefined();
16+
});
17+
});
18+
});

tst/unit/featureFlag/FeatureFlagSupplier.test.ts

Lines changed: 55 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,57 +3,86 @@ import { DynamicTargetedFeatureFlag } from '../../../src/featureFlag/DynamicFeat
33
import { FeatureFlagSupplier } from '../../../src/featureFlag/FeatureFlagSupplier';
44

55
describe('FeatureFlagSupplier', () => {
6-
afterEach(() => {
7-
vi.restoreAllMocks();
8-
});
9-
10-
it('should initialize with targeted feature flags', () => {
11-
const configSupplier = vi.fn(() => ({
6+
const configSupplier = () => {
7+
return {
128
version: 1,
139
description: 'test',
1410
features: {
15-
EnhancedDryRun: { enabled: true },
11+
EnhancedDryRun: { enabled: true, fleetPercentage: 100, allowlistedRegions: ['us-east-1'] },
12+
Constants: { enabled: false },
1613
},
17-
}));
14+
};
15+
};
16+
17+
const throwError = () => {
18+
throw new Error('Error fallback');
19+
};
20+
21+
afterEach(() => {
22+
vi.restoreAllMocks();
23+
});
1824

19-
const supplier = new FeatureFlagSupplier(configSupplier);
25+
it('should initialize with feature flags', () => {
26+
const supplier = new FeatureFlagSupplier(configSupplier, throwError);
27+
28+
expect([...supplier.featureFlags.keys()]).toEqual(['Constants']);
29+
expect(supplier.featureFlags.get('Constants')?.isEnabled()).toBe(false);
30+
31+
expect([...supplier.targetedFeatureFlags.keys()]).toEqual(['EnhancedDryRun']);
32+
expect(supplier.targetedFeatureFlags.get('EnhancedDryRun')?.isEnabled('us-east-1')).toBe(true);
33+
expect(supplier.targetedFeatureFlags.get('EnhancedDryRun')?.isEnabled('us-east-2')).toBe(false);
2034

21-
expect(supplier.featureFlags.size).toBe(1);
22-
expect(supplier.targetedFeatureFlags.size).toBe(1);
23-
expect(supplier.targetedFeatureFlags.has('EnhancedDryRun')).toBe(true);
2435
supplier.close();
2536
});
2637

2738
it('should close all dynamic feature flags', () => {
28-
const configSupplier = vi.fn(() => ({
29-
version: 1,
30-
description: 'test',
31-
features: {},
32-
}));
33-
34-
const supplier = new FeatureFlagSupplier(configSupplier);
39+
const supplier = new FeatureFlagSupplier(
40+
() => {
41+
return { version: 1, description: 'test', features: {} };
42+
},
43+
() => {},
44+
);
3545
const closeSpy = vi.spyOn(DynamicTargetedFeatureFlag.prototype, 'close');
3646

3747
supplier.close();
3848

3949
expect(closeSpy).toHaveBeenCalled();
4050
});
4151

42-
it('should handle invalid config gracefully', () => {
43-
const configSupplier = vi.fn(() => 'invalid');
52+
it('should handle invalid config and fallback to default', () => {
53+
const supplier = new FeatureFlagSupplier(() => 'invalid', configSupplier);
4454

45-
const supplier = new FeatureFlagSupplier(configSupplier);
55+
expect([...supplier.featureFlags.keys()]).toEqual(['Constants']);
56+
expect([...supplier.targetedFeatureFlags.keys()]).toEqual(['EnhancedDryRun']);
4657

47-
expect(supplier.targetedFeatureFlags.size).toBe(1);
4858
supplier.close();
4959
});
5060

5161
it('should handle undefined config', () => {
52-
const configSupplier = vi.fn(() => undefined);
62+
const supplier = new FeatureFlagSupplier(() => undefined, configSupplier);
5363

54-
const supplier = new FeatureFlagSupplier(configSupplier);
64+
expect([...supplier.featureFlags.keys()]).toEqual(['Constants']);
65+
expect([...supplier.targetedFeatureFlags.keys()]).toEqual(['EnhancedDryRun']);
5566

56-
expect(supplier.targetedFeatureFlags.size).toBe(1);
5767
supplier.close();
5868
});
69+
70+
it('throws if both suppliers fail', () => {
71+
expect(
72+
() =>
73+
new FeatureFlagSupplier(
74+
() => undefined,
75+
() => undefined,
76+
),
77+
).toThrow(
78+
'[\n' +
79+
' {\n' +
80+
' "expected": "object",\n' +
81+
' "code": "invalid_type",\n' +
82+
' "path": [],\n' +
83+
' "message": "Invalid input: expected object, received undefined"\n' +
84+
' }\n' +
85+
']',
86+
);
87+
});
5988
});

tst/unit/utils/Region.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ describe('Region', () => {
2626
expect(getRegion('EU_WEST_1')).toBe(AwsRegion.EU_WEST_1);
2727
});
2828

29-
it('should throw an error for invalid region strings', () => {
30-
expect(() => getRegion('invalid-region')).toThrow('Unknown region invalid-region');
31-
expect(() => getRegion('us-central-1')).toThrow('Unknown region us-central-1');
29+
it('should not throw when region is unknown or invalid', () => {
30+
expect(getRegion('invalid_region')).toBe('invalid-region');
31+
expect(getRegion('US_test-1')).toBe('us-test-1');
3232
});
3333
});
3434
});

0 commit comments

Comments
 (0)