diff --git a/libs/backend-apisix/e2e/resources/service-upstream.e2e-spec.ts b/libs/backend-apisix/e2e/resources/service-upstream.e2e-spec.ts index 15e0f135..305b174a 100644 --- a/libs/backend-apisix/e2e/resources/service-upstream.e2e-spec.ts +++ b/libs/backend-apisix/e2e/resources/service-upstream.e2e-spec.ts @@ -1,3 +1,4 @@ +import { Differ } from '@api7/adc-differ'; import * as ADCSDK from '@api7/adc-sdk'; import { BackendAPISIX } from '../../src'; @@ -22,6 +23,143 @@ describe('Service-Upstreams E2E', () => { }); }); + describe('Service inline upstream', () => { + const serviceName = 'test-inline-upstream'; + const service = { + name: serviceName, + upstream: { + type: 'roundrobin', + nodes: [ + { + host: 'httpbin.org', + port: 443, + weight: 100, + }, + ], + }, + } satisfies ADCSDK.Service; + + it('Create service with inline upstream', async () => + syncEvents(backend, Differ.diff({ services: [service] }, {}))); + + it('Dump (inline upstream should exist)', async () => { + const result = await dumpConfiguration(backend); + const testService = result.services?.find((s) => s.name === serviceName); + expect(testService).toBeDefined(); + expect(testService?.upstream).toMatchObject({ + type: 'roundrobin', + nodes: [ + { + host: 'httpbin.org', + port: 443, + weight: 100, + }, + ], + }); + // Verify that inline upstream has no id and name + expect(testService?.upstream?.id).toBeUndefined(); + expect(testService?.upstream?.name).toBeUndefined(); + }); + + const updatedService = { + name: serviceName, + upstream: { + type: 'roundrobin', + nodes: [ + { + host: 'httpbin.org', + port: 443, + weight: 50, + }, + { + host: 'example.com', + port: 80, + weight: 50, + }, + ], + }, + } satisfies ADCSDK.Service; + it('Update service inline upstream', async () => + syncEvents( + backend, + Differ.diff( + { services: [updatedService] }, + await dumpConfiguration(backend), + ), + )); + + it('Dump (inline upstream should be updated)', async () => { + const result = await dumpConfiguration(backend); + const testService = result.services?.find((s) => s.name === serviceName); + expect(testService).toBeDefined(); + expect(testService?.upstream?.nodes).toHaveLength(2); + expect(testService?.upstream).toMatchObject(updatedService.upstream); + // Verify that inline upstream still has no id and name + expect(testService?.upstream?.id).toBeUndefined(); + expect(testService?.upstream?.name).toBeUndefined(); + }); + + const serviceWithoutUpstream = { + name: serviceName, + hosts: ['test.example.com'], + } satisfies ADCSDK.Service; + it('Update service to remove inline upstream', async () => + syncEvents( + backend, + Differ.diff( + { services: [serviceWithoutUpstream] }, + await dumpConfiguration(backend), + ), + )); + + it('Dump (inline upstream should be removed)', async () => { + const result = await dumpConfiguration(backend); + const testService = result.services?.find((s) => s.name === serviceName); + expect(testService).toBeDefined(); + expect(testService?.upstream).toBeUndefined(); + expect(testService?.hosts).toEqual(['test.example.com']); + }); + + const serviceForDeletion = { + name: serviceName, + hosts: ['test.example.com'], + upstream: { + type: 'roundrobin', + nodes: [ + { + host: 'httpbin.org', + port: 443, + weight: 100, + }, + ], + }, + } satisfies ADCSDK.Service; + it('Re-add inline upstream for deletion test', async () => + syncEvents( + backend, + Differ.diff( + { services: [serviceForDeletion] }, + await dumpConfiguration(backend), + ), + )); + + it('Dump (inline upstream should exist again)', async () => { + const result = await dumpConfiguration(backend); + const testService = result.services?.find((s) => s.name === serviceName); + expect(testService).toBeDefined(); + expect(testService?.upstream).toBeDefined(); + }); + + it('Delete service with inline upstream', async () => + syncEvents(backend, Differ.diff({}, await dumpConfiguration(backend)))); + + it('Dump again (service should not exist)', async () => { + const result = await dumpConfiguration(backend); + const testService = result.services?.find((s) => s.name === serviceName); + expect(testService).toBeUndefined(); + }); + }); + describe('Service multiple upstreams', () => { const serviceName = 'test'; const service = { diff --git a/libs/backend-apisix/package.json b/libs/backend-apisix/package.json index b57d2037..3e8960b1 100644 --- a/libs/backend-apisix/package.json +++ b/libs/backend-apisix/package.json @@ -9,7 +9,8 @@ } }, "devDependencies": { - "@api7/adc-sdk": "workspace:*" + "@api7/adc-sdk": "workspace:*", + "@api7/adc-differ": "workspace:*" }, "nx": { "name": "backend-apisix", diff --git a/libs/backend-apisix/src/fetcher.ts b/libs/backend-apisix/src/fetcher.ts index 78b78309..1c88f05e 100644 --- a/libs/backend-apisix/src/fetcher.ts +++ b/libs/backend-apisix/src/fetcher.ts @@ -1,6 +1,7 @@ import * as ADCSDK from '@api7/adc-sdk'; import { type AxiosInstance } from 'axios'; import { produce } from 'immer'; +import { unset } from 'lodash'; import { Subject, combineLatest, @@ -271,6 +272,8 @@ export class Fetcher extends ADCSDK.backend.BackendEventSource { produce(service, (serviceDraft) => { if (service.upstream_id) serviceDraft.upstream = upstreamIdMap[service.upstream_id]; + unset(serviceDraft, 'upstream.id'); + unset(serviceDraft, 'upstream.name'); if (upstreamServiceIdMap[service.id]) serviceDraft.upstreams = upstreamServiceIdMap[service.id]; }), diff --git a/libs/backend-apisix/src/operator.ts b/libs/backend-apisix/src/operator.ts index 25789ef5..ebae40ce 100644 --- a/libs/backend-apisix/src/operator.ts +++ b/libs/backend-apisix/src/operator.ts @@ -6,11 +6,13 @@ import { Subject, catchError, concatMap, + delay, from, map, mergeMap, of, reduce, + retry, tap, throwError, } from 'rxjs'; @@ -43,6 +45,11 @@ export class Operator extends ADCSDK.backend.BackendEventSource { : `${resourceTypeToAPIName(resourceType)}/${resourceId}` }`; + // Handle service operations separately + if (resourceType === ADCSDK.ResourceType.SERVICE) { + return this.operateService(event, path); + } + return from( this.client.request({ method: 'DELETE', @@ -55,6 +62,123 @@ export class Operator extends ADCSDK.backend.BackendEventSource { ); } + private operateService(event: ADCSDK.Event, servicePath: string) { + const { type } = event; + + // Handle service deletion + if (type === ADCSDK.EventType.DELETE) { + // Delete service with upstream: delete service first, then upstream + if (event.oldValue && (event.oldValue as ADCSDK.Service).upstream) { + return this.deleteServiceWithUpstream(event, servicePath); + } + return from(this.client.request({ url: servicePath, method: 'DELETE' })); + } + + // Handle service create/update + const data = this.fromADC(event, this.opts.version) as typing.Service; + const oldUpstream = event.oldValue + ? (event.oldValue as ADCSDK.Service).upstream + : undefined; + const newUpstream = (event.newValue as ADCSDK.Service).upstream; + + // oldValue has upstream, newValue doesn't -> delete upstream + if (oldUpstream && !newUpstream) { + return this.deleteUpstreamThenUpdateService(event, data, servicePath); + } + + // newValue has upstream -> create/update upstream + if (newUpstream) { + return this.upsertServiceWithUpstream(event, data, servicePath); + } + + // Service without upstream + return from(this.client.request({ url: servicePath, method: 'PUT', data })); + } + + private getUpstreamUrl(upstreamId: string) { + return `/apisix/admin/upstreams/${upstreamId}`; + } + + private upsertServiceWithUpstream( + event: ADCSDK.Event, + data: typing.Service, + servicePath: string, + ) { + // Create/Update upstream first, then service + return from( + this.client.request({ + url: this.getUpstreamUrl(event.resourceId), + method: 'PUT', + data: { + ...data.upstream, + id: event.resourceId, + name: event.resourceName, + }, + }), + ).pipe( + concatMap(() => + this.client.request({ + url: servicePath, + method: 'PUT', + data: { ...data, upstream: undefined, upstream_id: event.resourceId }, + }), + ), + ); + } + + private deleteUpstreamWithRetry(upstreamId: string) { + return from( + this.client.request({ + url: this.getUpstreamUrl(upstreamId), + method: 'DELETE', + }), + ).pipe( + retry({ + count: 3, + delay: (error: Error | AxiosError, retryCount: number) => { + if ( + axios.isAxiosError(error) && + error.response?.data?.error_msg?.includes('is still using it') + ) { + return of(null).pipe(delay(100 * Math.pow(2, retryCount - 1))); + } + return throwError(() => error); + }, + }), + ); + } + + private deleteServiceWithUpstream(event: ADCSDK.Event, servicePath: string) { + return this.executeServiceOpThenDeleteUpstream( + { url: servicePath, method: 'DELETE' }, + event.resourceId, + ); + } + + private deleteUpstreamThenUpdateService( + event: ADCSDK.Event, + data: typing.Service, + servicePath: string, + ) { + return this.executeServiceOpThenDeleteUpstream( + { url: servicePath, method: 'PUT', data }, + event.resourceId, + ); + } + + private executeServiceOpThenDeleteUpstream( + serviceRequest: { + url: string; + method: 'DELETE' | 'PUT'; + data?: typing.Service; + }, + upstreamId: string, + ) { + return from(this.client.request(serviceRequest)).pipe( + concatMap(() => this.deleteUpstreamWithRetry(upstreamId)), + ); + } + public sync( events: Array, opts: ADCSDK.BackendSyncOptions = { exitOnFailure: true }, diff --git a/libs/backend-apisix/src/transformer.ts b/libs/backend-apisix/src/transformer.ts index a577b51c..e22a30fc 100644 --- a/libs/backend-apisix/src/transformer.ts +++ b/libs/backend-apisix/src/transformer.ts @@ -47,7 +47,9 @@ export class ToADC { hosts: service.hosts, - upstream: this.transformUpstream(service.upstream), + upstream: service.upstream + ? this.transformUpstream(service.upstream) + : undefined, upstreams: service.upstreams, plugins: service.plugins, } as ADCSDK.Service); @@ -282,7 +284,9 @@ export class FromADC { name: service.name, desc: service.description, labels: FromADC.transformLabels(service.labels), - upstream: this.transformUpstream(service.upstream), + upstream: service.upstream + ? this.transformUpstream(service.upstream) + : undefined, plugins: service.plugins, hosts: service.hosts, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a65f9849..9afd19b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,7 +86,7 @@ importers: version: 21.4.1(@babel/traverse@7.28.0)(@swc-node/register@1.9.2(@swc/core@1.13.5(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3))(@swc/core@1.13.5(@swc/helpers@0.5.17))(@types/node@22.16.5)(@zkochan/js-yaml@0.0.7)(babel-plugin-macros@3.1.0)(eslint@9.33.0(jiti@2.4.2))(nx@21.4.1(@swc-node/register@1.9.2(@swc/core@1.13.5(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3))(@swc/core@1.13.5(@swc/helpers@0.5.17)))(ts-node@10.9.2(@swc/core@1.13.5(@swc/helpers@0.5.17))(@types/node@22.16.5)(typescript@5.8.3))(typescript@5.8.3) '@nx/vite': specifier: 21.4.1 - version: 21.4.1(@babel/traverse@7.28.0)(@swc-node/register@1.9.2(@swc/core@1.13.5(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3))(@swc/core@1.13.5(@swc/helpers@0.5.17))(nx@21.4.1(@swc-node/register@1.9.2(@swc/core@1.13.5(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3))(@swc/core@1.13.5(@swc/helpers@0.5.17)))(typescript@5.8.3)(vite@6.3.5(@types/node@22.16.5)(jiti@2.4.2)(less@4.1.3)(sass-embedded@1.86.0)(sass@1.86.0)(terser@5.39.1)(yaml@2.8.0))(vitest@3.2.4) + version: 21.4.1(@babel/traverse@7.28.0)(@swc-node/register@1.9.2(@swc/core@1.13.5(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3))(@swc/core@1.13.5(@swc/helpers@0.5.17))(nx@21.4.1(@swc-node/register@1.9.2(@swc/core@1.13.5(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3))(@swc/core@1.13.5(@swc/helpers@0.5.17)))(typescript@5.8.3)(vite@6.3.5(@types/node@22.16.5)(jiti@2.4.2)(less@4.1.3)(sass-embedded@1.86.0)(sass@1.86.0)(terser@5.39.1)(yaml@2.8.0))(vitest@3.2.4(@types/node@22.16.5)(@vitest/ui@3.2.4)(jiti@2.4.2)(less@4.1.3)(sass-embedded@1.86.0)(sass@1.86.0)(terser@5.39.1)(yaml@2.8.0)) '@nx/web': specifier: 21.4.1 version: 21.4.1(@babel/traverse@7.28.0)(@swc-node/register@1.9.2(@swc/core@1.13.5(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3))(@swc/core@1.13.5(@swc/helpers@0.5.17))(nx@21.4.1(@swc-node/register@1.9.2(@swc/core@1.13.5(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3))(@swc/core@1.13.5(@swc/helpers@0.5.17))) @@ -146,7 +146,7 @@ importers: version: 8.41.0(eslint@9.33.0(jiti@2.4.2))(typescript@5.8.3) '@vitest/coverage-v8': specifier: ^3.0.5 - version: 3.2.4(vitest@3.2.4) + version: 3.2.4(vitest@3.2.4(@types/node@22.16.5)(@vitest/ui@3.2.4)(jiti@2.4.2)(less@4.1.3)(sass-embedded@1.86.0)(sass@1.86.0)(terser@5.39.1)(yaml@2.8.0)) '@vitest/ui': specifier: ^3.0.0 version: 3.2.4(vitest@3.2.4) @@ -247,6 +247,9 @@ importers: libs/backend-apisix: devDependencies: + '@api7/adc-differ': + specifier: workspace:* + version: link:../differ '@api7/adc-sdk': specifier: workspace:* version: link:../sdk @@ -286,12 +289,6 @@ importers: libs/sdk: {} - libs/tstetsetse: - dependencies: - tslib: - specifier: ^2.3.0 - version: 2.6.3 - packages: '@ampproject/remapping@2.3.0': @@ -8378,7 +8375,7 @@ snapshots: '@nx/nx-win32-x64-msvc@21.4.1': optional: true - '@nx/vite@21.4.1(@babel/traverse@7.28.0)(@swc-node/register@1.9.2(@swc/core@1.13.5(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3))(@swc/core@1.13.5(@swc/helpers@0.5.17))(nx@21.4.1(@swc-node/register@1.9.2(@swc/core@1.13.5(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3))(@swc/core@1.13.5(@swc/helpers@0.5.17)))(typescript@5.8.3)(vite@6.3.5(@types/node@22.16.5)(jiti@2.4.2)(less@4.1.3)(sass-embedded@1.86.0)(sass@1.86.0)(terser@5.39.1)(yaml@2.8.0))(vitest@3.2.4)': + '@nx/vite@21.4.1(@babel/traverse@7.28.0)(@swc-node/register@1.9.2(@swc/core@1.13.5(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3))(@swc/core@1.13.5(@swc/helpers@0.5.17))(nx@21.4.1(@swc-node/register@1.9.2(@swc/core@1.13.5(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3))(@swc/core@1.13.5(@swc/helpers@0.5.17)))(typescript@5.8.3)(vite@6.3.5(@types/node@22.16.5)(jiti@2.4.2)(less@4.1.3)(sass-embedded@1.86.0)(sass@1.86.0)(terser@5.39.1)(yaml@2.8.0))(vitest@3.2.4(@types/node@22.16.5)(@vitest/ui@3.2.4)(jiti@2.4.2)(less@4.1.3)(sass-embedded@1.86.0)(sass@1.86.0)(terser@5.39.1)(yaml@2.8.0))': dependencies: '@nx/devkit': 21.4.1(nx@21.4.1(@swc-node/register@1.9.2(@swc/core@1.13.5(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3))(@swc/core@1.13.5(@swc/helpers@0.5.17))) '@nx/js': 21.4.1(@babel/traverse@7.28.0)(@swc-node/register@1.9.2(@swc/core@1.13.5(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3))(@swc/core@1.13.5(@swc/helpers@0.5.17))(nx@21.4.1(@swc-node/register@1.9.2(@swc/core@1.13.5(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.8.3))(@swc/core@1.13.5(@swc/helpers@0.5.17))) @@ -9217,7 +9214,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vitest/coverage-v8@3.2.4(vitest@3.2.4)': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@22.16.5)(@vitest/ui@3.2.4)(jiti@2.4.2)(less@4.1.3)(sass-embedded@1.86.0)(sass@1.86.0)(terser@5.39.1)(yaml@2.8.0))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2