diff --git a/workspaces/boost/.changeset/ogx-tls-config.md b/workspaces/boost/.changeset/ogx-tls-config.md new file mode 100644 index 00000000000..cf553b96dc9 --- /dev/null +++ b/workspaces/boost/.changeset/ogx-tls-config.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-ogx-entity-provider': minor +--- + +Add per-provider TLS connection settings (`caData` and `skipTLSVerify`) to `OgxEntityProviderConfig` so `OgxModelEntityProvider` can fetch `/v1/models` from OGX endpoints that use a private CA or self-signed certificates. diff --git a/workspaces/boost/openspec/specs/ogx-entity-provider/spec.md b/workspaces/boost/openspec/specs/ogx-entity-provider/spec.md index 8cf9f60c7bb..51b7b0ae4b3 100644 --- a/workspaces/boost/openspec/specs/ogx-entity-provider/spec.md +++ b/workspaces/boost/openspec/specs/ogx-entity-provider/spec.md @@ -4,13 +4,14 @@ > > **Scope:** The independently deployable OGX model and agent entity > providers, including their configuration, annotations, version handling, -> and synchronization behavior. +> TLS connection settings, and synchronization behavior. ## Purpose This specification describes the shipped OGX entity provider: module registration, configuration resolution, model and agent entity mapping, -annotation and version normalization, and scheduled full synchronization. +annotation and version normalization, TLS connection configuration, and +scheduled full synchronization. ## Requirements @@ -51,6 +52,87 @@ URL when neither configuration path provides an OGX base URL. - **WHEN** the OGX module reads configuration - **THEN** it uses `http://localhost:8321` +#### Scenario: Read TLS settings from either configuration path + +- **GIVEN** `caData` or `skipTLSVerify` is set under the OGX configuration in use +- **WHEN** the OGX module reads configuration +- **THEN** both settings are read from that path +- **AND** the same settings are supported on the `boost.providers.ogx` fallback path +- **AND** each is left unset when the configuration does not provide it + +### Requirement: OGX configuration schema + +The plugin SHALL declare its configuration schema so that Backstage validates +the OGX configuration keys and enforces their visibility when the module is +loaded independently of `boost-backend`. + +#### Scenario: Declare the OGX configuration contract + +- **GIVEN** the `ogx-entity-provider` package is installed +- **WHEN** Backstage loads the configuration schema +- **THEN** the package contributes a schema covering `boost.entityProviders.ogx` + and `boost.providers.ogx` +- **AND** `apiKey` is marked with `@visibility secret` +- **AND** `caData` is marked with `@visibility backend` +- **AND** `baseUrl` and `skipTLSVerify` are marked `@configScope yaml-only` + +### Requirement: TLS connection configuration + +The model provider SHALL apply the configured TLS settings when requesting the +OGX model endpoint. `skipTLSVerify` SHALL take precedence over `caData`. The +dispatcher SHALL be created once and reused across refresh cycles. + +#### Scenario: Use default TLS behavior when nothing is configured + +- **GIVEN** neither `caData` nor `skipTLSVerify` is set +- **WHEN** the model provider fetches the OGX model endpoint +- **THEN** it issues the request without a custom dispatcher +- **AND** the runtime default certificate verification applies + +#### Scenario: Verify against a custom CA + +- **GIVEN** `caData` contains a PEM-encoded certificate or bundle +- **AND** `skipTLSVerify` is not set +- **WHEN** the model provider fetches the OGX model endpoint +- **THEN** it issues the request with a dispatcher carrying that CA +- **AND** certificate verification remains enabled + +#### Scenario: Disable certificate verification + +- **GIVEN** `skipTLSVerify` is true +- **WHEN** the model provider fetches the OGX model endpoint +- **THEN** it issues the request with certificate verification disabled +- **AND** it logs a warning that this is intended for development environments only + +#### Scenario: Prefer skipTLSVerify over caData + +- **GIVEN** both `caData` and `skipTLSVerify` are set +- **WHEN** the model provider fetches the OGX model endpoint +- **THEN** certificate verification is disabled +- **AND** the configured `caData` is not applied + +#### Scenario: Report malformed CA data without blocking the request + +- **GIVEN** `caData` does not contain matching PEM certificate markers +- **WHEN** the model provider fetches the OGX model endpoint +- **THEN** it logs an error naming the expected PEM markers +- **AND** it still applies the configured `caData` and issues the request + +#### Scenario: Reuse the dispatcher and warn only once + +- **GIVEN** a TLS setting is configured +- **WHEN** the model provider refreshes repeatedly +- **THEN** the dispatcher is created on the first refresh and reused afterwards +- **AND** the `skipTLSVerify` warning is logged only once + +#### Scenario: Preserve existing request behavior under TLS settings + +- **GIVEN** a TLS setting is configured +- **AND** an API key is configured +- **WHEN** the model provider fetches the OGX model endpoint +- **THEN** the Bearer authorization header is still sent +- **AND** a non-2xx response is still treated as a failed fetch + ### Requirement: Model-server entity emission The model provider SHALL request the OGX `/v1/models` endpoint and emit one diff --git a/workspaces/boost/plugins/ogx-entity-provider/config.d.ts b/workspaces/boost/plugins/ogx-entity-provider/config.d.ts new file mode 100644 index 00000000000..3e3fb03c0ed --- /dev/null +++ b/workspaces/boost/plugins/ogx-entity-provider/config.d.ts @@ -0,0 +1,80 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Configuration schema for the OGX entity provider module. + * + * Declares the config paths read by readOgxEntityProviderConfig so that + * Backstage validates and enforces visibility on these keys even if + * the module is loaded independently of boost-backend. + */ +export interface Config { + boost?: { + /** Entity-provider-specific config (standalone deployment). */ + entityProviders?: { + /** OGX entity provider connection. */ + ogx?: { + /** + * Base URL of the OGX API endpoint. + * @configScope yaml-only + */ + baseUrl?: string; + /** + * API key for authenticated endpoints. + * @visibility secret + */ + apiKey?: string; + /** + * PEM-encoded CA certificate or certificate bundle used to verify the OGX endpoint. + * @visibility backend + */ + caData?: string; + /** + * Disable TLS certificate verification. Development use only. + * @configScope yaml-only + */ + skipTLSVerify?: boolean; + }; + }; + + /** Provider module config (composed deployment). */ + providers?: { + /** OGX provider connection. */ + ogx?: { + /** + * Base URL of the OGX API endpoint. + * @configScope yaml-only + */ + baseUrl?: string; + /** + * API key for authenticated endpoints. + * @visibility secret + */ + apiKey?: string; + /** + * PEM-encoded CA certificate or certificate bundle used to verify the OGX endpoint. + * @visibility backend + */ + caData?: string; + /** + * Disable TLS certificate verification. Development use only. + * @configScope yaml-only + */ + skipTLSVerify?: boolean; + }; + }; + }; +} diff --git a/workspaces/boost/plugins/ogx-entity-provider/package.json b/workspaces/boost/plugins/ogx-entity-provider/package.json index 43eec9bc08c..1bb3208e172 100644 --- a/workspaces/boost/plugins/ogx-entity-provider/package.json +++ b/workspaces/boost/plugins/ogx-entity-provider/package.json @@ -2,6 +2,7 @@ "name": "@red-hat-developer-hub/backstage-plugin-ogx-entity-provider", "version": "0.4.2", "license": "Apache-2.0", + "configSchema": "config.d.ts", "description": "OGX entity provider for the Backstage catalog — emits AI models and agents as catalog entities", "main": "src/index.ts", "types": "src/index.ts", @@ -31,11 +32,13 @@ "@backstage/backend-plugin-api": "^1.10.0", "@backstage/catalog-model": "^1.10.0", "@backstage/plugin-catalog-node": "^2.2.4", - "@red-hat-developer-hub/backstage-plugin-boost-entity-provider-sdk": "workspace:^" + "@red-hat-developer-hub/backstage-plugin-boost-entity-provider-sdk": "workspace:^", + "undici": "^6.21.1" }, "devDependencies": { "@backstage/backend-test-utils": "^1.11.6", - "@backstage/cli": "^0.36.5" + "@backstage/cli": "^0.36.5", + "@backstage/config": "^1.3.2" }, "sideEffects": false, "scripts": { diff --git a/workspaces/boost/plugins/ogx-entity-provider/src/module.test.ts b/workspaces/boost/plugins/ogx-entity-provider/src/module.test.ts new file mode 100644 index 00000000000..e44f100fa1a --- /dev/null +++ b/workspaces/boost/plugins/ogx-entity-provider/src/module.test.ts @@ -0,0 +1,116 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; + +import { readOgxEntityProviderConfig } from './module'; + +describe('readOgxEntityProviderConfig', () => { + it('reads caData and skipTLSVerify from boost.entityProviders.ogx', () => { + const config = new ConfigReader({ + boost: { + entityProviders: { + ogx: { + baseUrl: 'https://ogx.example.com', + caData: + '-----BEGIN CERTIFICATE-----\nMIIBxTCC...\n-----END CERTIFICATE-----', + skipTLSVerify: true, + }, + }, + }, + }); + + const result = readOgxEntityProviderConfig(config); + + expect(result.baseUrl).toBe('https://ogx.example.com'); + expect(result.caData).toBe( + '-----BEGIN CERTIFICATE-----\nMIIBxTCC...\n-----END CERTIFICATE-----', + ); + expect(result.skipTLSVerify).toBe(true); + }); + + it('reads caData and skipTLSVerify from fallback boost.providers.ogx', () => { + const config = new ConfigReader({ + boost: { + providers: { + ogx: { + baseUrl: 'https://ogx-fallback.example.com', + caData: 'PEM-CERT-DATA', + skipTLSVerify: false, + }, + }, + }, + }); + + const result = readOgxEntityProviderConfig(config); + + expect(result.baseUrl).toBe('https://ogx-fallback.example.com'); + expect(result.caData).toBe('PEM-CERT-DATA'); + expect(result.skipTLSVerify).toBe(false); + }); + + it('returns undefined for caData and skipTLSVerify when not configured', () => { + const config = new ConfigReader({ + boost: { + entityProviders: { + ogx: { + baseUrl: 'http://localhost:8321', + }, + }, + }, + }); + + const result = readOgxEntityProviderConfig(config); + + expect(result.baseUrl).toBe('http://localhost:8321'); + expect(result.caData).toBeUndefined(); + expect(result.skipTLSVerify).toBeUndefined(); + }); + + it('falls back to localhost when no OGX config is present', () => { + const config = new ConfigReader({}); + + const result = readOgxEntityProviderConfig(config); + + expect(result.baseUrl).toBe('http://localhost:8321'); + expect(result.caData).toBeUndefined(); + expect(result.skipTLSVerify).toBeUndefined(); + }); + + it('prefers entityProviders.ogx over providers.ogx', () => { + const config = new ConfigReader({ + boost: { + entityProviders: { + ogx: { + baseUrl: 'https://primary.example.com', + caData: 'PRIMARY-CA', + }, + }, + providers: { + ogx: { + baseUrl: 'https://fallback.example.com', + caData: 'FALLBACK-CA', + }, + }, + }, + }); + + const result = readOgxEntityProviderConfig(config); + + expect(result.baseUrl).toBe('https://primary.example.com'); + expect(result.caData).toBe('PRIMARY-CA'); + }); +}); diff --git a/workspaces/boost/plugins/ogx-entity-provider/src/module.ts b/workspaces/boost/plugins/ogx-entity-provider/src/module.ts index 3d9bbf56ed9..1faca862723 100644 --- a/workspaces/boost/plugins/ogx-entity-provider/src/module.ts +++ b/workspaces/boost/plugins/ogx-entity-provider/src/module.ts @@ -49,6 +49,8 @@ const DEFAULT_AGENT_REFRESH_SECONDS = 300; * ogx: * baseUrl: http://localhost:8321 * apiKey: ${OGX_API_KEY} # optional + * caData: '-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----' # optional + * skipTLSVerify: false # optional * modelRefreshIntervalSeconds: 60 * agentRefreshIntervalSeconds: 300 * agents: @@ -114,8 +116,10 @@ export const catalogModuleOgxEntityProvider = createBackendModule({ /** * Read OGX entity provider configuration from app-config.yaml. + * + * @internal Exported for testing only. */ -function readOgxEntityProviderConfig( +export function readOgxEntityProviderConfig( config: typeof coreServices.rootConfig extends { T: infer T } ? T : never, ): OgxEntityProviderConfig { // Try the entity-provider-specific config first @@ -134,6 +138,8 @@ function readOgxEntityProviderConfig( defaultAgent: epConfig.getOptionalString('defaultAgent'), maxAgentTurns: epConfig.getOptionalNumber('maxAgentTurns'), agents: readAgentConfigs(epConfig), + caData: epConfig.getOptionalString('caData'), + skipTLSVerify: epConfig.getOptionalBoolean('skipTLSVerify'), }; } @@ -147,6 +153,8 @@ function readOgxEntityProviderConfig( defaultAgent: providerConfig.getOptionalString('defaultAgent'), maxAgentTurns: providerConfig.getOptionalNumber('maxAgentTurns'), agents: readAgentConfigs(providerConfig), + caData: providerConfig.getOptionalString('caData'), + skipTLSVerify: providerConfig.getOptionalBoolean('skipTLSVerify'), }; } diff --git a/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxModelEntityProvider.test.ts b/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxModelEntityProvider.test.ts index e52d84299fa..886df54f5e0 100644 --- a/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxModelEntityProvider.test.ts +++ b/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxModelEntityProvider.test.ts @@ -27,9 +27,17 @@ import { AI_ASSET_VERSION_ANNOTATION, } from '@red-hat-developer-hub/backstage-plugin-boost-entity-provider-sdk'; +import { Agent } from 'undici'; + import { OgxModelEntityProvider } from './OgxModelEntityProvider'; import type { OgxEntityProviderConfig } from '../types'; +jest.mock('undici', () => ({ + Agent: jest.fn(() => ({ mocked: true })), +})); + +const MockAgent = Agent as jest.MockedClass; + const mockFetch = jest.fn() as jest.MockedFunction; global.fetch = mockFetch; @@ -62,6 +70,7 @@ describe('OgxModelEntityProvider', () => { beforeEach(() => { jest.clearAllMocks(); + MockAgent.mockClear(); taskRunner = new TaskRunnerMock(); }); @@ -246,4 +255,343 @@ describe('OgxModelEntityProvider', () => { expect(mutation.entities).toHaveLength(1); expect(mutation.entities[0].entity.spec.models.available).toEqual([]); }); + + describe('TLS configuration', () => { + it('should not create a dispatcher when neither caData nor skipTLSVerify is set', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: [] }), + } as Response); + + const provider = new OgxModelEntityProvider({ + config: defaultConfig, + logger: mockServices.logger.mock(), + taskRunner, + }); + + await provider.connect(mockConnection); + await taskRunner.runAll(); + + expect(MockAgent).not.toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:8321/v1/models', + expect.not.objectContaining({ dispatcher: expect.anything() }), + ); + }); + + it('should configure HTTPS request with custom CA when caData is set', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: [{ id: 'model-1' }] }), + } as Response); + + const provider = new OgxModelEntityProvider({ + config: { + ...defaultConfig, + baseUrl: 'https://ogx.example.com', + caData: + '-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----', + }, + logger: mockServices.logger.mock(), + taskRunner, + }); + + await provider.connect(mockConnection); + await taskRunner.runAll(); + + expect(MockAgent).toHaveBeenCalledWith({ + connect: { + ca: '-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----', + rejectUnauthorized: true, + }, + }); + expect(mockFetch).toHaveBeenCalledWith( + 'https://ogx.example.com/v1/models', + expect.objectContaining({ + dispatcher: expect.anything(), + }), + ); + }); + + it('should disable certificate verification when skipTLSVerify is true and logs a warning', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: [{ id: 'model-1' }] }), + } as Response); + + const childWarn = jest.fn(); + const mockLogger = { + ...mockServices.logger.mock(), + child: jest.fn().mockReturnValue({ + info: jest.fn(), + warn: childWarn, + error: jest.fn(), + debug: jest.fn(), + child: jest.fn(), + }), + }; + + const provider = new OgxModelEntityProvider({ + config: { ...defaultConfig, skipTLSVerify: true }, + logger: mockLogger, + taskRunner, + }); + + await provider.connect(mockConnection); + await taskRunner.runAll(); + + expect(MockAgent).toHaveBeenCalledWith({ + connect: { rejectUnauthorized: false }, + }); + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + dispatcher: expect.anything(), + }), + ); + expect(childWarn).toHaveBeenCalledWith( + expect.stringContaining('TLS certificate verification is disabled'), + ); + }); + + it('should give skipTLSVerify precedence when both fields are set', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: [] }), + } as Response); + + const childWarn = jest.fn(); + const mockLogger = { + ...mockServices.logger.mock(), + child: jest.fn().mockReturnValue({ + info: jest.fn(), + warn: childWarn, + error: jest.fn(), + debug: jest.fn(), + child: jest.fn(), + }), + }; + + const provider = new OgxModelEntityProvider({ + config: { + ...defaultConfig, + caData: '-----BEGIN CERTIFICATE-----\nCA\n-----END CERTIFICATE-----', + skipTLSVerify: true, + }, + logger: mockLogger, + taskRunner, + }); + + await provider.connect(mockConnection); + await taskRunner.runAll(); + + expect(MockAgent).toHaveBeenCalledWith({ + connect: { rejectUnauthorized: false }, + }); + expect(childWarn).toHaveBeenCalledWith( + expect.stringContaining('TLS certificate verification is disabled'), + ); + }); + + it('should preserve Authorization header when TLS settings are used', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: [] }), + } as Response); + + const provider = new OgxModelEntityProvider({ + config: { + ...defaultConfig, + apiKey: 'secret-key', + caData: + '-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----', + }, + logger: mockServices.logger.mock(), + taskRunner, + }); + + await provider.connect(mockConnection); + await taskRunner.runAll(); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer secret-key', + }), + dispatcher: expect.anything(), + }), + ); + }); + + it('should retain non-2xx error handling with TLS settings', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 502, + } as Response); + + const provider = new OgxModelEntityProvider({ + config: { ...defaultConfig, skipTLSVerify: true }, + logger: mockServices.logger.mock(), + taskRunner, + }); + + await provider.connect(mockConnection); + await taskRunner.runAll(); + + const mutation = (mockConnection.applyMutation as jest.Mock).mock + .calls[0][0]; + expect(mutation.entities).toHaveLength(0); + }); + + it('should reuse the cached Agent across multiple refresh cycles', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: [{ id: 'model-1' }] }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: [{ id: 'model-2' }] }), + } as Response); + + const provider = new OgxModelEntityProvider({ + config: { + ...defaultConfig, + caData: + '-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----', + }, + logger: mockServices.logger.mock(), + taskRunner, + }); + + await provider.connect(mockConnection); + await taskRunner.runAll(); + + // First refresh creates the Agent + expect(MockAgent).toHaveBeenCalledTimes(1); + + // Simulate a second refresh cycle + await provider.run(); + + // Agent is reused — still only one instance created + expect(MockAgent).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('should log skipTLSVerify warning only once across multiple refresh cycles', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: [] }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: [] }), + } as Response); + + const childWarn = jest.fn(); + const mockLogger = { + ...mockServices.logger.mock(), + child: jest.fn().mockReturnValue({ + info: jest.fn(), + warn: childWarn, + error: jest.fn(), + debug: jest.fn(), + child: jest.fn(), + }), + }; + + const provider = new OgxModelEntityProvider({ + config: { ...defaultConfig, skipTLSVerify: true }, + logger: mockLogger, + taskRunner, + }); + + await provider.connect(mockConnection); + await taskRunner.runAll(); + + // Second refresh + await provider.run(); + + // Warning emitted only once despite two refresh cycles + const tlsWarnings = childWarn.mock.calls.filter((call: string[]) => + call[0].includes('TLS certificate verification is disabled'), + ); + expect(tlsWarnings).toHaveLength(1); + }); + + it('should log an error when caData is not valid PEM', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: [] }), + } as Response); + + const childError = jest.fn(); + const mockLogger = { + ...mockServices.logger.mock(), + child: jest.fn().mockReturnValue({ + info: jest.fn(), + warn: jest.fn(), + error: childError, + debug: jest.fn(), + child: jest.fn(), + }), + }; + + const provider = new OgxModelEntityProvider({ + config: { + ...defaultConfig, + caData: 'not-a-valid-pem-string', + }, + logger: mockLogger, + taskRunner, + }); + + await provider.connect(mockConnection); + await taskRunner.runAll(); + + expect(childError).toHaveBeenCalledWith( + expect.stringContaining( + 'does not contain valid PEM certificate markers', + ), + ); + // Agent is not created — invalid PEM is a hard stop + expect(MockAgent).toHaveBeenCalledTimes(0); + }); + + it('should not log PEM error when caData has valid PEM markers', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: [] }), + } as Response); + + const childError = jest.fn(); + const mockLogger = { + ...mockServices.logger.mock(), + child: jest.fn().mockReturnValue({ + info: jest.fn(), + warn: jest.fn(), + error: childError, + debug: jest.fn(), + child: jest.fn(), + }), + }; + + const provider = new OgxModelEntityProvider({ + config: { + ...defaultConfig, + caData: + '-----BEGIN CERTIFICATE-----\nMIIBxTCC...\n-----END CERTIFICATE-----', + }, + logger: mockLogger, + taskRunner, + }); + + await provider.connect(mockConnection); + await taskRunner.runAll(); + + expect(childError).not.toHaveBeenCalled(); + }); + }); }); diff --git a/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxModelEntityProvider.ts b/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxModelEntityProvider.ts index 552499fde82..63a0431d7b9 100644 --- a/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxModelEntityProvider.ts +++ b/workspaces/boost/plugins/ogx-entity-provider/src/providers/OgxModelEntityProvider.ts @@ -35,6 +35,8 @@ import { normalizeAIAssetVersion, } from '@red-hat-developer-hub/backstage-plugin-boost-entity-provider-sdk'; +import { Agent } from 'undici'; + import type { OgxEntityProviderConfig, OgxModelListResponse, @@ -61,6 +63,8 @@ export class OgxModelEntityProvider implements EntityProvider { private readonly scheduleFn: () => Promise; private connection?: EntityProviderConnection; private cachedEntity: Entity | undefined; + /** Lazily-initialized TLS dispatcher — created once and reused across refresh cycles. */ + private cachedTlsDispatcher: Agent | null | undefined; constructor(options: { config: OgxEntityProviderConfig; @@ -128,7 +132,14 @@ export class OgxModelEntityProvider implements EntityProvider { headers.Authorization = `Bearer ${this.config.apiKey}`; } - const response = await fetch(url, { headers }); + const fetchOptions: RequestInit & { dispatcher?: Agent } = { headers }; + + const dispatcher = this.getTlsDispatcher(); + if (dispatcher) { + fetchOptions.dispatcher = dispatcher; + } + + const response = await fetch(url, fetchOptions); if (!response.ok) { throw new Error(`OGX API returned ${response.status} from ${url}`); @@ -148,6 +159,53 @@ export class OgxModelEntityProvider implements EntityProvider { return []; } + /** + * Return the cached TLS dispatcher, creating it on first call. + * + * The dispatcher is created once and reused across refresh cycles to avoid + * accumulating orphaned Agents with their own connection pools. + * + * - skipTLSVerify takes precedence: sets rejectUnauthorized=false and logs a warning (once). + * - caData alone: validates PEM markers, then sets the custom CA with rejectUnauthorized=true. + * - Neither: returns undefined (use default fetch behavior). + */ + private getTlsDispatcher(): Agent | undefined { + // undefined = not yet initialized; null = initialized, no TLS needed + if (this.cachedTlsDispatcher !== undefined) { + return this.cachedTlsDispatcher ?? undefined; + } + + const { caData, skipTLSVerify } = this.config; + + if (!caData && !skipTLSVerify) { + this.cachedTlsDispatcher = null; + return undefined; + } + + if (skipTLSVerify) { + this.logger.warn( + 'TLS certificate verification is disabled for OGX endpoint — this should only be used in development environments', + ); + this.cachedTlsDispatcher = new Agent({ + connect: { rejectUnauthorized: false }, + }); + return this.cachedTlsDispatcher; + } + + // caData only — validate PEM markers before passing to undici + if (caData && !isValidPem(caData)) { + this.logger.error( + 'caData does not contain valid PEM certificate markers (expected -----BEGIN CERTIFICATE----- / -----END CERTIFICATE-----) — TLS connections to the OGX endpoint may fail', + ); + return undefined; + } + + this.cachedTlsDispatcher = new Agent({ + connect: { ca: caData, rejectUnauthorized: true }, + }); + return this.cachedTlsDispatcher; + } + /** * Convert the OGX server + its models into a single AiModelServerAPI entity. */ @@ -212,3 +270,19 @@ export class OgxModelEntityProvider implements EntityProvider { }; } } + +const PEM_HEADER = '-----BEGIN CERTIFICATE-----'; +const PEM_FOOTER = '-----END CERTIFICATE-----'; + +/** + * Check whether a string contains at least one complete PEM certificate block. + * Mirrors the logic in boost-connector-utils/src/ca-bundle.ts. + */ +function isValidPem(content: string): boolean { + if (!content.includes(PEM_HEADER) || !content.includes(PEM_FOOTER)) { + return false; + } + const beginCount = content.split(PEM_HEADER).length - 1; + const endCount = content.split(PEM_FOOTER).length - 1; + return beginCount > 0 && beginCount === endCount; +} diff --git a/workspaces/boost/plugins/ogx-entity-provider/src/types.ts b/workspaces/boost/plugins/ogx-entity-provider/src/types.ts index 40bf7fe38c7..c43d74814a2 100644 --- a/workspaces/boost/plugins/ogx-entity-provider/src/types.ts +++ b/workspaces/boost/plugins/ogx-entity-provider/src/types.ts @@ -112,4 +112,8 @@ export interface OgxEntityProviderConfig { maxAgentTurns?: number; /** Static agent configurations from YAML/admin config. */ agents?: OgxAgentConfig[]; + /** PEM-encoded CA certificate or certificate bundle used to verify OGX. */ + caData?: string; + /** Disable TLS certificate verification. Development use only. */ + skipTLSVerify?: boolean; } diff --git a/workspaces/boost/specifications/CURRENT.md b/workspaces/boost/specifications/CURRENT.md index ea8b91d02ad..c41e831c145 100644 --- a/workspaces/boost/specifications/CURRENT.md +++ b/workspaces/boost/specifications/CURRENT.md @@ -1,6 +1,6 @@ # Boost current status -Release map as of 2026-09-09. After this file exists, treat it as the +Release map as of 2026-09-10. After this file exists, treat it as the workspace map for what is in this release. Implemented frontend behavior lives in `openspec/specs/`. Active work lives in `openspec/changes/`. PRDs and Jira analysis are background, not current truth. @@ -49,12 +49,24 @@ current behavior is captured in the focused `ogx-entity-provider` spec archived under `openspec/specs/`. The broader `ai-catalog-entity-model` change remains active and deferred; it is not the release behavior source of truth. +The provider now accepts per-provider TLS settings, `caData` and +`skipTLSVerify`, on both supported configuration paths, and the package +declares a `config.d.ts` schema so Backstage validates these keys and enforces +`@visibility secret` on `apiKey`. This shape was patched directly into the +`ogx-entity-provider` spec rather than routed through a new OpenSpec change, +because the `openspec/changes/` content is being reset. + ## Open question for the backend team `boost.providers.ogx` was never released, and the Boost backend is outside the RHDH 2.1 release. Should the OGX entity provider stop supporting that fallback -and use only `boost.entityProviders.ogx`? If yes, the fallback code, tests, and -the archived OGX spec should be updated together. +and use only `boost.entityProviders.ogx`? + +The TLS work extended the fallback rather than retiring it: `caData` and +`skipTLSVerify` are read on both paths, and `config.d.ts` declares the full +schema for both. That raises the cost of removal — the fallback code, its +tests, the declared schema, and the OGX spec would all have to be updated +together. ## Active remaining frontend work (`openspec/changes/`) diff --git a/workspaces/boost/yarn.lock b/workspaces/boost/yarn.lock index 859c24a75e7..41f8b75a987 100644 --- a/workspaces/boost/yarn.lock +++ b/workspaces/boost/yarn.lock @@ -3089,7 +3089,7 @@ __metadata: languageName: node linkType: hard -"@backstage/config@npm:^1.3.6, @backstage/config@npm:^1.3.8": +"@backstage/config@npm:^1.3.2, @backstage/config@npm:^1.3.6, @backstage/config@npm:^1.3.8": version: 1.3.8 resolution: "@backstage/config@npm:1.3.8" dependencies: @@ -9041,8 +9041,10 @@ __metadata: "@backstage/backend-test-utils": "npm:^1.11.6" "@backstage/catalog-model": "npm:^1.10.0" "@backstage/cli": "npm:^0.36.5" + "@backstage/config": "npm:^1.3.2" "@backstage/plugin-catalog-node": "npm:^2.2.4" "@red-hat-developer-hub/backstage-plugin-boost-entity-provider-sdk": "workspace:^" + undici: "npm:^6.21.1" languageName: unknown linkType: soft @@ -29758,7 +29760,7 @@ __metadata: languageName: node linkType: hard -"undici@npm:^6.25.0": +"undici@npm:^6.21.1, undici@npm:^6.25.0": version: 6.28.0 resolution: "undici@npm:6.28.0" checksum: 10c0/3029a70df06b38b5b2f30732932a1e92544c753cd82c8abdf0d35afad48e0ba91612e79fe3a442dbbb9434d6a9eba2b714d5ea28984c903dda2b5d5444f38354