Repository navigation
feat: Add the file-based override source #2053
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
Open
kinyoklion
wants to merge
9
commits into
rlamb/overrides-node-events
Choose a base branch
from
rlamb/overrides-node-file-source
base: rlamb/overrides-node-events
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
77a24e1
feat: Add the file-based override source
kinyoklion 2fdac52
fix: Expand value-only overrides to a flag served by fallthrough
kinyoklion e3d7474
fix: Treat a polling interval that a timer cannot honor as invalid an…
kinyoklion 6addc74
test: Expect the fallthrough reason for a value-only YAML override
kinyoklion c8a3f35
docs: Describe the invalid polling interval values that fall back to …
kinyoklion 673127d
test: Wait for the initial override load before asserting on the file…
kinyoklion ae07657
test: Cover a definition of the wrong shape as a failed load of the f…
kinyoklion 2a41257
refactor: Reuse existing helpers in the file override source and its …
kinyoklion 0329a1f
fix: Key file override entries by their map key
kinyoklion File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
135 changes: 135 additions & 0 deletions
135
packages/sdk/server-node/__tests__/LDClientNode.fileOverrides.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| import { mkdtemp, rm, unlink, writeFile } from 'node:fs/promises'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { join } from 'node:path'; | ||
|
|
||
| import { FileOverrideSourceOptions } from '@launchdarkly/js-server-sdk-common'; | ||
|
|
||
| import LDClientNode from '../src/LDClientNode'; | ||
| import waitFor, { sleep } from './waitFor'; | ||
|
|
||
| const user = { key: 'user-key' }; | ||
|
|
||
| // The client never obtains data from LaunchDarkly: its only initializer reads a file that does | ||
| // not exist. Overrides are served from the override layer alone. | ||
| function makeClient( | ||
| directory: string, | ||
| overrides: Omit<FileOverrideSourceOptions, 'type'>, | ||
| ): LDClientNode { | ||
| return new LDClientNode('sdk-key-file-overrides', { | ||
| sendEvents: false, | ||
| diagnosticOptOut: true, | ||
| logger: { error: () => {}, warn: () => {}, info: () => {}, debug: () => {} }, | ||
| dataSystem: { | ||
| dataSource: { | ||
| dataSourceOptionsType: 'custom', | ||
| initializers: [{ type: 'file', paths: [join(directory, 'no-launchdarkly-data.json')] }], | ||
| synchronizers: [], | ||
| }, | ||
| overrides: { type: 'file', ...overrides }, | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| const document = (value: string) => JSON.stringify({ flagValues: { 'overridden-flag': value } }); | ||
|
|
||
| describe('given a temporary directory of override files', () => { | ||
| let directory: string; | ||
| let client: LDClientNode | undefined; | ||
|
|
||
| beforeEach(async () => { | ||
| directory = await mkdtemp(join(tmpdir(), 'ld-file-overrides-')); | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| client?.close(); | ||
| client = undefined; | ||
| await rm(directory, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| it('reads a YAML file with the built-in parser', async () => { | ||
| const path = join(directory, 'overrides.yaml'); | ||
| await writeFile(path, 'flagValues:\n yaml-flag: "override-value"\n'); | ||
| client = makeClient(directory, { paths: [path] }); | ||
|
|
||
| // Evaluation does not wait for the initial load, so wait for the override to be in effect. | ||
| await waitFor( | ||
| async () => | ||
| (await client!.variationDetail('yaml-flag', user, 'default')).value === 'override-value', | ||
| 10000, | ||
| ); | ||
| const detail = await client.variationDetail('yaml-flag', user, 'default'); | ||
|
|
||
| expect(detail.value).toEqual('override-value'); | ||
| expect(detail.reason).toEqual({ kind: 'FALLTHROUGH', overrideAffected: true }); | ||
| expect(client.initialized()).toBe(false); | ||
| }); | ||
|
|
||
| it('reloads a changed file in polling mode', async () => { | ||
| const path = join(directory, 'overrides.json'); | ||
| await writeFile(path, document('b')); | ||
| client = makeClient(directory, { paths: [path], changeDetection: 'polling', pollInterval: 1 }); | ||
| await waitFor( | ||
| async () => (await client!.variation('overridden-flag', user, 'default')) === 'b', | ||
| 10000, | ||
| ); | ||
|
|
||
| await writeFile(path, document('c')); | ||
|
|
||
| await waitFor( | ||
| async () => (await client!.variation('overridden-flag', user, 'default')) === 'c', | ||
| 10000, | ||
| ); | ||
| }); | ||
|
|
||
| it('picks up a file that does not exist yet in watching mode', async () => { | ||
| const path = join(directory, 'overrides.json'); | ||
| client = makeClient(directory, { paths: [path], changeDetection: 'watching' }); | ||
| const before = await client.variationDetail('overridden-flag', user, 'default'); | ||
| expect(before.reason).toEqual({ kind: 'ERROR', errorKind: 'CLIENT_NOT_READY' }); | ||
|
|
||
| await writeFile(path, document('b')); | ||
|
|
||
| await waitFor( | ||
| async () => (await client!.variation('overridden-flag', user, 'default')) === 'b', | ||
| 10000, | ||
| ); | ||
| }); | ||
|
|
||
| it('removes the overrides of a deleted file', async () => { | ||
| const path = join(directory, 'overrides.json'); | ||
| await writeFile(path, document('b')); | ||
| client = makeClient(directory, { paths: [path] }); | ||
| await waitFor( | ||
| async () => (await client!.variation('overridden-flag', user, 'default')) === 'b', | ||
| 10000, | ||
| ); | ||
|
|
||
| await unlink(path); | ||
|
|
||
| await waitFor(async () => { | ||
| const detail = await client!.variationDetail('overridden-flag', user, 'default'); | ||
| return detail.reason.errorKind === 'CLIENT_NOT_READY'; | ||
| }, 10000); | ||
| }); | ||
|
|
||
| it('keeps the last good overrides while the file is malformed', async () => { | ||
| const path = join(directory, 'overrides.json'); | ||
| await writeFile(path, document('b')); | ||
| client = makeClient(directory, { paths: [path] }); | ||
| await waitFor( | ||
| async () => (await client!.variation('overridden-flag', user, 'default')) === 'b', | ||
| 10000, | ||
| ); | ||
|
|
||
| await writeFile(path, '{"flagValues"'); | ||
| await sleep(1500); | ||
| expect(await client.variation('overridden-flag', user, 'default')).toEqual('b'); | ||
|
|
||
| await writeFile(path, document('c')); | ||
|
|
||
| await waitFor( | ||
| async () => (await client!.variation('overridden-flag', user, 'default')) === 'c', | ||
| 10000, | ||
| ); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| /** | ||
| * Resolves after the given number of milliseconds. | ||
| */ | ||
| export function sleep(ms: number): Promise<void> { | ||
| return new Promise<void>((resolve) => { | ||
| setTimeout(resolve, ms); | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Polls a condition until it holds or the timeout elapses. | ||
| */ | ||
| export default async function waitFor( | ||
| condition: () => Promise<boolean> | boolean, | ||
| timeoutMs: number = 5000, | ||
| intervalMs: number = 20, | ||
| ): Promise<void> { | ||
| const deadline = Date.now() + timeoutMs; | ||
| while (Date.now() < deadline) { | ||
| // eslint-disable-next-line no-await-in-loop | ||
| if (await condition()) { | ||
| return; | ||
| } | ||
| // eslint-disable-next-line no-await-in-loop | ||
| await sleep(intervalMs); | ||
| } | ||
| throw new Error('timed out waiting for the condition'); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
131 changes: 131 additions & 0 deletions
131
packages/shared/sdk-server/__tests__/LDClientImpl.fileOverrides.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| import { FileOverrideSourceOptions } from '../src/api/options/LDDataSystemOptions'; | ||
| import { LDOptions } from '../src/api/options/LDOptions'; | ||
| import LDClientImpl from '../src/LDClientImpl'; | ||
| import MockFilesystem from './data_sources/filedata/MockFilesystem'; | ||
| import makeMockLogger from './mockLogger'; | ||
| import { makeCallbacks, makeFDv2Platform } from './overrides/overridesTestSupport'; | ||
| import waitFor from './waitFor'; | ||
|
|
||
| const user = { key: 'user-key' }; | ||
| const directory = '/etc/launchdarkly'; | ||
| const jsonPath = `${directory}/overrides.json`; | ||
| const yamlPath = `${directory}/overrides.yaml`; | ||
|
|
||
| describe('given a client with a file override source over a mock filesystem', () => { | ||
| let filesystem: MockFilesystem; | ||
| let client: LDClientImpl | undefined; | ||
| let platformYamlParser: jest.Mock; | ||
|
|
||
| const makeClient = ( | ||
| overrides: Omit<FileOverrideSourceOptions, 'type'>, | ||
| options: LDOptions = {}, | ||
| ) => { | ||
| client = new LDClientImpl( | ||
| 'sdk-key-file-overrides', | ||
| { ...makeFDv2Platform(), fileSystem: filesystem }, | ||
| { | ||
| sendEvents: false, | ||
| diagnosticOptOut: true, | ||
| logger: makeMockLogger(), | ||
| ...options, | ||
| dataSystem: { | ||
| dataSource: { | ||
| dataSourceOptionsType: 'custom', | ||
| initializers: [], | ||
| synchronizers: [{ type: 'polling', pollInterval: 1000 }], | ||
| }, | ||
| overrides: { type: 'file', changeDetection: 'watching', ...overrides }, | ||
| }, | ||
| }, | ||
| makeCallbacks(), | ||
| { yamlParser: platformYamlParser }, | ||
| ); | ||
| return client; | ||
| }; | ||
|
|
||
| beforeEach(() => { | ||
| filesystem = new MockFilesystem(); | ||
| platformYamlParser = jest.fn(() => ({ flagValues: { 'yaml-flag': 'from-platform-parser' } })); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| client?.close(); | ||
| client = undefined; | ||
| }); | ||
|
|
||
| it('serves an override from a file that is present when the client is created', async () => { | ||
| filesystem.set(jsonPath, '{"flagValues": {"overridden-flag": true}}'); | ||
| makeClient({ paths: [jsonPath] }); | ||
|
|
||
| // The initial load reads the file asynchronously and evaluation does not wait for it, so the | ||
| // test waits until the override shows. | ||
| await waitFor( | ||
| async () => (await client!.boolVariation('overridden-flag', user, false)) === true, | ||
| ); | ||
| const detail = await client!.boolVariationDetail('overridden-flag', user, false); | ||
|
|
||
| expect(detail.value).toBe(true); | ||
| expect(detail.reason).toEqual({ kind: 'FALLTHROUGH', overrideAffected: true }); | ||
| expect(client!.initialized()).toBe(false); | ||
| }); | ||
|
|
||
| it('reads a YAML file with the parser the platform supplies', async () => { | ||
| filesystem.set(yamlPath, 'flagValues:\n yaml-flag: from-platform-parser\n'); | ||
| makeClient({ paths: [yamlPath] }); | ||
|
|
||
| await waitFor( | ||
| async () => | ||
| (await client!.variation('yaml-flag', user, 'default')) === 'from-platform-parser', | ||
| ); | ||
| expect(platformYamlParser).toHaveBeenCalledWith( | ||
| 'flagValues:\n yaml-flag: from-platform-parser\n', | ||
| ); | ||
| }); | ||
|
|
||
| it('prefers a configured YAML parser over the one the platform supplies', async () => { | ||
| const yamlParser = jest.fn(() => ({ flagValues: { 'yaml-flag': 'from-configured-parser' } })); | ||
| filesystem.set(yamlPath, 'flagValues:\n yaml-flag: x\n'); | ||
| makeClient({ paths: [yamlPath], yamlParser }); | ||
|
|
||
| await waitFor( | ||
| async () => | ||
| (await client!.variation('yaml-flag', user, 'default')) === 'from-configured-parser', | ||
| ); | ||
| expect(platformYamlParser).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('reloads when the directory reports a change', async () => { | ||
| filesystem.set(jsonPath, '{"flagValues": {"overridden-flag": "b"}}'); | ||
| makeClient({ paths: [jsonPath] }); | ||
| await waitFor( | ||
| async () => (await client!.variation('overridden-flag', user, 'default')) === 'b', | ||
| ); | ||
|
|
||
| filesystem.set(jsonPath, '{"flagValues": {"overridden-flag": "c"}}'); | ||
| filesystem.emit(directory); | ||
|
|
||
| await waitFor( | ||
| async () => (await client!.variation('overridden-flag', user, 'default')) === 'c', | ||
| ); | ||
| }); | ||
|
|
||
| it('contributes no overrides for a file that does not exist until it appears', async () => { | ||
| makeClient({ paths: [jsonPath] }); | ||
|
|
||
| const before = await client!.variationDetail('overridden-flag', user, 'default'); | ||
| expect(before.reason).toEqual({ kind: 'ERROR', errorKind: 'CLIENT_NOT_READY' }); | ||
|
|
||
| filesystem.set(jsonPath, '{"flagValues": {"overridden-flag": "b"}}'); | ||
| filesystem.emit(directory, 'rename'); | ||
|
|
||
| await waitFor( | ||
| async () => (await client!.variation('overridden-flag', user, 'default')) === 'b', | ||
| ); | ||
| }); | ||
|
|
||
| it('fails client construction when no file paths are configured', () => { | ||
| expect(() => makeClient({ paths: [] })).toThrow( | ||
| 'The file-based override source requires at least one file path', | ||
| ); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.