feat: allow dynamically enabling and disabling extensions

Signed-off-by: aramissennyeydd <aramis.sennyey@doordash.com>
This commit is contained in:
aramissennyeydd
2026-03-08 18:08:09 -04:00
committed by Patrik Oldsberg
parent 00381fa7b2
commit 25b7ddd664
23 changed files with 458 additions and 21 deletions
+2
View File
@@ -36,8 +36,10 @@
"@backstage/core-app-api": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/filter-predicates": "workspace:^",
"@backstage/frontend-defaults": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
"@backstage/types": "workspace:^",
"@backstage/version-bridge": "workspace:^",
"lodash": "^4.17.21",
@@ -1782,4 +1782,96 @@ describe('instantiateAppNodeTree', () => {
});
});
});
describe('enabled predicate', () => {
function makeNodeWithEnabled(
enabled: AppNodeSpec['enabled'],
disabled = false,
): AppNode {
const ext = resolveExtensionDefinition(
createExtension({
attachTo: { id: 'ignored', input: 'ignored' },
output: [testDataRef],
factory: () => [testDataRef('value')],
}),
{ namespace: 'test-ext' },
);
return {
spec: {
id: ext.id,
attachTo: ext.attachTo,
disabled,
enabled,
extension: ext as Extension<unknown, unknown>,
plugin: createFrontendPlugin({ pluginId: 'app' }),
},
edges: { attachments: new Map() },
};
}
it('should skip a node when the predicate is not satisfied', () => {
const node = makeNodeWithEnabled({
featureFlags: { $contains: 'the-flag' },
});
const tree = resolveAppTree('test-ext', [node.spec], collector);
instantiateAppNodeTree(tree.root, testApis, collector, undefined, {
featureFlags: [],
});
expect(tree.root.instance).toBeUndefined();
});
it('should instantiate a node when the predicate is satisfied', () => {
const node = makeNodeWithEnabled({
featureFlags: { $contains: 'the-flag' },
});
const tree = resolveAppTree('test-ext', [node.spec], collector);
instantiateAppNodeTree(tree.root, testApis, collector, undefined, {
featureFlags: ['the-flag'],
});
expect(tree.root.instance).toBeDefined();
expect(tree.root.instance?.getData(testDataRef)).toBe('value');
});
it('should support $all operator across multiple flags', () => {
const node = makeNodeWithEnabled({
$all: [
{ featureFlags: { $contains: 'flag-a' } },
{ featureFlags: { $contains: 'flag-b' } },
],
});
const tree = resolveAppTree('test-ext', [node.spec], collector);
// Only one flag active — should not instantiate
instantiateAppNodeTree(tree.root, testApis, collector, undefined, {
featureFlags: ['flag-a'],
});
expect(tree.root.instance).toBeUndefined();
// Both flags active — should instantiate
const tree2 = resolveAppTree('test-ext', [node.spec], collector);
instantiateAppNodeTree(tree2.root, testApis, collector, undefined, {
featureFlags: ['flag-a', 'flag-b'],
});
expect(tree2.root.instance).toBeDefined();
});
it('should instantiate nodes without an enabled field regardless of predicateContext', () => {
const node = makeNodeWithEnabled(undefined);
const tree = resolveAppTree('test-ext', [node.spec], collector);
instantiateAppNodeTree(tree.root, testApis, collector, undefined, {
featureFlags: [],
});
expect(tree.root.instance).toBeDefined();
});
it('should instantiate nodes with enabled predicate when predicateContext is not provided', () => {
const node = makeNodeWithEnabled({
featureFlags: { $contains: 'the-flag' },
});
const tree = resolveAppTree('test-ext', [node.spec], collector);
// No predicateContext passed — predicate evaluation is skipped
instantiateAppNodeTree(tree.root, testApis, collector);
expect(tree.root.instance).toBeDefined();
});
});
});
@@ -29,6 +29,7 @@ import { AppNode, AppNodeInstance } from '@backstage/frontend-plugin-api';
import { toInternalExtension } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
import { createExtensionDataContainer } from '@internal/frontend';
import { ErrorCollector } from '../wiring/createErrorCollector';
import { evaluateFilterPredicate } from '@backstage/filter-predicates';
const INSTANTIATION_FAILED = new Error('Instantiation failed');
@@ -509,10 +510,25 @@ export function instantiateAppNodeTree(
apis: ApiHolder,
collector: ErrorCollector,
extensionFactoryMiddleware?: ExtensionFactoryMiddleware,
options?: {
stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean;
},
optionsOrPredicateContext?:
| {
stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean;
predicateContext?: Record<string, unknown>;
}
| Record<string, unknown>,
): boolean {
const options: {
stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean;
predicateContext?: Record<string, unknown>;
} =
optionsOrPredicateContext &&
('stopAtAttachment' in optionsOrPredicateContext ||
'predicateContext' in optionsOrPredicateContext)
? optionsOrPredicateContext
: {
predicateContext: optionsOrPredicateContext,
};
function createInstance(node: AppNode): AppNodeInstance | undefined {
if (node.instance) {
return node.instance;
@@ -520,6 +536,13 @@ export function instantiateAppNodeTree(
if (node.spec.disabled) {
return undefined;
}
if (
options?.predicateContext !== undefined &&
node.spec.enabled !== undefined &&
!evaluateFilterPredicate(node.spec.enabled, options.predicateContext)
) {
return undefined;
}
const instantiatedAttachments = new Map<string, AppNode[]>();
@@ -15,6 +15,8 @@
*/
import {
createExtension,
createExtensionDataRef,
createFrontendModule,
createFrontendPlugin,
Extension,
@@ -506,4 +508,28 @@ describe('resolveAppNodeSpecs', () => {
},
]);
});
it('should carry enabled predicate through to AppNodeSpec', () => {
const dataRef = createExtensionDataRef<string>().with({ id: 'test.data' });
const enabledPredicate = { featureFlags: { $contains: 'my-flag' } };
const plugin = createFrontendPlugin({
pluginId: 'test-plugin',
extensions: [
createExtension({
attachTo: { id: 'app', input: 'root' },
enabled: enabledPredicate,
output: [dataRef],
factory: () => [dataRef('value')],
}),
],
});
const specs = resolveAppNodeSpecs({
features: [plugin],
builtinExtensions: [],
parameters: [],
collector,
});
expect(specs).toHaveLength(1);
expect(specs[0].enabled).toEqual(enabledPredicate);
});
});
@@ -116,6 +116,10 @@ export function resolveAppNodeSpecs(options: {
source: plugin,
attachTo: internalExtension.attachTo,
disabled: internalExtension.disabled,
enabled:
internalExtension.version === 'v2'
? internalExtension.enabled
: undefined,
config: undefined as unknown,
},
};
@@ -129,6 +133,10 @@ export function resolveAppNodeSpecs(options: {
plugin: appPlugin,
attachTo: internalExtension.attachTo,
disabled: internalExtension.disabled,
enabled:
internalExtension.version === 'v2'
? internalExtension.enabled
: undefined,
config: undefined as unknown,
},
};
@@ -148,6 +156,10 @@ export function resolveAppNodeSpecs(options: {
configuredExtensions[index].extension = internalExtension;
configuredExtensions[index].params.attachTo = internalExtension.attachTo;
configuredExtensions[index].params.disabled = internalExtension.disabled;
configuredExtensions[index].params.enabled =
internalExtension.version === 'v2'
? internalExtension.enabled
: undefined;
} else {
// Add the extension as a new one when not overriding an existing one
configuredExtensions.push({
@@ -157,6 +169,10 @@ export function resolveAppNodeSpecs(options: {
source: extension.plugin,
attachTo: internalExtension.attachTo,
disabled: internalExtension.disabled,
enabled:
internalExtension.version === 'v2'
? internalExtension.enabled
: undefined,
config: undefined,
},
});
@@ -235,6 +251,7 @@ export function resolveAppNodeSpecs(options: {
attachTo: param.params.attachTo,
extension: param.extension,
disabled: param.params.disabled,
enabled: param.params.enabled,
plugin: param.params.plugin,
source: param.params.source,
config: param.params.config,