From e374db9780f0ac88ec103ad1ea602ce79c65c948 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Thu, 16 Apr 2026 19:34:58 +0800 Subject: [PATCH 1/7] feat: add validate command for API7 EE backend Add a new 'validate' subcommand that validates local ADC configuration against the server-side /apisix/admin/configs/validate API. This performs comprehensive server-side validation (JSON Schema, plugin check_schema, duplicate ID detection) as a dry-run without persisting any changes. Changes: - SDK: Add BackendValidateResult, BackendValidationError interfaces and optional validate()/supportValidate() methods to Backend interface - backend-api7: Implement Validator class that transforms ADC hierarchical configuration to flat backend-native format and POSTs to validate endpoint - backend-api7: Add supportValidate() with version gating (>= 3.9.10) - CLI: Add ValidateTask with version checking and error formatting - CLI: Add validate command with -f, --no-lint options - E2E: Add validate test suite with valid/invalid/multi-error/dry-run cases APISIX and standalone backend support will follow in a separate PR. --- apps/cli/src/command/index.ts | 5 +- apps/cli/src/command/validate.command.ts | 71 ++++++ apps/cli/src/tasks/index.ts | 1 + apps/cli/src/tasks/validate.ts | 41 ++++ libs/backend-api7/e2e/validate.e2e-spec.ts | 242 +++++++++++++++++++++ libs/backend-api7/src/index.ts | 19 ++ libs/backend-api7/src/validator.ts | 153 +++++++++++++ libs/sdk/src/backend/index.ts | 16 ++ 8 files changed, 546 insertions(+), 2 deletions(-) create mode 100644 apps/cli/src/command/validate.command.ts create mode 100644 apps/cli/src/tasks/validate.ts create mode 100644 libs/backend-api7/e2e/validate.e2e-spec.ts create mode 100644 libs/backend-api7/src/validator.ts 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..d4723184 --- /dev/null +++ b/apps/cli/src/command/validate.command.ts @@ -0,0 +1,71 @@ +import { Listr } from 'listr2'; + +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; + 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 }, + 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..061f11be --- /dev/null +++ b/apps/cli/src/tasks/validate.ts @@ -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; + } + }, +}); 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..f11efa9a --- /dev/null +++ b/libs/backend-api7/e2e/validate.e2e-spec.ts @@ -0,0 +1,242 @@ +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, + syncEvents, + createEvent, + deleteEvent, +} from './support/utils'; + +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', + paths: ['/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 () => { + 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', + paths: ['/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 + paths: [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', + paths: ['/multi-err-1'], + plugins: { + 'limit-count': {}, + }, + }, + { + name: 'validate-multi-err-route2', + paths: ['/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', + paths: ['/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, + paths: ['/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, toArray } = await import('rxjs'); + 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..7ba18b57 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,20 @@ 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 async validate( + config: ADCSDK.Configuration, + ): Promise { + const gatewayGroupId = await this.getGatewayGroupId(); + return new Validator({ + client: this.client, + eventSubject: this.subject, + gatewayGroupId, + }).validate(config); + } } diff --git a/libs/backend-api7/src/validator.ts b/libs/backend-api7/src/validator.ts new file mode 100644 index 00000000..09d3a5d3 --- /dev/null +++ b/libs/backend-api7/src/validator.ts @@ -0,0 +1,153 @@ +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( + config: ADCSDK.Configuration, + ): Promise { + const body = this.buildRequestBody(config); + + 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; + return { + success: false, + errorMessage: data?.error_msg, + errors: data?.errors ?? [], + }; + } + throw error; + } + } + + private buildRequestBody(config: ADCSDK.Configuration): ValidateRequestBody { + const body: ValidateRequestBody = {}; + + if (config.services?.length) { + const services: Array = []; + const routes: Array = []; + const streamRoutes: Array = []; + + for (const service of config.services) { + const serviceId = + service.id ?? ADCSDK.utils.generateId(service.name); + const svc = { ...service, id: serviceId }; + const transformed = this.fromADC.transformService(svc); + services.push(transformed); + + for (const route of service.routes ?? []) { + const routeId = route.id ?? ADCSDK.utils.generateId(route.name); + const r = { ...route, id: routeId }; + routes.push(this.fromADC.transformRoute(r, serviceId)); + } + + for (const streamRoute of service.stream_routes ?? []) { + const streamRouteId = + streamRoute.id ?? ADCSDK.utils.generateId(streamRoute.name); + const sr = { ...streamRoute, id: streamRouteId }; + streamRoutes.push( + this.fromADC.transformStreamRoute(sr, serviceId), + ); + } + } + + body.services = services; + if (routes.length) body.routes = routes; + if (streamRoutes.length) body.stream_routes = streamRoutes; + } + + if (config.consumers?.length) { + body.consumers = config.consumers.map((c) => + this.fromADC.transformConsumer(c), + ); + } + + if (config.ssls?.length) { + body.ssls = config.ssls.map((ssl) => { + const sslId = ssl.id ?? ADCSDK.utils.generateId(ssl.snis?.[0] ?? ''); + return this.fromADC.transformSSL({ ...ssl, id: sslId }); + }); + } + + if (config.global_rules && Object.keys(config.global_rules).length) { + body.global_rules = this.fromADC.transformGlobalRule( + config.global_rules as Record, + ); + } + + if ( + config.plugin_metadata && + Object.keys(config.plugin_metadata).length + ) { + body.plugin_metadata = Object.entries(config.plugin_metadata).map( + ([pluginName, config]) => ({ + id: pluginName, + ...ADCSDK.utils.recursiveOmitUndefined(config), + }), + ); + } + + if (config.consumer_groups?.length) { + body.consumer_groups = config.consumer_groups.map((cg) => { + const id = ADCSDK.utils.generateId(cg.name); + return ADCSDK.utils.recursiveOmitUndefined({ + id, + name: cg.name, + desc: cg.description, + labels: cg.labels, + plugins: cg.plugins, + }) as unknown as Record; + }); + } + + return body; + } +} diff --git a/libs/sdk/src/backend/index.ts b/libs/sdk/src/backend/index.ts index 447ce34b..46cd5ada 100644 --- a/libs/sdk/src/backend/index.ts +++ b/libs/sdk/src/backend/index.ts @@ -67,6 +67,19 @@ export interface BackendSyncResult { server?: string; } +export interface BackendValidationError { + resource_type: string; + resource_id?: string; + index: number; + error: string; +} + +export interface BackendValidateResult { + success: boolean; + errorMessage?: string; + errors: BackendValidationError[]; +} + export interface BackendMetadata { logScope: string[]; } @@ -84,6 +97,9 @@ export interface Backend { opts?: BackendSyncOptions, ) => Observable; + validate?: ( + config: ADCSDK.Configuration, + ) => Promise; supportValidate?: () => Promise; supportStreamRoute?: () => Promise; From 1054b4075cc8b0161eea1f72bcf9dae67519b259 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Thu, 16 Apr 2026 19:40:06 +0800 Subject: [PATCH 2/7] fix: use 'uris' instead of 'paths' in E2E test (ADC type) --- libs/backend-api7/e2e/validate.e2e-spec.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/libs/backend-api7/e2e/validate.e2e-spec.ts b/libs/backend-api7/e2e/validate.e2e-spec.ts index f11efa9a..e70e76cd 100644 --- a/libs/backend-api7/e2e/validate.e2e-spec.ts +++ b/libs/backend-api7/e2e/validate.e2e-spec.ts @@ -51,7 +51,7 @@ conditionalDescribe(semverCondition(gte, '3.9.10'))( routes: [ { name: 'validate-test-route', - paths: ['/validate-test'], + uris: ['/validate-test'], methods: ['GET'], }, ], @@ -93,7 +93,7 @@ conditionalDescribe(semverCondition(gte, '3.9.10'))( routes: [ { name: 'validate-bad-plugin-route', - paths: ['/bad-plugin'], + uris: ['/bad-plugin'], plugins: { 'limit-count': { // missing required fields: count, time_window @@ -124,7 +124,7 @@ conditionalDescribe(semverCondition(gte, '3.9.10'))( { name: 'validate-bad-route', // paths should be an array of strings, provide number instead - paths: [123 as unknown as string], + uris: [123 as unknown as string], }, ], }, @@ -148,14 +148,14 @@ conditionalDescribe(semverCondition(gte, '3.9.10'))( routes: [ { name: 'validate-multi-err-route1', - paths: ['/multi-err-1'], + uris: ['/multi-err-1'], plugins: { 'limit-count': {}, }, }, { name: 'validate-multi-err-route2', - paths: ['/multi-err-2'], + uris: ['/multi-err-2'], plugins: { 'limit-count': {}, }, @@ -182,7 +182,7 @@ conditionalDescribe(semverCondition(gte, '3.9.10'))( routes: [ { name: 'validate-mixed-route', - paths: ['/mixed-test'], + uris: ['/mixed-test'], methods: ['GET', 'POST'], }, ], @@ -221,7 +221,7 @@ conditionalDescribe(semverCondition(gte, '3.9.10'))( routes: [ { name: routeName, - paths: ['/dryrun-test'], + uris: ['/dryrun-test'], }, ], }, From 5106e647054b80b9961fb3f3f325f5aa107c7e36 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Thu, 16 Apr 2026 19:43:43 +0800 Subject: [PATCH 3/7] fix: remove unused imports in E2E test --- libs/backend-api7/e2e/validate.e2e-spec.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/libs/backend-api7/e2e/validate.e2e-spec.ts b/libs/backend-api7/e2e/validate.e2e-spec.ts index e70e76cd..4af2a1eb 100644 --- a/libs/backend-api7/e2e/validate.e2e-spec.ts +++ b/libs/backend-api7/e2e/validate.e2e-spec.ts @@ -7,9 +7,6 @@ import { conditionalDescribe, generateHTTPSAgent, semverCondition, - syncEvents, - createEvent, - deleteEvent, } from './support/utils'; conditionalDescribe(semverCondition(gte, '3.9.10'))( @@ -233,7 +230,7 @@ conditionalDescribe(semverCondition(gte, '3.9.10'))( expect(result.success).toBe(true); // Verify no resources were created by dumping - const { lastValueFrom, toArray } = await import('rxjs'); + const { lastValueFrom } = await import('rxjs'); const dumped = await lastValueFrom(backend.dump()); const found = dumped.services?.find((s) => s.name === serviceName); expect(found).toBeUndefined(); From 627f08ecc323479c159d3da7aeb226da8b29903a Mon Sep 17 00:00:00 2001 From: Jarvis Date: Fri, 17 Apr 2026 10:46:23 +0800 Subject: [PATCH 4/7] fix: expect plural resource_type 'routes' from validate API --- libs/backend-api7/e2e/validate.e2e-spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/backend-api7/e2e/validate.e2e-spec.ts b/libs/backend-api7/e2e/validate.e2e-spec.ts index 4af2a1eb..78024b8c 100644 --- a/libs/backend-api7/e2e/validate.e2e-spec.ts +++ b/libs/backend-api7/e2e/validate.e2e-spec.ts @@ -105,7 +105,7 @@ conditionalDescribe(semverCondition(gte, '3.9.10'))( 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'); + expect(result.errors[0].resource_type).toBe('routes'); }); it('should fail with invalid route (bad uri type)', async () => { From f07f059492abc714cf4230e99df8ee984bf3a0cd Mon Sep 17 00:00:00 2001 From: Jarvis Date: Fri, 17 Apr 2026 11:59:57 +0800 Subject: [PATCH 5/7] fix: include resource_id and index in validation error messages --- apps/cli/src/tasks/validate.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/tasks/validate.ts b/apps/cli/src/tasks/validate.ts index 061f11be..5942e808 100644 --- a/apps/cli/src/tasks/validate.ts +++ b/apps/cli/src/tasks/validate.ts @@ -28,8 +28,10 @@ export const ValidateTask = (): ListrTask<{ 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 parts: string[] = [e.resource_type]; + 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')}`, From 01eae10b7a446342e587adba534098e61971f5a3 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Fri, 17 Apr 2026 17:26:39 +0800 Subject: [PATCH 6/7] f Signed-off-by: Jarvis --- apps/cli/src/tasks/validate.ts | 8 ++++-- libs/backend-api7/src/validator.ts | 40 +++++++++++++++++++++++++----- libs/sdk/src/backend/index.ts | 1 + 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/tasks/validate.ts b/apps/cli/src/tasks/validate.ts index 5942e808..cb93ac8f 100644 --- a/apps/cli/src/tasks/validate.ts +++ b/apps/cli/src/tasks/validate.ts @@ -29,8 +29,12 @@ export const ValidateTask = (): ListrTask<{ } for (const e of result.errors) { const parts: string[] = [e.resource_type]; - if (e.resource_id) parts.push(`id="${e.resource_id}"`); - if (e.index !== undefined) parts.push(`index=${e.index}`); + 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( diff --git a/libs/backend-api7/src/validator.ts b/libs/backend-api7/src/validator.ts index 09d3a5d3..4778f95e 100644 --- a/libs/backend-api7/src/validator.ts +++ b/libs/backend-api7/src/validator.ts @@ -35,7 +35,7 @@ export class Validator extends ADCSDK.backend.BackendEventSource { public async validate( config: ADCSDK.Configuration, ): Promise { - const body = this.buildRequestBody(config); + const { body, nameIndex } = this.buildRequestBody(config); try { const resp = await this.client.post( @@ -58,23 +58,36 @@ export class Validator extends ADCSDK.backend.BackendEventSource { }, }); 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: data?.errors ?? [], + errors, }; } throw error; } } - private buildRequestBody(config: ADCSDK.Configuration): ValidateRequestBody { + private buildRequestBody(config: ADCSDK.Configuration): { + body: ValidateRequestBody; + nameIndex: Record; + } { const body: ValidateRequestBody = {}; + const nameIndex: Record = {}; if (config.services?.length) { const services: Array = []; const routes: Array = []; const streamRoutes: Array = []; + const serviceNames: string[] = []; + const routeNames: string[] = []; + const streamRouteNames: string[] = []; for (const service of config.services) { const serviceId = @@ -82,11 +95,13 @@ export class Validator extends ADCSDK.backend.BackendEventSource { const svc = { ...service, id: serviceId }; const transformed = this.fromADC.transformService(svc); services.push(transformed); + serviceNames.push(service.name); for (const route of service.routes ?? []) { const routeId = route.id ?? ADCSDK.utils.generateId(route.name); const r = { ...route, id: routeId }; routes.push(this.fromADC.transformRoute(r, serviceId)); + routeNames.push(route.name); } for (const streamRoute of service.stream_routes ?? []) { @@ -96,18 +111,27 @@ export class Validator extends ADCSDK.backend.BackendEventSource { streamRoutes.push( this.fromADC.transformStreamRoute(sr, serviceId), ); + streamRouteNames.push(streamRoute.name); } } body.services = services; - if (routes.length) body.routes = routes; - if (streamRoutes.length) body.stream_routes = streamRoutes; + nameIndex.services = serviceNames; + if (routes.length) { + body.routes = routes; + nameIndex.routes = routeNames; + } + if (streamRoutes.length) { + body.stream_routes = streamRoutes; + nameIndex.stream_routes = streamRouteNames; + } } if (config.consumers?.length) { body.consumers = config.consumers.map((c) => this.fromADC.transformConsumer(c), ); + nameIndex.consumers = config.consumers.map((c) => c.username); } if (config.ssls?.length) { @@ -115,12 +139,14 @@ export class Validator extends ADCSDK.backend.BackendEventSource { const sslId = ssl.id ?? ADCSDK.utils.generateId(ssl.snis?.[0] ?? ''); return this.fromADC.transformSSL({ ...ssl, id: sslId }); }); + nameIndex.ssls = config.ssls.map((ssl) => ssl.snis?.[0] ?? ''); } if (config.global_rules && Object.keys(config.global_rules).length) { body.global_rules = this.fromADC.transformGlobalRule( config.global_rules as Record, ); + nameIndex.global_rules = Object.keys(config.global_rules); } if ( @@ -133,6 +159,7 @@ export class Validator extends ADCSDK.backend.BackendEventSource { ...ADCSDK.utils.recursiveOmitUndefined(config), }), ); + nameIndex.plugin_metadata = Object.keys(config.plugin_metadata); } if (config.consumer_groups?.length) { @@ -146,8 +173,9 @@ export class Validator extends ADCSDK.backend.BackendEventSource { plugins: cg.plugins, }) as unknown as Record; }); + nameIndex.consumer_groups = config.consumer_groups.map((cg) => cg.name); } - return body; + return { body, nameIndex }; } } diff --git a/libs/sdk/src/backend/index.ts b/libs/sdk/src/backend/index.ts index 46cd5ada..9975a0f2 100644 --- a/libs/sdk/src/backend/index.ts +++ b/libs/sdk/src/backend/index.ts @@ -70,6 +70,7 @@ export interface BackendSyncResult { export interface BackendValidationError { resource_type: string; resource_id?: string; + resource_name?: string; index: number; error: string; } From a98878bf74d5f13cfc9cc740f372ffc6338df889 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Fri, 17 Apr 2026 17:37:39 +0800 Subject: [PATCH 7/7] refactor: accept events instead of config in validate API Per reviewer feedback, change validate() to accept Array instead of ADCSDK.Configuration. This aligns with the existing sync() pattern and allows future diff-then-validate workflows. The validate command now runs DiffResourceTask (with empty remote config) before ValidateTask, generating CREATE events for all local resources. The Validator rebuilds the request body from flattened events using the same fromADC transformation logic as the Operator. --- apps/cli/src/command/validate.command.ts | 8 +- apps/cli/src/tasks/validate.ts | 5 +- libs/backend-api7/e2e/validate.e2e-spec.ts | 42 +++- libs/backend-api7/src/index.ts | 21 +- libs/backend-api7/src/validator.ts | 221 +++++++++++++-------- libs/sdk/src/backend/index.ts | 4 +- 6 files changed, 195 insertions(+), 106 deletions(-) diff --git a/apps/cli/src/command/validate.command.ts b/apps/cli/src/command/validate.command.ts index d4723184..4e9c3b3f 100644 --- a/apps/cli/src/command/validate.command.ts +++ b/apps/cli/src/command/validate.command.ts @@ -1,6 +1,11 @@ import { Listr } from 'listr2'; -import { LintTask, LoadLocalConfigurationTask, ValidateTask } from '../tasks'; +import { + DiffResourceTask, + LintTask, + LoadLocalConfigurationTask, + ValidateTask, +} from '../tasks'; import { InitializeBackendTask } from '../tasks/init_backend'; import { SignaleRenderer } from '../utils/listr'; import { TaskContext } from './diff.command'; @@ -53,6 +58,7 @@ export const ValidateCommand = new BackendCommand( opts.excludeResourceType, ), opts.lint ? LintTask() : { task: () => undefined }, + DiffResourceTask(), ValidateTask(), ], { diff --git a/apps/cli/src/tasks/validate.ts b/apps/cli/src/tasks/validate.ts index cb93ac8f..32649330 100644 --- a/apps/cli/src/tasks/validate.ts +++ b/apps/cli/src/tasks/validate.ts @@ -1,9 +1,10 @@ import * as ADCSDK from '@api7/adc-sdk'; import { ListrTask } from 'listr2'; +import { lastValueFrom } from 'rxjs'; export const ValidateTask = (): ListrTask<{ backend: ADCSDK.Backend; - local: ADCSDK.Configuration; + diff: ADCSDK.Event[]; }> => ({ title: 'Validate configuration against backend', task: async (ctx) => { @@ -21,7 +22,7 @@ export const ValidateTask = (): ListrTask<{ ); } - const result = await ctx.backend.validate!(ctx.local); + const result = await lastValueFrom(ctx.backend.validate!(ctx.diff)); if (!result.success) { const lines: string[] = []; if (result.errorMessage) { diff --git a/libs/backend-api7/e2e/validate.e2e-spec.ts b/libs/backend-api7/e2e/validate.e2e-spec.ts index 78024b8c..5f6963a0 100644 --- a/libs/backend-api7/e2e/validate.e2e-spec.ts +++ b/libs/backend-api7/e2e/validate.e2e-spec.ts @@ -1,5 +1,7 @@ +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'; @@ -9,6 +11,15 @@ import { 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', () => { @@ -31,7 +42,7 @@ conditionalDescribe(semverCondition(gte, '3.9.10'))( }); it('should succeed with empty configuration', async () => { - const result = await backend.validate({}); + const result = await lastValueFrom(backend.validate([])); expect(result.success).toBe(true); expect(result.errors).toEqual([]); }); @@ -56,7 +67,9 @@ conditionalDescribe(semverCondition(gte, '3.9.10'))( ], }; - const result = await backend.validate(config); + const result = await lastValueFrom( + backend.validate(configToEvents(config)), + ); expect(result.success).toBe(true); expect(result.errors).toEqual([]); }); @@ -73,7 +86,9 @@ conditionalDescribe(semverCondition(gte, '3.9.10'))( ], }; - const result = await backend.validate(config); + const result = await lastValueFrom( + backend.validate(configToEvents(config)), + ); expect(result.success).toBe(true); expect(result.errors).toEqual([]); }); @@ -102,7 +117,9 @@ conditionalDescribe(semverCondition(gte, '3.9.10'))( ], }; - const result = await backend.validate(config); + 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'); @@ -128,7 +145,9 @@ conditionalDescribe(semverCondition(gte, '3.9.10'))( ], }; - const result = await backend.validate(config); + const result = await lastValueFrom( + backend.validate(configToEvents(config)), + ); expect(result.success).toBe(false); expect(result.errors.length).toBeGreaterThan(0); }); @@ -162,7 +181,9 @@ conditionalDescribe(semverCondition(gte, '3.9.10'))( ], }; - const result = await backend.validate(config); + const result = await lastValueFrom( + backend.validate(configToEvents(config)), + ); expect(result.success).toBe(false); expect(result.errors.length).toBeGreaterThanOrEqual(2); }); @@ -198,7 +219,9 @@ conditionalDescribe(semverCondition(gte, '3.9.10'))( } as ADCSDK.Configuration['global_rules'], }; - const result = await backend.validate(config); + const result = await lastValueFrom( + backend.validate(configToEvents(config)), + ); expect(result.success).toBe(true); expect(result.errors).toEqual([]); }); @@ -226,11 +249,12 @@ conditionalDescribe(semverCondition(gte, '3.9.10'))( }; // Validate should succeed - const result = await backend.validate(config); + const result = await lastValueFrom( + backend.validate(configToEvents(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(); diff --git a/libs/backend-api7/src/index.ts b/libs/backend-api7/src/index.ts index 7ba18b57..d80f6186 100644 --- a/libs/backend-api7/src/index.ts +++ b/libs/backend-api7/src/index.ts @@ -235,14 +235,17 @@ export class BackendAPI7 implements ADCSDK.Backend { return semver.gte(version, MINIMUM_VALIDATE_VERSION); } - public async validate( - config: ADCSDK.Configuration, - ): Promise { - const gatewayGroupId = await this.getGatewayGroupId(); - return new Validator({ - client: this.client, - eventSubject: this.subject, - gatewayGroupId, - }).validate(config); + 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 index 4778f95e..388e7ef8 100644 --- a/libs/backend-api7/src/validator.ts +++ b/libs/backend-api7/src/validator.ts @@ -33,9 +33,9 @@ export class Validator extends ADCSDK.backend.BackendEventSource { } public async validate( - config: ADCSDK.Configuration, + events: Array, ): Promise { - const { body, nameIndex } = this.buildRequestBody(config); + const { body, nameIndex } = this.buildRequestBody(events); try { const resp = await this.client.post( @@ -74,106 +74,161 @@ export class Validator extends ADCSDK.backend.BackendEventSource { } } - private buildRequestBody(config: ADCSDK.Configuration): { + 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 = {}; - if (config.services?.length) { - const services: Array = []; - const routes: Array = []; - const streamRoutes: Array = []; - const serviceNames: string[] = []; - const routeNames: string[] = []; - const streamRouteNames: string[] = []; - - for (const service of config.services) { - const serviceId = - service.id ?? ADCSDK.utils.generateId(service.name); - const svc = { ...service, id: serviceId }; - const transformed = this.fromADC.transformService(svc); - services.push(transformed); - serviceNames.push(service.name); - - for (const route of service.routes ?? []) { - const routeId = route.id ?? ADCSDK.utils.generateId(route.name); - const r = { ...route, id: routeId }; - routes.push(this.fromADC.transformRoute(r, serviceId)); - routeNames.push(route.name); + 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; } - - for (const streamRoute of service.stream_routes ?? []) { - const streamRouteId = - streamRoute.id ?? ADCSDK.utils.generateId(streamRoute.name); - const sr = { ...streamRoute, id: streamRouteId }; + 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(sr, serviceId), + 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, ); - streamRouteNames.push(streamRoute.name); + 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 (config.consumers?.length) { - body.consumers = config.consumers.map((c) => - this.fromADC.transformConsumer(c), - ); - nameIndex.consumers = config.consumers.map((c) => c.username); + if (routes.length) { + body.routes = routes; + nameIndex.routes = routeNames; } - - if (config.ssls?.length) { - body.ssls = config.ssls.map((ssl) => { - const sslId = ssl.id ?? ADCSDK.utils.generateId(ssl.snis?.[0] ?? ''); - return this.fromADC.transformSSL({ ...ssl, id: sslId }); - }); - nameIndex.ssls = config.ssls.map((ssl) => ssl.snis?.[0] ?? ''); + if (streamRoutes.length) { + body.stream_routes = streamRoutes; + nameIndex.stream_routes = streamRouteNames; } - - if (config.global_rules && Object.keys(config.global_rules).length) { - body.global_rules = this.fromADC.transformGlobalRule( - config.global_rules as Record, - ); - nameIndex.global_rules = Object.keys(config.global_rules); + if (consumers.length) { + body.consumers = consumers; + nameIndex.consumers = consumerNames; } - - if ( - config.plugin_metadata && - Object.keys(config.plugin_metadata).length - ) { - body.plugin_metadata = Object.entries(config.plugin_metadata).map( - ([pluginName, config]) => ({ - id: pluginName, - ...ADCSDK.utils.recursiveOmitUndefined(config), - }), - ); - nameIndex.plugin_metadata = Object.keys(config.plugin_metadata); + if (ssls.length) { + body.ssls = ssls; + nameIndex.ssls = sslNames; } - - if (config.consumer_groups?.length) { - body.consumer_groups = config.consumer_groups.map((cg) => { - const id = ADCSDK.utils.generateId(cg.name); - return ADCSDK.utils.recursiveOmitUndefined({ - id, - name: cg.name, - desc: cg.description, - labels: cg.labels, - plugins: cg.plugins, - }) as unknown as Record; - }); - nameIndex.consumer_groups = config.consumer_groups.map((cg) => cg.name); + 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 9975a0f2..0f687b18 100644 --- a/libs/sdk/src/backend/index.ts +++ b/libs/sdk/src/backend/index.ts @@ -99,8 +99,8 @@ export interface Backend { ) => Observable; validate?: ( - config: ADCSDK.Configuration, - ) => Promise; + events: Array, + ) => Observable; supportValidate?: () => Promise; supportStreamRoute?: () => Promise;