Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions workspaces/boost/.changeset/wire-runtime-config-resolver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@red-hat-developer-hub/backstage-plugin-boost-backend': minor
---

BREAKING: Wire ConnectorConfigReader runtimeEnabled to RuntimeConfigResolver so that admin panel toggles for connector runtime sync take effect within the 30-second cache TTL. listCandidates() is now async — callers must `await reader.listCandidates()`. ConnectorConfigReaderOptions now requires a `resolver` (RuntimeConfigResolver).
3 changes: 2 additions & 1 deletion workspaces/boost/plugins/boost-backend/report.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,13 +337,14 @@ export interface ConnectorCandidate {
// @public
export class ConnectorConfigReader {
constructor(options: ConnectorConfigReaderOptions);
listCandidates(): ConnectorCandidate[];
listCandidates(): Promise<ConnectorCandidate[]>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] breaking-api-change-semver-mismatch

ConnectorConfigReader.listCandidates() return type changes from ConnectorCandidate[] to Promise<ConnectorCandidate[]>, and ConnectorConfigReaderOptions gains a new required field resolver: RuntimeConfigResolver. Both are source-level breaking changes to the @public API surface. The practical risk is mitigated: no other package in the monorepo imports ConnectorConfigReader, and the package is at version 0.x where semver permits breaking changes in minor bumps.

Suggested fix: Consider noting the breaking change in the changeset description for better developer communication.

}

// @public
export interface ConnectorConfigReaderOptions {
config: RootConfigService;
logger: LoggerService;
resolver: RuntimeConfigResolver;
}

// @public
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
import type { LoggerService } from '@backstage/backend-plugin-api';
import { ConfigReader } from '@backstage/config';
import { ConnectorConfigReader } from './ConnectorConfigReader';
import type { RuntimeConfigResolver } from '../config/RuntimeConfigResolver';
import type { BoostConfigKey } from '../config/schemas';

function createMockLogger(): LoggerService {
return {
Expand All @@ -28,8 +30,24 @@ function createMockLogger(): LoggerService {
};
}

/**
* Create a mock RuntimeConfigResolver that resolves from a flat map.
* Keys not present in the map resolve to `undefined`.
*/
function createMockResolver(
overrides: Map<string, unknown> = new Map(),
): RuntimeConfigResolver {
return {
resolve: jest.fn(async (key: BoostConfigKey) => overrides.get(key)),
resolveAll: jest.fn(async () => overrides),
invalidate: jest.fn(),
set: jest.fn(),
remove: jest.fn(),
} as unknown as RuntimeConfigResolver;
}

describe('ConnectorConfigReader', () => {
it('discovers startup-enabled known providers', () => {
it('discovers startup-enabled known providers', async () => {
const config = new ConfigReader({
'ai-catalog': {
providers: {
Expand All @@ -42,9 +60,10 @@ describe('ConnectorConfigReader', () => {

const reader = new ConnectorConfigReader({
config,
resolver: createMockResolver(),
logger: createMockLogger(),
});
const candidates = reader.listCandidates();
const candidates = await reader.listCandidates();

expect(candidates).toHaveLength(3);
expect(candidates.map(c => c.connectorId).sort()).toEqual([
Expand All @@ -56,7 +75,7 @@ describe('ConnectorConfigReader', () => {
expect(candidates.every(c => c.runtimeEnabled)).toBe(true);
});

it('excludes startup-disabled providers', () => {
it('excludes startup-disabled providers', async () => {
const config = new ConfigReader({
'ai-catalog': {
providers: {
Expand All @@ -68,43 +87,48 @@ describe('ConnectorConfigReader', () => {

const reader = new ConnectorConfigReader({
config,
resolver: createMockResolver(),
logger: createMockLogger(),
});
const candidates = reader.listCandidates();
const candidates = await reader.listCandidates();

expect(candidates).toHaveLength(1);
expect(candidates[0].connectorId).toBe('jira');
});

it('honors boost.connectors.<id>.enabled for runtimeEnabled', () => {
it('honors resolver-returned values for runtimeEnabled', async () => {
// The mock map simulates RuntimeConfigResolver's merged result
// (YAML baseline + DB overrides), not a direct ConfigApi read.
const config = new ConfigReader({
'ai-catalog': {
providers: {
github: { enabled: true },
jira: { enabled: true },
},
},
boost: {
connectors: {
github: { enabled: false },
jira: { enabled: true },
},
},
});

const resolver = createMockResolver(
new Map<string, unknown>([
['boost.connectors.github.enabled', false],
['boost.connectors.jira.enabled', true],
]),
);

const reader = new ConnectorConfigReader({
config,
resolver,
logger: createMockLogger(),
});
const candidates = reader.listCandidates();
const candidates = await reader.listCandidates();

const github = candidates.find(c => c.connectorId === 'github');
const jira = candidates.find(c => c.connectorId === 'jira');
expect(github?.runtimeEnabled).toBe(false);
expect(jira?.runtimeEnabled).toBe(true);
});

it('excludes boost-only IDs without a provider block', () => {
it('excludes boost-only IDs without a provider block', async () => {
const config = new ConfigReader({
'ai-catalog': {
providers: {
Expand All @@ -121,14 +145,188 @@ describe('ConnectorConfigReader', () => {

const reader = new ConnectorConfigReader({
config,
resolver: createMockResolver(),
logger: createMockLogger(),
});
const candidates = reader.listCandidates();
const candidates = await reader.listCandidates();

expect(candidates).toHaveLength(1);
expect(candidates[0].connectorId).toBe('github');
expect(
candidates.find(c => c.connectorId === 'orphanConnector'),
).toBeUndefined();
});

it('YAML-only: runtimeEnabled defaults true when resolver returns undefined', async () => {
const config = new ConfigReader({
'ai-catalog': {
providers: {
github: { enabled: true },
},
},
});

// Resolver returns undefined for the key — no YAML or DB value
const resolver = createMockResolver();

const reader = new ConnectorConfigReader({
config,
resolver,
logger: createMockLogger(),
});
const candidates = await reader.listCandidates();

expect(candidates).toHaveLength(1);
expect(candidates[0].connectorId).toBe('github');
expect(candidates[0].runtimeEnabled).toBe(true);
});

it('DB override: enabled false via resolver overrides YAML true', async () => {
const config = new ConfigReader({
'ai-catalog': {
providers: {
jira: { enabled: true },
},
},
boost: {
connectors: {
jira: { enabled: true },
},
},
});

// Simulate DB override: jira disabled at runtime
const resolver = createMockResolver(
new Map<string, unknown>([['boost.connectors.jira.enabled', false]]),
);

const reader = new ConnectorConfigReader({
config,
resolver,
logger: createMockLogger(),
});
const candidates = await reader.listCandidates();

const jira = candidates.find(c => c.connectorId === 'jira');
expect(jira).toBeDefined();
expect(jira!.runtimeEnabled).toBe(false);
});

it('startup-disabled providers excluded even with includeDisabled', async () => {
// Startup-disabled providers are filtered out before the
// runtimeEnabled flag is even checked, so they never appear
// in the candidate list regardless of includeDisabled.
const config = new ConfigReader({
'ai-catalog': {
providers: {
github: { enabled: true },
jira: { enabled: false },
},
},
});

const reader = new ConnectorConfigReader({
config,
resolver: createMockResolver(),
logger: createMockLogger(),
});
const candidates = await reader.listCandidates();

expect(candidates).toHaveLength(1);
expect(candidates[0].connectorId).toBe('github');
// Jira never appears — startup-disabled is a hard gate
expect(candidates.find(c => c.connectorId === 'jira')).toBeUndefined();
});

it('resolver.resolve is called with the correct key', async () => {
const config = new ConfigReader({
'ai-catalog': {
providers: {
github: { enabled: true },
},
},
});

const resolver = createMockResolver();
const reader = new ConnectorConfigReader({
config,
resolver,
logger: createMockLogger(),
});
await reader.listCandidates();

expect(resolver.resolve).toHaveBeenCalledWith(
'boost.connectors.github.enabled',
);
});

it('non-boolean resolver value warns and falls back to YAML', async () => {
const config = new ConfigReader({
'ai-catalog': {
providers: {
github: { enabled: true },
},
},
boost: {
connectors: {
github: { enabled: false },
},
},
});

const resolver = createMockResolver(
new Map<string, unknown>([['boost.connectors.github.enabled', 'false']]),
);
const logger = createMockLogger();
const reader = new ConnectorConfigReader({
config,
resolver,
logger,
});
const candidates = await reader.listCandidates();

expect(candidates).toHaveLength(1);
// YAML says false — do not fail-open to true on unexpected type
expect(candidates[0].runtimeEnabled).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining(
'Unexpected type for boost.connectors.github.enabled',
),
);
});

it('falls back to YAML ConfigApi when resolver.resolve rejects', async () => {
const config = new ConfigReader({
'ai-catalog': {
providers: {
github: { enabled: true },
},
},
boost: {
connectors: {
github: { enabled: false },
},
},
});

const resolver = createMockResolver();
(resolver.resolve as jest.Mock).mockRejectedValue(
new Error('cache unavailable'),
);
const logger = createMockLogger();
const reader = new ConnectorConfigReader({
config,
resolver,
logger,
});
const candidates = await reader.listCandidates();

expect(candidates).toHaveLength(1);
expect(candidates[0].runtimeEnabled).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining(
'Failed to resolve runtime config for boost.connectors.github.enabled',
),
);
});
});
Loading
Loading