diff --git a/apps/cli/src/command/utils.ts b/apps/cli/src/command/utils.ts index d06c20e9..faab1a66 100644 --- a/apps/cli/src/command/utils.ts +++ b/apps/cli/src/command/utils.ts @@ -58,6 +58,7 @@ export const toKVConfiguration = ( 'plugin_metadata', 'stream_routes', 'upstreams', + 'custom_plugins', ].includes(resourceType) // ensure that you don't convert unexpected keys ) { return [ @@ -131,7 +132,13 @@ export const filterConfiguration = ( ): [ADCSDK.Configuration, ADCSDK.Configuration] => { const removed: ADCSDK.Configuration = {}; Object.keys(configuration).forEach((resourceType) => { - if (resourceType === 'plugin_metadata' || resourceType === 'global_rules') + // Keyed-object and label-less resources are not subject to label filtering; + // custom plugins are global and must not be pruned by a label selector. + if ( + resourceType === 'plugin_metadata' || + resourceType === 'global_rules' || + resourceType === 'custom_plugins' + ) return; const result = labelFilter(configuration[resourceType], rules); configuration[resourceType] = result.filtered; @@ -146,11 +153,10 @@ const labelFilter = ( rules: Record = {}, ) => { const filtered = resources.filter((resource) => { + // Some resources (e.g. custom plugins) carry no labels. + const labels = (resource as { labels?: ADCSDK.Labels })?.labels; return Object.entries(rules).every( - ([key, value]) => - resource?.labels && - resource?.labels[key] && - resource?.labels[key] === value, + ([key, value]) => labels && labels[key] && labels[key] === value, ); }); @@ -170,10 +176,17 @@ export const fillLabels = ( : labels; for (const resourceType in configuration) { - if (['global_rules', 'plugin_metadata'].includes(resourceType)) continue; + // global_rules/plugin_metadata are keyed objects; custom_plugins are + // label-less, so none of them participate in label selectors. + if ( + ['global_rules', 'plugin_metadata', 'custom_plugins'].includes( + resourceType, + ) + ) + continue; - (configuration[resourceType] as Array>).forEach( - (resource) => { + (configuration[resourceType] as Array<{ labels?: ADCSDK.Labels }>).forEach( + (resource: any) => { resource.labels = assignSelector(resource.labels as ADCSDK.Labels); // Process the nested resources diff --git a/apps/cli/src/tasks/load_local.ts b/apps/cli/src/tasks/load_local.ts index fef15f21..a2395455 100644 --- a/apps/cli/src/tasks/load_local.ts +++ b/apps/cli/src/tasks/load_local.ts @@ -52,12 +52,16 @@ export const LoadLocalConfigurationTask = ( (await readFile(filePath, { encoding: 'utf-8' })) ?? ''; const ext = path.extname(filePath).toLowerCase(); - if (ext === '.json') { - subCtx.configurations[filePath] = - JSON.parse(fileContent) ?? {}; - return; - } - subCtx.configurations[filePath] = YAML.load(fileContent) ?? {}; + const config: ADCSDK.Configuration = + ext === '.json' + ? (JSON.parse(fileContent) ?? {}) + : (YAML.load(fileContent) ?? {}); + + // Inline external Lua sources referenced by custom plugins and + // validate the declared name against the source. + await resolveCustomPluginSources(config, filePath); + + subCtx.configurations[filePath] = config; }, }; }), @@ -111,3 +115,43 @@ export const LoadLocalConfigurationTask = ( ); }, }); + +// Resolve each custom plugin's `path` reference into an inline `content` +// (read relative to the config file), and verify that the declared `name` +// actually appears in the (plaintext) Lua source so a typo is caught locally +// rather than rejected later by the control plane. +const resolveCustomPluginSources = async ( + config: ADCSDK.Configuration, + configFilePath: string, +) => { + const customPlugins = config.custom_plugins; + if (!Array.isArray(customPlugins) || customPlugins.length === 0) return; + + const baseDir = path.dirname(configFilePath); + const looksLikeLua = (source: string) => + /\b(function|return|local)\b/.test(source); + + for (const plugin of customPlugins) { + if (plugin.path) { + const sourcePath = path.resolve(baseDir, plugin.path); + if (!existsSync(sourcePath)) + throw new Error( + `Custom plugin "${plugin.name}" references a source file that does not exist: ${sourcePath}`, + ); + plugin.content = + (await readFile(sourcePath, { encoding: 'utf-8' })) ?? ''; + delete plugin.path; + } + + // Only validate when the source is plaintext Lua; obfuscated/bytecode + // uploads will not contain the name literally. + if ( + plugin.content && + looksLikeLua(plugin.content) && + !plugin.content.includes(plugin.name) + ) + throw new Error( + `Custom plugin name "${plugin.name}" was not found in its Lua source; the declared name must match the plugin's name in the source.`, + ); + } +}; diff --git a/libs/backend-api7/e2e/resources/custom-plugin.e2e-spec.ts b/libs/backend-api7/e2e/resources/custom-plugin.e2e-spec.ts new file mode 100644 index 00000000..ec329daa --- /dev/null +++ b/libs/backend-api7/e2e/resources/custom-plugin.e2e-spec.ts @@ -0,0 +1,84 @@ +import * as ADCSDK from '@api7/adc-sdk'; +import { globalAgent as httpAgent } from 'node:http'; + +import { BackendAPI7 } from '../../src'; +import { + createEvent, + deleteEvent, + dumpConfiguration, + generateHTTPSAgent, + syncEvents, + updateEvent, +} from '../support/utils'; + +describe('Custom Plugin E2E', () => { + 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(), + }); + }); + + describe('Sync and dump custom plugins', () => { + const pluginName = 'e2e-custom-plugin'; + const pluginContent = [ + 'local core = require("apisix.core")', + 'local schema = { type = "object", properties = {} }', + `local _M = { version = 0.1, priority = 0, name = "${pluginName}", schema = schema }`, + 'function _M.check_schema(conf) return core.schema.check(schema, conf) end', + 'function _M.access(conf, ctx) end', + 'return _M', + ].join('\n'); + const plugin = { + name: pluginName, + content: pluginContent, + description: 'created by e2e', + } as ADCSDK.CustomPlugin; + + it('Create custom plugin', async () => + syncEvents(backend, [ + createEvent(ADCSDK.ResourceType.CUSTOM_PLUGIN, pluginName, plugin), + ])); + + it('Dump', async () => { + const result = (await dumpConfiguration(backend)) as ADCSDK.Configuration; + expect(result.custom_plugins).toHaveLength(1); + expect(result.custom_plugins?.[0]).toMatchObject({ + name: pluginName, + content: pluginContent, + }); + }); + + it('Update custom plugin (description changed)', async () => { + plugin.description = 'updated by e2e'; + await syncEvents(backend, [ + updateEvent(ADCSDK.ResourceType.CUSTOM_PLUGIN, pluginName, plugin), + ]); + }); + + it('Dump again (description updated)', async () => { + const result = (await dumpConfiguration(backend)) as ADCSDK.Configuration; + expect(result.custom_plugins?.[0]).toMatchObject({ + name: pluginName, + description: 'updated by e2e', + }); + }); + + it('Delete custom plugin', async () => + syncEvents(backend, [ + deleteEvent(ADCSDK.ResourceType.CUSTOM_PLUGIN, pluginName), + ])); + + it('Dump again (custom plugin should not exist)', async () => { + const result = (await dumpConfiguration(backend)) as ADCSDK.Configuration; + expect(result.custom_plugins ?? []).toHaveLength(0); + }); + }); +}); diff --git a/libs/backend-api7/src/fetcher.ts b/libs/backend-api7/src/fetcher.ts index 7d1b9e7c..fdfb3620 100644 --- a/libs/backend-api7/src/fetcher.ts +++ b/libs/backend-api7/src/fetcher.ts @@ -5,6 +5,7 @@ import { isEmpty } from 'lodash-es'; import { Subject, combineLatest, + filter, from, map, mergeMap, @@ -257,6 +258,37 @@ export class Fetcher extends ADCSDK.backend.BackendEventSource { ); } + public listCustomPlugins() { + if (this.isSkip(ADCSDK.ResourceType.CUSTOM_PLUGIN)) + return of>([]); + + const taskName = 'Fetch custom plugins'; + const logger = this.getLogger(taskName); + const taskStateEvent = this.taskStateEvent(taskName); + logger(taskStateEvent('TASK_START')); + return from( + this.client.get>( + '/api/custom_plugins', + ), + ).pipe( + tap((resp) => logger(this.debugLogEvent(resp))), + mergeMap((resp) => + from(resp.data.list ?? []).pipe( + // Custom plugins are control-plane-global; only manage those deployed + // to the gateway group that this backend targets. + filter( + (plugin) => + !this.opts.gatewayGroupId || + (plugin.gateway_groups ?? []).includes(this.opts.gatewayGroupId), + ), + map((plugin) => this.toADC.transformCustomPlugin(plugin)), + ), + ), + toArray(), + tap(() => logger(taskStateEvent('TASK_DONE'))), + ); + } + public dump() { return combineLatest([ this.listServices(), @@ -264,16 +296,25 @@ export class Fetcher extends ADCSDK.backend.BackendEventSource { this.listSSLs(), this.listGlobalRules(), this.listMetadatas(), + this.listCustomPlugins(), ]).pipe( takeLast(1), map( - ([services, consumers, ssls, global_rules, plugin_metadata]) => + ([ + services, + consumers, + ssls, + global_rules, + plugin_metadata, + custom_plugins, + ]) => ({ services, consumers, ssls, global_rules, plugin_metadata, + custom_plugins, }) as ADCSDK.Configuration, ), ); diff --git a/libs/backend-api7/src/operator.ts b/libs/backend-api7/src/operator.ts index dfd71ee4..fb9ddf1f 100644 --- a/libs/backend-api7/src/operator.ts +++ b/libs/backend-api7/src/operator.ts @@ -25,6 +25,7 @@ import { import { SemVer } from 'semver'; import { FromADC } from './transformer'; +import * as typing from './typing'; import { capitalizeFirstLetter } from './utils'; export interface OperatorOptions { @@ -37,6 +38,10 @@ export interface OperatorOptions { export class Operator extends ADCSDK.backend.BackendEventSource { private readonly client: AxiosInstance; + // Memoized control-plane-global custom plugin list, used to reconcile group + // membership during a sync (resolve ids, preserve/trim gateway_groups). + private customPluginListCache?: Promise>; + constructor(private readonly opts: OperatorOptions) { super(); this.client = opts.client; @@ -45,6 +50,13 @@ export class Operator extends ADCSDK.backend.BackendEventSource { private operate(event: ADCSDK.Event) { const { type, resourceType, resourceId, parentId } = event; + + // Custom plugins are control-plane-global resources addressed under + // "/api/custom_plugins" (not the gateway-group-scoped admin API). They are + // reconciled by membership rather than created/deleted outright. + if (resourceType === ADCSDK.ResourceType.CUSTOM_PLUGIN) + return from(this.operateCustomPlugin(event)); + const isUpdate = type !== ADCSDK.EventType.DELETE; const path = `/apisix/admin/${ resourceType === ADCSDK.ResourceType.CONSUMER_CREDENTIAL @@ -94,9 +106,7 @@ export class Operator extends ADCSDK.backend.BackendEventSource { if (axios.isAxiosError(error) && error.response) return throwError( () => - new Error( - ADCSDK.utils.formatAxiosErrorMessage(error), - ), + new Error(ADCSDK.utils.formatAxiosErrorMessage(error)), ); return throwError(() => error); } @@ -276,4 +286,94 @@ export class Operator extends ADCSDK.backend.BackendEventSource { throw new Error(`Unsupported resource type: ${event.resourceType}`); } } + + private getCustomPluginList(): Promise> { + if (!this.customPluginListCache) + this.customPluginListCache = this.client + .get>('/api/custom_plugins') + .then((resp) => resp.data?.list ?? []); + return this.customPluginListCache; + } + + // Reconciles a custom plugin against the control plane while only touching + // the gateway group this backend targets: + // - create/update: ensure this group is in the plugin's membership (POST when + // the plugin does not exist yet, otherwise PUT and append the group). + // - delete (prune): drop this group from the membership; the plugin is only + // removed entirely when no other group still references it. + // Because a custom plugin is shared across its member groups, the uploaded + // source/metadata in the local config is authoritative for every group. + private async operateCustomPlugin( + event: ADCSDK.Event, + ): Promise { + const groupId = this.opts.gatewayGroupId; + if (!groupId) + throw new Error( + 'Managing custom plugins requires a resolved gateway group, but none is available for the current backend.', + ); + + const name = event.resourceName; + const existing = (await this.getCustomPluginList()).find( + (plugin) => plugin.name === name, + ); + const fromADC = new FromADC(); + + if (event.type === ADCSDK.EventType.DELETE) { + if (!existing) + return this.syntheticResponse('delete', 'custom plugin already absent'); + + const remaining = (existing.gateway_groups ?? []).filter( + (id) => id !== groupId, + ); + if (remaining.length === 0) + return this.client.request({ + method: 'DELETE', + url: `/api/custom_plugins/${existing.id}`, + }); + + return this.client.request({ + method: 'PUT', + url: `/api/custom_plugins/${existing.id}`, + data: { + ...fromADC.transformCustomPlugin( + event.oldValue as ADCSDK.CustomPlugin, + ), + gateway_groups: remaining, + }, + }); + } + + const body = fromADC.transformCustomPlugin( + event.newValue as ADCSDK.CustomPlugin, + ); + if (existing) + return this.client.request({ + method: 'PUT', + url: `/api/custom_plugins/${existing.id}`, + data: { + ...body, + gateway_groups: Array.from( + new Set([...(existing.gateway_groups ?? []), groupId]), + ), + }, + }); + + return this.client.request({ + method: 'POST', + url: '/api/custom_plugins', + data: { ...body, gateway_groups: [groupId] }, + }); + } + + // A no-op result for the case where a pruned plugin is already absent, shaped + // so the shared debug/logging path can render it without a real request. + private syntheticResponse(method: string, message: string): AxiosResponse { + return { + status: 200, + statusText: 'OK', + headers: {}, + config: { method, url: '/api/custom_plugins', headers: {} }, + data: { value: { message } }, + } as unknown as AxiosResponse; + } } diff --git a/libs/backend-api7/src/transformer.ts b/libs/backend-api7/src/transformer.ts index 11b73b66..cae473f5 100644 --- a/libs/backend-api7/src/transformer.ts +++ b/libs/backend-api7/src/transformer.ts @@ -154,6 +154,25 @@ export class ToADC { ): Record { return pluginMetadatas; } + + public transformCustomPlugin( + plugin: typing.CustomPlugin, + ): ADCSDK.CustomPlugin { + // Note: when the control plane stores an obfuscated/bytecode plugin + // (is_obfuscated), the returned source_code will not match the user's + // plaintext source, which would surface as a perpetual update. Such + // plugins are expected to be managed outside the declarative workflow. + return ADCSDK.utils.recursiveOmitUndefined({ + id: plugin.id, + name: plugin.name!, + description: plugin.description, + content: plugin.source_code, + catalog: plugin.catalog, + documentation_link: plugin.documentation_link, + author: plugin.author, + logo: plugin.logo, + }); + } } export class FromADC { @@ -307,4 +326,21 @@ export class FromADC { ): typing.PluginMetadata { return ADCSDK.utils.recursiveOmitUndefined(pluginMetadatas); } + + // Produces the metadata portion of the create/update body. The "file" + // (base64 Lua source) and "gateway_groups" membership are attached by the + // operator, which owns the group-scoping reconciliation. By load time, + // "path" has already been resolved into "content". + public transformCustomPlugin( + plugin: ADCSDK.CustomPlugin, + ): Omit { + return ADCSDK.utils.recursiveOmitUndefined({ + file: Buffer.from(plugin.content ?? '', 'utf-8').toString('base64'), + catalog: plugin.catalog, + description: plugin.description, + documentation_link: plugin.documentation_link, + author: plugin.author, + logo: plugin.logo, + }); + } } diff --git a/libs/backend-api7/src/typing.ts b/libs/backend-api7/src/typing.ts index eba6deea..66724680 100644 --- a/libs/backend-api7/src/typing.ts +++ b/libs/backend-api7/src/typing.ts @@ -112,6 +112,35 @@ export interface GlobalRule { plugins: Plugins; } export type PluginMetadata = Record; +// Custom plugins are control-plane-global objects (not scoped by a single +// gateway group via query param). Their "name"/"version" are parsed by the +// control plane from the uploaded Lua source, and "gateway_groups" lists the +// groups the plugin is deployed to. +export interface CustomPlugin { + id?: string; + name?: string; + version?: string; + source_code?: string; + catalog?: string; + description?: string; + documentation_link?: string; + author?: string; + logo?: string; + gateway_groups?: Array; + is_obfuscated?: boolean; + updated_at?: number; +} +// Request body for creating/updating a custom plugin. "file" is the base64 +// encoded Lua source; "gateway_groups" is the full membership list. +export interface CustomPluginInput { + file: string; + catalog?: string; + description?: string; + documentation_link?: string; + author?: string; + logo?: string; + gateway_groups: Array; +} export interface Upstream { id?: string; name: string; diff --git a/libs/backend-api7/test/fetcher.spec.ts b/libs/backend-api7/test/fetcher.spec.ts index ec2aa4c0..ca395182 100644 --- a/libs/backend-api7/test/fetcher.spec.ts +++ b/libs/backend-api7/test/fetcher.spec.ts @@ -77,6 +77,23 @@ describe('Fetcher', () => { ); }); + it('should include only custom plugins', () => { + const fetcher = newFetcher({ + includeResourceType: [ADCSDK.ResourceType.CUSTOM_PLUGIN], + }); + expect(fetcher.isSkip(ADCSDK.ResourceType.CUSTOM_PLUGIN)).toEqual(false); + expect(fetcher.isSkip(ADCSDK.ResourceType.SERVICE)).toEqual(true); + expect(fetcher.isSkip(ADCSDK.ResourceType.GLOBAL_RULE)).toEqual(true); + }); + + it('should exclude custom plugins', () => { + const fetcher = newFetcher({ + excludeResourceType: [ADCSDK.ResourceType.CUSTOM_PLUGIN], + }); + expect(fetcher.isSkip(ADCSDK.ResourceType.CUSTOM_PLUGIN)).toEqual(true); + expect(fetcher.isSkip(ADCSDK.ResourceType.SERVICE)).toEqual(false); + }); + it('should exclude services', () => { const fetcher = newFetcher({ excludeResourceType: [ADCSDK.ResourceType.SERVICE], diff --git a/libs/differ/src/differv3.ts b/libs/differ/src/differv3.ts index 34ab3a5b..5bd18e27 100644 --- a/libs/differ/src/differv3.ts +++ b/libs/differ/src/differv3.ts @@ -23,6 +23,12 @@ const order: Record<`${ADCSDK.ResourceType}.${ADCSDK.EventType}`, number> = { [`${ADCSDK.ResourceType.CONSUMER_GROUP}.${ADCSDK.EventType.UPDATE}`]: 15, [`${ADCSDK.ResourceType.CONSUMER}.${ADCSDK.EventType.UPDATE}`]: 16, + // Custom plugin definitions must exist before anything references them + // (route/service/consumer plugins, global rules), so create/update them + // first; prune them only after every reference has been removed. + [`${ADCSDK.ResourceType.CUSTOM_PLUGIN}.${ADCSDK.EventType.CREATE}`]: 7.1, + [`${ADCSDK.ResourceType.CUSTOM_PLUGIN}.${ADCSDK.EventType.UPDATE}`]: 7.2, + [`${ADCSDK.ResourceType.SERVICE}.${ADCSDK.EventType.CREATE}`]: 17, [`${ADCSDK.ResourceType.PLUGIN_CONFIG}.${ADCSDK.EventType.CREATE}`]: 18, [`${ADCSDK.ResourceType.ROUTE}.${ADCSDK.EventType.CREATE}`]: 19, @@ -40,6 +46,7 @@ const order: Record<`${ADCSDK.ResourceType}.${ADCSDK.EventType}`, number> = { [`${ADCSDK.ResourceType.CONSUMER_CREDENTIAL}.${ADCSDK.EventType.DELETE}`]: 30, [`${ADCSDK.ResourceType.CONSUMER_CREDENTIAL}.${ADCSDK.EventType.CREATE}`]: 31, [`${ADCSDK.ResourceType.CONSUMER_CREDENTIAL}.${ADCSDK.EventType.UPDATE}`]: 32, + [`${ADCSDK.ResourceType.CUSTOM_PLUGIN}.${ADCSDK.EventType.DELETE}`]: 33, // Just placeholders, simply to fix ts type errors, they never appear at runtime [`${ADCSDK.ResourceType.ROUTE}.${ADCSDK.EventType.ONLY_SUB_EVENTS}`]: 999, @@ -53,6 +60,7 @@ const order: Record<`${ADCSDK.ResourceType}.${ADCSDK.EventType}`, number> = { [`${ADCSDK.ResourceType.GLOBAL_RULE}.${ADCSDK.EventType.ONLY_SUB_EVENTS}`]: 999, [`${ADCSDK.ResourceType.PLUGIN_METADATA}.${ADCSDK.EventType.ONLY_SUB_EVENTS}`]: 999, [`${ADCSDK.ResourceType.CONSUMER_CREDENTIAL}.${ADCSDK.EventType.ONLY_SUB_EVENTS}`]: 999, + [`${ADCSDK.ResourceType.CUSTOM_PLUGIN}.${ADCSDK.EventType.ONLY_SUB_EVENTS}`]: 999, [`${ADCSDK.ResourceType.INTERNAL_STREAM_SERVICE}.${ADCSDK.EventType.DELETE}`]: 999, [`${ADCSDK.ResourceType.INTERNAL_STREAM_SERVICE}.${ADCSDK.EventType.UPDATE}`]: 999, [`${ADCSDK.ResourceType.INTERNAL_STREAM_SERVICE}.${ADCSDK.EventType.CREATE}`]: 999, @@ -190,6 +198,11 @@ export class DifferV3 { remote?.consumer_credentials?.map((res) => [res.name, res.id!, res]) ?? [], ), + ...differ.diffResource( + ADCSDK.ResourceType.CUSTOM_PLUGIN, + local?.custom_plugins?.map((res) => [res.name, res.name, res]) ?? [], + remote?.custom_plugins?.map((res) => [res.name, res.name, res]) ?? [], + ), ...differ.diffResource( ADCSDK.ResourceType.UPSTREAM, local?.upstreams?.map((res: ADCSDK.Upstream & { id?: string }) => [ diff --git a/libs/differ/src/test/custom-plugin.spec.ts b/libs/differ/src/test/custom-plugin.spec.ts new file mode 100644 index 00000000..3bb78a3e --- /dev/null +++ b/libs/differ/src/test/custom-plugin.spec.ts @@ -0,0 +1,71 @@ +import * as ADCSDK from '@api7/adc-sdk'; + +import { DifferV3 } from '../differv3.js'; + +describe('Differ V3 - custom plugin', () => { + const name = 'my-plugin'; + const content = 'local _M = { name = "my-plugin" }\nreturn _M'; + + it('should create custom plugin', () => { + expect(DifferV3.diff({ custom_plugins: [{ name, content }] }, {})).toEqual([ + { + resourceType: ADCSDK.ResourceType.CUSTOM_PLUGIN, + type: ADCSDK.EventType.CREATE, + resourceId: name, + resourceName: name, + newValue: { name, content }, + }, + ] as Array); + }); + + it('should update custom plugin when the source changes', () => { + const newContent = `${content}\n-- updated`; + const events = DifferV3.diff( + { custom_plugins: [{ name, content: newContent }] }, + { custom_plugins: [{ name, content }] }, + ); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + resourceType: ADCSDK.ResourceType.CUSTOM_PLUGIN, + type: ADCSDK.EventType.UPDATE, + resourceId: name, + resourceName: name, + newValue: { name, content: newContent }, + }); + }); + + it('should not emit events when the custom plugin is unchanged', () => { + expect( + DifferV3.diff( + { custom_plugins: [{ name, content }] }, + { custom_plugins: [{ name, content }] }, + ), + ).toEqual([]); + }); + + it('should delete (prune) a custom plugin missing locally', () => { + const events = DifferV3.diff({}, { custom_plugins: [{ name, content }] }); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + resourceType: ADCSDK.ResourceType.CUSTOM_PLUGIN, + type: ADCSDK.EventType.DELETE, + resourceId: name, + resourceName: name, + oldValue: { name, content }, + }); + }); + + it('should order custom plugin creation before route creation', () => { + const events = DifferV3.diff( + { + custom_plugins: [{ name, content }], + routes: [{ name: 'r1', uris: ['/'], plugins: { [name]: {} } }], + }, + {}, + ); + const types = events.map((e) => e.resourceType); + expect(types.indexOf(ADCSDK.ResourceType.CUSTOM_PLUGIN)).toBeLessThan( + types.indexOf(ADCSDK.ResourceType.ROUTE), + ); + }); +}); diff --git a/libs/sdk/src/core/index.ts b/libs/sdk/src/core/index.ts index 4c977370..3167d15a 100644 --- a/libs/sdk/src/core/index.ts +++ b/libs/sdk/src/core/index.ts @@ -2,6 +2,7 @@ import { ResourceType } from './resource'; import { Consumer, ConsumerCredential, + CustomPlugin, GlobalRule, Labels, PluginMetadata, @@ -36,6 +37,7 @@ export type { PluginMetadata, ConsumerCredential, Consumer, + CustomPlugin, StreamRoute, Configuration, InternalConfiguration, @@ -83,4 +85,6 @@ export type ResourceFor = T extends ResourceType.SERVICE ? PluginConfig : T extends ResourceType.CONSUMER_CREDENTIAL ? ConsumerCredential - : never; + : T extends ResourceType.CUSTOM_PLUGIN + ? CustomPlugin + : never; diff --git a/libs/sdk/src/core/resource.ts b/libs/sdk/src/core/resource.ts index 7efb6c77..021db9cb 100644 --- a/libs/sdk/src/core/resource.ts +++ b/libs/sdk/src/core/resource.ts @@ -10,6 +10,7 @@ export enum ResourceType { CONSUMER_GROUP = 'consumer_group', CONSUMER_CREDENTIAL = 'consumer_credential', STREAM_ROUTE = 'stream_route', + CUSTOM_PLUGIN = 'custom_plugin', // internal use only INTERNAL_STREAM_SERVICE = 'stream_service', diff --git a/libs/sdk/src/core/schema.ts b/libs/sdk/src/core/schema.ts index b40686a7..e35dbf8e 100644 --- a/libs/sdk/src/core/schema.ts +++ b/libs/sdk/src/core/schema.ts @@ -356,6 +356,37 @@ const consumerGroupSchema = z.strictObject({ }); export type ConsumerGroup = z.infer; +const customPluginSchema = z + .strictObject({ + id: idSchema.optional(), + // The plugin name must match the one declared inside the Lua source. It is + // declared explicitly so the diff can be keyed without parsing the source. + name: z + .string() + .min(1) + .max(256) + .regex(/^[a-zA-Z0-9-_.]+$/), + description: descriptionSchema.optional(), + + // The Lua source. Either provided inline via "content" or referenced from + // an external file via "path" (resolved into "content" at load time). + content: z.string().min(1).optional(), + path: z.string().min(1).optional(), + + // Display-only metadata stored on the control plane. + catalog: z.string().optional(), + documentation_link: z.string().optional(), + author: z.string().optional(), + logo: z.string().optional(), + }) + .refine((val) => !isNil(val.content) || !isNil(val.path), { + error: 'Custom plugin must set either "content" or "path"', + }) + .refine((val) => !(!isNil(val.content) && !isNil(val.path)), { + error: 'Custom plugin "content" and "path" are mutually exclusive', + }); +export type CustomPlugin = z.infer; + const globalRuleSchema = pluginsSchema; export type GlobalRule = z.infer; @@ -369,6 +400,7 @@ export const ConfigurationSchema = z.strictObject({ consumer_groups: z.array(consumerGroupSchema).optional(), global_rules: globalRuleSchema.optional(), plugin_metadata: pluginMetadataSchema.optional(), + custom_plugins: z.array(customPluginSchema).optional(), }); export type Configuration = z.infer; @@ -380,6 +412,7 @@ export const InternalConfigurationSchema = z.strictObject({ // object format resources global_rules: globalRuleSchema.optional(), plugin_metadata: pluginMetadataSchema.optional(), + custom_plugins: z.array(customPluginSchema).optional(), // internal use only routes: z.array(routeSchema).optional(), diff --git a/schema.json b/schema.json index b5989657..ef2df8f1 100644 --- a/schema.json +++ b/schema.json @@ -1607,6 +1607,54 @@ "properties": {}, "additionalProperties": {} } + }, + "custom_plugins": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^[a-zA-Z0-9-_.]+$" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^[a-zA-Z0-9-_.]+$" + }, + "description": { + "type": "string", + "maxLength": 65536 + }, + "content": { + "type": "string", + "minLength": 1 + }, + "path": { + "type": "string", + "minLength": 1 + }, + "catalog": { + "type": "string" + }, + "documentation_link": { + "type": "string" + }, + "author": { + "type": "string" + }, + "logo": { + "type": "string" + } + }, + "required": [ + "name" + ], + "additionalProperties": false + } } }, "additionalProperties": false