diff --git a/apps/cli/src/command/index.ts b/apps/cli/src/command/index.ts index 9ea67898..2b93d7a2 100644 --- a/apps/cli/src/command/index.ts +++ b/apps/cli/src/command/index.ts @@ -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'; @@ -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); diff --git a/apps/cli/src/command/validate.command.ts b/apps/cli/src/command/validate.command.ts new file mode 100644 index 00000000..4e9c3b3f --- /dev/null +++ b/apps/cli/src/command/validate.command.ts @@ -0,0 +1,77 @@ +import { Listr } from 'listr2'; + +import { + DiffResourceTask, + 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; + lint: boolean; +}; + +export const ValidateCommand = new BackendCommand( + '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 to validate', + (filePath, files: Array = []) => 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( + [ + InitializeBackendTask(opts.backend, opts), + LoadLocalConfigurationTask( + opts.file, + opts.labelSelector, + opts.includeResourceType, + opts.excludeResourceType, + ), + opts.lint ? LintTask() : { task: () => undefined }, + DiffResourceTask(), + 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); + } + }); diff --git a/apps/cli/src/tasks/index.ts b/apps/cli/src/tasks/index.ts index 1a74ade6..9408a1e3 100644 --- a/apps/cli/src/tasks/index.ts +++ b/apps/cli/src/tasks/index.ts @@ -2,4 +2,5 @@ export * from './load_local'; export * from './load_remote'; export * from './diff'; export * from './lint'; +export * from './validate'; export * from './experimental'; diff --git a/apps/cli/src/tasks/validate.ts b/apps/cli/src/tasks/validate.ts new file mode 100644 index 00000000..32649330 --- /dev/null +++ b/apps/cli/src/tasks/validate.ts @@ -0,0 +1,48 @@ +import * as ADCSDK from '@api7/adc-sdk'; +import { ListrTask } from 'listr2'; +import { lastValueFrom } from 'rxjs'; + +export const ValidateTask = (): ListrTask<{ + backend: ADCSDK.Backend; + diff: ADCSDK.Event[]; +}> => ({ + 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 lastValueFrom(ctx.backend.validate!(ctx.diff)); + if (!result.success) { + const lines: string[] = []; + if (result.errorMessage) { + lines.push(result.errorMessage); + } + for (const e of result.errors) { + const parts: string[] = [e.resource_type]; + if (e.resource_name) { + parts.push(`name="${e.resource_name}"`); + } else { + if (e.resource_id) parts.push(`id="${e.resource_id}"`); + if (e.index !== undefined) parts.push(`index=${e.index}`); + } + lines.push(` - [${parts.join(', ')}]: ${e.error}`); + } + const error = new Error( + `Configuration validation failed:\n${lines.join('\n')}`, + ); + error.stack = ''; + throw error; + } + }, +}); diff --git a/libs/backend-api7/e2e/validate.e2e-spec.ts b/libs/backend-api7/e2e/validate.e2e-spec.ts new file mode 100644 index 00000000..5f6963a0 --- /dev/null +++ b/libs/backend-api7/e2e/validate.e2e-spec.ts @@ -0,0 +1,263 @@ +import { DifferV3 } from '@api7/adc-differ'; +import * as ADCSDK from '@api7/adc-sdk'; +import { gte } from 'semver'; +import { lastValueFrom } from 'rxjs'; +import { globalAgent as httpAgent } from 'node:http'; + +import { BackendAPI7 } from '../src'; +import { + conditionalDescribe, + generateHTTPSAgent, + semverCondition, +} from './support/utils'; + +const configToEvents = ( + config: ADCSDK.Configuration, +): Array => { + return DifferV3.diff( + config as ADCSDK.InternalConfiguration, + {} as ADCSDK.InternalConfiguration, + ); +}; + +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 lastValueFrom(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 lastValueFrom( + backend.validate(configToEvents(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 lastValueFrom( + backend.validate(configToEvents(config)), + ); + expect(result.success).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('should fail with invalid plugin configuration', async () => { + 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 lastValueFrom( + backend.validate(configToEvents(config)), + ); + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors[0].resource_type).toBe('routes'); + }); + + 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 lastValueFrom( + backend.validate(configToEvents(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 lastValueFrom( + backend.validate(configToEvents(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 lastValueFrom( + backend.validate(configToEvents(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 lastValueFrom( + backend.validate(configToEvents(config)), + ); + expect(result.success).toBe(true); + + // Verify no resources were created by dumping + const dumped = await lastValueFrom(backend.dump()); + const found = dumped.services?.find((s) => s.name === serviceName); + expect(found).toBeUndefined(); + }); + }, +); diff --git a/libs/backend-api7/src/index.ts b/libs/backend-api7/src/index.ts index 3c6a1f2f..d80f6186 100644 --- a/libs/backend-api7/src/index.ts +++ b/libs/backend-api7/src/index.ts @@ -9,6 +9,9 @@ import { Fetcher } from './fetcher'; import { Operator } from './operator'; import { ToADC } from './transformer'; import * as typing from './typing'; +import { Validator } from './validator'; + +const MINIMUM_VALIDATE_VERSION = '3.9.10'; export class BackendAPI7 implements ADCSDK.Backend { private readonly client: AxiosInstance; @@ -226,4 +229,23 @@ export class BackendAPI7 implements ADCSDK.Backend { if (eventType === type) cb(event); }); } + + public async supportValidate(): Promise { + const version = await this.version(); + return semver.gte(version, MINIMUM_VALIDATE_VERSION); + } + + public validate(events: Array) { + return from(this.getGatewayGroupId()).pipe( + switchMap((gatewayGroupId) => + from( + new Validator({ + client: this.client, + eventSubject: this.subject, + gatewayGroupId, + }).validate(events), + ), + ), + ); + } } diff --git a/libs/backend-api7/src/validator.ts b/libs/backend-api7/src/validator.ts new file mode 100644 index 00000000..388e7ef8 --- /dev/null +++ b/libs/backend-api7/src/validator.ts @@ -0,0 +1,236 @@ +import * as ADCSDK from '@api7/adc-sdk'; +import axios, { type AxiosInstance } from 'axios'; +import { Subject } from 'rxjs'; + +import { FromADC } from './transformer'; +import * as typing from './typing'; + +export interface ValidatorOptions { + client: AxiosInstance; + eventSubject: Subject; + gatewayGroupId?: string; +} + +interface ValidateRequestBody { + routes?: Array; + services?: Array; + consumers?: Array; + ssls?: Array; + global_rules?: Array; + stream_routes?: Array; + plugin_metadata?: Array>; + consumer_groups?: Array>; +} + +export class Validator extends ADCSDK.backend.BackendEventSource { + private readonly client: AxiosInstance; + private readonly fromADC = new FromADC(); + + constructor(private readonly opts: ValidatorOptions) { + super(); + this.client = opts.client; + this.subject = opts.eventSubject; + } + + public async validate( + events: Array, + ): Promise { + const { body, nameIndex } = this.buildRequestBody(events); + + try { + const resp = await this.client.post( + '/apisix/admin/configs/validate', + body, + { params: { gateway_group_id: this.opts.gatewayGroupId } }, + ); + this.subject.next({ + type: ADCSDK.BackendEventType.AXIOS_DEBUG, + event: { response: resp, description: 'Validate configuration' }, + }); + return { success: true, errors: [] }; + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 400) { + this.subject.next({ + type: ADCSDK.BackendEventType.AXIOS_DEBUG, + event: { + response: error.response, + description: 'Validate configuration (failed)', + }, + }); + const data = error.response.data; + const errors: ADCSDK.BackendValidationError[] = (data?.errors ?? []).map( + (e: ADCSDK.BackendValidationError) => { + const name = nameIndex[e.resource_type]?.[e.index]; + return name ? { ...e, resource_name: name } : e; + }, + ); + return { + success: false, + errorMessage: data?.error_msg, + errors, + }; + } + throw error; + } + } + + private flattenEvents(events: Array): Array { + const flat: Array = []; + for (const event of events) { + if (event.type !== ADCSDK.EventType.ONLY_SUB_EVENTS) { + flat.push(event); + } + if (event.subEvents?.length) { + flat.push(...this.flattenEvents(event.subEvents)); + } + } + return flat; + } + + private buildRequestBody(events: Array): { + body: ValidateRequestBody; + nameIndex: Record; + } { + const body: ValidateRequestBody = {}; + const nameIndex: Record = {}; + + const flat = this.flattenEvents(events).filter( + (e) => + e.type === ADCSDK.EventType.CREATE || + e.type === ADCSDK.EventType.UPDATE, + ); + + const services: Array = []; + const serviceNames: string[] = []; + const routes: Array = []; + const routeNames: string[] = []; + const streamRoutes: Array = []; + const streamRouteNames: string[] = []; + const consumers: Array = []; + const consumerNames: string[] = []; + const ssls: Array = []; + const sslNames: string[] = []; + const globalRules: Array = []; + const globalRuleNames: string[] = []; + const pluginMetadata: Array> = []; + const pluginMetadataNames: string[] = []; + const consumerGroups: Array> = []; + const consumerGroupNames: string[] = []; + + for (const event of flat) { + switch (event.resourceType) { + case ADCSDK.ResourceType.SERVICE: { + (event.newValue as ADCSDK.Service).id = event.resourceId; + services.push( + this.fromADC.transformService(event.newValue as ADCSDK.Service), + ); + serviceNames.push(event.resourceName); + break; + } + case ADCSDK.ResourceType.ROUTE: { + (event.newValue as ADCSDK.Route).id = event.resourceId; + routes.push( + this.fromADC.transformRoute( + event.newValue as ADCSDK.Route, + event.parentId!, + ), + ); + routeNames.push(event.resourceName); + break; + } + case ADCSDK.ResourceType.STREAM_ROUTE: { + (event.newValue as ADCSDK.StreamRoute).id = event.resourceId; + streamRoutes.push( + this.fromADC.transformStreamRoute( + event.newValue as ADCSDK.StreamRoute, + event.parentId!, + ), + ); + streamRouteNames.push(event.resourceName); + break; + } + case ADCSDK.ResourceType.CONSUMER: { + consumers.push( + this.fromADC.transformConsumer(event.newValue as ADCSDK.Consumer), + ); + consumerNames.push(event.resourceName); + break; + } + case ADCSDK.ResourceType.SSL: { + (event.newValue as ADCSDK.SSL).id = event.resourceId; + ssls.push( + this.fromADC.transformSSL(event.newValue as ADCSDK.SSL), + ); + sslNames.push(event.resourceName); + break; + } + case ADCSDK.ResourceType.GLOBAL_RULE: { + globalRules.push({ + plugins: { [event.resourceId]: event.newValue }, + } as unknown as typing.GlobalRule); + globalRuleNames.push(event.resourceName); + break; + } + case ADCSDK.ResourceType.PLUGIN_METADATA: { + pluginMetadata.push({ + id: event.resourceId, + ...ADCSDK.utils.recursiveOmitUndefined( + event.newValue as Record, + ), + }); + pluginMetadataNames.push(event.resourceName); + break; + } + case ADCSDK.ResourceType.CONSUMER_GROUP: { + const cg = event.newValue as ADCSDK.ConsumerGroup; + consumerGroups.push( + ADCSDK.utils.recursiveOmitUndefined({ + id: event.resourceId, + name: cg.name, + desc: cg.description, + labels: cg.labels, + plugins: cg.plugins, + }) as unknown as Record, + ); + consumerGroupNames.push(event.resourceName); + break; + } + } + } + + if (services.length) { + body.services = services; + nameIndex.services = serviceNames; + } + if (routes.length) { + body.routes = routes; + nameIndex.routes = routeNames; + } + if (streamRoutes.length) { + body.stream_routes = streamRoutes; + nameIndex.stream_routes = streamRouteNames; + } + if (consumers.length) { + body.consumers = consumers; + nameIndex.consumers = consumerNames; + } + if (ssls.length) { + body.ssls = ssls; + nameIndex.ssls = sslNames; + } + if (globalRules.length) { + body.global_rules = globalRules; + nameIndex.global_rules = globalRuleNames; + } + if (pluginMetadata.length) { + body.plugin_metadata = pluginMetadata; + nameIndex.plugin_metadata = pluginMetadataNames; + } + if (consumerGroups.length) { + body.consumer_groups = consumerGroups; + nameIndex.consumer_groups = consumerGroupNames; + } + + return { body, nameIndex }; + } +} diff --git a/libs/sdk/src/backend/index.ts b/libs/sdk/src/backend/index.ts index 447ce34b..0f687b18 100644 --- a/libs/sdk/src/backend/index.ts +++ b/libs/sdk/src/backend/index.ts @@ -67,6 +67,20 @@ export interface BackendSyncResult { server?: string; } +export interface BackendValidationError { + resource_type: string; + resource_id?: string; + resource_name?: string; + index: number; + error: string; +} + +export interface BackendValidateResult { + success: boolean; + errorMessage?: string; + errors: BackendValidationError[]; +} + export interface BackendMetadata { logScope: string[]; } @@ -84,6 +98,9 @@ export interface Backend { opts?: BackendSyncOptions, ) => Observable; + validate?: ( + events: Array, + ) => Observable; supportValidate?: () => Promise; supportStreamRoute?: () => Promise;