diff --git a/workspaces/boost/.changeset/connector-config-defaults.md b/workspaces/boost/.changeset/connector-config-defaults.md new file mode 100644 index 00000000000..d56bd5c9889 --- /dev/null +++ b/workspaces/boost/.changeset/connector-config-defaults.md @@ -0,0 +1,9 @@ +--- +'@red-hat-developer-hub/backstage-plugin-boost-backend': minor +--- + +Add optional `defaultValue` field to `ConfigFieldMeta` and apply it as a third +precedence layer in `RuntimeConfigResolver`: DB override, YAML baseline, field +default, undefined. Connector defaults: `schedule.intervalMs` 300000, +`batchSize` 100, `timeout.connectionMs` 30000 (Jira only). Bumps +`BOOST_CONFIG_SCHEMA_VERSION` from 4 to 5. diff --git a/workspaces/boost/.changeset/connector-config-schemas.md b/workspaces/boost/.changeset/connector-config-schemas.md deleted file mode 100644 index 6eb72ae614e..00000000000 --- a/workspaces/boost/.changeset/connector-config-schemas.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@red-hat-developer-hub/backstage-plugin-boost-backend': minor ---- - -Add connector config Zod schemas and RuntimeConfigResolver support for Jira, GitHub, and GitLab connectors. Registers flat leaf keys in `boostConfigFields` with `db-overridable` scope covering enabled, endpoint, schedule, batchSize, timeout, and cron fields. Bumps `BOOST_CONFIG_SCHEMA_VERSION` to 4. diff --git a/workspaces/boost/plugins/boost-backend/report.api.md b/workspaces/boost/plugins/boost-backend/report.api.md index 8500bcd5bbb..8f1c51a52f6 100644 --- a/workspaces/boost/plugins/boost-backend/report.api.md +++ b/workspaces/boost/plugins/boost-backend/report.api.md @@ -119,7 +119,7 @@ export interface BackendApprovalStoreOptions { } // @public -export const BOOST_CONFIG_SCHEMA_VERSION = 4; +export const BOOST_CONFIG_SCHEMA_VERSION = 5; // @public export const boostAiProviderServiceFactory: ServiceFactory< @@ -235,6 +235,7 @@ export const boostConfigFields: { readonly schema: z.ZodOptional; readonly configScope: ConfigScope; readonly description: string; + readonly defaultValue: 300000; }; readonly 'boost.connectors.jira.schedule.cron': { readonly schema: z.ZodOptional>; @@ -245,11 +246,13 @@ export const boostConfigFields: { readonly schema: z.ZodOptional; readonly configScope: ConfigScope; readonly description: string; + readonly defaultValue: 100; }; readonly 'boost.connectors.jira.timeout.connectionMs': { readonly schema: z.ZodOptional; readonly configScope: ConfigScope; readonly description: string; + readonly defaultValue: 30000; }; readonly 'boost.connectors.github.enabled': { readonly schema: z.ZodOptional; @@ -265,11 +268,13 @@ export const boostConfigFields: { readonly schema: z.ZodOptional; readonly configScope: ConfigScope; readonly description: string; + readonly defaultValue: 300000; }; readonly 'boost.connectors.github.batchSize': { readonly schema: z.ZodOptional; readonly configScope: ConfigScope; readonly description: string; + readonly defaultValue: 100; }; readonly 'boost.connectors.gitlab.enabled': { readonly schema: z.ZodOptional; @@ -285,11 +290,13 @@ export const boostConfigFields: { readonly schema: z.ZodOptional; readonly configScope: ConfigScope; readonly description: string; + readonly defaultValue: 300000; }; readonly 'boost.connectors.gitlab.batchSize': { readonly schema: z.ZodOptional; readonly configScope: ConfigScope; readonly description: string; + readonly defaultValue: 100; }; }; @@ -318,6 +325,7 @@ export interface ClassifyOptions { // @public export interface ConfigFieldMeta { configScope: ConfigScope; + defaultValue?: z.output; description: string; schema: T; sensitive?: boolean; diff --git a/workspaces/boost/plugins/boost-backend/src/config/RuntimeConfigResolver.test.ts b/workspaces/boost/plugins/boost-backend/src/config/RuntimeConfigResolver.test.ts index 00d569f8ace..a8c4b6098af 100644 --- a/workspaces/boost/plugins/boost-backend/src/config/RuntimeConfigResolver.test.ts +++ b/workspaces/boost/plugins/boost-backend/src/config/RuntimeConfigResolver.test.ts @@ -587,4 +587,278 @@ describe('RuntimeConfigResolver', () => { expect(allConfig.get('boost.connectors.gitlab.enabled')).toBe(true); }); }); + + describe('field defaults', () => { + it('returns field default when DB and YAML are both unset', async () => { + const config = createMockConfig({}); + const adminConfigService = { + getAllOverrides: jest.fn().mockResolvedValue(new Map()), + } as unknown as AdminConfigService; + + const resolver = new RuntimeConfigResolver({ + cache, + config, + adminConfigService, + logger, + }); + + expect( + await resolver.resolve('boost.connectors.jira.schedule.intervalMs'), + ).toBe(300000); + expect( + await resolver.resolve('boost.connectors.github.schedule.intervalMs'), + ).toBe(300000); + expect( + await resolver.resolve('boost.connectors.gitlab.schedule.intervalMs'), + ).toBe(300000); + }); + + it('returns batchSize default when DB and YAML are both unset', async () => { + const config = createMockConfig({}); + const adminConfigService = { + getAllOverrides: jest.fn().mockResolvedValue(new Map()), + } as unknown as AdminConfigService; + + const resolver = new RuntimeConfigResolver({ + cache, + config, + adminConfigService, + logger, + }); + + expect(await resolver.resolve('boost.connectors.jira.batchSize')).toBe( + 100, + ); + expect(await resolver.resolve('boost.connectors.github.batchSize')).toBe( + 100, + ); + expect(await resolver.resolve('boost.connectors.gitlab.batchSize')).toBe( + 100, + ); + }); + + it('returns timeout.connectionMs default for Jira when unset', async () => { + const config = createMockConfig({}); + const adminConfigService = { + getAllOverrides: jest.fn().mockResolvedValue(new Map()), + } as unknown as AdminConfigService; + + const resolver = new RuntimeConfigResolver({ + cache, + config, + adminConfigService, + logger, + }); + + expect( + await resolver.resolve('boost.connectors.jira.timeout.connectionMs'), + ).toBe(30000); + }); + + it('YAML value takes precedence over field default', async () => { + const config = createMockConfig({ + boost: { + connectors: { + jira: { + schedule: { intervalMs: 600000 }, + batchSize: 50, + timeout: { connectionMs: 15000 }, + }, + }, + }, + }); + + const adminConfigService = { + getAllOverrides: jest.fn().mockResolvedValue(new Map()), + } as unknown as AdminConfigService; + + const resolver = new RuntimeConfigResolver({ + cache, + config, + adminConfigService, + logger, + }); + + expect( + await resolver.resolve('boost.connectors.jira.schedule.intervalMs'), + ).toBe(600000); + expect(await resolver.resolve('boost.connectors.jira.batchSize')).toBe( + 50, + ); + expect( + await resolver.resolve('boost.connectors.jira.timeout.connectionMs'), + ).toBe(15000); + }); + + it('DB override takes precedence over YAML and field default', async () => { + const config = createMockConfig({ + boost: { + connectors: { + jira: { + schedule: { intervalMs: 600000 }, + batchSize: 50, + }, + }, + }, + }); + + const dbOverrides = new Map([ + ['boost.connectors.jira.schedule.intervalMs', 120000], + ['boost.connectors.jira.batchSize', 25], + ]); + const adminConfigService = { + getAllOverrides: jest.fn().mockResolvedValue(dbOverrides), + } as unknown as AdminConfigService; + + const resolver = new RuntimeConfigResolver({ + cache, + config, + adminConfigService, + logger, + }); + + expect( + await resolver.resolve('boost.connectors.jira.schedule.intervalMs'), + ).toBe(120000); + expect(await resolver.resolve('boost.connectors.jira.batchSize')).toBe( + 25, + ); + }); + + it('falls back to default after removeOverride when no YAML', async () => { + const config = createMockConfig({}); + + let dbOverrides = new Map([ + ['boost.connectors.jira.batchSize', 25], + ]); + const adminConfigService = { + getAllOverrides: jest.fn().mockImplementation(async () => dbOverrides), + removeOverride: jest.fn().mockImplementation(async () => { + dbOverrides = new Map(); + }), + } as unknown as AdminConfigService; + + const resolver = new RuntimeConfigResolver({ + cache, + config, + adminConfigService, + logger, + }); + + // DB override is present + expect(await resolver.resolve('boost.connectors.jira.batchSize')).toBe( + 25, + ); + + // Remove override and invalidate + await resolver.remove('boost.connectors.jira.batchSize'); + + // Should fall back to field default + expect(await resolver.resolve('boost.connectors.jira.batchSize')).toBe( + 100, + ); + }); + + it('returns undefined for fields without defaults', async () => { + const config = createMockConfig({}); + const adminConfigService = { + getAllOverrides: jest.fn().mockResolvedValue(new Map()), + } as unknown as AdminConfigService; + + const resolver = new RuntimeConfigResolver({ + cache, + config, + adminConfigService, + logger, + }); + + // enabled, endpoint, schedule.cron have no defaults + expect( + await resolver.resolve('boost.connectors.jira.enabled'), + ).toBeUndefined(); + expect( + await resolver.resolve('boost.connectors.jira.endpoint'), + ).toBeUndefined(); + expect( + await resolver.resolve('boost.connectors.jira.schedule.cron'), + ).toBeUndefined(); + }); + + it('resolveAll includes field defaults for unset keys', async () => { + const config = createMockConfig({}); + const adminConfigService = { + getAllOverrides: jest.fn().mockResolvedValue(new Map()), + } as unknown as AdminConfigService; + + const resolver = new RuntimeConfigResolver({ + cache, + config, + adminConfigService, + logger, + }); + + const allConfig = await resolver.resolveAll(); + + // Defaults should be present + expect(allConfig.get('boost.connectors.jira.schedule.intervalMs')).toBe( + 300000, + ); + expect(allConfig.get('boost.connectors.jira.batchSize')).toBe(100); + expect(allConfig.get('boost.connectors.jira.timeout.connectionMs')).toBe( + 30000, + ); + expect(allConfig.get('boost.connectors.github.schedule.intervalMs')).toBe( + 300000, + ); + expect(allConfig.get('boost.connectors.github.batchSize')).toBe(100); + expect(allConfig.get('boost.connectors.gitlab.schedule.intervalMs')).toBe( + 300000, + ); + expect(allConfig.get('boost.connectors.gitlab.batchSize')).toBe(100); + + // Fields without defaults should NOT be present + expect(allConfig.has('boost.connectors.jira.enabled')).toBe(false); + expect(allConfig.has('boost.connectors.jira.endpoint')).toBe(false); + expect(allConfig.has('boost.connectors.jira.schedule.cron')).toBe(false); + }); + + it('resolveAll does not override YAML or DB values with defaults', async () => { + const config = createMockConfig({ + boost: { + connectors: { + jira: { + schedule: { intervalMs: 600000 }, + }, + }, + }, + }); + + const dbOverrides = new Map([ + ['boost.connectors.jira.batchSize', 250], + ]); + const adminConfigService = { + getAllOverrides: jest.fn().mockResolvedValue(dbOverrides), + } as unknown as AdminConfigService; + + const resolver = new RuntimeConfigResolver({ + cache, + config, + adminConfigService, + logger, + }); + + const allConfig = await resolver.resolveAll(); + + // YAML value beats default + expect(allConfig.get('boost.connectors.jira.schedule.intervalMs')).toBe( + 600000, + ); + // DB override beats default + expect(allConfig.get('boost.connectors.jira.batchSize')).toBe(250); + // No YAML or DB — default applied + expect(allConfig.get('boost.connectors.jira.timeout.connectionMs')).toBe( + 30000, + ); + }); + }); }); diff --git a/workspaces/boost/plugins/boost-backend/src/config/RuntimeConfigResolver.ts b/workspaces/boost/plugins/boost-backend/src/config/RuntimeConfigResolver.ts index 6d0d2d5b980..c5b7a90dac3 100644 --- a/workspaces/boost/plugins/boost-backend/src/config/RuntimeConfigResolver.ts +++ b/workspaces/boost/plugins/boost-backend/src/config/RuntimeConfigResolver.ts @@ -23,6 +23,7 @@ import type { JsonValue } from '@backstage/types'; import { AdminConfigService } from './AdminConfigService'; import { boostConfigFields, + getFieldDefault, isSensitiveField, type BoostConfigKey, } from './schemas'; @@ -83,25 +84,56 @@ export class RuntimeConfigResolver { } /** - * Resolve a single config value. Checks DB override first, then - * YAML baseline. The merged result is cached for 30 seconds. + * Resolve a single config value. Precedence: + * 1. DB override (highest) + * 2. YAML baseline + * 3. Field default from schema metadata (lowest) * * @param key - The config field key. - * @returns The resolved value, or `undefined` if not set anywhere. + * @returns The resolved value, or `undefined` if not set anywhere + * and no field default is defined. */ async resolve(key: BoostConfigKey): Promise { const effectiveConfig = await this.getEffectiveConfig(); - return effectiveConfig.get(key); + const value = effectiveConfig.get(key); + if (value !== undefined) { + return value; + } + return getFieldDefault(key); } /** * Resolve all config values. Returns a map of key → resolved value - * with DB overrides taking precedence over YAML baseline. + * with precedence: DB override → YAML baseline → field default. + * + * The returned Map is a fresh snapshot for this call only (defaults + * are layered on top of a copy of the effective config and are never + * written back to the cache) — safe for callers to mutate or retain + * without affecting subsequent resolver calls. + * + * Note: the returned map does not indicate *which* layer a value + * came from — synthesized field defaults are indistinguishable from + * real DB overrides or YAML baseline values. Consumers that need to + * make that distinction (e.g. an admin UI showing "using default" + * vs. "explicitly set", see issue #4066) will need a separate + * mechanism (e.g. a per-key `source` tag) — not implemented here. * * @returns Map of all resolved config values. */ async resolveAll(): Promise> { - return this.getEffectiveConfig(); + const effective = await this.getEffectiveConfig(); + // Layer 3: apply field defaults for keys not already set. `effective` + // is a fresh Map from getEffectiveConfig() (see method docs above), + // so mutating it here is safe and never leaks into the cache. + for (const key of Object.keys(boostConfigFields) as BoostConfigKey[]) { + if (!effective.has(key)) { + const fieldDefault = getFieldDefault(key); + if (fieldDefault !== undefined) { + effective.set(key, fieldDefault); + } + } + } + return effective; } /** diff --git a/workspaces/boost/plugins/boost-backend/src/config/index.ts b/workspaces/boost/plugins/boost-backend/src/config/index.ts index 6bb69654428..d4ac9df5196 100644 --- a/workspaces/boost/plugins/boost-backend/src/config/index.ts +++ b/workspaces/boost/plugins/boost-backend/src/config/index.ts @@ -28,6 +28,7 @@ export { validateConfigValue, isDbWritable, isSensitiveField, + getFieldDefault, type BoostConfigKey, type ConfigScope, type ConfigFieldMeta, diff --git a/workspaces/boost/plugins/boost-backend/src/config/schemas.test.ts b/workspaces/boost/plugins/boost-backend/src/config/schemas.test.ts index 0332d2fbafd..76fc6a71d1f 100644 --- a/workspaces/boost/plugins/boost-backend/src/config/schemas.test.ts +++ b/workspaces/boost/plugins/boost-backend/src/config/schemas.test.ts @@ -22,11 +22,12 @@ import { isDbWritable, isSensitiveField, isValidCronExpression, + getFieldDefault, } from './schemas'; describe('boostConfigFields', () => { - it('has schema version 4', () => { - expect(BOOST_CONFIG_SCHEMA_VERSION).toBe(4); + it('has schema version 5', () => { + expect(BOOST_CONFIG_SCHEMA_VERSION).toBe(5); }); it('has entries for all expected config keys', () => { @@ -542,4 +543,50 @@ describe('connector config schemas', () => { }); }); }); + + describe('getFieldDefault', () => { + it('returns 300000 for all schedule.intervalMs fields', () => { + expect(getFieldDefault('boost.connectors.jira.schedule.intervalMs')).toBe( + 300000, + ); + expect( + getFieldDefault('boost.connectors.github.schedule.intervalMs'), + ).toBe(300000); + expect( + getFieldDefault('boost.connectors.gitlab.schedule.intervalMs'), + ).toBe(300000); + }); + + it('returns 100 for all batchSize fields', () => { + expect(getFieldDefault('boost.connectors.jira.batchSize')).toBe(100); + expect(getFieldDefault('boost.connectors.github.batchSize')).toBe(100); + expect(getFieldDefault('boost.connectors.gitlab.batchSize')).toBe(100); + }); + + it('returns 30000 for Jira timeout.connectionMs', () => { + expect( + getFieldDefault('boost.connectors.jira.timeout.connectionMs'), + ).toBe(30000); + }); + + it('returns undefined for fields without defaults', () => { + expect(getFieldDefault('boost.connectors.jira.enabled')).toBeUndefined(); + expect(getFieldDefault('boost.connectors.jira.endpoint')).toBeUndefined(); + expect( + getFieldDefault('boost.connectors.jira.schedule.cron'), + ).toBeUndefined(); + expect(getFieldDefault('boost.model.baseUrl')).toBeUndefined(); + }); + + it('does not use Zod .default() on connector schemas', () => { + const connectorKeys = Object.keys(boostConfigFields).filter(key => + key.startsWith('boost.connectors.'), + ) as Array; + connectorKeys.forEach(key => { + // validateConfigValue(key, undefined) must still return + // undefined — Zod .default() would return the default instead + expect(validateConfigValue(key, undefined)).toBeUndefined(); + }); + }); + }); }); diff --git a/workspaces/boost/plugins/boost-backend/src/config/schemas.ts b/workspaces/boost/plugins/boost-backend/src/config/schemas.ts index fd99b08b2a3..6b4d7126569 100644 --- a/workspaces/boost/plugins/boost-backend/src/config/schemas.ts +++ b/workspaces/boost/plugins/boost-backend/src/config/schemas.ts @@ -167,6 +167,14 @@ export interface ConfigFieldMeta { description: string; /** Whether this field contains sensitive credentials. */ sensitive?: boolean; + /** + * Default value applied by {@link RuntimeConfigResolver} when neither + * DB override nor YAML baseline provides a value. Intentionally kept + * outside Zod `.default()` so `validateConfigValue(key, undefined)` + * preserves "unset" semantics and resolver precedence + * (DB → YAML → field default) is respected. + */ + defaultValue?: z.output; } // --------------------------------------------------------------------------- @@ -179,7 +187,7 @@ export interface ConfigFieldMeta { * * @public */ -export const BOOST_CONFIG_SCHEMA_VERSION = 4; +export const BOOST_CONFIG_SCHEMA_VERSION = 5; // --------------------------------------------------------------------------- // Connector field factories — shared patterns for per-connector leaves @@ -223,6 +231,7 @@ function connectorIntervalMs(label: string) { description: `Interval in milliseconds between ${label} sync runs. ` + `Defaults to 300000 (5 minutes) when not set.`, + defaultValue: 300_000, } as const; } @@ -240,6 +249,7 @@ function connectorBatchSize(label: string) { description: `Number of ${label} items to fetch per sync batch. ` + `Defaults to 100 when not set.`, + defaultValue: 100, } as const; } @@ -464,6 +474,7 @@ export const boostConfigFields = { description: 'Connection timeout in milliseconds for Jira API requests. ' + 'Defaults to 30000 (30 seconds) when not set.', + defaultValue: 30_000, }, // -- Connector config: GitHub -- @@ -535,3 +546,22 @@ export function isSensitiveField(key: BoostConfigKey): boolean { const field = boostConfigFields[key] as ConfigFieldMeta | undefined; return field?.sensitive === true; } + +/** + * Returns the default value for a config field, or `undefined` if no + * default is defined. + * + * @param key - The config field key. + * @returns The default value, typed to the field's schema output, or + * `undefined`. + * + * @public + */ +export function getFieldDefault( + key: K, +): z.output<(typeof boostConfigFields)[K]['schema']> | undefined { + const field = boostConfigFields[key] as ConfigFieldMeta; + return field.defaultValue as + | z.output<(typeof boostConfigFields)[K]['schema']> + | undefined; +}