Repository navigation
feat: add feature flag recomposition support #2853
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
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; | ||
| import { Command, program } from 'commander'; | ||
| import ora from 'ora'; | ||
| import pc from 'picocolors'; | ||
| import { getBaseHeaders } from '../../../core/config.js'; | ||
| import { BaseCommandOptions } from '../../../core/types/types.js'; | ||
| import { handleCompositionResult } from '../../../handle-composition-result.js'; | ||
| import { limitMaxValue } from '../../../constants.js'; | ||
|
|
||
| export default (opts: BaseCommandOptions) => { | ||
| const command = new Command('recompose'); | ||
| command.description('Triggers a recomposition of the specified feature flags using its current subgraphs.'); | ||
| command.argument('<name>', 'The name of the feature flag to recompose.'); | ||
| command.option('-n, --namespace [string]', 'The namespace of the feature flag.'); | ||
| command.option('--suppress-warnings', 'This flag suppresses any warning produced by composition.'); | ||
| command.option( | ||
| '--disable-resolvability-validation', | ||
| 'This flag will disable the validation for whether all nodes of the feature flag are resolvable. Do NOT use unless troubleshooting.', | ||
| ); | ||
| command.option( | ||
| '--fail-on-composition-error', | ||
| 'If set, the command will fail if the composition of the feature flag fails.', | ||
| false, | ||
| ); | ||
| command.option( | ||
| '--fail-on-admission-webhook-error', | ||
| 'If set, the command will fail if the admission webhook fails.', | ||
| false, | ||
| ); | ||
| command.option( | ||
| '-l, --limit <number>', | ||
| 'The maximum number of composition errors, warnings, and deployment errors to display.', | ||
| '50', | ||
| ); | ||
|
|
||
| command.action(async (name, options) => { | ||
| const limit = Number(options.limit); | ||
| if (!Number.isInteger(limit) || limit <= 0 || limit > limitMaxValue) { | ||
| program.error( | ||
| pc.red(`The limit must be a valid number between 1 and ${limitMaxValue}. Received: '${options.limit}'`), | ||
| ); | ||
| } | ||
|
|
||
| const spinner = ora(`Recomposing feature flag "${name}"...`).start(); | ||
| const resp = await opts.client.platform.recomposeFeatureFlag( | ||
| { | ||
| disableResolvabilityValidation: options.disableResolvabilityValidation, | ||
| limit, | ||
| name, | ||
| namespace: options.namespace, | ||
| }, | ||
| { | ||
| headers: getBaseHeaders(), | ||
| }, | ||
| ); | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
|
|
||
| if (!resp.response) { | ||
| spinner.fail(`Failed to recompose feature flag "${pc.bold(name)}".`); | ||
| process.exitCode = 1; | ||
| return; | ||
| } | ||
|
|
||
| switch (resp.response.code) { | ||
| case EnumStatusCode.ERR: { | ||
| spinner.fail(`Failed to recompose feature flag "${pc.bold(name)}".`); | ||
| let message = `${pc.red('Split configuration loading is not enabled on the organization.')}`; | ||
|
|
||
| if (resp.response.details) { | ||
| message += `\n${pc.red(pc.bold(resp.response.details))}`; | ||
| } | ||
|
|
||
| program.error(message); | ||
| break; | ||
| } | ||
| case EnumStatusCode.ERR_NOT_FOUND: { | ||
| spinner.fail(`Failed to recompose feature flag "${pc.bold(name)}".`); | ||
| let message = | ||
| `${pc.red(`No valid record could be found for feature flag "${pc.bold(name)}".`)}\n` + | ||
| `Please check the name and namespace for the feature flag in Cosmo Studio.`; | ||
|
|
||
| if (resp.response.details) { | ||
| message += `\n${pc.red(pc.bold(resp.response.details))}`; | ||
| } | ||
|
|
||
| program.error(message); | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| try { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is |
||
| handleCompositionResult({ | ||
| totalErrorCounts: resp.errorCounts, | ||
| responseCode: resp.response.code, | ||
| responseDetails: resp.response.details, | ||
| compositionErrors: resp.compositionErrors, | ||
| compositionWarnings: resp.compositionWarnings, | ||
| deploymentErrors: resp.deploymentErrors, | ||
| spinner, | ||
| successMessage: `Feature flag "${pc.bold(name)}" recomposed successfully.`, | ||
| subgraphCompositionBaseErrorMessage: `Recomposition of feature flag "${pc.bold(name)}" failed.`, | ||
| subgraphCompositionDetailedErrorMessage: pc.bold('Please check the errors below:'), | ||
| deploymentErrorMessage: | ||
| `Feature flag was recomposed but the updated composition could not be deployed.` + | ||
| `\nThis means the updated composition is not accessible to the router.` + | ||
| `\n${pc.bold('Please check the errors below:')}`, | ||
| defaultErrorMessage: `Failed to recompose feature flag "${pc.bold(name)}".`, | ||
| suppressWarnings: options.suppressWarnings, | ||
| failOnCompositionError: options.failOnCompositionError, | ||
| failOnAdmissionWebhookError: options.failOnAdmissionWebhookError, | ||
| }); | ||
| } catch { | ||
| process.exitCode = 1; | ||
| } | ||
| }); | ||
|
|
||
| return command; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,155 @@ | ||
| import { afterEach, beforeEach, describe, expect, type MockInstance, test, vi } from 'vitest'; | ||
| import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; | ||
| import { runRecompose } from './utils.js'; | ||
|
|
||
| describe('feature-flag recompose', () => { | ||
| let logSpy: MockInstance<typeof console.log>; | ||
|
|
||
| beforeEach(() => { | ||
| logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); | ||
| vi.spyOn(process.stderr, 'write').mockImplementation(() => true); | ||
| vi.spyOn(process, 'exit').mockImplementation(() => { | ||
| throw new Error('process.exit'); | ||
| }); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| process.exitCode = undefined; | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| test('that recompose is successful with default namespace', async () => { | ||
| await runRecompose({ | ||
| response: { code: EnumStatusCode.OK }, | ||
| compositionErrors: [], | ||
| compositionWarnings: [], | ||
| deploymentErrors: [], | ||
| }); | ||
|
|
||
| expect(process.exitCode).toBeUndefined(); | ||
| }); | ||
|
|
||
| test('that recompose is successful with specific namespace', async () => { | ||
| await runRecompose( | ||
| { | ||
| response: { code: EnumStatusCode.OK }, | ||
| compositionErrors: [], | ||
| compositionWarnings: [], | ||
| deploymentErrors: [], | ||
| }, | ||
| { namespace: 'production' }, | ||
| ); | ||
|
|
||
| expect(process.exitCode).toBeUndefined(); | ||
| }); | ||
|
|
||
| test('that recompose fails with exit code 1 if response is missing', async () => { | ||
| await runRecompose({}); | ||
|
|
||
| expect(process.exitCode).toBe(1); | ||
| }); | ||
|
|
||
| test('that recompose fails fails with exit code 1 if config splitting is not enabled', async () => { | ||
| await expect( | ||
| runRecompose({ | ||
| response: { code: EnumStatusCode.ERR, details: 'Configuration splitting not enabled' }, | ||
| compositionErrors: [], | ||
| compositionWarnings: [], | ||
| deploymentErrors: [], | ||
| }), | ||
| ).rejects.toThrow(); | ||
|
|
||
| expect(process.exitCode).toBeUndefined(); | ||
| }); | ||
|
|
||
| test('that recompose fails but does not return exit code 1 if the feature flag is not found', async () => { | ||
| await expect( | ||
| runRecompose({ | ||
| response: { code: EnumStatusCode.ERR_NOT_FOUND, details: 'The feature flag "feature-flag" was not found' }, | ||
| compositionErrors: [], | ||
| compositionWarnings: [], | ||
| deploymentErrors: [], | ||
| }), | ||
| ).rejects.toThrow(); | ||
|
|
||
| expect(process.exitCode).toBeUndefined(); | ||
| }); | ||
|
|
||
| test('that recompose fails if composition errors occur but does not return exit code 1', async () => { | ||
| await runRecompose({ | ||
| response: { code: EnumStatusCode.ERR_SUBGRAPH_COMPOSITION_FAILED }, | ||
| compositionErrors: [{ federatedGraphName: 'mygraph', namespace: 'default', message: 'Composition failed' }], | ||
| compositionWarnings: [], | ||
| deploymentErrors: [], | ||
| }); | ||
|
|
||
| expect(process.exitCode).toBeUndefined(); | ||
| }); | ||
|
|
||
| test('that recompose fails if composition errors occur and returns exit code 1 if --fail-on-composition-error is set', async () => { | ||
| await runRecompose( | ||
| { | ||
| response: { code: EnumStatusCode.ERR_SUBGRAPH_COMPOSITION_FAILED }, | ||
| compositionErrors: [{ federatedGraphName: 'mygraph', namespace: 'default', message: 'Composition failed' }], | ||
| compositionWarnings: [], | ||
| deploymentErrors: [], | ||
| }, | ||
| { failOnCompositionError: true }, | ||
| ); | ||
|
|
||
| expect(process.exitCode).toBe(1); | ||
| }); | ||
|
|
||
| test('that recompose fails if deployment errors occur but does not return exit code 1', async () => { | ||
| await runRecompose({ | ||
| response: { code: EnumStatusCode.ERR_DEPLOYMENT_FAILED }, | ||
| compositionErrors: [], | ||
| compositionWarnings: [], | ||
| deploymentErrors: [{ federatedGraphName: 'mygraph', namespace: 'default', message: 'Deploy failed' }], | ||
| }); | ||
|
|
||
| expect(process.exitCode).toBeUndefined(); | ||
| }); | ||
|
|
||
| test('that recompose fails if deployment errors occur and returns exit code 1 if --fail-on-admission-webhook-error is set', async () => { | ||
| await runRecompose( | ||
| { | ||
| response: { code: EnumStatusCode.ERR_DEPLOYMENT_FAILED }, | ||
| compositionErrors: [], | ||
| compositionWarnings: [], | ||
| deploymentErrors: [{ federatedGraphName: 'mygraph', namespace: 'default', message: 'Deploy failed' }], | ||
| }, | ||
| { failOnAdmissionWebhookError: true }, | ||
| ); | ||
|
|
||
| expect(process.exitCode).toBe(1); | ||
| }); | ||
|
|
||
| test('that composition warnings are shown by default', async () => { | ||
| await runRecompose({ | ||
| response: { code: EnumStatusCode.OK }, | ||
| compositionErrors: [], | ||
| compositionWarnings: [{ federatedGraphName: 'mygraph', namespace: 'default', message: 'Some warning' }], | ||
| deploymentErrors: [], | ||
| }); | ||
|
|
||
| expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('warnings were produced')); | ||
| }); | ||
|
|
||
| test('suppresses composition warnings when --suppress-warnings is set', async () => { | ||
| await runRecompose( | ||
| { | ||
| response: { code: EnumStatusCode.OK }, | ||
| compositionErrors: [], | ||
| compositionWarnings: [{ federatedGraphName: 'mygraph', namespace: 'default', message: 'Some warning' }], | ||
| deploymentErrors: [], | ||
| }, | ||
| { suppressWarnings: true }, | ||
| ); | ||
|
|
||
| const warningCalls = logSpy.mock.calls.filter( | ||
| ([arg]) => typeof arg === 'string' && arg.includes('warnings were produced'), | ||
| ); | ||
| expect(warningCalls).toHaveLength(0); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import type { PartialMessage } from '@bufbuild/protobuf'; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think we need these utils in separate file. |
||
| import { RecomposeFeatureFlagResponse } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb'; | ||
| import { createPromiseClient, createRouterTransport, Transport } from '@connectrpc/connect'; | ||
| import { PlatformService } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_connect'; | ||
| import { Command } from 'commander'; | ||
| import RecomposeCommand from '../../src/commands/feature-flag/commands/recompose.js'; | ||
| import { Client } from '../../src/core/client/client.js'; | ||
|
|
||
| export function createMockTransport(response: PartialMessage<RecomposeFeatureFlagResponse>): Transport { | ||
| return createRouterTransport(({ service }): void => { | ||
| service(PlatformService, { | ||
| recomposeFeatureFlag: () => response, | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| export function createClient(response: PartialMessage<RecomposeFeatureFlagResponse>): Client { | ||
| return { | ||
| platform: createPromiseClient(PlatformService, createMockTransport(response)), | ||
| }; | ||
| } | ||
|
|
||
| export async function runRecompose( | ||
| response: PartialMessage<RecomposeFeatureFlagResponse>, | ||
| opts: { | ||
| namespace?: string; | ||
| failOnCompositionError?: boolean; | ||
| failOnAdmissionWebhookError?: boolean; | ||
| suppressWarnings?: boolean; | ||
| } = {}, | ||
| ): Promise<void> { | ||
| const args = ['recompose', 'feature-flag']; | ||
| if (opts.namespace) { | ||
| args.push('--namespace', opts.namespace); | ||
| } | ||
| if (opts.failOnCompositionError) { | ||
| args.push('--fail-on-composition-error'); | ||
| } | ||
| if (opts.failOnAdmissionWebhookError) { | ||
| args.push('--fail-on-admission-webhook-error'); | ||
| } | ||
| if (opts.suppressWarnings) { | ||
| args.push('--suppress-warnings'); | ||
| } | ||
|
|
||
| const program = new Command(); | ||
| program.addCommand(RecomposeCommand({ client: createClient(response) })); | ||
| await program.parseAsync(args, { from: 'user' }); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What about
--json? ThehandleCompositionResultcan already process output as JSON. Maybe we could at least add it as a follow up but it shouldn't take a lot of effort to add it in.