Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
117 changes: 117 additions & 0 deletions cli/src/commands/feature-flag/commands/recompose.ts
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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about --json? The handleCompositionResult can 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.

'-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(),
},
);
Comment thread
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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is try/catch necessary here? There's a top-level function captureCommandFailure which handles this I believe.

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;
};
2 changes: 2 additions & 0 deletions cli/src/commands/feature-flag/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import EnableFeatureFlagCommand from './commands/enable.js';
import DisableFeatureFlagCommand from './commands/disable.js';
import UpdateFeatureFlagCommand from './commands/update.js';
import ListFeatureFlagCommand from './commands/list.js';
import RecomposeFeatureFlagCommand from './commands/recompose.js';

export default (opts: BaseCommandOptions) => {
const command = new Command('feature-flag').alias('ff');
Expand All @@ -18,6 +19,7 @@ export default (opts: BaseCommandOptions) => {
command.addCommand(DisableFeatureFlagCommand(opts));
command.addCommand(UpdateFeatureFlagCommand(opts));
command.addCommand(ListFeatureFlagCommand(opts));
command.addCommand(RecomposeFeatureFlagCommand(opts));

command.hook('preAction', async () => {
await checkAuth();
Expand Down
155 changes: 155 additions & 0 deletions cli/test/feature-flag/recompose.test.ts
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);
});
});
49 changes: 49 additions & 0 deletions cli/test/feature-flag/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { PartialMessage } from '@bufbuild/protobuf';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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' });
}
Loading
Loading