diff --git a/.gitignore b/.gitignore index 1d8fc3f9..be8e8b05 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ # compiled output dist tmp -/out-tsc +out-tsc/ # dependencies node_modules @@ -51,4 +51,4 @@ vitest.config.*.timestamp* .github/instructions/nx.instructions.md .claude/worktrees -.claude/settings.local.json \ No newline at end of file +.claude/settings.local.json diff --git a/apps/cli/src/tasks/validate.ts b/apps/cli/src/tasks/validate.ts index 32649330..ff5eb7b5 100644 --- a/apps/cli/src/tasks/validate.ts +++ b/apps/cli/src/tasks/validate.ts @@ -8,41 +8,34 @@ export const ValidateTask = (): ListrTask<{ }> => ({ title: 'Validate configuration against backend', task: async (ctx) => { - if (!ctx.backend.supportValidate) { - throw new Error( - 'Validate is not supported by the current backend', - ); - } + if (!ctx.backend.validate) + 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) return; - 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; - } + throw buildPlainTextError(result); }, }); + +function buildPlainTextError(res: ADCSDK.BackendValidateResult) { + const lines: string[] = []; + if (res.errorMessage) { + lines.push(res.errorMessage); + } + for (const e of res.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 = ''; + return error; +} diff --git a/libs/backend-api7/e2e/validate.e2e-spec.ts b/libs/backend-api7/e2e/validate.e2e-spec.ts index 5f6963a0..c2bee783 100644 --- a/libs/backend-api7/e2e/validate.e2e-spec.ts +++ b/libs/backend-api7/e2e/validate.e2e-spec.ts @@ -1,8 +1,8 @@ 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 { lastValueFrom } from 'rxjs'; +import { gte, lt } from 'semver'; import { BackendAPI7 } from '../src'; import { @@ -11,17 +11,15 @@ import { semverCondition, } from './support/utils'; -const configToEvents = ( - config: ADCSDK.Configuration, -): Array => { +const configToEvents = (config: ADCSDK.Configuration): Array => { return DifferV3.diff( config as ADCSDK.InternalConfiguration, {} as ADCSDK.InternalConfiguration, ); }; -conditionalDescribe(semverCondition(gte, '3.9.10'))( - 'Validate', +conditionalDescribe(semverCondition(lt, '3.9.10'))( + 'Validate (unsupported version)', () => { let backend: BackendAPI7; @@ -37,227 +35,245 @@ conditionalDescribe(semverCondition(gte, '3.9.10'))( }); }); - it('should report supportValidate as true', async () => { - expect(await backend.supportValidate()).toBe(true); + it('should report unsupported version', async () => { + await expect(lastValueFrom(backend.validate([]))).rejects.toThrow( + 'not supported', + ); }); + }, +); + +conditionalDescribe(semverCondition(gte, '3.9.10'))('Validate', () => { + let backend: BackendAPI7; - it('should succeed with empty configuration', async () => { - const result = await lastValueFrom(backend.validate([])); - expect(result.success).toBe(true); - expect(result.errors).toEqual([]); + 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 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'], - }, - ], + 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([]); - }); + 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' }, - }, + 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([]); - }); + 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 - }, + 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'); - }); + 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], - }, - ], + 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); - }); + 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': {}, - }, + 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': {}, - }, + }, + { + 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); - }); + 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'], - }, - ], + 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 }], }, - ], - consumers: [ - { - username: 'validate-mixed-consumer', - plugins: { - 'key-auth': { key: 'mixed-key-456' }, + 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'], - }; + }, + ], + 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([]); - }); + 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'; + 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'], - }, - ], + 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); + // 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(); - }); - }, -); + // 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 d80f6186..3e273175 100644 --- a/libs/backend-api7/src/index.ts +++ b/libs/backend-api7/src/index.ts @@ -11,8 +11,6 @@ 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; private readonly gatewayGroupName: string; @@ -230,17 +228,16 @@ export class BackendAPI7 implements ADCSDK.Backend { }); } - 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) => + return forkJoin([ + from(this.version()), + from(this.getGatewayGroupId()), + ]).pipe( + switchMap(([version, gatewayGroupId]) => from( new Validator({ client: this.client, + version, eventSubject: this.subject, gatewayGroupId, }).validate(events), diff --git a/libs/backend-api7/src/validator.ts b/libs/backend-api7/src/validator.ts index 388e7ef8..b1112a3f 100644 --- a/libs/backend-api7/src/validator.ts +++ b/libs/backend-api7/src/validator.ts @@ -1,27 +1,30 @@ import * as ADCSDK from '@api7/adc-sdk'; import axios, { type AxiosInstance } from 'axios'; import { Subject } from 'rxjs'; +import { SemVer, lt as semverLT } from 'semver'; import { FromADC } from './transformer'; import * as typing from './typing'; export interface ValidatorOptions { client: AxiosInstance; + version: SemVer; 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>; + routes: Array; + services: Array; + consumers: Array; + ssls: Array; + global_rules: Array; + stream_routes: Array; + plugin_metadata: Array>; } +const MINIMUM_VALIDATE_VERSION = '3.9.10'; + export class Validator extends ADCSDK.backend.BackendEventSource { private readonly client: AxiosInstance; private readonly fromADC = new FromADC(); @@ -35,6 +38,11 @@ export class Validator extends ADCSDK.backend.BackendEventSource { public async validate( events: Array, ): Promise { + if (semverLT(this.opts.version, MINIMUM_VALIDATE_VERSION)) + throw new Error( + `Validate is not supported by the current backend version (${this.opts.version}). Please upgrade to a newer version.`, + ); + const { body, nameIndex } = this.buildRequestBody(events); try { @@ -58,12 +66,12 @@ 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; - }, - ); + 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, @@ -74,163 +82,96 @@ export class Validator extends ADCSDK.backend.BackendEventSource { } } - 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 body: ValidateRequestBody = { + routes: [], + services: [], + consumers: [], + ssls: [], + global_rules: [], + stream_routes: [], + plugin_metadata: [], + }; + const nameIndex = structuredClone(body) as unknown as Record< + string, + string[] + >; - const flat = this.flattenEvents(events).filter( + const flat = 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( + body.services.push( this.fromADC.transformService(event.newValue as ADCSDK.Service), ); - serviceNames.push(event.resourceName); + nameIndex.services.push(event.resourceName); break; } case ADCSDK.ResourceType.ROUTE: { (event.newValue as ADCSDK.Route).id = event.resourceId; - routes.push( + body.routes.push( this.fromADC.transformRoute( event.newValue as ADCSDK.Route, event.parentId!, ), ); - routeNames.push(event.resourceName); + nameIndex.routes.push(event.resourceName); break; } case ADCSDK.ResourceType.STREAM_ROUTE: { (event.newValue as ADCSDK.StreamRoute).id = event.resourceId; - streamRoutes.push( + body.stream_routes.push( this.fromADC.transformStreamRoute( event.newValue as ADCSDK.StreamRoute, event.parentId!, ), ); - streamRouteNames.push(event.resourceName); + nameIndex.stream_routes.push(event.resourceName); break; } case ADCSDK.ResourceType.CONSUMER: { - consumers.push( + body.consumers.push( this.fromADC.transformConsumer(event.newValue as ADCSDK.Consumer), ); - consumerNames.push(event.resourceName); + nameIndex.consumers.push(event.resourceName); break; } case ADCSDK.ResourceType.SSL: { (event.newValue as ADCSDK.SSL).id = event.resourceId; - ssls.push( + body.ssls.push( this.fromADC.transformSSL(event.newValue as ADCSDK.SSL), ); - sslNames.push(event.resourceName); + nameIndex.ssls.push(event.resourceName); break; } case ADCSDK.ResourceType.GLOBAL_RULE: { - globalRules.push({ + body.global_rules.push({ plugins: { [event.resourceId]: event.newValue }, } as unknown as typing.GlobalRule); - globalRuleNames.push(event.resourceName); + nameIndex.global_rules.push(event.resourceName); break; } case ADCSDK.ResourceType.PLUGIN_METADATA: { - pluginMetadata.push({ + body.plugin_metadata.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); + nameIndex.plugin_metadata.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/backend-apisix-standalone/src/index.ts b/libs/backend-apisix-standalone/src/index.ts index e3305b72..f7abcecd 100644 --- a/libs/backend-apisix-standalone/src/index.ts +++ b/libs/backend-apisix-standalone/src/index.ts @@ -166,7 +166,6 @@ export class BackendAPISIXStandalone implements ADCSDK.Backend { }); } - supportValidate?: () => Promise; supportStreamRoute?: () => Promise; public __TEST_ONLY = { diff --git a/libs/backend-apisix/src/index.ts b/libs/backend-apisix/src/index.ts index 45bef485..2b4f3473 100644 --- a/libs/backend-apisix/src/index.ts +++ b/libs/backend-apisix/src/index.ts @@ -102,6 +102,5 @@ export class BackendAPISIX implements ADCSDK.Backend { }); } - supportValidate?: () => Promise; supportStreamRoute?: () => Promise; } diff --git a/libs/sdk/src/backend/index.ts b/libs/sdk/src/backend/index.ts index 0f687b18..d35bdbea 100644 --- a/libs/sdk/src/backend/index.ts +++ b/libs/sdk/src/backend/index.ts @@ -97,11 +97,8 @@ export interface Backend { events: Array, opts?: BackendSyncOptions, ) => Observable; + validate?: (events: Array) => Observable; - validate?: ( - events: Array, - ) => Observable; - supportValidate?: () => Promise; supportStreamRoute?: () => Promise; // Event report: optional standard, backends may not implement event reporting.