-
Notifications
You must be signed in to change notification settings - Fork 14
feat: support custom plugins as a declarative resource #455
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
Comment on lines
+55
to
+64
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Expand env vars in
Proposed fix const resolveCustomPluginSources = async (
config: ADCSDK.Configuration,
configFilePath: string,
) => {
+ const expandEnv = (value: string) =>
+ value.replace(/\$\{(\w+)\}/g, (_, key) => process.env[key] ?? '');
+
const customPlugins = config.custom_plugins;
if (!Array.isArray(customPlugins) || customPlugins.length === 0) return;
const baseDir = path.dirname(configFilePath);
@@
for (const plugin of customPlugins) {
if (plugin.path) {
- const sourcePath = path.resolve(baseDir, plugin.path);
+ const sourcePath = path.resolve(baseDir, expandEnv(plugin.path));
if (!existsSync(sourcePath))
throw new Error(
`Custom plugin "${plugin.name}" references a source file that does not exist: ${sourcePath}`,
);Also applies to: 135-143 🤖 Prompt for AI Agents |
||
| }, | ||
| }; | ||
| }), | ||
|
|
@@ -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.`, | ||
| ); | ||
| } | ||
| }; | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+17
to
+26
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fail fast on missing E2E env vars (especially Line 17–26 should validate required env vars before constructing Suggested guard beforeAll(() => {
+ const { SERVER, TOKEN, GATEWAY_GROUP } = process.env;
+ if (!SERVER || !TOKEN || !GATEWAY_GROUP) {
+ throw new Error(
+ 'Missing required env vars: SERVER, TOKEN, and GATEWAY_GROUP must be set for Custom Plugin E2E',
+ );
+ }
+
backend = new BackendAPI7({
- server: process.env.SERVER!,
- token: process.env.TOKEN!,
+ server: SERVER,
+ token: TOKEN,
tlsSkipVerify: true,
- gatewayGroup: process.env.GATEWAY_GROUP,
+ gatewayGroup: GATEWAY_GROUP,
cacheKey: 'default',
httpAgent,
httpsAgent: generateHTTPSAgent(),
});
});📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+29
to
+83
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Verify explicit inter-test dependency and shared mutable state in this spec file.
rg -n --type=ts -C2 'const plugin =|plugin\.description\s*=|it\(' libs/backend-api7/e2e/resources/custom-plugin.e2e-spec.tsRepository: api7/adc Length of output: 1440 Avoid order-coupled, stateful E2E tests in
Suggested refactor- describe('Sync and dump custom plugins', () => {
+ describe('Sync and dump custom plugins', () => {
@@
- it('Create custom plugin', async () =>
- syncEvents(backend, [
- createEvent(ADCSDK.ResourceType.CUSTOM_PLUGIN, pluginName, plugin),
- ]));
-
- it('Dump', async () => {
+ it('creates, dumps, updates, and deletes a custom plugin', async () => {
+ await syncEvents(backend, [
+ createEvent(ADCSDK.ResourceType.CUSTOM_PLUGIN, pluginName, plugin),
+ ]);
+
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({
+
+ const updated = (await dumpConfiguration(backend)) as ADCSDK.Configuration;
+ expect(updated.custom_plugins?.[0]).toMatchObject({
name: pluginName,
description: 'updated by e2e',
});
- });
-
- it('Delete custom plugin', async () =>
- syncEvents(backend, [
+
+ await 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);
+ ]);
+
+ const afterDelete = (await dumpConfiguration(backend)) as ADCSDK.Configuration;
+ expect(afterDelete.custom_plugins ?? []).toHaveLength(0);
});
});🤖 Prompt for AI AgentsSource: Coding guidelines |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ import { isEmpty } from 'lodash-es'; | |
| import { | ||
| Subject, | ||
| combineLatest, | ||
| filter, | ||
| from, | ||
| map, | ||
| mergeMap, | ||
|
|
@@ -257,23 +258,63 @@ export class Fetcher extends ADCSDK.backend.BackendEventSource { | |
| ); | ||
| } | ||
|
|
||
| public listCustomPlugins() { | ||
| if (this.isSkip(ADCSDK.ResourceType.CUSTOM_PLUGIN)) | ||
| return of<Array<ADCSDK.CustomPlugin>>([]); | ||
|
|
||
| const taskName = 'Fetch custom plugins'; | ||
| const logger = this.getLogger(taskName); | ||
| const taskStateEvent = this.taskStateEvent(taskName); | ||
| logger(taskStateEvent('TASK_START')); | ||
| return from( | ||
| this.client.get<typing.ListResponse<typing.CustomPlugin>>( | ||
| '/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), | ||
| ), | ||
|
Comment on lines
+279
to
+283
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't widen
🤖 Prompt for AI Agents |
||
| map((plugin) => this.toADC.transformCustomPlugin(plugin)), | ||
| ), | ||
| ), | ||
| toArray(), | ||
| tap(() => logger(taskStateEvent('TASK_DONE'))), | ||
| ); | ||
| } | ||
|
|
||
| public dump() { | ||
| return combineLatest([ | ||
| this.listServices(), | ||
| this.listConsumers(), | ||
| 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, | ||
| ), | ||
| ); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Handle array-valued labels in selector matching.
ADCSDK.Labelsallowsstring | string[], but this predicate only matches scalar strings. A selector like{ team: "blue" }will incorrectly drop resources whose label isteam: ["blue", "prod"], which can cascade into unintended pruning during sync.Proposed fix
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]) => labels && labels[key] && labels[key] === value, + ([key, value]) => { + const actual = labels?.[key]; + return Array.isArray(actual) + ? actual.includes(value) + : actual === value; + }, ); });📝 Committable suggestion
🤖 Prompt for AI Agents