Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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: 3 additions & 2 deletions apps/cli/src/command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { IngressSyncCommand } from './ingress-sync.command';
import { LintCommand } from './lint.command';
import { PingCommand } from './ping.command';
import { SyncCommand } from './sync.command';
import { ValidateCommand } from './validate.command';
import { configurePluralize } from './utils';

const versionCode = '0.24.3';
Expand Down Expand Up @@ -47,8 +48,8 @@ export const setupCommands = (): Command => {
.addCommand(DiffCommand)
.addCommand(SyncCommand)
.addCommand(ConvertCommand)
.addCommand(LintCommand);
//.addCommand(ValidateCommand)
.addCommand(LintCommand)
.addCommand(ValidateCommand);

if (process.env.NODE_ENV === 'development') program.addCommand(DevCommand);

Expand Down
71 changes: 71 additions & 0 deletions apps/cli/src/command/validate.command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Listr } from 'listr2';
Comment thread
jarvis9443 marked this conversation as resolved.

import { LintTask, LoadLocalConfigurationTask, ValidateTask } from '../tasks';
import { InitializeBackendTask } from '../tasks/init_backend';
import { SignaleRenderer } from '../utils/listr';
import { TaskContext } from './diff.command';
import { BackendCommand, NoLintOption } from './helper';
import { BackendOptions } from './typing';

export type ValidateOptions = BackendOptions & {
file: Array<string>;
lint: boolean;
};

export const ValidateCommand = new BackendCommand<ValidateOptions>(
'validate',
'validate the local configuration against the backend',
'Validate the configuration from the local file(s) against the backend without applying any changes.',
)
.option(
'-f, --file <file-path>',
'file to validate',
(filePath, files: Array<string> = []) => files.concat(filePath),
)
.addOption(NoLintOption)
.addExamples([
{
title: 'Validate configuration from a single file',
command: 'adc validate -f adc.yaml',
},
{
title: 'Validate configuration from multiple files',
command: 'adc validate -f service-a.yaml -f service-b.yaml',
},
{
title: 'Validate configuration against API7 EE backend',
command:
'adc validate -f adc.yaml --backend api7ee --gateway-group default',
},
{
title: 'Validate configuration without lint check',
command: 'adc validate -f adc.yaml --no-lint',
},
])
.handle(async (opts) => {
const tasks = new Listr<TaskContext, typeof SignaleRenderer>(
[
InitializeBackendTask(opts.backend, opts),
LoadLocalConfigurationTask(
opts.file,
opts.labelSelector,
opts.includeResourceType,
opts.excludeResourceType,
),
opts.lint ? LintTask() : { task: () => undefined },
ValidateTask(),
],
{
renderer: SignaleRenderer,
rendererOptions: { verbose: opts.verbose },
ctx: { remote: {}, local: {}, diff: [] },
},
);

try {
await tasks.run();
} catch (err) {
if (opts.verbose === 2) console.log(err);
process.exit(1);
}
});
1 change: 1 addition & 0 deletions apps/cli/src/tasks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export * from './load_local';
export * from './load_remote';
export * from './diff';
export * from './lint';
export * from './validate';
export * from './experimental';
41 changes: 41 additions & 0 deletions apps/cli/src/tasks/validate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import * as ADCSDK from '@api7/adc-sdk';
import { ListrTask } from 'listr2';

export const ValidateTask = (): ListrTask<{
backend: ADCSDK.Backend;
local: ADCSDK.Configuration;
}> => ({
title: 'Validate configuration against backend',
task: async (ctx) => {
if (!ctx.backend.supportValidate) {
throw new Error(
'Validate is not supported by the current backend',
);
}

const supported = await ctx.backend.supportValidate();
if (!supported) {
const version = await ctx.backend.version();
throw new Error(
`Validate is not supported by the current backend version (${version}). Please upgrade to a newer version.`,
);
}

const result = await ctx.backend.validate!(ctx.local);
if (!result.success) {
const lines: string[] = [];
if (result.errorMessage) {
lines.push(result.errorMessage);
}
for (const e of result.errors) {
const id = e.resource_id ? ` "${e.resource_id}"` : '';
lines.push(` - [${e.resource_type}${id}]: ${e.error}`);
}
const error = new Error(
`Configuration validation failed:\n${lines.join('\n')}`,
);
error.stack = '';
throw error;
}
},
});
239 changes: 239 additions & 0 deletions libs/backend-api7/e2e/validate.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import * as ADCSDK from '@api7/adc-sdk';
import { gte } from 'semver';
import { globalAgent as httpAgent } from 'node:http';

import { BackendAPI7 } from '../src';
import {
conditionalDescribe,
generateHTTPSAgent,
semverCondition,
} from './support/utils';
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed

conditionalDescribe(semverCondition(gte, '3.9.10'))(
'Validate',
() => {
let backend: BackendAPI7;

beforeAll(() => {
backend = new BackendAPI7({
server: process.env.SERVER!,
token: process.env.TOKEN!,
tlsSkipVerify: true,
gatewayGroup: process.env.GATEWAY_GROUP,
cacheKey: 'default',
httpAgent,
httpsAgent: generateHTTPSAgent(),
});
});

it('should report supportValidate as true', async () => {
expect(await backend.supportValidate()).toBe(true);
});

it('should succeed with empty configuration', async () => {
const result = await backend.validate({});
expect(result.success).toBe(true);
expect(result.errors).toEqual([]);
});

it('should succeed with valid service and route', async () => {
const config: ADCSDK.Configuration = {
services: [
{
name: 'validate-test-svc',
upstream: {
scheme: 'http',
nodes: [{ host: 'httpbin.org', port: 80, weight: 100 }],
},
routes: [
{
name: 'validate-test-route',
uris: ['/validate-test'],
methods: ['GET'],
},
],
},
],
};

const result = await backend.validate(config);
expect(result.success).toBe(true);
expect(result.errors).toEqual([]);
});

it('should succeed with valid consumer', async () => {
const config: ADCSDK.Configuration = {
consumers: [
{
username: 'validate-test-consumer',
plugins: {
'key-auth': { key: 'test-key-123' },
},
},
],
};

const result = await backend.validate(config);
expect(result.success).toBe(true);
expect(result.errors).toEqual([]);
});

it('should fail with invalid plugin configuration', async () => {
Comment thread
jarvis9443 marked this conversation as resolved.
const config: ADCSDK.Configuration = {
services: [
{
name: 'validate-bad-plugin-svc',
upstream: {
scheme: 'http',
nodes: [{ host: 'httpbin.org', port: 80, weight: 100 }],
},
routes: [
{
name: 'validate-bad-plugin-route',
uris: ['/bad-plugin'],
plugins: {
'limit-count': {
// missing required fields: count, time_window
},
},
},
],
},
],
};

const result = await backend.validate(config);
expect(result.success).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
expect(result.errors[0].resource_type).toBe('route');
});

it('should fail with invalid route (bad uri type)', async () => {
const config: ADCSDK.Configuration = {
services: [
{
name: 'validate-bad-route-svc',
upstream: {
scheme: 'http',
nodes: [{ host: 'httpbin.org', port: 80, weight: 100 }],
},
routes: [
{
name: 'validate-bad-route',
// paths should be an array of strings, provide number instead
uris: [123 as unknown as string],
},
],
},
],
};

const result = await backend.validate(config);
expect(result.success).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
});

it('should collect multiple errors', async () => {
const config: ADCSDK.Configuration = {
services: [
{
name: 'validate-multi-err-svc',
upstream: {
scheme: 'http',
nodes: [{ host: 'httpbin.org', port: 80, weight: 100 }],
},
routes: [
{
name: 'validate-multi-err-route1',
uris: ['/multi-err-1'],
plugins: {
'limit-count': {},
},
},
{
name: 'validate-multi-err-route2',
uris: ['/multi-err-2'],
plugins: {
'limit-count': {},
},
},
],
},
],
};

const result = await backend.validate(config);
expect(result.success).toBe(false);
expect(result.errors.length).toBeGreaterThanOrEqual(2);
});

it('should succeed with mixed resource types', async () => {
const config: ADCSDK.Configuration = {
services: [
{
name: 'validate-mixed-svc',
upstream: {
scheme: 'https',
nodes: [{ host: 'httpbin.org', port: 443, weight: 100 }],
},
routes: [
{
name: 'validate-mixed-route',
uris: ['/mixed-test'],
methods: ['GET', 'POST'],
},
],
},
],
consumers: [
{
username: 'validate-mixed-consumer',
plugins: {
'key-auth': { key: 'mixed-key-456' },
},
},
],
global_rules: {
'prometheus': { prefer_name: false },
} as ADCSDK.Configuration['global_rules'],
};

const result = await backend.validate(config);
expect(result.success).toBe(true);
expect(result.errors).toEqual([]);
});

it('should be a dry-run (no side effects on server)', async () => {
const serviceName = 'validate-dryrun-svc';
const routeName = 'validate-dryrun-route';

const config: ADCSDK.Configuration = {
services: [
{
name: serviceName,
upstream: {
scheme: 'http',
nodes: [{ host: 'httpbin.org', port: 80, weight: 100 }],
},
routes: [
{
name: routeName,
uris: ['/dryrun-test'],
},
],
},
],
};

// Validate should succeed
const result = await backend.validate(config);
expect(result.success).toBe(true);

// Verify no resources were created by dumping
const { lastValueFrom } = await import('rxjs');
const dumped = await lastValueFrom(backend.dump());
const found = dumped.services?.find((s) => s.name === serviceName);
expect(found).toBeUndefined();
});
},
);
Loading
Loading