diff --git a/workspaces/boost/.changeset/wire-runtime-config-resolver.md b/workspaces/boost/.changeset/wire-runtime-config-resolver.md new file mode 100644 index 00000000000..81005949e53 --- /dev/null +++ b/workspaces/boost/.changeset/wire-runtime-config-resolver.md @@ -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). diff --git a/workspaces/boost/plugins/boost-backend/report.api.md b/workspaces/boost/plugins/boost-backend/report.api.md index 8500bcd5bbb..f9157a187ae 100644 --- a/workspaces/boost/plugins/boost-backend/report.api.md +++ b/workspaces/boost/plugins/boost-backend/report.api.md @@ -337,13 +337,14 @@ export interface ConnectorCandidate { // @public export class ConnectorConfigReader { constructor(options: ConnectorConfigReaderOptions); - listCandidates(): ConnectorCandidate[]; + listCandidates(): Promise; } // @public export interface ConnectorConfigReaderOptions { config: RootConfigService; logger: LoggerService; + resolver: RuntimeConfigResolver; } // @public diff --git a/workspaces/boost/plugins/boost-backend/src/ingestion/ConnectorConfigReader.test.ts b/workspaces/boost/plugins/boost-backend/src/ingestion/ConnectorConfigReader.test.ts index cb8e79ba502..088358cf11c 100644 --- a/workspaces/boost/plugins/boost-backend/src/ingestion/ConnectorConfigReader.test.ts +++ b/workspaces/boost/plugins/boost-backend/src/ingestion/ConnectorConfigReader.test.ts @@ -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 { @@ -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 = 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: { @@ -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([ @@ -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: { @@ -68,15 +87,18 @@ 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..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: { @@ -84,19 +106,21 @@ describe('ConnectorConfigReader', () => { jira: { enabled: true }, }, }, - boost: { - connectors: { - github: { enabled: false }, - jira: { enabled: true }, - }, - }, }); + const resolver = createMockResolver( + new Map([ + ['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'); @@ -104,7 +128,7 @@ describe('ConnectorConfigReader', () => { 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: { @@ -121,9 +145,10 @@ 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'); @@ -131,4 +156,177 @@ describe('ConnectorConfigReader', () => { 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([['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([['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', + ), + ); + }); }); diff --git a/workspaces/boost/plugins/boost-backend/src/ingestion/ConnectorConfigReader.ts b/workspaces/boost/plugins/boost-backend/src/ingestion/ConnectorConfigReader.ts index e6f36e36f40..8f4f9d75e72 100644 --- a/workspaces/boost/plugins/boost-backend/src/ingestion/ConnectorConfigReader.ts +++ b/workspaces/boost/plugins/boost-backend/src/ingestion/ConnectorConfigReader.ts @@ -18,6 +18,8 @@ import type { LoggerService, RootConfigService, } from '@backstage/backend-plugin-api'; +import type { RuntimeConfigResolver } from '../config/RuntimeConfigResolver'; +import { boostConfigFields, type BoostConfigKey } from '../config/schemas'; // --------------------------------------------------------------------------- // Types @@ -36,8 +38,10 @@ export interface ConnectorCandidate { /** Whether the provider is registered at startup (`ai-catalog.providers..enabled`). */ startupEnabled: boolean; /** - * Whether runtime syncing is enabled (`boost.connectors..enabled`). - * Defaults to `true` if the key is missing. + * Whether runtime syncing is enabled for this connector. + * Resolved via {@link RuntimeConfigResolver} for + * `boost.connectors..enabled` (YAML baseline + DB overrides). + * Defaults to `true` when unset in both layers. */ runtimeEnabled: boolean; } @@ -50,6 +54,8 @@ export interface ConnectorCandidate { export interface ConnectorConfigReaderOptions { /** The Backstage root config service. */ config: RootConfigService; + /** The runtime config resolver for DB-overridable fields. */ + resolver: RuntimeConfigResolver; /** The Backstage logger service. */ logger: LoggerService; } @@ -69,22 +75,24 @@ const KNOWN_CONNECTOR_TYPES = ['github', 'gitlab', 'jira'] as const; // --------------------------------------------------------------------------- /** - * Reads connector configuration from Backstage ConfigApi for health - * API discovery. + * Reads connector configuration from Backstage ConfigApi and + * {@link RuntimeConfigResolver} for health API discovery. * - * This is a seam for #4044 (connector config hot-reload). Today it - * reads both `enabled` flags from YAML via ConfigApi. After #4044 - * lands, `runtimeEnabled` will come from `RuntimeConfigResolver` - * (YAML + DB overrides) while `startupEnabled` stays YAML-only. + * `startupEnabled` is read from YAML-only (`ai-catalog.providers..enabled`) + * via ConfigApi. `runtimeEnabled` is resolved via + * {@link RuntimeConfigResolver} so that DB overrides (admin panel + * toggles) take effect within the 30-second cache TTL. * * @public */ export class ConnectorConfigReader { private readonly config: RootConfigService; + private readonly resolver: RuntimeConfigResolver; private readonly logger: LoggerService; constructor(options: ConnectorConfigReaderOptions) { this.config = options.config; + this.resolver = options.resolver; this.logger = options.logger.child({ service: 'ConnectorConfigReader' }); } @@ -96,23 +104,24 @@ export class ConnectorConfigReader { * `ai-catalog.providers.*` for in-scope types. * 2. Drop any ID where `ai-catalog.providers..enabled === false` * (or provider block absent). - * 3. Set `runtimeEnabled` from `boost.connectors..enabled` - * (default `true`). + * 3. Set `runtimeEnabled` from `RuntimeConfigResolver` resolution of + * `boost.connectors..enabled` (default `true`), which merges + * YAML baseline with DB overrides. * * @returns Array of connector candidates that passed startup-enabled * filtering. */ - listCandidates(): ConnectorCandidate[] { + async listCandidates(): Promise { const candidates: ConnectorCandidate[] = []; const seen = new Set(); // Scan ai-catalog.providers for known connector types for (const connectorType of KNOWN_CONNECTOR_TYPES) { - this.discoverFromProviders(connectorType, candidates, seen); + await this.discoverFromProviders(connectorType, candidates, seen); } // Scan boost.connectors for any additional IDs - this.discoverFromBoostConnectors(candidates, seen); + await this.discoverFromBoostConnectors(candidates, seen); this.logger.debug(`Discovered ${candidates.length} connector candidate(s)`); return candidates; @@ -121,11 +130,11 @@ export class ConnectorConfigReader { /** * Discover connectors from `ai-catalog.providers.` config. */ - private discoverFromProviders( + private async discoverFromProviders( connectorType: string, candidates: ConnectorCandidate[], seen: Set, - ): void { + ): Promise { try { const providersConfig = this.config.getOptionalConfig( 'ai-catalog.providers', @@ -158,8 +167,8 @@ export class ConnectorConfigReader { return; } - // Check runtime-enabled flag from boost.connectors - const runtimeEnabled = this.getRuntimeEnabled(connectorId); + // Resolve runtime-enabled flag via RuntimeConfigResolver + const runtimeEnabled = await this.resolveRuntimeEnabled(connectorId); candidates.push({ connectorId, @@ -178,10 +187,10 @@ export class ConnectorConfigReader { * Discover connectors from `boost.connectors.` config that * were not already found via `ai-catalog.providers`. */ - private discoverFromBoostConnectors( + private async discoverFromBoostConnectors( candidates: ConnectorCandidate[], seen: Set, - ): void { + ): Promise { try { const connectorsConfig = this.config.getOptionalConfig('boost.connectors'); @@ -211,7 +220,7 @@ export class ConnectorConfigReader { continue; } - const runtimeEnabled = this.getRuntimeEnabled(connectorId); + const runtimeEnabled = await this.resolveRuntimeEnabled(connectorId); const connectorType = this.inferConnectorType(connectorId); candidates.push({ @@ -256,10 +265,39 @@ export class ConnectorConfigReader { } /** - * Get the runtime-enabled flag from `boost.connectors..enabled`. - * Defaults to `true` if the key is missing. + * Resolve the runtime-enabled flag via {@link RuntimeConfigResolver}. + * This merges YAML baseline with DB overrides so that admin panel + * toggles take effect within the 30-second cache TTL. + * + * Defaults to `true` when the key is unset in both layers. + * Falls back to YAML-only if the key is not a registered + * {@link BoostConfigKey}. */ - private getRuntimeEnabled(connectorId: string): boolean { + private async resolveRuntimeEnabled(connectorId: string): Promise { + const key = `boost.connectors.${connectorId}.enabled`; + + // Only use the resolver for keys registered in boostConfigFields; + // unregistered keys fall back to YAML-only via ConfigApi. + if (key in boostConfigFields) { + try { + const value = await this.resolver.resolve(key as BoostConfigKey); + if (typeof value === 'boolean') { + return value; + } + if (value !== undefined) { + this.logger.warn( + `Unexpected type for ${key}: ${typeof value}; falling back to YAML`, + ); + } + // undefined or unexpected type → fall through to YAML ConfigApi + } catch (error) { + this.logger.warn( + `Failed to resolve runtime config for ${key}, falling back to YAML: ${error}`, + ); + } + } + + // Fallback: read from YAML-only via ConfigApi try { return ( this.config.getOptionalBoolean( diff --git a/workspaces/boost/plugins/boost-backend/src/ingestion/HealthStatusService.test.ts b/workspaces/boost/plugins/boost-backend/src/ingestion/HealthStatusService.test.ts index 9f3382cdc96..2db3b6bb949 100644 --- a/workspaces/boost/plugins/boost-backend/src/ingestion/HealthStatusService.test.ts +++ b/workspaces/boost/plugins/boost-backend/src/ingestion/HealthStatusService.test.ts @@ -133,7 +133,7 @@ describe('HealthStatusService', () => { getLastSuccessfulAttempt: jest.fn().mockResolvedValue(null), }; mockConfigReader = { - listCandidates: jest.fn().mockReturnValue([]), + listCandidates: jest.fn().mockResolvedValue([]), }; service = new HealthStatusService({ store: mockRepo as unknown as SyncAttemptsStore, @@ -143,7 +143,7 @@ describe('HealthStatusService', () => { }); it('returns empty array when no connectors configured', async () => { - mockConfigReader.listCandidates.mockReturnValue([]); + mockConfigReader.listCandidates.mockResolvedValue([]); const result = await service.getHealthStatuses(); expect(result).toEqual([]); }); @@ -157,7 +157,7 @@ describe('HealthStatusService', () => { runtimeEnabled: true, }, ]; - mockConfigReader.listCandidates.mockReturnValue(candidates); + mockConfigReader.listCandidates.mockResolvedValue(candidates); mockRepo.getLatestAttemptsForAll.mockResolvedValue( new Map([['github', []]]), ); @@ -184,7 +184,7 @@ describe('HealthStatusService', () => { runtimeEnabled: false, }, ]; - mockConfigReader.listCandidates.mockReturnValue(candidates); + mockConfigReader.listCandidates.mockResolvedValue(candidates); mockRepo.getLatestAttemptsForAll.mockResolvedValue( new Map([['github', []]]), ); @@ -209,7 +209,7 @@ describe('HealthStatusService', () => { runtimeEnabled: false, }, ]; - mockConfigReader.listCandidates.mockReturnValue(candidates); + mockConfigReader.listCandidates.mockResolvedValue(candidates); mockRepo.getLatestAttemptsForAll.mockResolvedValue( new Map([ ['github', []], @@ -234,7 +234,7 @@ describe('HealthStatusService', () => { runtimeEnabled: true, }, ]; - mockConfigReader.listCandidates.mockReturnValue(candidates); + mockConfigReader.listCandidates.mockResolvedValue(candidates); const attempts = [ makeAttempt({ @@ -286,7 +286,7 @@ describe('HealthStatusService', () => { runtimeEnabled: true, }, ]; - mockConfigReader.listCandidates.mockReturnValue(candidates); + mockConfigReader.listCandidates.mockResolvedValue(candidates); const attempts = [ makeAttempt({ @@ -316,7 +316,7 @@ describe('HealthStatusService', () => { runtimeEnabled: true, }, ]; - mockConfigReader.listCandidates.mockReturnValue(candidates); + mockConfigReader.listCandidates.mockResolvedValue(candidates); const attempts = [ makeAttempt({ @@ -362,7 +362,7 @@ describe('HealthStatusService', () => { runtimeEnabled: true, }, ]; - mockConfigReader.listCandidates.mockReturnValue(candidates); + mockConfigReader.listCandidates.mockResolvedValue(candidates); const recentFailures = [ makeAttempt({ @@ -412,7 +412,7 @@ describe('HealthStatusService', () => { runtimeEnabled: true, }, ]; - mockConfigReader.listCandidates.mockReturnValue(candidates); + mockConfigReader.listCandidates.mockResolvedValue(candidates); // Ambiguous message that could reclassify differently; stored type wins. const attempts = [ diff --git a/workspaces/boost/plugins/boost-backend/src/ingestion/HealthStatusService.ts b/workspaces/boost/plugins/boost-backend/src/ingestion/HealthStatusService.ts index 52112cec0e7..7fd821f98fd 100644 --- a/workspaces/boost/plugins/boost-backend/src/ingestion/HealthStatusService.ts +++ b/workspaces/boost/plugins/boost-backend/src/ingestion/HealthStatusService.ts @@ -89,8 +89,9 @@ export class HealthStatusService { async getHealthStatuses( includeDisabled: boolean = false, ): Promise { - // Step 1: Discover candidates from config - const candidates = this.configReader.listCandidates(); + // Step 1: Discover candidates from config (async — resolves + // runtimeEnabled via RuntimeConfigResolver for DB overrides) + const candidates = await this.configReader.listCandidates(); // Step 2: Filter out disabled connectors unless includeDisabled const filtered = includeDisabled diff --git a/workspaces/boost/plugins/boost-backend/src/plugin.ts b/workspaces/boost/plugins/boost-backend/src/plugin.ts index d6329423ec2..607bd31a4a9 100644 --- a/workspaces/boost/plugins/boost-backend/src/plugin.ts +++ b/workspaces/boost/plugins/boost-backend/src/plugin.ts @@ -255,6 +255,7 @@ export const boostPlugin = createBackendPlugin({ const connectorConfigReader = new ConnectorConfigReader({ config, + resolver: runtimeConfigResolver, logger, });